fix(ingestion): drop the inert public/build machinery, discriminate _next by segment, ignore _next on the web upload path

Addresses the review findings on #3018.

Remove DEFAULT_IGNORED_PATH_FRAGMENTS, hasIgnoredPathFragment and its
shouldIgnorePath branch. The mechanism was correct but unreachable: all four
of its match forms put a `/` or end-of-string on both sides of `build`, so a
fragment match strictly implies `build` is a whole segment, which the
per-segment DEFAULT_IGNORE_LIST loop already catches one branch earlier.
Measured over 768,420 generated paths: 65,506 fragment matches, 0 of them
decisive, 0 implication violations. `'public/build'` really was an inert
member of the name set, but its paths were never unignored — bare `'build'`
covered them on both sides — so the entry is deleted rather than relocated,
which is the other option #3007 offered. The slash-free guard test stays; it
is what stops the next slash-bearing entry from dying the same way.

Add negative cases pinning that `_next` matches as a whole path segment. The
previous suite could not tell a segment rule from a substring rule: replacing
the entry with `normalizedPath.includes('_next')` passed all five tests, while
eating `src/_nextgen/index.ts`.

Rename the public/build test to what it actually pins — that deleting the
inert entry changed no behavior — since it is green on both sides by design.

Add `_next` to the web upload filter's EXCLUDED_DIRS. That list is the live
browser ingestion path (RepoAnalyzer -> filterRepoFiles -> /api/analyze/upload)
and had `.next` but not `_next`, so a Capacitor-wrapped Next.js app uploaded
its entire minified tree against the server's 20000-file / 250MB caps for
files the analyzer then discards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-08-27 08:09:42 +00:00
parent d867ef031c
commit cb5e9727d0
4 changed files with 45 additions and 34 deletions

View file

@ -31,6 +31,22 @@ describe('filterRepoFiles', () => {
expect(r.droppedCount).toBe(4);
});
it('excludes emitted _next output, including the Capacitor/Cordova copy', () => {
// `.next` was listed but `_next` was not, so a mobile-wrapped Next.js app
// uploaded its whole minified bundle against the server's caps for files
// the analyzer then discards anyway (#3007).
const input = [
f('repo/android/app/src/main/assets/public/_next/static/chunks/main.js'),
f('repo/ios/App/App/public/_next/static/chunks/framework.js'),
f('repo/_next/static/chunks/x.js'),
f('repo/src/index.ts'),
f('repo/src/_nextgen/index.ts'),
];
const r = filterRepoFiles(input);
expect(r.manifest).toEqual(['repo/src/index.ts', 'repo/src/_nextgen/index.ts']);
expect(r.droppedCount).toBe(3);
});
it('drops files over the per-file size cap', () => {
const input = [f('repo/big.bin', MAX_FILE_BYTES + 1), f('repo/small.ts', 10)];
const r = filterRepoFiles(input);

View file

@ -22,6 +22,12 @@ export const EXCLUDED_DIRS = new Set([
'build',
'out',
'.next',
// `.next` is the build CACHE, `_next` the EMITTED output — different
// directories. A Capacitor/Cordova shell leaves the emitted bundle at
// `<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).
'_next',
'.nuxt',
'.cache',
'coverage',

View file

@ -71,6 +71,12 @@ const DEFAULT_IGNORE_LIST = new Set([
'.netlify',
'.serverless',
'_build',
// `'public/build'` used to sit here. This set is tested one path SEGMENT at a
// 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.
'.parcel-cache',
'.turbo',
'.svelte-kit',
@ -117,30 +123,6 @@ const DEFAULT_IGNORE_LIST = new Set([
'__snapshots__',
]);
/**
* Multi-segment paths to ignore, matched against the whole POSIX path.
*
* These CANNOT live in {@link DEFAULT_IGNORE_LIST}: that set is tested one path
* SEGMENT at a time (`parts.some(p => DEFAULT_IGNORE_LIST.has(p))`) and is also
* exposed through `isHardcodedIgnoredDirectory(name)`, which receives a bare
* directory name. A slash-containing member can never equal a single segment,
* so `'public/build'` sat in that set matching nothing at all until #3007. A
* guard test pins the invariant that the name set stays slash-free.
*/
const DEFAULT_IGNORED_PATH_FRAGMENTS: readonly string[] = [
'public/build', // Remix / Laravel Mix compiled asset output
];
/** True when `normalizedPath` contains any ignored multi-segment fragment. */
function hasIgnoredPathFragment(normalizedPath: string): boolean {
return DEFAULT_IGNORED_PATH_FRAGMENTS.some(
(fragment) =>
normalizedPath === fragment ||
normalizedPath.startsWith(`${fragment}/`) ||
normalizedPath.includes(`/${fragment}/`) ||
normalizedPath.endsWith(`/${fragment}`),
);
}
// Ambiguous names that conventionally denote generated artifacts only at the
// repository root. Nested directories with these names are frequently source
// modules (for example apps/web/src/env or packages/api/generated).
@ -353,11 +335,6 @@ export const shouldIgnorePath = (filePath: string): boolean => {
}
}
// Multi-segment entries cannot be matched by the per-segment loop above.
if (hasIgnoredPathFragment(normalizedPath)) {
return true;
}
// Check exact filename matches
if (IGNORED_FILES.has(fileName) || IGNORED_FILES.has(fileNameLower)) {
return true;

View file

@ -34,9 +34,20 @@ describe('build-output ignores', () => {
expect(shouldIgnorePath('packages/next-auth/src/index.ts')).toBe(false);
});
it('ignores public/build, which never matched while it sat in the name set', () => {
// A slash-containing member of DEFAULT_IGNORE_LIST can never equal a single
// path segment, so this entry matched nothing at all before #3007.
it('matches _next as a whole segment, not as a substring', () => {
// Without these, `normalizedPath.includes('_next')` would satisfy every
// other assertion in this file — the suite could not tell a segment rule
// from a substring rule, and a substring rule would eat real source.
expect(shouldIgnorePath('src/_nextgen/index.ts')).toBe(false);
expect(shouldIgnorePath('packages/my_next/src/index.ts')).toBe(false);
expect(shouldIgnorePath('src/prefix_next.ts')).toBe(false);
});
it('keeps public/build ignored after the inert name-set entry was removed', () => {
// NOT a regression test for new behavior — it pins that DELETING the inert
// `'public/build'` entry changed nothing, because bare `'build'` matches
// these as an ordinary segment and always did. Green on both sides of the
// change by design; that is the point.
expect(shouldIgnorePath('public/build/entry.client.js')).toBe(true);
expect(shouldIgnorePath('apps/web/public/build/manifest.js')).toBe(true);
});
@ -47,8 +58,9 @@ describe('build-output ignores', () => {
});
it('keeps the name set free of slashes so a fragment cannot silently die', () => {
// The invariant that makes the bug above impossible to reintroduce: entries
// needing a slash belong in DEFAULT_IGNORED_PATH_FRAGMENTS.
// The invariant that makes the inert entry impossible to reintroduce: this
// set is matched one path segment at a time, so a member containing a slash
// is dead on arrival and must be expressed some other way.
const source = fs.readFileSync(
path.resolve(
path.dirname(fileURLToPath(import.meta.url)),