test(ignore-service): guard the twin build-output ignore lists against drift

_next now lives in two lists in two packages — the analyzer's DEFAULT_IGNORE_LIST
and the browser upload filter's EXCLUDED_DIRS — with nothing tying them
together. This is the seventh twin-list pair in this repo; the header of
receiver-twin-list-drift.test.ts records that the previous ones each shipped a
bug when one side moved.

Containment runs web -> CLI only, and that is the load-bearing direction: the
browser filter decides what the server ever sees, and it reads no
.gitnexusignore, so a name it drops that the analyzer would have indexed is
silent source loss with no recovery. The reverse is not an error — the analyzer
prunes far more aggressively than an upload needs to.

.gitnexus is the one exemption and has a mechanism: the walker passes
dot: false to glob, so it never enumerates dot-directories. Asserted in both
directions so re-adding it to the CLI list or dropping it from the web list
both fail.

Both sides are source-parsed through the shared helper. DEFAULT_IGNORE_LIST is
module-private, and no test in this package imports across the package boundary
— every cross-package precedent reads source instead.

Also corrects the documentation this PR's comments got wrong: the guard test is
cited by path rather than as "below", the unreproducible per-repo percentage is
gone, the reason _next is deliberately unanchored is recorded next to the entry
(no <web-root>/_next form matches a root-level _next/static/…), and the upload
filter now states that it consults no repository ignore rules — so unlike the
CLI, a negation cannot recover what it drops.

Verified by mutation: a web-only addition and a CLI removal each turn the guard
red. 194 targeted tests pass; tsc clean in both packages.
This commit is contained in:
Gergo Magyar 2026-08-27 11:33:13 +00:00
parent 00c2fe81be
commit 1dc5328af5
3 changed files with 118 additions and 5 deletions

View file

@ -27,6 +27,11 @@ export const EXCLUDED_DIRS = new Set([
// `<platform>/app/src/main/assets/public/_next/`, so without this the whole
// minified tree is uploaded against the server's file/byte caps only to be
// discarded by the analyzer's own ignore list (#3007).
//
// This pre-filter reads no repository ignore rules, so unlike the CLI walker
// a `.gitnexusignore` negation cannot recover anything dropped here. Names
// added below must therefore stay a subset of the analyzer's own list; see
// `gitnexus/test/unit/upload-filter-ignore-drift.test.ts`.
'_next',
'.nuxt',
'.cache',

View file

@ -61,9 +61,15 @@ const DEFAULT_IGNORE_LIST = new Set([
// `.next` is Next.js's build CACHE; `_next` is the EMITTED output, and the two
// are different directories. A Capacitor/Cordova shell copies the emitted
// bundle to `<platform>/app/src/main/assets/public/_next/static/…`, where none
// of the path segments hit this list — so a mobile-wrapped Next.js app had 40%
// of its indexed files come from minified chunks, and every Route node it
// produced pointed at a webpack bundle rather than source (#3007).
// of the path segments hit this list — so a mobile-wrapped Next.js app had its
// shipped bundle indexed as source, and every Route node it produced pointed at
// a webpack chunk rather than code anyone wrote (#3007).
//
// The name is deliberately unanchored. No `<web-root>/_next` form matches a
// root-level `_next/static/…`, which is the shape the reported repo has, so
// anchoring it would miss the case it was added for. The accepted cost is a
// hand-written directory literally named `_next`; recover one with a bare
// `!_next/` line in `.gitnexusignore`.
'_next',
'.nuxt',
'.output',
@ -75,8 +81,10 @@ const DEFAULT_IGNORE_LIST = new Set([
// time, and `isHardcodedIgnoredDirectory(name)` takes a bare directory name,
// so a slash-containing member could never match either — it was inert. Its
// paths were never unignored though: bare `'build'` above already prunes
// `public/build/**`, so removing the entry changes no behavior (#3007). The
// guard test below keeps the next slash-bearing entry from dying the same way.
// `public/build/**`, so removing the entry changes no behavior (#3007).
// `test/unit/ignore-build-output.test.ts` keeps the next slash-bearing entry
// in this set — or in IGNORED_FILES, ROOT_ARTIFACT_DIRECTORIES or
// IGNORED_EXTENSIONS — from dying the same way.
'.parcel-cache',
'.turbo',
'.svelte-kit',

View file

@ -0,0 +1,100 @@
/**
* The drift guard for the twin build-output ignore lists (#3007 follow-up).
*
* TWO lists spell "do not index this directory", in two packages:
*
* - `DEFAULT_IGNORE_LIST` gitnexus `src/config/ignore-service.ts`. The
* analyzer's own list, consulted for every path during the repository walk.
* - `EXCLUDED_DIRS` gitnexus-web `src/lib/upload-filter.ts`. A client-side
* pre-filter that decides what a browser folder upload sends at all.
*
* `_next` was added to both in the same PR, one commit apart. Before it, neither
* carried the name so #3007 was a shared omission rather than drift between
* them. What this test guards is the divergence that becomes possible now that
* the same name lives in two places with nothing tying them together.
*
* The containment runs web -> CLI only, and that direction is the load-bearing
* one: the browser filter decides what the server ever sees, so a name it drops
* that the analyzer would have indexed is silent source loss with no recovery
* this pre-filter reads no `.gitnexusignore`, so a negation cannot bring the
* files back. The reverse direction is not an error: roughly sixty CLI-only
* names exist because the analyzer prunes far more aggressively than an upload
* needs to, and the walker's own `dot: false` already hides dot-directories from
* it. That asymmetry is why equality is not the assertion.
*
* `.gitnexus` is the one deliberate exception, and it has a mechanism rather
* than being an oversight: the CLI walker passes `dot: false` to glob
* (`src/core/ingestion/filesystem-walker.ts`), so it never enumerates
* dot-directories and does not need the name in its list. The browser filter has
* no equivalent and must name it. That exemption is asserted explicitly in both
* directions, so re-adding `.gitnexus` to the CLI list or dropping it from the
* web list both fail loudly.
*
* Structural (source-parsed) rather than value-imported: `DEFAULT_IGNORE_LIST`
* is module-private and exporting it purely to be testable would widen a
* production surface to satisfy a test. `EXCLUDED_DIRS` is exported, but no test
* in this package imports across the package boundary every cross-package
* precedent here reads source instead so both sides use the same parser.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { setEntries } from '../helpers/ignore-set-source.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
const cliSource = readFileSync(
path.join(REPO_ROOT, 'gitnexus', 'src', 'config', 'ignore-service.ts'),
'utf8',
);
const webSource = readFileSync(
path.join(REPO_ROOT, 'gitnexus-web', 'src', 'lib', 'upload-filter.ts'),
'utf8',
);
const cliNames = () => setEntries(cliSource, 'const DEFAULT_IGNORE_LIST = new Set([');
const webNames = () => setEntries(webSource, 'export const EXCLUDED_DIRS = new Set([');
/**
* Names the browser filter may drop that the analyzer does not list.
*
* Only `.gitnexus`, and only because the CLI walker's `dot: false` makes the
* entry unnecessary there. A name added here must cite a comparable structural
* reason in the walker this list is not a place to park a failing assertion.
*/
const WEB_ONLY_ALLOWLIST = ['.gitnexus'];
describe('build-output ignore lists stay in agreement across packages', () => {
it('parses both lists — neither is silently empty', () => {
// Guards the guard: a parser that read nothing would make every containment
// assertion below vacuously true.
expect(cliNames().length).toBeGreaterThan(20);
expect(webNames().length).toBeGreaterThan(5);
});
it('every name the browser filter drops is one the analyzer also ignores', () => {
const cli = new Set(cliNames());
const unmatched = webNames().filter(
(name) => !cli.has(name) && !WEB_ONLY_ALLOWLIST.includes(name),
);
expect(unmatched).toEqual([]);
});
it('carries the documented web-only exemption, and only that one', () => {
// Asserted separately from the containment above so the intent survives if
// that assertion is ever relaxed.
expect(webNames()).toContain('.gitnexus');
expect(cliNames()).not.toContain('.gitnexus');
});
it('shares the build-output names the reported bug was about', () => {
const cli = new Set(cliNames());
const web = new Set(webNames());
for (const name of ['_next', '.next', 'dist', 'build', 'out']) {
expect(web.has(name), `${name} missing from the browser upload filter`).toBe(true);
expect(cli.has(name), `${name} missing from the analyzer ignore list`).toBe(true);
}
});
});