@promptci/core 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/dist/action-pinning.d.ts +37 -0
  2. package/dist/action-pinning.d.ts.map +1 -0
  3. package/dist/action-pinning.js +277 -0
  4. package/dist/action-pinning.js.map +1 -0
  5. package/dist/agent-practices.d.ts +1 -1
  6. package/dist/agent-practices.d.ts.map +1 -1
  7. package/dist/agent-practices.js +107 -43
  8. package/dist/agent-practices.js.map +1 -1
  9. package/dist/ai-config.d.ts +42 -3
  10. package/dist/ai-config.d.ts.map +1 -1
  11. package/dist/ai-config.js +210 -12
  12. package/dist/ai-config.js.map +1 -1
  13. package/dist/alias-files.d.ts +33 -0
  14. package/dist/alias-files.d.ts.map +1 -0
  15. package/dist/alias-files.js +60 -0
  16. package/dist/alias-files.js.map +1 -0
  17. package/dist/buried-critical.d.ts.map +1 -1
  18. package/dist/buried-critical.js +4 -9
  19. package/dist/buried-critical.js.map +1 -1
  20. package/dist/canonical-owner.d.ts.map +1 -1
  21. package/dist/canonical-owner.js +45 -25
  22. package/dist/canonical-owner.js.map +1 -1
  23. package/dist/ci-alignment.d.ts.map +1 -1
  24. package/dist/ci-alignment.js +3 -2
  25. package/dist/ci-alignment.js.map +1 -1
  26. package/dist/command-validity.d.ts +16 -0
  27. package/dist/command-validity.d.ts.map +1 -1
  28. package/dist/command-validity.js +337 -63
  29. package/dist/command-validity.js.map +1 -1
  30. package/dist/conflicts.d.ts.map +1 -1
  31. package/dist/conflicts.js +3 -2
  32. package/dist/conflicts.js.map +1 -1
  33. package/dist/context-bloat.d.ts +1 -1
  34. package/dist/context-bloat.d.ts.map +1 -1
  35. package/dist/context-bloat.js +18 -7
  36. package/dist/context-bloat.js.map +1 -1
  37. package/dist/context-optimizer.d.ts.map +1 -1
  38. package/dist/context-optimizer.js +5 -2
  39. package/dist/context-optimizer.js.map +1 -1
  40. package/dist/custom-rules.d.ts.map +1 -1
  41. package/dist/custom-rules.js +6 -1
  42. package/dist/custom-rules.js.map +1 -1
  43. package/dist/dead-references.d.ts +16 -2
  44. package/dist/dead-references.d.ts.map +1 -1
  45. package/dist/dead-references.js +635 -77
  46. package/dist/dead-references.js.map +1 -1
  47. package/dist/dependency-staleness.d.ts +35 -0
  48. package/dist/dependency-staleness.d.ts.map +1 -0
  49. package/dist/dependency-staleness.js +1001 -0
  50. package/dist/dependency-staleness.js.map +1 -0
  51. package/dist/detectors.d.ts.map +1 -1
  52. package/dist/detectors.js +31 -6
  53. package/dist/detectors.js.map +1 -1
  54. package/dist/duplicates.d.ts.map +1 -1
  55. package/dist/duplicates.js +5 -4
  56. package/dist/duplicates.js.map +1 -1
  57. package/dist/evidence.d.ts +11 -0
  58. package/dist/evidence.d.ts.map +1 -1
  59. package/dist/evidence.js +40 -0
  60. package/dist/evidence.js.map +1 -1
  61. package/dist/finding-id.d.ts +21 -0
  62. package/dist/finding-id.d.ts.map +1 -0
  63. package/dist/finding-id.js +31 -0
  64. package/dist/finding-id.js.map +1 -0
  65. package/dist/frameworks/dotnet.js +2 -2
  66. package/dist/frameworks/dotnet.js.map +1 -1
  67. package/dist/frameworks/rust.d.ts.map +1 -1
  68. package/dist/frameworks/rust.js +2 -1
  69. package/dist/frameworks/rust.js.map +1 -1
  70. package/dist/git-symlinks.d.ts +30 -0
  71. package/dist/git-symlinks.d.ts.map +1 -0
  72. package/dist/git-symlinks.js +161 -0
  73. package/dist/git-symlinks.js.map +1 -0
  74. package/dist/gitignore.d.ts +22 -0
  75. package/dist/gitignore.d.ts.map +1 -0
  76. package/dist/gitignore.js +157 -0
  77. package/dist/gitignore.js.map +1 -0
  78. package/dist/health-score.d.ts.map +1 -1
  79. package/dist/health-score.js +34 -5
  80. package/dist/health-score.js.map +1 -1
  81. package/dist/index.d.ts +9 -3
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +5 -2
  84. package/dist/index.js.map +1 -1
  85. package/dist/makefile.d.ts +37 -0
  86. package/dist/makefile.d.ts.map +1 -0
  87. package/dist/makefile.js +156 -0
  88. package/dist/makefile.js.map +1 -0
  89. package/dist/manifest-consistency.d.ts.map +1 -1
  90. package/dist/manifest-consistency.js +52 -8
  91. package/dist/manifest-consistency.js.map +1 -1
  92. package/dist/negative-instructions.d.ts.map +1 -1
  93. package/dist/negative-instructions.js +4 -9
  94. package/dist/negative-instructions.js.map +1 -1
  95. package/dist/path-containment.d.ts +29 -0
  96. package/dist/path-containment.d.ts.map +1 -1
  97. package/dist/path-containment.js +70 -0
  98. package/dist/path-containment.js.map +1 -1
  99. package/dist/prompt-cache.d.ts.map +1 -1
  100. package/dist/prompt-cache.js +8 -6
  101. package/dist/prompt-cache.js.map +1 -1
  102. package/dist/repo-context.d.ts +53 -0
  103. package/dist/repo-context.d.ts.map +1 -1
  104. package/dist/repo-context.js +134 -11
  105. package/dist/repo-context.js.map +1 -1
  106. package/dist/report.d.ts +7 -0
  107. package/dist/report.d.ts.map +1 -1
  108. package/dist/report.js +36 -7
  109. package/dist/report.js.map +1 -1
  110. package/dist/scan.d.ts.map +1 -1
  111. package/dist/scan.js +41 -2
  112. package/dist/scan.js.map +1 -1
  113. package/dist/scanner.d.ts +49 -1
  114. package/dist/scanner.d.ts.map +1 -1
  115. package/dist/scanner.js +323 -38
  116. package/dist/scanner.js.map +1 -1
  117. package/dist/security-pack.d.ts.map +1 -1
  118. package/dist/security-pack.js +6 -4
  119. package/dist/security-pack.js.map +1 -1
  120. package/dist/skill-supply-chain.d.ts +132 -0
  121. package/dist/skill-supply-chain.d.ts.map +1 -0
  122. package/dist/skill-supply-chain.js +2954 -0
  123. package/dist/skill-supply-chain.js.map +1 -0
  124. package/dist/skills-detector.d.ts +7 -3
  125. package/dist/skills-detector.d.ts.map +1 -1
  126. package/dist/skills-detector.js +98 -22
  127. package/dist/skills-detector.js.map +1 -1
  128. package/dist/stale-instructions.d.ts.map +1 -1
  129. package/dist/stale-instructions.js +2 -1
  130. package/dist/stale-instructions.js.map +1 -1
  131. package/dist/suppression.d.ts +2 -0
  132. package/dist/suppression.d.ts.map +1 -1
  133. package/dist/suppression.js +5 -2
  134. package/dist/suppression.js.map +1 -1
  135. package/dist/types.d.ts +35 -2
  136. package/dist/types.d.ts.map +1 -1
  137. package/dist/types.js +22 -0
  138. package/dist/types.js.map +1 -1
  139. package/dist/vague-guidance.d.ts.map +1 -1
  140. package/dist/vague-guidance.js +2 -1
  141. package/dist/vague-guidance.js.map +1 -1
  142. package/dist/windsurf-detector.d.ts.map +1 -1
  143. package/dist/windsurf-detector.js +15 -6
  144. package/dist/windsurf-detector.js.map +1 -1
  145. package/dist/within-section-dedup.d.ts.map +1 -1
  146. package/dist/within-section-dedup.js +4 -9
  147. package/dist/within-section-dedup.js.map +1 -1
  148. package/dist/workspace-manifests.d.ts +80 -0
  149. package/dist/workspace-manifests.d.ts.map +1 -0
  150. package/dist/workspace-manifests.js +333 -0
  151. package/dist/workspace-manifests.js.map +1 -0
  152. package/package.json +1 -1
@@ -7,16 +7,32 @@
7
7
  * - Reference-style links: [text][ref] with a `[ref]: path` definition
8
8
  * - @-file refs: @AGENTS.md | @docs/guide.md
9
9
  *
10
+ * - Directory / extension-less paths (pcic-2b6.8): links like
11
+ * [api](./src/api/) and code spans like `apps/admin/` or `src/payments`
12
+ *
10
13
  * External URLs (http/https), anchor-only links (#section), and mailto:
11
- * are always skipped. Only paths with a recognisable file extension are
12
- * checked to avoid false-positiving on badge shields, npm package names, etc.
14
+ * are always skipped. File paths need a recognisable extension; a directory or
15
+ * extension-less path in a code span is only checked when its FIRST segment
16
+ * exists in the repository (so `owner/repo`, `origin/main`, `@scope/pkg` and
17
+ * paths describing some other project stay silent); one three or more levels
18
+ * below its first segment also needs its second segment to exist. A bare
19
+ * filename in a code span (`ci.yml`) resolves against any committed file of
20
+ * that name in the repository.
21
+ *
22
+ * Every lookup stays inside the scan root and matches names with exact case
23
+ * (pcic-xhzb, see RepoTree): references that leave the root are not checked,
24
+ * and a directory that cannot be listed leaves its references unchecked and
25
+ * named in one "check incomplete" warning instead of reported as broken.
13
26
  *
14
27
  * Findings are heuristic — wording is intentionally cautious.
15
28
  */
16
29
  import * as fs from 'node:fs';
17
30
  import * as path from 'node:path';
18
31
  import * as crypto from 'node:crypto';
19
- import { fencedBlocks } from './markdown-fences.js';
32
+ import fg from 'fast-glob';
33
+ import { fencedBlocks, scanFencedLines } from './markdown-fences.js';
34
+ import { createGitIgnoreChecker } from './gitignore.js';
35
+ import { isWithinRoot, realPath } from './path-containment.js';
20
36
  // ── Patterns ──────────────────────────────────────────────────────────────────
21
37
  /** Matches markdown inline links: [label](target) — captures target (may include a title). */
22
38
  const MD_LINK_RE = /\[(?:[^\]]*)\]\(([^)]+)\)/g;
@@ -42,6 +58,37 @@ const AT_FILE_RE = /(?<!\w)@((?:\.{1,2}\/)?[\w./\\-]+\.(?:md|ts|js|mjs|cjs|tsx|j
42
58
  * after the extension but excluded from the captured path.
43
59
  */
44
60
  const BACKTICK_PATH_RE = /`([^\s`\n]+\.(?:md|sh|py|ts|js|tsx|jsx|mjs|cjs|json|yml|yaml|toml|cs|go|rs|txt|lock|env|png|jpg|jpeg|gif|svg|webp|pdf))(?::\d+(?::\d+)?)?`/g;
61
+ /**
62
+ * pcic-2b6.8: code spans holding a directory or extension-less repo path —
63
+ * `src/payments/`, `apps/admin`, `./src/api/`. Path characters only (Next.js
64
+ * route segments like `(auth)` and `[id]` included); anything with a space,
65
+ * colon, `$`, `*`, `~` or `<…>` is a command, URL, variable, glob or
66
+ * placeholder and never matches. At least two segments are required: a lone
67
+ * `docs/` or `./scripts` names no location specific enough to call stale.
68
+ */
69
+ const BACKTICK_DIR_RE = /`((?:\.\/)?[\w@.()[\]-]+(?:\/[\w@.()[\]-]+)+\/?)`/g;
70
+ /**
71
+ * A line talking about git branches, HTTP routes or URLs: its slash-shaped
72
+ * code spans (`docs/update-readme`, `api/v1/users`) are names, not paths.
73
+ */
74
+ const NON_PATH_CONTEXT_RE = /\b(?:branch(?:es)?|checkout|rebase|merge|cherry-pick|route[sd]?|routing|endpoints?|urls?|uris?|http\s+(?:get|post|put|patch|delete))\b/i;
75
+ /**
76
+ * Conventional git branch prefixes. A span starting with one of these that has
77
+ * no trailing slash (`docs/update-readme`, `test/flaky-fix`) is more likely a
78
+ * branch than a directory, so only the explicit directory form (`docs/guides/`)
79
+ * is checked.
80
+ */
81
+ const BRANCH_PREFIXES = new Set([
82
+ 'feat', 'feature', 'features', 'fix', 'bugfix', 'hotfix', 'chore', 'docs', 'doc', 'refactor',
83
+ 'test', 'tests', 'ci', 'perf', 'build', 'release', 'releases', 'style', 'wip', 'dependabot',
84
+ ]);
85
+ /**
86
+ * Generated output and dependency directories: never committed, so a path
87
+ * under one says nothing about the repository's current layout.
88
+ */
89
+ const OUTPUT_DIRS = new Set([
90
+ 'dist', 'build', 'out', '.next', 'coverage', 'target', 'bin', 'obj', 'node_modules', '.turbo',
91
+ ]);
45
92
  /**
46
93
  * BUG-004: Bullet-list bare file paths in Repository Layout / Project Structure sections.
47
94
  * Catches lines like:
@@ -169,6 +216,10 @@ function isPlaceholderRef(ref) {
169
216
  // These are pattern descriptions, not real file paths.
170
217
  if (/\{[A-Za-z][A-Za-z0-9_-]*\}/.test(ref))
171
218
  return true;
219
+ // pcic-mh72: Brace alternation names several files at once, like a glob:
220
+ // `translations/{de,en,ru}.json`. It is a pattern, not a single path.
221
+ if (/\{[^{}/\\]*,[^{}/\\]*\}/.test(ref))
222
+ return true;
172
223
  // BUG-006 (eval): Ellipsis segments ("...") are glob placeholders, not real path components.
173
224
  // e.g. "src/test/java/.../unit/" is a structural description, not a literal path.
174
225
  if (ref.split('/').includes('...'))
@@ -232,22 +283,44 @@ function isGitignored(ref) {
232
283
  const normalised = ref.replace(/\\/g, '/');
233
284
  return GITIGNORED_CREDENTIAL_PATTERNS.some((re) => re.test(normalised));
234
285
  }
286
+ /**
287
+ * pcic-2b6.12: Paths rooted in the reader's home or profile directory —
288
+ * `~/.config/x/settings.json`, `$HOME/.claude/settings.json`,
289
+ * `%USERPROFILE%\.claude.json`. They live outside the repository by definition,
290
+ * so their absence from the checkout says nothing about the reference.
291
+ */
292
+ const HOME_ROOTED_RE = /^(?:~(?:[/\\]|$)|\$\{?(?:HOME|USERPROFILE|XDG_CONFIG_HOME|XDG_DATA_HOME)\}?(?:[/\\]|$)|\$env:(?:HOME|USERPROFILE|APPDATA|LOCALAPPDATA)(?:[/\\]|$)|%(?:USERPROFILE|HOMEPATH|APPDATA|LOCALAPPDATA)%(?:[/\\]|$))/i;
293
+ export function isHomeRooted(ref) {
294
+ return HOME_ROOTED_RE.test(ref.trim());
295
+ }
235
296
  function hasCheckableExtension(ref) {
236
297
  const ext = path.extname(ref.split('#')[0] ?? '').toLowerCase();
237
298
  return CHECKABLE_EXTENSIONS.has(ext);
238
299
  }
300
+ /**
301
+ * pcic-2b6.8: a markdown link target naming a directory or an extension-less
302
+ * file (`./src/api/`, `docs/`, `LICENSE`). Site-absolute (`/pricing`) and
303
+ * scheme-qualified targets, and anything with characters a repo path would not
304
+ * carry, are left alone.
305
+ */
306
+ function isExtensionlessPathRef(ref) {
307
+ if (!ref || /^[a-z][a-z0-9+.-]*:/i.test(ref) || ref.startsWith('/'))
308
+ return false;
309
+ if (/[\s*?$%<>{}|"'`~\\]/.test(ref))
310
+ return false;
311
+ if (!/[A-Za-z]/.test(ref))
312
+ return false; // `./`, `../`, `..`
313
+ // Path-shaped only: a separator or a leading dot (`./x`, `.github`). A bare
314
+ // word (`[WARNING]: Never …`, `[x](payload)`) is too ambiguous to judge.
315
+ if (!ref.includes('/') && !ref.startsWith('.'))
316
+ return false;
317
+ const last = ref.replace(/\/+$/, '').split('/').pop() ?? '';
318
+ return ref.endsWith('/') || path.extname(last) === '';
319
+ }
239
320
  /** Strip query strings and anchor fragments from a path reference. */
240
321
  function stripFragment(ref) {
241
322
  return ref.split('#')[0].split('?')[0].trim();
242
323
  }
243
- function fileExists(p) {
244
- try {
245
- return fs.existsSync(p);
246
- }
247
- catch {
248
- return false;
249
- }
250
- }
251
324
  /**
252
325
  * BUG-C1: Source-code file extensions. When a path has one of these extensions
253
326
  * and its parent directory exists (but the file itself doesn't), the reference
@@ -258,14 +331,16 @@ const SOURCE_CODE_EXTENSIONS = new Set([
258
331
  '.py', '.rs', '.sh', '.java', '.kt', '.swift',
259
332
  ]);
260
333
  /**
261
- * BUG-002: Compiled/native source extensions that, when encountered as backtick
262
- * inline spans (NOT markdown links or @-refs) in any instruction file type,
263
- * should be skipped. These are almost always architecture documentation, not real
264
- * file references — e.g. `internal/client/errors.go`, `src/components/Button.tsx`,
265
- * `pkg/registry/`, `PlayerController.cs`.
334
+ * BUG-002 / pcic-2b6.8: Compiled/native source extensions that, as backtick
335
+ * inline spans (NOT markdown links or @-refs), are often architecture
336
+ * documentation — `internal/client/errors.go`, `PlayerController.cs` — or
337
+ * examples from some other project. They are no longer skipped outright: a
338
+ * missing one is reported (at reduced confidence) only when its first path
339
+ * segment exists in this repository, i.e. it names a real part of THIS tree.
266
340
  *
267
341
  * NOTE: .sh, .py, .js are intentionally excluded — shell/Python/JS scripts are often
268
- * legitimately referenced by name in instruction files (e.g. `scripts/setup-dev.sh`).
342
+ * legitimately referenced by name in instruction files (e.g. `scripts/setup-dev.sh`)
343
+ * and are checked like any other file.
269
344
  */
270
345
  const BACKTICK_SKIP_SOURCE_EXTENSIONS = new Set([
271
346
  '.go', '.rs', '.cs', '.java', '.kt', '.swift',
@@ -278,30 +353,24 @@ const BACKTICK_SKIP_SOURCE_EXTENSIONS = new Set([
278
353
  */
279
354
  const STALE_SOURCE_REF_RE = /(?:^|[/\\])(?:legacy|old|deprecated|archive|archived)(?:[/\\]|[-_.])/i;
280
355
  /**
281
- * File types for which backtick-enclosed compiled/native source paths should be
282
- * skipped. All instruction file types are included because architecture
283
- * descriptions appear in all of them.
356
+ * File types whose backtick-enclosed compiled/native source paths and
357
+ * directory paths get the first-segment rule above. All instruction file types
358
+ * are included because architecture descriptions appear in all of them.
284
359
  *
285
360
  * BUG-002: Expanded from readme-only to all instruction file types.
286
- *
287
- * NOTE: this covers every FileType value, which also means backtick-quoted bare
288
- * *directory* paths (no extension, e.g. `pkg/registry/`) are always treated as
289
- * architecture descriptions rather than checked — that is intentional per
290
- * BUG-002, not an oversight.
291
361
  */
292
362
  const SKIP_SOURCE_BACKTICK_FILE_TYPES = new Set([
293
- 'readme', 'claude', 'agents', 'cursor', 'windsurf', 'copilot', 'docs', 'prompt', 'unknown',
363
+ 'readme', 'claude', 'agents', 'cursor', 'windsurf', 'copilot', 'gemini', 'cline', 'persona',
364
+ 'docs', 'prompt', 'unknown',
294
365
  ]);
366
+ /**
367
+ * pcic-mh72: how many missing levels a code-span path may have below a first
368
+ * segment that is the only part of it present in this repository. Deeper than
369
+ * this, the first segment alone does not show the path belongs to this tree.
370
+ */
371
+ const MAX_UNANCHORED_DEPTH = 2;
295
372
  /** Minimum confidence to emit a dead-reference issue. Below this the signal is too noisy. */
296
373
  const MIN_CONFIDENCE = 0.45;
297
- function parentDirExists(p) {
298
- try {
299
- return fs.existsSync(path.dirname(p));
300
- }
301
- catch {
302
- return false;
303
- }
304
- }
305
374
  function issueId(normRef) {
306
375
  const key = `dead-ref:${normRef}`;
307
376
  const hash = crypto.createHash('sha1').update(key).digest('hex').slice(0, 12);
@@ -414,6 +483,26 @@ function extractLayoutTreePaths(content, lineOffsets) {
414
483
  function collectRefs(content, opts = {}) {
415
484
  const found = [];
416
485
  const lineOffsets = buildLineOffsets(content);
486
+ const fenceLines = scanFencedLines(content);
487
+ // `handlers[type](payload)` in a code fence or `a[i](b)` in a code span has
488
+ // the shape of a link without being one. Checkable-extension targets keep
489
+ // their historical behavior; the extension-less check only reads real prose.
490
+ const inCode = (index) => {
491
+ const line = lineForOffset(lineOffsets, index);
492
+ if (fenceLines[line - 1]?.inFence)
493
+ return true;
494
+ const before = content.slice(lineOffsets[line - 1] ?? 0, index);
495
+ return (before.match(/`/g)?.length ?? 0) % 2 === 1;
496
+ };
497
+ const pushLink = (ref, evidence, index) => {
498
+ if (!ref || isExternalRef(ref))
499
+ return;
500
+ const line = lineForOffset(lineOffsets, index);
501
+ if (hasCheckableExtension(ref))
502
+ found.push({ ref, evidence, line, kind: 'file' });
503
+ else if (isExtensionlessPathRef(ref) && !inCode(index))
504
+ found.push({ ref, evidence, line, kind: 'linkPath' });
505
+ };
417
506
  // Markdown links: [label](target) or [label](target "Title")
418
507
  let m;
419
508
  const linkRe = new RegExp(MD_LINK_RE.source, MD_LINK_RE.flags);
@@ -421,10 +510,7 @@ function collectRefs(content, opts = {}) {
421
510
  if (m.index >= 2 && content[m.index - 2] === '[' && content[m.index - 1] === '!')
422
511
  continue;
423
512
  const raw = m[1] ?? '';
424
- const ref = stripFragment(stripLinkTitle(raw));
425
- if (ref && !isExternalRef(ref) && hasCheckableExtension(ref)) {
426
- found.push({ ref, evidence: `[...](${raw})`, line: lineForOffset(lineOffsets, m.index) });
427
- }
513
+ pushLink(stripFragment(stripLinkTitle(raw)), `[...](${raw})`, m.index);
428
514
  }
429
515
  const nestedLinkRe = new RegExp(NESTED_MD_LINK_RE.source, NESTED_MD_LINK_RE.flags);
430
516
  while ((m = nestedLinkRe.exec(content)) !== null) {
@@ -432,47 +518,62 @@ function collectRefs(content, opts = {}) {
432
518
  if (!labelPart.includes(']('))
433
519
  continue;
434
520
  const raw = m[1] ?? '';
435
- const ref = stripFragment(stripLinkTitle(raw));
436
- if (ref && !isExternalRef(ref) && hasCheckableExtension(ref)) {
437
- found.push({ ref, evidence: `[...](${raw})`, line: lineForOffset(lineOffsets, m.index) });
438
- }
521
+ pushLink(stripFragment(stripLinkTitle(raw)), `[...](${raw})`, m.index);
439
522
  }
440
523
  // Reference-style link definitions: [ref]: docs/missing.md "Title"
441
524
  const linkDefRe = new RegExp(LINK_REF_DEF_RE.source, LINK_REF_DEF_RE.flags);
442
525
  while ((m = linkDefRe.exec(content)) !== null) {
443
526
  const raw = m[1] ?? '';
444
- const ref = stripFragment(raw);
445
- if (ref && !isExternalRef(ref) && hasCheckableExtension(ref)) {
446
- found.push({ ref, evidence: m[0].trim(), line: lineForOffset(lineOffsets, m.index) });
447
- }
527
+ // `[^1]: Footnote text` is a footnote, not a link definition.
528
+ if (/^[ \t]*\[\^/.test(m[0]))
529
+ continue;
530
+ pushLink(stripFragment(raw), m[0].trim(), m.index);
448
531
  }
449
532
  // @-file refs
450
533
  const atRe = new RegExp(AT_FILE_RE.source, AT_FILE_RE.flags);
451
534
  while ((m = atRe.exec(content)) !== null) {
452
535
  const ref = m[1] ?? '';
453
536
  if (ref && !isExternalRef(ref)) {
454
- found.push({ ref, evidence: `@${ref}`, line: lineForOffset(lineOffsets, m.index) });
537
+ found.push({ ref, evidence: `@${ref}`, line: lineForOffset(lineOffsets, m.index), kind: 'file' });
455
538
  }
456
539
  }
457
540
  // Backtick file paths: `scripts/setup-dev.sh`, `Assets/Scripts/Foo.cs`
458
541
  // No spaces = file path not a command; must have a recognisable extension.
459
542
  // Skip glob patterns (contain * or ?) — those are pattern documentation, not file refs.
460
- // BUG-002: In all instruction file types (CLAUDE.md, AGENTS.md, .cursorrules, copilot, etc.)
461
- // skip backtick-quoted compiled/native source files — they are almost always architecture
462
- // descriptions (e.g. `internal/client/errors.go`), not references to committed files.
463
- // Shell (.sh), Python (.py), and JS files are kept because they CAN be real script refs.
543
+ // BUG-002 / pcic-2b6.8: In all instruction file types (CLAUDE.md, AGENTS.md, .cursorrules,
544
+ // copilot, etc.) backtick-quoted compiled/native source files are often architecture
545
+ // descriptions or examples from another project, so they are checked under the
546
+ // first-segment rule (`rootedSource`). Shell (.sh), Python (.py), and JS files are
547
+ // checked like any other file because they are usually real script refs.
464
548
  const btRe = new RegExp(BACKTICK_PATH_RE.source, BACKTICK_PATH_RE.flags);
465
549
  while ((m = btRe.exec(content)) !== null) {
466
550
  const ref = m[1] ?? '';
467
551
  if (ref && !isExternalRef(ref) && !ref.includes('*') && !ref.includes('?')) {
468
- if (opts.skipSourceBackticks) {
469
- const ext = path.extname(ref).toLowerCase();
470
- if (BACKTICK_SKIP_SOURCE_EXTENSIONS.has(ext) && !STALE_SOURCE_REF_RE.test(ref))
471
- continue;
472
- }
473
- found.push({ ref, evidence: `\`${ref}\``, line: lineForOffset(lineOffsets, m.index) });
552
+ const ext = path.extname(ref).toLowerCase();
553
+ const rooted = opts.skipSourceBackticks && BACKTICK_SKIP_SOURCE_EXTENSIONS.has(ext) && !STALE_SOURCE_REF_RE.test(ref);
554
+ found.push({
555
+ ref,
556
+ evidence: `\`${ref}\``,
557
+ line: lineForOffset(lineOffsets, m.index),
558
+ kind: rooted ? 'rootedSource' : 'file',
559
+ codeSpan: true,
560
+ });
474
561
  }
475
562
  }
563
+ // pcic-2b6.8: backtick directory / extension-less paths: `src/payments/`, `apps/admin`.
564
+ const btDirRe = new RegExp(BACKTICK_DIR_RE.source, BACKTICK_DIR_RE.flags);
565
+ while ((m = btDirRe.exec(content)) !== null) {
566
+ const ref = m[1] ?? '';
567
+ const last = ref.replace(/\/+$/, '').split('/').pop() ?? '';
568
+ if (!ref.endsWith('/') && path.extname(last) !== '')
569
+ continue; // a file: BACKTICK_PATH_RE's job
570
+ // Slash-shaped names that are not paths: git branches, HTTP routes, URLs.
571
+ const lineNo = lineForOffset(lineOffsets, m.index);
572
+ const lineText = content.slice(lineOffsets[lineNo - 1] ?? 0, lineOffsets[lineNo] ?? content.length);
573
+ if (NON_PATH_CONTEXT_RE.test(lineText))
574
+ continue;
575
+ found.push({ ref, evidence: `\`${ref}\``, line: lineForOffset(lineOffsets, m.index), kind: 'rootedPath' });
576
+ }
476
577
  // BUG-004: Bare paths in Repository Layout / Project Structure sections.
477
578
  // These appear as bullet list items or ASCII tree lines without backticks.
478
579
  if (LAYOUT_SECTION_HEADING_RE.test(content)) {
@@ -480,14 +581,16 @@ function collectRefs(content, opts = {}) {
480
581
  while ((m = bulletRe.exec(content)) !== null) {
481
582
  const ref = m[1] ?? '';
482
583
  if (ref && !isExternalRef(ref)) {
483
- found.push({ ref, evidence: m[0].trim(), line: lineForOffset(lineOffsets, m.index) });
584
+ found.push({ ref, evidence: m[0].trim(), line: lineForOffset(lineOffsets, m.index), kind: 'file' });
484
585
  }
485
586
  }
486
- found.push(...extractLayoutTreePaths(content, lineOffsets));
587
+ for (const item of extractLayoutTreePaths(content, lineOffsets)) {
588
+ found.push({ ...item, kind: 'file' });
589
+ }
487
590
  // BUG-002 (eval): Fenced code blocks in layout sections may list documentation
488
591
  // files as indented children under a parent directory line ("docs/\n runbook.md").
489
592
  for (const item of extractCodeBlockLayoutPaths(content, lineOffsets)) {
490
- found.push(item);
593
+ found.push({ ...item, kind: 'file' });
491
594
  }
492
595
  }
493
596
  // BUG-D1: Drop only the reference the line instructs the reader to CREATE
@@ -510,6 +613,391 @@ function collectRefs(content, opts = {}) {
510
613
  return !isTarget;
511
614
  });
512
615
  }
616
+ // ── Resolution ───────────────────────────────────────────────────────────────
617
+ /** Path segments of a reference, without `.` / empty segments. */
618
+ function segmentsOf(ref) {
619
+ return stripFragment(ref).replace(/\\/g, '/').split('/').filter((s) => s !== '' && s !== '.');
620
+ }
621
+ function underOutputDir(ref) {
622
+ return segmentsOf(ref).some((s) => OUTPUT_DIRS.has(s.toLowerCase()));
623
+ }
624
+ /** The `code` of a filesystem error (`EACCES`, `ENOENT`, …), when it has one. */
625
+ function errorCode(err) {
626
+ const code = err?.code;
627
+ return typeof code === 'string' ? code : undefined;
628
+ }
629
+ /** Links followed for one lookup before it gives up (a cycle, or a chain no real tree has). */
630
+ const MAX_LINK_HOPS = 40;
631
+ /**
632
+ * pcic-xhzb: the only way the detector asks the filesystem about a reference.
633
+ *
634
+ * - Contained. A path is looked up by listing the directories from the scan
635
+ * root down to it, so nothing outside the root is ever listed, stat'ed or
636
+ * opened. A committed link is read (`readlink`, a property of the link,
637
+ * which lives in the tree) and followed only while its target stays inside
638
+ * the root; a link out of the tree makes the lookup `outside` without
639
+ * touching what it points at. A hosted scan runs against a server temp
640
+ * directory, and an existence check outside it would tell whoever wrote the
641
+ * scanned repository whether a server path exists.
642
+ * - Exact case. A segment matches a directory entry of exactly that name, so
643
+ * `Deploy_PR.yml` does not resolve to `deploy_pr.yml` on a case-insensitive
644
+ * filesystem (Windows, macOS). The result is the same on every platform, and
645
+ * the same as on Linux and GitHub, where the reference is broken.
646
+ * - Honest about failure. A directory that cannot be listed makes the lookup
647
+ * `unreadable`, never `missing`, and is recorded so the scan can say so.
648
+ *
649
+ * Listings are cached for the scan, so each directory is read at most once.
650
+ */
651
+ class RepoTree {
652
+ root;
653
+ /** The root's real path, when it differs (a root under a symlink, like macOS `/var`). */
654
+ realRoot;
655
+ listings = new Map();
656
+ /** What could not be read, as shown in the report (`dir/` or a link's path) → why. */
657
+ unreadable = new Map();
658
+ constructor(repoRoot) {
659
+ this.root = path.resolve(repoRoot);
660
+ const real = realPath(this.root);
661
+ this.realRoot = real !== null && real !== this.root ? real : undefined;
662
+ }
663
+ /** Forward-slash path relative to the root (`.` for the root itself); never absolute. */
664
+ relative(abs) {
665
+ return path.relative(this.root, abs).split(path.sep).join('/') || '.';
666
+ }
667
+ /** Record what could not be read: a directory (shown as `dir/`) or a link (shown as its path). */
668
+ noteUnreadable(p, reason, kind = 'dir') {
669
+ const abs = path.resolve(this.root, p);
670
+ // Only in-root names are ever recorded: the report must not carry a server path.
671
+ if (!isWithinRoot(this.root, abs))
672
+ return;
673
+ const rel = this.relative(abs);
674
+ const shown = kind === 'link' ? rel : rel === '.' ? './' : `${rel}/`;
675
+ if (!this.unreadable.has(shown))
676
+ this.unreadable.set(shown, reason);
677
+ }
678
+ /** The entries of an in-root directory, by exact name. */
679
+ list(dir) {
680
+ let listing = this.listings.get(dir);
681
+ if (listing === undefined) {
682
+ try {
683
+ listing = new Map(fs.readdirSync(dir, { withFileTypes: true }).map((d) => [d.name, d]));
684
+ }
685
+ catch (err) {
686
+ const code = errorCode(err);
687
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
688
+ listing = 'missing';
689
+ }
690
+ else {
691
+ listing = 'unreadable';
692
+ this.noteUnreadable(dir, code ?? 'error');
693
+ }
694
+ }
695
+ this.listings.set(dir, listing);
696
+ }
697
+ return listing;
698
+ }
699
+ /** In-root equivalent of a link target written against the root's real path. */
700
+ fromRealRoot(p) {
701
+ if (this.realRoot === undefined || isWithinRoot(this.root, p) || !isWithinRoot(this.realRoot, p))
702
+ return p;
703
+ return path.join(this.root, path.relative(this.realRoot, p));
704
+ }
705
+ lookup(candidate) {
706
+ let target = path.resolve(this.root, candidate);
707
+ let lastLink = '';
708
+ for (let hop = 0; hop <= MAX_LINK_HOPS; hop++) {
709
+ // Lexical containment first: `..`, absolute, other-drive and UNC paths stop here.
710
+ if (!isWithinRoot(this.root, target))
711
+ return { state: 'outside' };
712
+ const parts = path.relative(this.root, target).split(path.sep).filter(Boolean);
713
+ let dir = this.root;
714
+ let isDir = true;
715
+ let next;
716
+ for (let i = 0; i < parts.length; i++) {
717
+ if (!isDir)
718
+ return { state: 'missing' }; // a file in the middle of a path
719
+ const listing = this.list(dir);
720
+ if (listing === 'missing')
721
+ return { state: 'missing' };
722
+ if (listing === 'unreadable')
723
+ return { state: 'unreadable' };
724
+ const name = parts[i];
725
+ const entry = listing.get(name);
726
+ if (!entry)
727
+ return { state: 'missing' };
728
+ const entryPath = path.join(dir, name);
729
+ if (entry.isSymbolicLink()) {
730
+ let link;
731
+ try {
732
+ link = fs.readlinkSync(entryPath);
733
+ }
734
+ catch (err) {
735
+ this.noteUnreadable(entryPath, errorCode(err) ?? 'unreadable link', 'link');
736
+ return { state: 'unreadable' };
737
+ }
738
+ lastLink = entryPath;
739
+ // Resolved lexically against the link's own directory, then re-walked
740
+ // from the root: the containment check above runs before anything
741
+ // under the target is touched.
742
+ next = path.join(this.fromRealRoot(path.resolve(dir, link)), ...parts.slice(i + 1));
743
+ break;
744
+ }
745
+ isDir = entry.isDirectory();
746
+ dir = entryPath;
747
+ }
748
+ if (next === undefined)
749
+ return { state: 'found', path: dir, isDir };
750
+ target = next;
751
+ }
752
+ // More links than the kernel itself follows (Linux MAXSYMLINKS): a cycle.
753
+ this.noteUnreadable(lastLink, 'symlink loop', 'link');
754
+ return { state: 'unreadable' };
755
+ }
756
+ isDir(candidate) {
757
+ const found = this.lookup(candidate);
758
+ return found.state === 'found' && found.isDir;
759
+ }
760
+ /**
761
+ * An extension-less module path names the file that carries the extension:
762
+ * `src/lib/db` is `src/lib/db.ts`. True when the directory holds `<name>.*`.
763
+ */
764
+ hasStemSibling(candidate) {
765
+ const dir = this.lookup(path.dirname(candidate));
766
+ if (dir.state !== 'found' || !dir.isDir)
767
+ return false;
768
+ const listing = this.list(dir.path);
769
+ if (typeof listing === 'string')
770
+ return false;
771
+ const prefix = `${path.basename(candidate)}.`;
772
+ for (const name of listing.keys())
773
+ if (name.startsWith(prefix))
774
+ return true;
775
+ return false;
776
+ }
777
+ /**
778
+ * fast-glob's directory reads, routed through node:fs here so a directory the
779
+ * walk cannot list is recorded instead of silently dropping out of the result.
780
+ * The walk itself (`cwd` = root, links not followed) never leaves the root.
781
+ */
782
+ walkFs(onError) {
783
+ const readdirSync = (dir, options) => {
784
+ try {
785
+ return options ? fs.readdirSync(dir, options) : fs.readdirSync(dir);
786
+ }
787
+ catch (err) {
788
+ const code = errorCode(err);
789
+ // A directory deleted mid-walk has nothing left to miss.
790
+ if (code !== 'ENOENT') {
791
+ this.noteUnreadable(dir, code ?? 'error');
792
+ onError();
793
+ }
794
+ throw err;
795
+ }
796
+ };
797
+ return { readdirSync: readdirSync };
798
+ }
799
+ }
800
+ /** Trees never searched for an abbreviated path (dependencies, VCS, worktrees, engine caches). */
801
+ const SUFFIX_SEARCH_IGNORE = [
802
+ ...[
803
+ ...OUTPUT_DIRS, '.git', 'worktrees', '.worktrees', 'Library', 'Temp', 'Logs', 'vendor',
804
+ '.venv', 'venv', '__pycache__', 'Pods', '.gradle', '.cache', '.idea',
805
+ ].map((d) => `**/${d}/**`),
806
+ ];
807
+ /**
808
+ * Runs one repository walk. `complete` is false when any directory could not
809
+ * be listed: what the walk did not find may still exist (pcic-xhzb).
810
+ */
811
+ function walk(tree, patterns, onlyFiles) {
812
+ let complete = true;
813
+ let entries = [];
814
+ try {
815
+ entries = fg.sync(patterns, {
816
+ cwd: tree.root,
817
+ dot: true,
818
+ onlyFiles,
819
+ followSymbolicLinks: false,
820
+ // Errors are recorded by walkFs and the walk goes on past them.
821
+ suppressErrors: true,
822
+ ignore: SUFFIX_SEARCH_IGNORE,
823
+ fs: tree.walkFs(() => { complete = false; }),
824
+ });
825
+ }
826
+ catch (err) {
827
+ tree.noteUnreadable('.', errorCode(err) ?? 'walk failed');
828
+ complete = false;
829
+ }
830
+ return { entries, complete };
831
+ }
832
+ /**
833
+ * True when the path exists somewhere below the repo root as a suffix — an
834
+ * abbreviated reference such as `Scripts/Interfaces/` for Unity's
835
+ * `Assets/Scripts/Interfaces/`, or a package-relative `src/lib/db.ts` in a
836
+ * monorepo's root instructions. Such a path is imprecise, not stale.
837
+ * `unknown` when it was not found but the walk could not list everything.
838
+ */
839
+ function existsAsSuffix(tree, segments, moduleStyle, cache) {
840
+ const rel = segments.join('/');
841
+ const key = `${moduleStyle ? 'm' : 'x'}:${rel}`;
842
+ const cached = cache.get(key);
843
+ if (cached !== undefined)
844
+ return cached;
845
+ const escaped = fg.escapePath(rel);
846
+ const patterns = [`**/${escaped}`];
847
+ if (moduleStyle)
848
+ patterns.push(`**/${escaped}.*`);
849
+ const { entries, complete } = walk(tree, patterns, false);
850
+ const found = entries.length > 0 ? true : complete ? false : 'unknown';
851
+ cache.set(key, found);
852
+ return found;
853
+ }
854
+ function buildNameIndex(tree) {
855
+ const paths = new Map();
856
+ const { entries, complete } = walk(tree, ['**'], true);
857
+ for (const rel of entries) {
858
+ const name = rel.slice(rel.lastIndexOf('/') + 1);
859
+ const list = paths.get(name);
860
+ if (list)
861
+ list.push(rel);
862
+ else
863
+ paths.set(name, [rel]);
864
+ }
865
+ return { paths, complete };
866
+ }
867
+ /** Probe name used to ask the gitignore matcher whether a DIRECTORY is ignored. */
868
+ const DIR_PROBE = '__promptci_dir_probe__';
869
+ /**
870
+ * Returned when part of the tree the verdict depends on could not be read: the
871
+ * reference is neither resolved nor reported, and the scan says what it skipped.
872
+ */
873
+ const UNJUDGED = 'unjudged';
874
+ /**
875
+ * Confidence that `ref` is a dead reference; `null` when it resolves, is
876
+ * expected to be absent (gitignored, generated output), lies outside the scan
877
+ * root, or is not specific enough to judge; {@link UNJUDGED} when the answer
878
+ * depends on a directory that could not be listed.
879
+ */
880
+ function judgeReference(ref, kind, codeSpan, fileDir, ctx) {
881
+ const { tree, isIgnored } = ctx;
882
+ // `.git/…` is a directory in one checkout and a file in a worktree.
883
+ if (segmentsOf(ref)[0] === '.git')
884
+ return null;
885
+ // pcic-2b6.8 kinds: generated output / dependency trees are never committed.
886
+ // (File references keep their historical behavior: a committed `bin/setup.sh`
887
+ // or `scripts/build/x.sh` is still checked.)
888
+ if (kind !== 'file' && underOutputDir(ref))
889
+ return null;
890
+ const pathLike = kind === 'rootedPath' || kind === 'linkPath';
891
+ const target = pathLike ? stripFragment(ref) : ref;
892
+ // pcic-xhzb: only candidates inside the scan root are looked up. One that
893
+ // leaves it — `../../etc/x.md`, `/etc/x.md`, `C:\x.md`, `\\host\share\x.md`,
894
+ // or a path through a committed link out of the tree — is dropped before
895
+ // any filesystem access, and a reference with no candidate left is not
896
+ // judged at all: not resolved, not reported. Reporting it would claim a
897
+ // file is missing without having looked; a monorepo package scanned on its
898
+ // own legitimately points at `../../AGENTS.md`. Skipping is also what the
899
+ // directory kinds below and home-rooted paths (`~/…`) already did.
900
+ // An absolute path is skipped even when it happens to point into the root:
901
+ // its verdict would depend on where the repository is checked out, and the
902
+ // same input must give the same report. Both platforms' forms count (`/x`,
903
+ // `C:\x`, `\\host\share\x`), so a Windows and a Linux scan agree, and a
904
+ // Windows scan never opens an SMB connection for `\\host\…`.
905
+ // A drive-relative `C:notes.md` is skipped too: Windows resolves it against
906
+ // that drive's working directory, Linux reads it as a file name.
907
+ if (path.posix.isAbsolute(target) || path.win32.isAbsolute(target) || /^[A-Za-z]:/.test(target))
908
+ return null;
909
+ const lookups = [];
910
+ for (const abs of new Set([path.resolve(fileDir, target), path.resolve(tree.root, target)])) {
911
+ if (!isWithinRoot(tree.root, abs))
912
+ continue;
913
+ const found = tree.lookup(abs);
914
+ if (found.state !== 'outside')
915
+ lookups.push({ abs, found });
916
+ }
917
+ if (lookups.length === 0)
918
+ return null;
919
+ // The repository's own ignore rules say the path is local state (pcic-2b6.8,
920
+ // same matcher the skills detector uses).
921
+ for (const { abs } of lookups) {
922
+ const rel = path.relative(tree.root, abs).replace(/\\/g, '/');
923
+ if (!rel)
924
+ continue;
925
+ if (isIgnored(rel) || (pathLike && isIgnored(`${rel}/${DIR_PROBE}`)))
926
+ return null;
927
+ }
928
+ const moduleStyle = pathLike && !target.endsWith('/');
929
+ if (lookups.some(({ abs, found }) => found.state === 'found' || (moduleStyle && tree.hasStemSibling(abs)))) {
930
+ return null;
931
+ }
932
+ if (lookups.some(({ found }) => found.state === 'unreadable'))
933
+ return UNJUDGED;
934
+ if (kind === 'file') {
935
+ // pcic-mh72: a bare filename in a code span (`deploy_pr.yml`,
936
+ // `verify-release.mjs`) names a file, not a location. Prose says "`ci.yml`
937
+ // runs the tests" about `.github/workflows/ci.yml`, so the name resolves
938
+ // wherever the repository commits a file by that exact name (case
939
+ // included, on every platform: pcic-xhzb). A name that exists nowhere, or
940
+ // only under a dependency or output tree or a gitignored path, is still
941
+ // reported. The trade-off: a bare name stays silent while ANY file carries
942
+ // it, so a stale `ci.yml` goes unreported if another `ci.yml` exists.
943
+ // Names too generic to judge (`index.ts`, `package.json`) were already
944
+ // skipped by isPlaceholderRef.
945
+ if (codeSpan && !/[/\\]/.test(target)) {
946
+ const index = ctx.nameIndex();
947
+ if (index.paths.get(target)?.some((rel) => !isIgnored(rel)))
948
+ return null;
949
+ // pcic-xhzb: a walk that could not list everything is no evidence of absence.
950
+ if (!index.complete)
951
+ return UNJUDGED;
952
+ }
953
+ // BUG-C1: Source-code paths whose parent directory exists → lower confidence
954
+ // (likely documentation references, not broken links).
955
+ const ext = path.extname(ref.split('#')[0] ?? '').toLowerCase();
956
+ const isSourceCodeRef = SOURCE_CODE_EXTENSIONS.has(ext);
957
+ const anyParentExists = lookups.some(({ abs }) => tree.isDir(path.dirname(abs)));
958
+ return isSourceCodeRef && anyParentExists ? 0.5 : 0.85;
959
+ }
960
+ if (kind === 'linkPath')
961
+ return 0.8;
962
+ // Code-span paths: report only when the path's FIRST segment exists here, so
963
+ // the reference demonstrably names part of THIS repository — `owner/repo`,
964
+ // `origin/main`, `@scope/pkg` and another project's `internal/x/y.go` stay silent.
965
+ // A `..` anywhere (`../shared/`, `src/../x/`) is not a path into this tree.
966
+ const segments = segmentsOf(ref);
967
+ if (segments.length < 2 || segments.includes('..'))
968
+ return null;
969
+ // The first segment must be a DIRECTORY here (`package.json/scripts` is not a path).
970
+ const anchorBases = [fileDir, tree.root].filter((base) => tree.isDir(path.join(base, segments[0])));
971
+ if (anchorBases.length === 0)
972
+ return null;
973
+ // pcic-mh72: a lone top-level match is a weak anchor. Almost every project has
974
+ // a `src/`, so `src/main/java/edu/harvard/iq/dataverse/api/` (another
975
+ // project's layout, described in prose) matches here on `src` alone. When the
976
+ // second segment is not a directory here and the path continues three or more
977
+ // levels below the first segment, it is treated as describing some other tree.
978
+ // A shorter tail (`src/payments/`, `src/services/billing.ts`) is still checked.
979
+ // The trade-off: a deep path under a removed intermediate directory
980
+ // (`src/features/auth/components/` with `src/features/` gone) is not reported.
981
+ if (segments.length - 1 > MAX_UNANCHORED_DEPTH &&
982
+ !anchorBases.some((base) => tree.isDir(path.join(base, segments[0], segments[1])))) {
983
+ return null;
984
+ }
985
+ if (kind === 'rootedPath') {
986
+ // `docs/update-readme`, `test/flaky-fix`: a branch name unless written as a directory.
987
+ if (BRANCH_PREFIXES.has(segments[0].toLowerCase()) && !target.endsWith('/'))
988
+ return null;
989
+ // `client/server`, `public/private`: alternatives when both halves exist side by side.
990
+ if (segments.length === 2 && anchorBases.some((base) => tree.lookup(path.join(base, segments[1])).state === 'found')) {
991
+ return null;
992
+ }
993
+ }
994
+ const suffix = existsAsSuffix(tree, segments, moduleStyle, ctx.suffixCache);
995
+ if (suffix === true)
996
+ return null;
997
+ if (suffix === 'unknown')
998
+ return UNJUDGED;
999
+ return 0.6;
1000
+ }
513
1001
  // ── Detector ─────────────────────────────────────────────────────────────────
514
1002
  /**
515
1003
  * Detect broken file references across all instruction files.
@@ -529,37 +1017,54 @@ function collectRefs(content, opts = {}) {
529
1017
  export function detectDeadReferences(files, repoRoot) {
530
1018
  // normRef → { displayRef, filePaths, fileLines, evidenceLines, minConfidence }
531
1019
  const byRef = new Map();
1020
+ const tree = new RepoTree(repoRoot);
1021
+ let ctx;
1022
+ let nameIndexCache;
1023
+ const nameIndex = () => (nameIndexCache ??= buildNameIndex(tree));
1024
+ // pcic-xhzb: references left unjudged because part of the tree could not be listed.
1025
+ const unjudged = new Map();
532
1026
  for (const file of files) {
533
1027
  const fileDir = path.dirname(file.path);
534
1028
  const refs = collectRefs(file.content, {
535
1029
  skipSourceBackticks: SKIP_SOURCE_BACKTICK_FILE_TYPES.has(file.fileType),
536
1030
  });
537
- for (const { ref, evidence, line } of refs) {
1031
+ for (const { ref, evidence, line, kind, codeSpan } of refs) {
538
1032
  // BUG-009: Skip obvious placeholder/example filenames (foo.ts, bar.go, etc.)
539
1033
  if (isPlaceholderRef(ref))
540
1034
  continue;
541
1035
  // BUG-008: Skip intentionally gitignored credential/config files.
542
1036
  if (isGitignored(ref))
543
1037
  continue;
1038
+ // pcic-2b6.12: Skip home-directory paths (`~/…`, `$HOME/…`, `%USERPROFILE%\…`).
1039
+ if (isHomeRooted(ref))
1040
+ continue;
544
1041
  // Resolve relative to the instruction file first, then repo root.
545
- const candidates = [
546
- path.resolve(fileDir, ref),
547
- path.resolve(repoRoot, ref),
548
- ];
549
- if (candidates.some(fileExists))
1042
+ ctx ??= { tree, isIgnored: createGitIgnoreChecker(repoRoot), suffixCache: new Map(), nameIndex };
1043
+ const confidence = judgeReference(ref, kind, codeSpan ?? false, fileDir, ctx);
1044
+ if (confidence === null)
550
1045
  continue;
551
- // BUG-C1: Source-code paths whose parent directory exists → lower confidence
552
- // (likely documentation references, not broken links).
553
- const ext = path.extname(ref.split('#')[0] ?? '').toLowerCase();
554
- const isSourceCodeRef = SOURCE_CODE_EXTENSIONS.has(ext);
555
- const anyParentExists = candidates.some(parentDirExists);
556
- const confidence = isSourceCodeRef && anyParentExists ? 0.5 : 0.85;
557
- // Normalise for deduplication (case-insensitive, forward slashes)
558
- const normRef = ref.toLowerCase().replace(/\\/g, '/');
1046
+ if (confidence === UNJUDGED) {
1047
+ const evidenceText = `Not checked: ${evidence}`;
1048
+ const entry = unjudged.get(evidenceText) ?? { filePaths: new Set(), fileLines: new Map(), evidence: evidenceText };
1049
+ entry.filePaths.add(file.path);
1050
+ const seen = entry.fileLines.get(file.path);
1051
+ if (seen === undefined || line < seen)
1052
+ entry.fileLines.set(file.path, line);
1053
+ unjudged.set(evidenceText, entry);
1054
+ continue;
1055
+ }
1056
+ // Normalise for deduplication (case-insensitive, forward slashes). Directory
1057
+ // forms also drop `./` and the trailing slash, so `src/api/`, `./src/api`
1058
+ // and a link to `./src/api/` are one finding.
1059
+ const normalized = ref.toLowerCase().replace(/\\/g, '/');
1060
+ const normRef = kind === 'rootedPath' || kind === 'linkPath'
1061
+ ? normalized.replace(/^\.\//, '').replace(/\/+$/, '')
1062
+ : normalized;
559
1063
  const evidenceLine = `Reference: ${evidence}`;
560
1064
  if (!byRef.has(normRef)) {
561
1065
  byRef.set(normRef, {
562
1066
  displayRef: ref,
1067
+ kind,
563
1068
  filePaths: new Set(),
564
1069
  fileLines: new Map(),
565
1070
  evidenceLines: [],
@@ -580,19 +1085,23 @@ export function detectDeadReferences(files, repoRoot) {
580
1085
  }
581
1086
  const issues = [];
582
1087
  const MIN_CONFIDENCE_THRESHOLD = MIN_CONFIDENCE;
583
- for (const [normRef, { displayRef, filePaths, fileLines, evidenceLines, minConfidence }] of byRef) {
1088
+ for (const [normRef, { displayRef, kind, filePaths, fileLines, evidenceLines, minConfidence }] of byRef) {
584
1089
  // BUG-002: Skip findings below the minimum confidence threshold.
585
1090
  // Low-confidence source-code path references are almost certainly architecture docs.
586
1091
  if (minConfidence < MIN_CONFIDENCE_THRESHOLD)
587
1092
  continue;
588
1093
  const allFilePaths = [...filePaths];
1094
+ const pathLike = kind === 'rootedPath' || kind === 'linkPath';
1095
+ const isDir = pathLike && stripFragment(displayRef).endsWith('/');
1096
+ const noun = isDir ? 'directory' : pathLike ? 'path' : 'file';
1097
+ const name = path.basename(stripFragment(displayRef).replace(/\/+$/, ''));
589
1098
  issues.push({
590
1099
  id: issueId(normRef),
591
1100
  severity: 'warning',
592
1101
  category: 'structure',
593
- title: `Broken file reference: ${path.basename(stripFragment(displayRef))}`,
1102
+ title: `Broken ${noun} reference: ${name}${isDir ? '/' : ''}`,
594
1103
  summary: `This instruction file references "${displayRef}" which does not appear to exist. ` +
595
- `The file may have been renamed, moved, or deleted.`,
1104
+ `The ${noun} may have been renamed, moved, or deleted.`,
596
1105
  filePaths: allFilePaths,
597
1106
  locations: allFilePaths.map((fp) => {
598
1107
  const startLine = fileLines.get(fp);
@@ -604,6 +1113,55 @@ export function detectDeadReferences(files, repoRoot) {
604
1113
  confidence: minConfidence,
605
1114
  });
606
1115
  }
1116
+ if (unjudged.size > 0)
1117
+ issues.push(incompleteCheckIssue(tree, [...unjudged.values()]));
607
1118
  return issues;
608
1119
  }
1120
+ /** Evidence lines listed per kind before the rest is summarised. */
1121
+ const MAX_INCOMPLETE_EVIDENCE = 10;
1122
+ /**
1123
+ * pcic-xhzb: a directory the scan could not list is named, never turned into
1124
+ * findings. Without this, an unreadable directory emptied the name index (or
1125
+ * part of it) and every bare name filed there was reported as broken, with no
1126
+ * sign the scan had failed. The gap is a `warning`, the severity of the
1127
+ * findings it stands in for, so a `--fail-on warning` gate that a broken
1128
+ * reference would have tripped is not passed silently by a scan that could
1129
+ * not look (the supply-chain scan's `unscanned-files` likewise warns about
1130
+ * content it could not read). Paths are repo-relative, so a hosted scan never
1131
+ * shows its temp dir.
1132
+ */
1133
+ function incompleteCheckIssue(tree, refs) {
1134
+ const capped = (lines, noun) => lines.length > MAX_INCOMPLETE_EVIDENCE
1135
+ ? [...lines.slice(0, MAX_INCOMPLETE_EVIDENCE), `…and ${lines.length - MAX_INCOMPLETE_EVIDENCE} more ${noun}`]
1136
+ : lines;
1137
+ const dirs = [...tree.unreadable].sort(([a], [b]) => a.localeCompare(b));
1138
+ const dirLines = dirs.map(([shown, reason]) => `Could not read: ${shown} (${reason})`);
1139
+ const filePaths = [...new Set(refs.flatMap((r) => [...r.filePaths]))];
1140
+ const firstLine = new Map();
1141
+ for (const r of refs) {
1142
+ for (const [fp, line] of r.fileLines) {
1143
+ const seen = firstLine.get(fp);
1144
+ if (seen === undefined || line < seen)
1145
+ firstLine.set(fp, line);
1146
+ }
1147
+ }
1148
+ return {
1149
+ id: issueId('(dead-reference check incomplete)'),
1150
+ severity: 'warning',
1151
+ category: 'structure',
1152
+ title: 'Dead-reference check incomplete: part of the repository could not be read',
1153
+ summary: `${refs.length} reference(s) were not checked because part of the repository could not be read ` +
1154
+ `(${dirs.length} location${dirs.length === 1 ? '' : 's'}). They may or may not exist; ` +
1155
+ 'no broken-reference finding was reported for them.',
1156
+ filePaths,
1157
+ locations: filePaths.map((fp) => {
1158
+ const startLine = firstLine.get(fp);
1159
+ return startLine !== undefined ? { filePath: fp, startLine } : { filePath: fp };
1160
+ }),
1161
+ evidence: [...capped(dirLines, 'locations'), ...capped(refs.map((r) => r.evidence), 'references')],
1162
+ recommendation: 'Make the listed locations readable by the user running the scan (or fix the symlink), then scan again to ' +
1163
+ 'check these references.',
1164
+ confidence: 0.8,
1165
+ };
1166
+ }
609
1167
  //# sourceMappingURL=dead-references.js.map