From 71e0ac830e0fd18a1cd5a03d9d1c9f4388ebcbeb Mon Sep 17 00:00:00 2001 From: Andrey Rychkov Date: Thu, 22 Oct 2026 17:41:00 +0000 Subject: [PATCH] fix: fill in every star of a target, and trim slashes without a regex CodeQL's default setup reported four alerts. In package.json `imports` and `exports`, Node.js fills in every `*` of a target, but the scan replaced only the first, so a target such as "./desks/*/*-fixtures.ts" pointed to a file that doesn't exist and the import wasn't followed. tsconfig `paths` gets `replaceAll` too: TypeScript allows one `*` there (error 5062), so nothing changes for a valid config, and the same code reads the same in all three places. The folder hint trimmed trailing slashes with /[\\/]+$/, which backtracks over every run of slashes: 200,000 slashes took 28.7 s. A loop takes 22 ms. Chose: replaceAll for tsconfig paths too (alternative: dismiss that alert as a false positive), so no alert needs dismissing by hand and the three lookups match. --- .changeset/star-and-slash-fixes.md | 9 +++++++++ src/resolve.ts | 8 +++++--- src/scan.ts | 5 ++++- tests/resolve.test.ts | 26 ++++++++++++++++++++++++++ tests/scan.test.ts | 7 +++++++ 5 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 .changeset/star-and-slash-fixes.md diff --git a/.changeset/star-and-slash-fixes.md b/.changeset/star-and-slash-fixes.md new file mode 100644 index 0000000..7836410 --- /dev/null +++ b/.changeset/star-and-slash-fixes.md @@ -0,0 +1,9 @@ +--- +'test-inventory': patch +--- + +A `#` import or workspace package export whose target repeats the `*` of its pattern, as in +`"#desks/*": "./desks/*/*-fixtures.ts"`, is followed to the file Node.js loads, with every `*` filled in. Only the +first was filled in before, so the import wasn't followed and its tests were missing, with an `unresolved-test-import` +error. A folder given as the pattern with a long run of slashes, as in `tests////unit`, no longer slows the scan down +while the hint for it is written. diff --git a/src/resolve.ts b/src/resolve.ts index 773403d..88e0b9b 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -203,7 +203,7 @@ function aliasFiles(specifier: string, options: PathOptions): string[] | null { const match = options.paths ? matchPattern(Object.keys(options.paths), specifier) : null; if (!match || !options.paths) return null; return targetStrings(options.paths[match.key]).map((target) => - path.resolve(options.pathsBase, target.replaceAll('${configDir}', options.configDir).replace('*', match.star)), + path.resolve(options.pathsBase, target.replaceAll('${configDir}', options.configDir).replaceAll('*', match.star)), ); } @@ -215,7 +215,9 @@ function subpathImportFiles(specifier: string, directory: string): string[] { if (manifest) { const match = imports ? matchPattern(Object.keys(imports), specifier) : null; if (!match || !imports) return []; - return targetStrings(imports[match.key]).map((target) => path.resolve(folder, target.replace('*', match.star))); + return targetStrings(imports[match.key]).map((target) => + path.resolve(folder, target.replaceAll('*', match.star)), + ); } if (path.dirname(folder) === folder) return []; } @@ -248,7 +250,7 @@ function packageEntry(folder: string, subpath: string): { file: string } | { rea let targets: string[]; if (isSubpathMap) { const match = matchPattern(Object.keys(map), subpath); - targets = match ? targetStrings(map[match.key]).map((target) => target.replace('*', match.star)) : []; + targets = match ? targetStrings(map[match.key]).map((target) => target.replaceAll('*', match.star)) : []; } else { targets = subpath === '.' ? targetStrings(exports) : []; } diff --git a/src/scan.ts b/src/scan.ts index f86d19d..2061748 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -67,7 +67,10 @@ function folderHint(root: string, patterns: string[], framework: Framework): str return existsSync(target) && statSync(target).isDirectory(); }); if (folder === undefined) return ''; - const example = `${folder.replace(/[\\/]+$/, '')}/**/*.${framework === 'playwright' ? 'spec' : 'test'}.ts`; + // A loop, as a regular expression for trailing slashes takes quadratic time on a long run of slashes. + let end = folder.length; + while (end > 0 && '/\\'.includes(folder[end - 1])) end -= 1; + const example = `${folder.slice(0, end)}/**/*.${framework === 'playwright' ? 'spec' : 'test'}.ts`; return ` "${folder}" is a folder; to scan the test files in it, use a pattern such as "${example}".`; } diff --git a/tests/resolve.test.ts b/tests/resolve.test.ts index 0ee4090..fb71af5 100644 --- a/tests/resolve.test.ts +++ b/tests/resolve.test.ts @@ -230,6 +230,19 @@ describe('a custom test object imported through package.json imports', () => { ]); }); + it('is followed through a pattern whose target repeats the star, as Node fills in each one', async () => { + const inventory = await scanSpec({ + 'package.json': JSON.stringify({ + devDependencies: { '@playwright/test': '1.63.0' }, + imports: { '#desks/*': './desks/*/*-fixtures.ts' }, + }), + 'desks/equities/equities-fixtures.ts': FIXTURES, + 'tests/portfolio.spec.ts': spec('#desks/equities'), + }); + expect(inventory.summary.testCount).toBe(1); + expect(inventory.diagnostics).toEqual([]); + }); + it('is reported when no package.json is found above the file', async () => { const inventory = await scanSpec({ 'tests/portfolio.spec.ts': spec('#fixtures') }, 'playwright'); expect(messages(inventory)).toContain( @@ -280,6 +293,19 @@ describe('a custom test object from a workspace package', () => { expect(inventory.diagnostics).toEqual([]); }); + it('is followed through a subpath pattern whose target repeats the star, as Node fills in each one', async () => { + const root = workspace( + { exports: { './*': './src/*/*-fixtures.ts' } }, + { + 'packages/test-utils/src/equities/equities-fixtures.ts': FIXTURES, + 'tests/portfolio.spec.ts': spec('@trading/test-utils/equities'), + }, + ); + const inventory = await scan({ root, patterns: ['tests/*.spec.ts'], framework: 'playwright' }); + expect(inventory.summary.testCount).toBe(1); + expect(inventory.diagnostics).toEqual([]); + }); + it.each([ [ 'only built JavaScript with no source next to it', diff --git a/tests/scan.test.ts b/tests/scan.test.ts index cb70c22..0af8a8a 100644 --- a/tests/scan.test.ts +++ b/tests/scan.test.ts @@ -146,6 +146,13 @@ describe('scan', () => { ]); }); + it('says how to scan a folder given with a long run of slashes, without slowing down', async () => { + const root = project({ 'package.json': PLAYWRIGHT_PACKAGE, 'tests/unit/orders.spec.ts': SPEC }); + const inventory = await scanProject(root, { patterns: [`tests${'/'.repeat(200_000)}unit`] }); + expect(inventory.diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['no-files-matched']); + expect(inventory.diagnostics[0].message).toMatch(/use a pattern such as "tests\/+unit\/\*\*\/\*\.spec\.ts"\.$/); + }); + it('does not read files inside a folder named like a test file', async () => { const root = project({ 'package.json': PLAYWRIGHT_PACKAGE,