Skip to content

Commit 52d6e80

Browse files
committed
fix(scripts): do not assert on generated output in the specifier audit
CI red on a fresh checkout, green locally — the tell that the audit was depending on build state rather than on source. apps/docs/lib/source.ts imports '@/.source/server'. apps/docs maps '@/.source/*' at './.source/*', which fumadocs-mdx generates and apps/docs/.gitignore excludes. It exists on any machine that has built the docs and is absent from CI's checkout, so the audit reported a valid import as unresolvable. A path landing in output the scanner itself refuses to read as source — node_modules, a build directory, any dot-directory — is now treated as unverifiable rather than missing. That is the consistent rule: if we do not scan it as source, we cannot assert on its presence, and asserting anyway makes the verdict depend on build order. Applied to all three resolution paths (relative, tsconfig paths, exports map), with a GENERATED sentinel keeping 'matched but generated' distinct from 'matched and genuinely missing'. Only the repo-relative portion is inspected. Checking the absolute path would match the '.claude/worktrees/...' a git worktree lives under and silently skip every specifier in the repo. Verified both directions: passes with apps/docs/.source moved away (CI's state), and still catches a require('@/blocks/still-not-real') planted in tools/params.ts.
1 parent 3f7c152 commit 52d6e80

1 file changed

Lines changed: 44 additions & 8 deletions

File tree

scripts/check-import-specifiers.ts

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,26 @@ function walk(dir: string, acc: string[] = []): string[] {
9898
return acc
9999
}
100100

101+
/**
102+
* True when a path lands in output the scanner itself refuses to read as source:
103+
* `node_modules`, a build directory, or any dot-directory.
104+
*
105+
* These are generated and gitignored, produced by a build step that has not necessarily run
106+
* yet — `apps/docs/.source` is emitted by fumadocs-mdx, so `@/.source/server` resolves on a
107+
* machine that has built the docs and is absent from a fresh CI checkout. Asserting on them
108+
* makes the verdict depend on build order rather than on the source, which is exactly the
109+
* kind of flake that gets a CI gate switched off.
110+
*
111+
* Only the repo-relative portion is inspected. The absolute path can itself sit under a
112+
* dot-directory — a git worktree lives in `.claude/worktrees/…` — which would otherwise make
113+
* every specifier in the repo look generated.
114+
*/
115+
function isGeneratedPath(absolute: string): boolean {
116+
const rel = relative(ROOT, absolute)
117+
if (rel.startsWith('..')) return true
118+
return rel.split('/').some((segment) => segment.startsWith('.') || SKIP_DIRS.has(segment))
119+
}
120+
101121
function isFile(p: string): boolean {
102122
try {
103123
return statSync(p).isFile()
@@ -185,14 +205,24 @@ function workspaceFor(file: string): Workspace | undefined {
185205
return workspaces.find((w) => file.startsWith(`${w.dir}/`))
186206
}
187207

188-
/** Resolve through the owning workspace's tsconfig `paths`, or null if no pattern matches. */
189-
function resolveViaPaths(spec: string, importer: string): string | null | undefined {
208+
/**
209+
* Sentinel for "a tsconfig path matched, but every candidate target is generated output".
210+
* Distinct from `null` (matched and genuinely missing) and `undefined` (no pattern matched).
211+
*/
212+
const GENERATED = Symbol('generated')
213+
214+
/** Resolve through the owning workspace's tsconfig `paths`. */
215+
function resolveViaPaths(
216+
spec: string,
217+
importer: string
218+
): string | null | undefined | typeof GENERATED {
190219
const ws = workspaceFor(importer)
191220
if (!ws) return undefined
192221
for (const { prefix, suffix, wildcard, targets } of ws.paths) {
193222
if (!spec.startsWith(prefix)) continue
194223
if (!wildcard) {
195224
if (spec !== prefix) continue
225+
if (targets.every(isGeneratedPath)) return GENERATED
196226
for (const t of targets) {
197227
const hit = probe(t)
198228
if (hit) return hit
@@ -201,8 +231,10 @@ function resolveViaPaths(spec: string, importer: string): string | null | undefi
201231
}
202232
if (suffix && !spec.endsWith(suffix)) continue
203233
const middle = spec.slice(prefix.length, suffix ? spec.length - suffix.length : undefined)
204-
for (const t of targets) {
205-
const hit = probe(t.replaceAll('*', middle))
234+
const filled = targets.map((t) => t.replaceAll('*', middle))
235+
if (filled.every(isGeneratedPath)) return GENERATED
236+
for (const t of filled) {
237+
const hit = probe(t)
206238
if (hit) return hit
207239
}
208240
return null
@@ -236,13 +268,14 @@ type Outcome = { ok: true } | { ok: false; reason: string }
236268

237269
function resolveSpecifier(spec: string, importer: string): Outcome | null {
238270
if (spec.startsWith('.')) {
239-
return probe(resolve(dirname(importer), spec))
240-
? { ok: true }
241-
: { ok: false, reason: 'no file at that path' }
271+
const base = resolve(dirname(importer), spec)
272+
if (isGeneratedPath(base)) return null
273+
return probe(base) ? { ok: true } : { ok: false, reason: 'no file at that path' }
242274
}
243275

244276
// tsconfig `paths` first — it legitimately overrides a package's exports map.
245277
const viaPaths = resolveViaPaths(spec, importer)
278+
if (viaPaths === GENERATED) return null
246279
if (viaPaths) return { ok: true }
247280
if (viaPaths === null) {
248281
return {
@@ -262,6 +295,7 @@ function resolveSpecifier(spec: string, importer: string): Outcome | null {
262295

263296
const exact = exports.get(key)
264297
if (exact) {
298+
if (isGeneratedPath(exact)) return null
265299
return probe(exact) ? { ok: true } : { ok: false, reason: `${key} points at a missing file` }
266300
}
267301

@@ -273,7 +307,9 @@ function resolveSpecifier(spec: string, importer: string): Outcome | null {
273307
const tail = pattern.slice(star + 1)
274308
if (!key.startsWith(head) || !key.endsWith(tail)) continue
275309
const middle = key.slice(head.length, key.length - tail.length)
276-
if (probe(target.replaceAll('*', middle))) return { ok: true }
310+
const filled = target.replaceAll('*', middle)
311+
if (isGeneratedPath(filled)) return null
312+
if (probe(filled)) return { ok: true }
277313
return { ok: false, reason: `${pkg}'s '${pattern}' export has no file for '${key}'` }
278314
}
279315

0 commit comments

Comments
 (0)