@promptci/core 0.2.1 → 0.3.0

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 (120) hide show
  1. package/dist/ai-config.d.ts +13 -6
  2. package/dist/ai-config.d.ts.map +1 -1
  3. package/dist/ai-config.js +25 -78
  4. package/dist/ai-config.js.map +1 -1
  5. package/dist/close-match.d.ts +97 -0
  6. package/dist/close-match.d.ts.map +1 -0
  7. package/dist/close-match.js +215 -0
  8. package/dist/close-match.js.map +1 -0
  9. package/dist/command-validity.d.ts.map +1 -1
  10. package/dist/command-validity.js +309 -76
  11. package/dist/command-validity.js.map +1 -1
  12. package/dist/context-optimizer.d.ts.map +1 -1
  13. package/dist/context-optimizer.js +9 -2
  14. package/dist/context-optimizer.js.map +1 -1
  15. package/dist/dead-references.d.ts +8 -1
  16. package/dist/dead-references.d.ts.map +1 -1
  17. package/dist/dead-references.js +162 -243
  18. package/dist/dead-references.js.map +1 -1
  19. package/dist/dependency-staleness.d.ts.map +1 -1
  20. package/dist/dependency-staleness.js +24 -38
  21. package/dist/dependency-staleness.js.map +1 -1
  22. package/dist/detectors.js +1 -1
  23. package/dist/detectors.js.map +1 -1
  24. package/dist/discovery.d.ts +138 -0
  25. package/dist/discovery.d.ts.map +1 -0
  26. package/dist/discovery.js +569 -0
  27. package/dist/discovery.js.map +1 -0
  28. package/dist/fix-engine.d.ts.map +1 -1
  29. package/dist/fix-engine.js +75 -1
  30. package/dist/fix-engine.js.map +1 -1
  31. package/dist/frameworks/dotnet.d.ts.map +1 -1
  32. package/dist/frameworks/dotnet.js +10 -11
  33. package/dist/frameworks/dotnet.js.map +1 -1
  34. package/dist/frameworks/go.d.ts.map +1 -1
  35. package/dist/frameworks/go.js +8 -4
  36. package/dist/frameworks/go.js.map +1 -1
  37. package/dist/frameworks/nextjs.d.ts.map +1 -1
  38. package/dist/frameworks/nextjs.js +8 -17
  39. package/dist/frameworks/nextjs.js.map +1 -1
  40. package/dist/frameworks/python.d.ts.map +1 -1
  41. package/dist/frameworks/python.js +5 -3
  42. package/dist/frameworks/python.js.map +1 -1
  43. package/dist/frameworks/rust.d.ts.map +1 -1
  44. package/dist/frameworks/rust.js +3 -3
  45. package/dist/frameworks/rust.js.map +1 -1
  46. package/dist/frameworks/unity.d.ts.map +1 -1
  47. package/dist/frameworks/unity.js +6 -5
  48. package/dist/frameworks/unity.js.map +1 -1
  49. package/dist/gitignore.d.ts +31 -2
  50. package/dist/gitignore.d.ts.map +1 -1
  51. package/dist/gitignore.js +33 -8
  52. package/dist/gitignore.js.map +1 -1
  53. package/dist/hooks-settings-detector.d.ts.map +1 -1
  54. package/dist/hooks-settings-detector.js +5 -1
  55. package/dist/hooks-settings-detector.js.map +1 -1
  56. package/dist/index.d.ts +7 -0
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +12 -0
  59. package/dist/index.js.map +1 -1
  60. package/dist/known-paths.d.ts +82 -0
  61. package/dist/known-paths.d.ts.map +1 -0
  62. package/dist/known-paths.js +152 -0
  63. package/dist/known-paths.js.map +1 -0
  64. package/dist/makefile.d.ts +11 -3
  65. package/dist/makefile.d.ts.map +1 -1
  66. package/dist/makefile.js +28 -22
  67. package/dist/makefile.js.map +1 -1
  68. package/dist/manifest-consistency.d.ts +1 -1
  69. package/dist/manifest-consistency.d.ts.map +1 -1
  70. package/dist/manifest-consistency.js +12 -8
  71. package/dist/manifest-consistency.js.map +1 -1
  72. package/dist/mcp-config-detector.d.ts.map +1 -1
  73. package/dist/mcp-config-detector.js +5 -1
  74. package/dist/mcp-config-detector.js.map +1 -1
  75. package/dist/project-type.d.ts +2 -1
  76. package/dist/project-type.d.ts.map +1 -1
  77. package/dist/project-type.js +36 -48
  78. package/dist/project-type.js.map +1 -1
  79. package/dist/prompt-cache.d.ts.map +1 -1
  80. package/dist/prompt-cache.js +13 -2
  81. package/dist/prompt-cache.js.map +1 -1
  82. package/dist/repo-context.d.ts +9 -0
  83. package/dist/repo-context.d.ts.map +1 -1
  84. package/dist/repo-context.js +32 -35
  85. package/dist/repo-context.js.map +1 -1
  86. package/dist/repo-tree.d.ts +281 -0
  87. package/dist/repo-tree.d.ts.map +1 -0
  88. package/dist/repo-tree.js +455 -0
  89. package/dist/repo-tree.js.map +1 -0
  90. package/dist/report.d.ts.map +1 -1
  91. package/dist/report.js +3 -0
  92. package/dist/report.js.map +1 -1
  93. package/dist/sarif.d.ts +27 -0
  94. package/dist/sarif.d.ts.map +1 -0
  95. package/dist/sarif.js +357 -0
  96. package/dist/sarif.js.map +1 -0
  97. package/dist/scan.d.ts.map +1 -1
  98. package/dist/scan.js +8 -2
  99. package/dist/scan.js.map +1 -1
  100. package/dist/scanner.d.ts +4 -3
  101. package/dist/scanner.d.ts.map +1 -1
  102. package/dist/scanner.js +36 -77
  103. package/dist/scanner.js.map +1 -1
  104. package/dist/security-pack.d.ts.map +1 -1
  105. package/dist/security-pack.js +34 -35
  106. package/dist/security-pack.js.map +1 -1
  107. package/dist/skill-supply-chain.d.ts.map +1 -1
  108. package/dist/skill-supply-chain.js +29 -19
  109. package/dist/skill-supply-chain.js.map +1 -1
  110. package/dist/skills-detector.d.ts.map +1 -1
  111. package/dist/skills-detector.js +11 -3
  112. package/dist/skills-detector.js.map +1 -1
  113. package/dist/token-estimates.d.ts +42 -0
  114. package/dist/token-estimates.d.ts.map +1 -0
  115. package/dist/token-estimates.js +200 -0
  116. package/dist/token-estimates.js.map +1 -0
  117. package/dist/types.d.ts +89 -0
  118. package/dist/types.d.ts.map +1 -1
  119. package/dist/types.js.map +1 -1
  120. package/package.json +5 -2
@@ -26,13 +26,13 @@
26
26
  *
27
27
  * Findings are heuristic — wording is intentionally cautious.
28
28
  */
29
- import * as fs from 'node:fs';
30
29
  import * as path from 'node:path';
31
30
  import * as crypto from 'node:crypto';
32
31
  import fg from 'fast-glob';
33
32
  import { fencedBlocks, scanFencedLines } from './markdown-fences.js';
34
33
  import { createGitIgnoreChecker } from './gitignore.js';
35
- import { isWithinRoot, realPath } from './path-containment.js';
34
+ import { isWithinRoot } from './path-containment.js';
35
+ import { RepoTree, errorCode, incompleteCheckIssue, isAbsoluteAnywhere } from './repo-tree.js';
36
36
  // ── Patterns ──────────────────────────────────────────────────────────────────
37
37
  /** Matches markdown inline links: [label](target) — captures target (may include a title). */
38
38
  const MD_LINK_RE = /\[(?:[^\]]*)\]\(([^)]+)\)/g;
@@ -494,14 +494,19 @@ function collectRefs(content, opts = {}) {
494
494
  const before = content.slice(lineOffsets[line - 1] ?? 0, index);
495
495
  return (before.match(/`/g)?.length ?? 0) % 2 === 1;
496
496
  };
497
- const pushLink = (ref, evidence, index) => {
497
+ const pushLink = (ref, evidence, index, linkRaw) => {
498
498
  if (!ref || isExternalRef(ref))
499
499
  return;
500
500
  const line = lineForOffset(lineOffsets, index);
501
+ // pcic-oonz: a link written inside a fence or a code span is sample text.
502
+ // It is still CHECKED (a stale example is worth reporting), but the
503
+ // mechanical fix must leave it alone.
504
+ const inCodeContext = fenceLines[line - 1]?.inFence === true || inCode(index);
501
505
  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' });
506
+ found.push({ ref, evidence, line, kind: 'file', linkRaw, inCodeContext });
507
+ else if (isExtensionlessPathRef(ref) && !inCode(index)) {
508
+ found.push({ ref, evidence, line, kind: 'linkPath', linkRaw, inCodeContext });
509
+ }
505
510
  };
506
511
  // Markdown links: [label](target) or [label](target "Title")
507
512
  let m;
@@ -510,7 +515,7 @@ function collectRefs(content, opts = {}) {
510
515
  if (m.index >= 2 && content[m.index - 2] === '[' && content[m.index - 1] === '!')
511
516
  continue;
512
517
  const raw = m[1] ?? '';
513
- pushLink(stripFragment(stripLinkTitle(raw)), `[...](${raw})`, m.index);
518
+ pushLink(stripFragment(stripLinkTitle(raw)), `[...](${raw})`, m.index, raw);
514
519
  }
515
520
  const nestedLinkRe = new RegExp(NESTED_MD_LINK_RE.source, NESTED_MD_LINK_RE.flags);
516
521
  while ((m = nestedLinkRe.exec(content)) !== null) {
@@ -518,7 +523,7 @@ function collectRefs(content, opts = {}) {
518
523
  if (!labelPart.includes(']('))
519
524
  continue;
520
525
  const raw = m[1] ?? '';
521
- pushLink(stripFragment(stripLinkTitle(raw)), `[...](${raw})`, m.index);
526
+ pushLink(stripFragment(stripLinkTitle(raw)), `[...](${raw})`, m.index, raw);
522
527
  }
523
528
  // Reference-style link definitions: [ref]: docs/missing.md "Title"
524
529
  const linkDefRe = new RegExp(LINK_REF_DEF_RE.source, LINK_REF_DEF_RE.flags);
@@ -621,182 +626,6 @@ function segmentsOf(ref) {
621
626
  function underOutputDir(ref) {
622
627
  return segmentsOf(ref).some((s) => OUTPUT_DIRS.has(s.toLowerCase()));
623
628
  }
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
629
  /** Trees never searched for an abbreviated path (dependencies, VCS, worktrees, engine caches). */
801
630
  const SUFFIX_SEARCH_IGNORE = [
802
631
  ...[
@@ -904,7 +733,7 @@ function judgeReference(ref, kind, codeSpan, fileDir, ctx) {
904
733
  // Windows scan never opens an SMB connection for `\\host\…`.
905
734
  // A drive-relative `C:notes.md` is skipped too: Windows resolves it against
906
735
  // 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))
736
+ if (isAbsoluteAnywhere(target))
908
737
  return null;
909
738
  const lookups = [];
910
739
  for (const abs of new Set([path.resolve(fileDir, target), path.resolve(tree.root, target)])) {
@@ -917,14 +746,26 @@ function judgeReference(ref, kind, codeSpan, fileDir, ctx) {
917
746
  if (lookups.length === 0)
918
747
  return null;
919
748
  // The repository's own ignore rules say the path is local state (pcic-2b6.8,
920
- // same matcher the skills detector uses).
749
+ // same matcher the skills detector uses). When those rules are not available
750
+ // the reference may well BE ignored local state, so it is never reported
751
+ // broken on the strength of rules nobody read: a `.gitignore` a caller's
752
+ // inventory listed without fetching (pcic-csf) is silent, and one the scan
753
+ // genuinely could not read is left unjudged and named in the notice below,
754
+ // the same way an unlistable directory is.
755
+ let ignoreRulesUnreadable = false;
921
756
  for (const { abs } of lookups) {
922
757
  const rel = path.relative(tree.root, abs).replace(/\\/g, '/');
923
758
  if (!rel)
924
759
  continue;
925
- if (isIgnored(rel) || (pathLike && isIgnored(`${rel}/${DIR_PROBE}`)))
926
- return null;
760
+ for (const verdict of [isIgnored(rel), ...(pathLike ? [isIgnored(`${rel}/${DIR_PROBE}`)] : [])]) {
761
+ if (verdict === true || verdict === 'unfetched')
762
+ return null;
763
+ if (verdict === 'unreadable')
764
+ ignoreRulesUnreadable = true;
765
+ }
927
766
  }
767
+ if (ignoreRulesUnreadable)
768
+ return UNJUDGED;
928
769
  const moduleStyle = pathLike && !target.endsWith('/');
929
770
  if (lookups.some(({ abs, found }) => found.state === 'found' || (moduleStyle && tree.hasStemSibling(abs)))) {
930
771
  return null;
@@ -944,7 +785,7 @@ function judgeReference(ref, kind, codeSpan, fileDir, ctx) {
944
785
  // skipped by isPlaceholderRef.
945
786
  if (codeSpan && !/[/\\]/.test(target)) {
946
787
  const index = ctx.nameIndex();
947
- if (index.paths.get(target)?.some((rel) => !isIgnored(rel)))
788
+ if (index.paths.get(target)?.some((rel) => isIgnored(rel) !== true))
948
789
  return null;
949
790
  // pcic-xhzb: a walk that could not list everything is no evidence of absence.
950
791
  if (!index.complete)
@@ -998,6 +839,94 @@ function judgeReference(ref, kind, codeSpan, fileDir, ctx) {
998
839
  return UNJUDGED;
999
840
  return 0.6;
1000
841
  }
842
+ // ── Mechanical fix: a link whose target moved (pcic-oonz) ────────────────────
843
+ /**
844
+ * pcic-oonz: the substitution that re-points a broken inline markdown link at
845
+ * the file it names, or `undefined` when the move is not unambiguous.
846
+ *
847
+ * Every condition below has to hold, and each one is there to keep the rewrite
848
+ * a fact rather than a guess:
849
+ *
850
+ * - The reference is an INLINE markdown link, outside any fence or code span,
851
+ * naming a FILE (a checkable extension, no trailing slash), whose raw target
852
+ * is a plain path (no angle brackets, no link title, no surrounding
853
+ * whitespace). The other forms are rewritable in principle, but each needs
854
+ * its own reassembly rule, and a recipe that is only sometimes right is worse
855
+ * than none.
856
+ * - The path it would be rewritten TO needs no escaping, and does not point at
857
+ * the linking file itself.
858
+ * - The repository holds EXACTLY ONE non-ignored file with that basename,
859
+ * found in the name index the detector already builds — one walk, inside the
860
+ * scan root, dependency and output trees excluded (see
861
+ * {@link SUFFIX_SEARCH_IGNORE}). Two candidates, or none, stay advisory:
862
+ * only the author knows which `README.md` was meant.
863
+ * - That walk was COMPLETE. A tree the scan could not list fully is no
864
+ * evidence that a basename is unique in it.
865
+ * - The `](target)` span occurs exactly once on the line, so the edit cannot
866
+ * land on a second link that happens to share the target text.
867
+ *
868
+ * The new target is the path from the linking file's own directory, with
869
+ * forward slashes and an explicit `./` when it does not already start with a
870
+ * `.` — the form the repository's own links use. A `#fragment` is carried over
871
+ * verbatim.
872
+ */
873
+ function movedLinkEdit(filePath, line, lineText, linkRaw, kind, ctx) {
874
+ // Only a link to a FILE — one with a checkable extension and no trailing
875
+ // slash. A directory link resolves to a tree, not to a name the file index
876
+ // can identify, and an extension-less target could match a same-named file
877
+ // that is not what the link meant.
878
+ if (kind !== 'file' || linkRaw.endsWith('/'))
879
+ return undefined;
880
+ if (linkRaw !== linkRaw.trim() || linkRaw.startsWith('<'))
881
+ return undefined;
882
+ if (stripLinkTitle(linkRaw) !== linkRaw)
883
+ return undefined; // carries a title
884
+ // Plain path characters only. The nested-link pattern can capture a target
885
+ // that still carries the shape of the link around it — seen live as
886
+ // `([detail](docs/agents/ci-and-deploys.md` — and splicing a replacement
887
+ // into the `](` of a DIFFERENT link would corrupt both. Brackets,
888
+ // parentheses, whitespace and backticks are rejected outright, and a target
889
+ // that needs them stays advisory.
890
+ if (/[()[\]\s`]/.test(linkRaw))
891
+ return undefined;
892
+ const hash = linkRaw.indexOf('#');
893
+ const targetPath = hash === -1 ? linkRaw : linkRaw.slice(0, hash);
894
+ const fragment = hash === -1 ? '' : linkRaw.slice(hash);
895
+ const base = path.basename(targetPath.replace(/\\/g, '/').replace(/\/+$/, ''));
896
+ if (!base)
897
+ return undefined;
898
+ const index = ctx.nameIndex();
899
+ if (!index.complete)
900
+ return undefined;
901
+ const candidates = (index.paths.get(base) ?? []).filter((rel) => ctx.isIgnored(rel) !== true);
902
+ if (candidates.length !== 1)
903
+ return undefined;
904
+ const absCandidate = path.resolve(ctx.tree.root, candidates[0]);
905
+ // A file cannot be the fix for a link inside itself.
906
+ if (absCandidate === path.resolve(filePath))
907
+ return undefined;
908
+ const relative = path.relative(path.dirname(filePath), absCandidate).split(path.sep).join('/');
909
+ if (!relative)
910
+ return undefined;
911
+ // The new target is written into `](…)` as-is, so it must need no escaping.
912
+ // A repository file whose path holds a space, a bracket, a parenthesis, `#`,
913
+ // `%`, `<`, `>`, `?` or a backtick would produce a link that renders as
914
+ // literal text or points somewhere else entirely — `](my docs/notes.md)` is
915
+ // not a link to `my docs/notes.md`. Percent-encoding it would be a second
916
+ // guess about the author's markdown flavour, so such a move stays advisory.
917
+ if (/[()[\]\s`#%<>?]/.test(relative))
918
+ return undefined;
919
+ const newTarget = `${relative.startsWith('.') ? relative : `./${relative}`}${fragment}`;
920
+ if (newTarget === linkRaw)
921
+ return undefined;
922
+ const find = `](${linkRaw})`;
923
+ let occurrences = 0;
924
+ for (let at = lineText.indexOf(find); at !== -1; at = lineText.indexOf(find, at + find.length))
925
+ occurrences++;
926
+ if (occurrences !== 1)
927
+ return undefined;
928
+ return { filePath, line, find, replace: `](${newTarget})` };
929
+ }
1001
930
  // ── Detector ─────────────────────────────────────────────────────────────────
1002
931
  /**
1003
932
  * Detect broken file references across all instruction files.
@@ -1014,10 +943,16 @@ function judgeReference(ref, kind, codeSpan, fileDir, ctx) {
1014
943
  * referenced in both CLAUDE.md and AGENTS.md would fire twice, inflating issue counts
1015
944
  * and health-score penalties.
1016
945
  */
1017
- export function detectDeadReferences(files, repoRoot) {
946
+ export function detectDeadReferences(files, repoRoot,
947
+ /**
948
+ * The scan's view of the repository. Pass `context.tree` so the listings and
949
+ * any caller-supplied file inventory (pcic-csf) are shared with every other
950
+ * detector; omitted, a plain filesystem-backed tree over `repoRoot` is used.
951
+ */
952
+ sharedTree) {
1018
953
  // normRef → { displayRef, filePaths, fileLines, evidenceLines, minConfidence }
1019
954
  const byRef = new Map();
1020
- const tree = new RepoTree(repoRoot);
955
+ const tree = sharedTree ?? new RepoTree(repoRoot);
1021
956
  let ctx;
1022
957
  let nameIndexCache;
1023
958
  const nameIndex = () => (nameIndexCache ??= buildNameIndex(tree));
@@ -1028,7 +963,10 @@ export function detectDeadReferences(files, repoRoot) {
1028
963
  const refs = collectRefs(file.content, {
1029
964
  skipSourceBackticks: SKIP_SOURCE_BACKTICK_FILE_TYPES.has(file.fileType),
1030
965
  });
1031
- for (const { ref, evidence, line, kind, codeSpan } of refs) {
966
+ // pcic-oonz: the file's own lines, so a rewrite can be confirmed
967
+ // unambiguous on the line it would edit.
968
+ const fileLineTexts = file.content.split(/\r?\n/);
969
+ for (const { ref, evidence, line, kind, codeSpan, linkRaw, inCodeContext } of refs) {
1032
970
  // BUG-009: Skip obvious placeholder/example filenames (foo.ts, bar.go, etc.)
1033
971
  if (isPlaceholderRef(ref))
1034
972
  continue;
@@ -1039,7 +977,7 @@ export function detectDeadReferences(files, repoRoot) {
1039
977
  if (isHomeRooted(ref))
1040
978
  continue;
1041
979
  // Resolve relative to the instruction file first, then repo root.
1042
- ctx ??= { tree, isIgnored: createGitIgnoreChecker(repoRoot), suffixCache: new Map(), nameIndex };
980
+ ctx ??= { tree, isIgnored: createGitIgnoreChecker(tree), suffixCache: new Map(), nameIndex };
1043
981
  const confidence = judgeReference(ref, kind, codeSpan ?? false, fileDir, ctx);
1044
982
  if (confidence === null)
1045
983
  continue;
@@ -1069,9 +1007,20 @@ export function detectDeadReferences(files, repoRoot) {
1069
1007
  fileLines: new Map(),
1070
1008
  evidenceLines: [],
1071
1009
  minConfidence: confidence,
1010
+ edits: [],
1011
+ allRewritable: true,
1072
1012
  });
1073
1013
  }
1074
1014
  const entry = byRef.get(normRef);
1015
+ // pcic-oonz: can THIS occurrence be re-pointed at the file it names?
1016
+ const edit = linkRaw === undefined || inCodeContext === true
1017
+ ? undefined
1018
+ : movedLinkEdit(file.path, line, fileLineTexts[line - 1] ?? '', linkRaw, kind, ctx);
1019
+ if (edit === undefined)
1020
+ entry.allRewritable = false;
1021
+ else if (!entry.edits.some((e) => e.filePath === edit.filePath && e.line === edit.line && e.find === edit.find)) {
1022
+ entry.edits.push(edit);
1023
+ }
1075
1024
  entry.filePaths.add(file.path);
1076
1025
  const existingLine = entry.fileLines.get(file.path);
1077
1026
  if (existingLine === undefined || line < existingLine) {
@@ -1085,7 +1034,7 @@ export function detectDeadReferences(files, repoRoot) {
1085
1034
  }
1086
1035
  const issues = [];
1087
1036
  const MIN_CONFIDENCE_THRESHOLD = MIN_CONFIDENCE;
1088
- for (const [normRef, { displayRef, kind, filePaths, fileLines, evidenceLines, minConfidence }] of byRef) {
1037
+ for (const [normRef, { displayRef, kind, filePaths, fileLines, evidenceLines, minConfidence, edits, allRewritable }] of byRef) {
1089
1038
  // BUG-002: Skip findings below the minimum confidence threshold.
1090
1039
  // Low-confidence source-code path references are almost certainly architecture docs.
1091
1040
  if (minConfidence < MIN_CONFIDENCE_THRESHOLD)
@@ -1110,58 +1059,28 @@ export function detectDeadReferences(files, repoRoot) {
1110
1059
  evidence: evidenceLines,
1111
1060
  recommendation: 'Verify the path is correct relative to the instruction file location, ' +
1112
1061
  'or remove the reference if the file no longer exists.',
1062
+ ...(allRewritable && edits.length > 0
1063
+ ? {
1064
+ fixRecipe: `Re-point the link${edits.length > 1 ? 's' : ''} at the one file of that name in the ` +
1065
+ `repository: ${[...new Set(edits.map((e) => `\`${e.find}\` -> \`${e.replace}\``))].join(', ')}.`,
1066
+ // `applyFixRecipe` rewrites exactly those `](target)` spans.
1067
+ autoApplySafe: true,
1068
+ fixEdits: edits,
1069
+ }
1070
+ : {}),
1113
1071
  confidence: minConfidence,
1114
1072
  });
1115
1073
  }
1116
- if (unjudged.size > 0)
1117
- issues.push(incompleteCheckIssue(tree, [...unjudged.values()]));
1118
- return issues;
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
- }
1074
+ if (unjudged.size > 0) {
1075
+ issues.push(incompleteCheckIssue(tree, [...unjudged.values()], {
1076
+ id: issueId('(dead-reference check incomplete)'),
1077
+ category: 'structure',
1078
+ title: 'Dead-reference check incomplete: part of the repository could not be read',
1079
+ unit: 'reference',
1080
+ unknown: 'They may or may not exist',
1081
+ finding: 'broken-reference',
1082
+ }));
1147
1083
  }
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
- };
1084
+ return issues;
1166
1085
  }
1167
1086
  //# sourceMappingURL=dead-references.js.map