@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.
- package/dist/ai-config.d.ts +13 -6
- package/dist/ai-config.d.ts.map +1 -1
- package/dist/ai-config.js +25 -78
- package/dist/ai-config.js.map +1 -1
- package/dist/close-match.d.ts +97 -0
- package/dist/close-match.d.ts.map +1 -0
- package/dist/close-match.js +215 -0
- package/dist/close-match.js.map +1 -0
- package/dist/command-validity.d.ts.map +1 -1
- package/dist/command-validity.js +309 -76
- package/dist/command-validity.js.map +1 -1
- package/dist/context-optimizer.d.ts.map +1 -1
- package/dist/context-optimizer.js +9 -2
- package/dist/context-optimizer.js.map +1 -1
- package/dist/dead-references.d.ts +8 -1
- package/dist/dead-references.d.ts.map +1 -1
- package/dist/dead-references.js +162 -243
- package/dist/dead-references.js.map +1 -1
- package/dist/dependency-staleness.d.ts.map +1 -1
- package/dist/dependency-staleness.js +24 -38
- package/dist/dependency-staleness.js.map +1 -1
- package/dist/detectors.js +1 -1
- package/dist/detectors.js.map +1 -1
- package/dist/discovery.d.ts +138 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +569 -0
- package/dist/discovery.js.map +1 -0
- package/dist/fix-engine.d.ts.map +1 -1
- package/dist/fix-engine.js +75 -1
- package/dist/fix-engine.js.map +1 -1
- package/dist/frameworks/dotnet.d.ts.map +1 -1
- package/dist/frameworks/dotnet.js +10 -11
- package/dist/frameworks/dotnet.js.map +1 -1
- package/dist/frameworks/go.d.ts.map +1 -1
- package/dist/frameworks/go.js +8 -4
- package/dist/frameworks/go.js.map +1 -1
- package/dist/frameworks/nextjs.d.ts.map +1 -1
- package/dist/frameworks/nextjs.js +8 -17
- package/dist/frameworks/nextjs.js.map +1 -1
- package/dist/frameworks/python.d.ts.map +1 -1
- package/dist/frameworks/python.js +5 -3
- package/dist/frameworks/python.js.map +1 -1
- package/dist/frameworks/rust.d.ts.map +1 -1
- package/dist/frameworks/rust.js +3 -3
- package/dist/frameworks/rust.js.map +1 -1
- package/dist/frameworks/unity.d.ts.map +1 -1
- package/dist/frameworks/unity.js +6 -5
- package/dist/frameworks/unity.js.map +1 -1
- package/dist/gitignore.d.ts +31 -2
- package/dist/gitignore.d.ts.map +1 -1
- package/dist/gitignore.js +33 -8
- package/dist/gitignore.js.map +1 -1
- package/dist/hooks-settings-detector.d.ts.map +1 -1
- package/dist/hooks-settings-detector.js +5 -1
- package/dist/hooks-settings-detector.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/known-paths.d.ts +82 -0
- package/dist/known-paths.d.ts.map +1 -0
- package/dist/known-paths.js +152 -0
- package/dist/known-paths.js.map +1 -0
- package/dist/makefile.d.ts +11 -3
- package/dist/makefile.d.ts.map +1 -1
- package/dist/makefile.js +28 -22
- package/dist/makefile.js.map +1 -1
- package/dist/manifest-consistency.d.ts +1 -1
- package/dist/manifest-consistency.d.ts.map +1 -1
- package/dist/manifest-consistency.js +12 -8
- package/dist/manifest-consistency.js.map +1 -1
- package/dist/mcp-config-detector.d.ts.map +1 -1
- package/dist/mcp-config-detector.js +5 -1
- package/dist/mcp-config-detector.js.map +1 -1
- package/dist/project-type.d.ts +2 -1
- package/dist/project-type.d.ts.map +1 -1
- package/dist/project-type.js +36 -48
- package/dist/project-type.js.map +1 -1
- package/dist/prompt-cache.d.ts.map +1 -1
- package/dist/prompt-cache.js +13 -2
- package/dist/prompt-cache.js.map +1 -1
- package/dist/repo-context.d.ts +9 -0
- package/dist/repo-context.d.ts.map +1 -1
- package/dist/repo-context.js +32 -35
- package/dist/repo-context.js.map +1 -1
- package/dist/repo-tree.d.ts +281 -0
- package/dist/repo-tree.d.ts.map +1 -0
- package/dist/repo-tree.js +455 -0
- package/dist/repo-tree.js.map +1 -0
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +3 -0
- package/dist/report.js.map +1 -1
- package/dist/sarif.d.ts +27 -0
- package/dist/sarif.d.ts.map +1 -0
- package/dist/sarif.js +357 -0
- package/dist/sarif.js.map +1 -0
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +8 -2
- package/dist/scan.js.map +1 -1
- package/dist/scanner.d.ts +4 -3
- package/dist/scanner.d.ts.map +1 -1
- package/dist/scanner.js +36 -77
- package/dist/scanner.js.map +1 -1
- package/dist/security-pack.d.ts.map +1 -1
- package/dist/security-pack.js +34 -35
- package/dist/security-pack.js.map +1 -1
- package/dist/skill-supply-chain.d.ts.map +1 -1
- package/dist/skill-supply-chain.js +29 -19
- package/dist/skill-supply-chain.js.map +1 -1
- package/dist/skills-detector.d.ts.map +1 -1
- package/dist/skills-detector.js +11 -3
- package/dist/skills-detector.js.map +1 -1
- package/dist/token-estimates.d.ts +42 -0
- package/dist/token-estimates.d.ts.map +1 -0
- package/dist/token-estimates.js +200 -0
- package/dist/token-estimates.js.map +1 -0
- package/dist/types.d.ts +89 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +5 -2
package/dist/dead-references.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
-
|
|
926
|
-
|
|
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) =>
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|