From f71e718d4da34f14b3097221e671b2afe97c5a0e Mon Sep 17 00:00:00 2001 From: ReidenXerx Date: Thu, 20 Aug 2026 16:16:39 +0300 Subject: [PATCH] fix(ingestion): ignore emitted Next.js build output, and restore the dead public/build entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 `/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) --- gitnexus/src/config/ignore-service.ts | 38 ++++++++- .../test/unit/ignore-build-output.test.ts | 77 +++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 gitnexus/test/unit/ignore-build-output.test.ts diff --git a/gitnexus/src/config/ignore-service.ts b/gitnexus/src/config/ignore-service.ts index 163998a8e..c7efd8fef 100644 --- a/gitnexus/src/config/ignore-service.ts +++ b/gitnexus/src/config/ignore-service.ts @@ -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 `/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; diff --git a/gitnexus/test/unit/ignore-build-output.test.ts b/gitnexus/test/unit/ignore-build-output.test.ts new file mode 100644 index 000000000..8a1fbe354 --- /dev/null +++ b/gitnexus/test/unit/ignore-build-output.test.ts @@ -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 + * `/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([]); + }); +});