@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.
- package/dist/action-pinning.d.ts +37 -0
- package/dist/action-pinning.d.ts.map +1 -0
- package/dist/action-pinning.js +277 -0
- package/dist/action-pinning.js.map +1 -0
- package/dist/agent-practices.d.ts +1 -1
- package/dist/agent-practices.d.ts.map +1 -1
- package/dist/agent-practices.js +107 -43
- package/dist/agent-practices.js.map +1 -1
- package/dist/ai-config.d.ts +42 -3
- package/dist/ai-config.d.ts.map +1 -1
- package/dist/ai-config.js +210 -12
- package/dist/ai-config.js.map +1 -1
- package/dist/alias-files.d.ts +33 -0
- package/dist/alias-files.d.ts.map +1 -0
- package/dist/alias-files.js +60 -0
- package/dist/alias-files.js.map +1 -0
- package/dist/buried-critical.d.ts.map +1 -1
- package/dist/buried-critical.js +4 -9
- package/dist/buried-critical.js.map +1 -1
- package/dist/canonical-owner.d.ts.map +1 -1
- package/dist/canonical-owner.js +45 -25
- package/dist/canonical-owner.js.map +1 -1
- package/dist/ci-alignment.d.ts.map +1 -1
- package/dist/ci-alignment.js +3 -2
- package/dist/ci-alignment.js.map +1 -1
- package/dist/command-validity.d.ts +16 -0
- package/dist/command-validity.d.ts.map +1 -1
- package/dist/command-validity.js +337 -63
- package/dist/command-validity.js.map +1 -1
- package/dist/conflicts.d.ts.map +1 -1
- package/dist/conflicts.js +3 -2
- package/dist/conflicts.js.map +1 -1
- package/dist/context-bloat.d.ts +1 -1
- package/dist/context-bloat.d.ts.map +1 -1
- package/dist/context-bloat.js +18 -7
- package/dist/context-bloat.js.map +1 -1
- package/dist/context-optimizer.d.ts.map +1 -1
- package/dist/context-optimizer.js +5 -2
- package/dist/context-optimizer.js.map +1 -1
- package/dist/custom-rules.d.ts.map +1 -1
- package/dist/custom-rules.js +6 -1
- package/dist/custom-rules.js.map +1 -1
- package/dist/dead-references.d.ts +16 -2
- package/dist/dead-references.d.ts.map +1 -1
- package/dist/dead-references.js +635 -77
- package/dist/dead-references.js.map +1 -1
- package/dist/dependency-staleness.d.ts +35 -0
- package/dist/dependency-staleness.d.ts.map +1 -0
- package/dist/dependency-staleness.js +1001 -0
- package/dist/dependency-staleness.js.map +1 -0
- package/dist/detectors.d.ts.map +1 -1
- package/dist/detectors.js +31 -6
- package/dist/detectors.js.map +1 -1
- package/dist/duplicates.d.ts.map +1 -1
- package/dist/duplicates.js +5 -4
- package/dist/duplicates.js.map +1 -1
- package/dist/evidence.d.ts +11 -0
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +40 -0
- package/dist/evidence.js.map +1 -1
- package/dist/finding-id.d.ts +21 -0
- package/dist/finding-id.d.ts.map +1 -0
- package/dist/finding-id.js +31 -0
- package/dist/finding-id.js.map +1 -0
- package/dist/frameworks/dotnet.js +2 -2
- package/dist/frameworks/dotnet.js.map +1 -1
- package/dist/frameworks/rust.d.ts.map +1 -1
- package/dist/frameworks/rust.js +2 -1
- package/dist/frameworks/rust.js.map +1 -1
- package/dist/git-symlinks.d.ts +30 -0
- package/dist/git-symlinks.d.ts.map +1 -0
- package/dist/git-symlinks.js +161 -0
- package/dist/git-symlinks.js.map +1 -0
- package/dist/gitignore.d.ts +22 -0
- package/dist/gitignore.d.ts.map +1 -0
- package/dist/gitignore.js +157 -0
- package/dist/gitignore.js.map +1 -0
- package/dist/health-score.d.ts.map +1 -1
- package/dist/health-score.js +34 -5
- package/dist/health-score.js.map +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/makefile.d.ts +37 -0
- package/dist/makefile.d.ts.map +1 -0
- package/dist/makefile.js +156 -0
- package/dist/makefile.js.map +1 -0
- package/dist/manifest-consistency.d.ts.map +1 -1
- package/dist/manifest-consistency.js +52 -8
- package/dist/manifest-consistency.js.map +1 -1
- package/dist/negative-instructions.d.ts.map +1 -1
- package/dist/negative-instructions.js +4 -9
- package/dist/negative-instructions.js.map +1 -1
- package/dist/path-containment.d.ts +29 -0
- package/dist/path-containment.d.ts.map +1 -1
- package/dist/path-containment.js +70 -0
- package/dist/path-containment.js.map +1 -1
- package/dist/prompt-cache.d.ts.map +1 -1
- package/dist/prompt-cache.js +8 -6
- package/dist/prompt-cache.js.map +1 -1
- package/dist/repo-context.d.ts +53 -0
- package/dist/repo-context.d.ts.map +1 -1
- package/dist/repo-context.js +134 -11
- package/dist/repo-context.js.map +1 -1
- package/dist/report.d.ts +7 -0
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +36 -7
- package/dist/report.js.map +1 -1
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +41 -2
- package/dist/scan.js.map +1 -1
- package/dist/scanner.d.ts +49 -1
- package/dist/scanner.d.ts.map +1 -1
- package/dist/scanner.js +323 -38
- package/dist/scanner.js.map +1 -1
- package/dist/security-pack.d.ts.map +1 -1
- package/dist/security-pack.js +6 -4
- package/dist/security-pack.js.map +1 -1
- package/dist/skill-supply-chain.d.ts +132 -0
- package/dist/skill-supply-chain.d.ts.map +1 -0
- package/dist/skill-supply-chain.js +2954 -0
- package/dist/skill-supply-chain.js.map +1 -0
- package/dist/skills-detector.d.ts +7 -3
- package/dist/skills-detector.d.ts.map +1 -1
- package/dist/skills-detector.js +98 -22
- package/dist/skills-detector.js.map +1 -1
- package/dist/stale-instructions.d.ts.map +1 -1
- package/dist/stale-instructions.js +2 -1
- package/dist/stale-instructions.js.map +1 -1
- package/dist/suppression.d.ts +2 -0
- package/dist/suppression.d.ts.map +1 -1
- package/dist/suppression.js +5 -2
- package/dist/suppression.js.map +1 -1
- package/dist/types.d.ts +35 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +22 -0
- package/dist/types.js.map +1 -1
- package/dist/vague-guidance.d.ts.map +1 -1
- package/dist/vague-guidance.js +2 -1
- package/dist/vague-guidance.js.map +1 -1
- package/dist/windsurf-detector.d.ts.map +1 -1
- package/dist/windsurf-detector.js +15 -6
- package/dist/windsurf-detector.js.map +1 -1
- package/dist/within-section-dedup.d.ts.map +1 -1
- package/dist/within-section-dedup.js +4 -9
- package/dist/within-section-dedup.js.map +1 -1
- package/dist/workspace-manifests.d.ts +80 -0
- package/dist/workspace-manifests.d.ts.map +1 -0
- package/dist/workspace-manifests.js +333 -0
- package/dist/workspace-manifests.js.map +1 -0
- package/package.json +1 -1
package/dist/dead-references.js
CHANGED
|
@@ -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.
|
|
12
|
-
*
|
|
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
|
|
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,
|
|
262
|
-
* inline spans (NOT markdown links or @-refs)
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
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
|
|
282
|
-
*
|
|
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', '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
445
|
-
if (
|
|
446
|
-
|
|
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,
|
|
461
|
-
//
|
|
462
|
-
// descriptions
|
|
463
|
-
// Shell (.sh), Python (.py), and JS files are
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
|
1102
|
+
title: `Broken ${noun} reference: ${name}${isDir ? '/' : ''}`,
|
|
594
1103
|
summary: `This instruction file references "${displayRef}" which does not appear to exist. ` +
|
|
595
|
-
`The
|
|
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
|