fix(ingestion): ignore emitted Next.js build output, and restore the dead public/build entry

`DEFAULT_IGNORE_LIST` contained `.next` — the build CACHE — but not `_next`, the
emitted OUTPUT, which are different directories. A Capacitor/Cordova shell copies
a built Next.js bundle to `<platform>/app/src/main/assets/public/_next/static/`,
where no path segment hits the list, so the walker indexed the bundle as source.
On a real mobile-wrapped Next.js app that was 256 minified chunk files, and every
`Route` node the repo produced pointed at a webpack chunk rather than at source.

The filename heuristics did not catch them either: they match `.bundle.`,
`.chunk.`, `.generated.` and `.d.ts`, while Next.js emits hashed names like
`6862-9d1cdcb99f169a06.js`.

Separately, `'public/build'` had been sitting in `DEFAULT_IGNORE_LIST` matching
nothing at all. That set is tested one path SEGMENT at a time, and is also read
by `isHardcodedIgnoredDirectory(name)`, which receives a bare directory name —
so a slash-containing member can never compare equal to anything. Rather than
delete the entry and lose its intent, multi-segment paths now live in
`DEFAULT_IGNORED_PATH_FRAGMENTS` and are matched against the whole path, so
Remix / Laravel Mix asset output is ignored as originally intended.

A guard test pins the invariant that made the dead entry possible: no member of
the name set may contain a slash.

Measured against a production Capacitor-wrapped Next.js app (1558 JS/TS files on
disk): 256 newly ignored, none of them under `src/`, and zero files that were
previously ignored become indexed.

Closes #3007

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ReidenXerx 2026-08-20 16:16:39 +03:00
parent aac7515d2a
commit f71e718d4d
2 changed files with 114 additions and 1 deletions

View file

@ -54,13 +54,19 @@ const DEFAULT_IGNORE_LIST = new Set([
'obj',
'target', // Java/Rust
'.next',
// `.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).
'_next',
'.nuxt',
'.output',
'.vercel',
'.netlify',
'.serverless',
'_build',
'public/build',
'.parcel-cache',
'.turbo',
'.svelte-kit',
@ -106,6 +112,31 @@ 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}`),
);
}
const IGNORED_EXTENSIONS = new Set([
// Images
'.png',
@ -306,6 +337,11 @@ 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

@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { shouldIgnorePath } from '../../src/config/ignore-service.js';
/**
* Emitted build output must not be indexed as source (#3007).
*
* `.next` (the build cache) was listed but `_next` (the emitted output) was
* not, so a Capacitor/Cordova shell that copies a Next.js bundle into
* `<platform>/app/src/main/assets/public/_next/static/` had 40% of its indexed
* files come from minified chunks — and every `Route` node the repo produced
* pointed at a webpack bundle instead of source.
*/
describe('build-output ignores', () => {
it('ignores emitted _next output, including the Capacitor/Cordova copy', () => {
expect(shouldIgnorePath('_next/static/chunks/main.js')).toBe(true);
expect(shouldIgnorePath('.next/server/app/page.js')).toBe(true);
expect(
shouldIgnorePath(
'android/app/src/main/assets/public/_next/static/chunks/6862-9d1cdcb99f169a06.js',
),
).toBe(true);
expect(shouldIgnorePath('ios/App/App/public/_next/static/chunks/framework-abc123.js')).toBe(
true,
);
});
it('does not ignore ordinary source that merely mentions next', () => {
expect(shouldIgnorePath('src/next-steps.ts')).toBe(false);
expect(shouldIgnorePath('src/nextConfig/index.ts')).toBe(false);
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.
expect(shouldIgnorePath('public/build/entry.client.js')).toBe(true);
expect(shouldIgnorePath('apps/web/public/build/manifest.js')).toBe(true);
});
it('does not ignore public/ or build/-adjacent source outside that pair', () => {
expect(shouldIgnorePath('public/favicon-loader.ts')).toBe(false);
expect(shouldIgnorePath('src/public/api.ts')).toBe(false);
});
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.
const source = fs.readFileSync(
path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'src',
'config',
'ignore-service.ts',
),
'utf8',
);
const block = source.slice(
source.indexOf('const DEFAULT_IGNORE_LIST = new Set(['),
source.indexOf(']);', source.indexOf('const DEFAULT_IGNORE_LIST = new Set([')),
);
// Parse ENTRY LINES only. Scanning the raw block would also read prose in
// the comments (an apostrophe in "Next.js's" opens a spurious quote).
const entries = block
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith("'"))
.map((line) => line.slice(1, line.indexOf("'", 1)));
expect(entries.length).toBeGreaterThan(20); // parsed something real
expect(entries.filter((e) => e.includes('/'))).toEqual([]);
});
});