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([]); + }); +});