@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
@@ -1 +1 @@
1
- {"version":3,"file":"command-validity.d.ts","sourceRoot":"","sources":["../src/command-validity.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAqLhD,8FAA8F;AAC9F,eAAO,MAAM,YAAY,EAAE,WAAW,CAAC,MAAM,CAK3C,CAAC;AA+mBH;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,WAAW,GAAG,aAAa,EAAE,CAG3E;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,EAAE,aAAa,EAAE,CAAC;IACxB;;;;;;;;OAQG;IACH,gBAAgB,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC;CAC5C,CAAC;AAQF,wBAAgB,eAAe,CAAC,OAAO,EAAE,WAAW,GAAG,eAAe,CAOrE"}
1
+ {"version":3,"file":"command-validity.d.ts","sourceRoot":"","sources":["../src/command-validity.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAoUhD,8FAA8F;AAC9F,eAAO,MAAM,YAAY,EAAE,WAAW,CAAC,MAAM,CAK3C,CAAC;AAusBH;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,WAAW,GAAG,aAAa,EAAE,CAG3E;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,EAAE,aAAa,EAAE,CAAC;IACxB;;;;;;;;OAQG;IACH,gBAAgB,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC;CAC5C,CAAC;AAQF,wBAAgB,eAAe,CAAC,OAAO,EAAE,WAAW,GAAG,eAAe,CAOrE"}
@@ -1,11 +1,11 @@
1
- import * as fs from 'node:fs';
2
1
  import * as path from 'node:path';
3
2
  import * as crypto from 'node:crypto';
4
3
  import { fencedBlocks, scanFencedLines } from './markdown-fences.js';
5
4
  import { fileIdPath } from './finding-id.js';
6
5
  import { anyWorkspaceHasScript, isKnownBinary, selectWorkspaces } from './workspace-manifests.js';
7
6
  import { makefileDefines, readMakefile } from './makefile.js';
8
- import { realPathWithinRoot } from './path-containment.js';
7
+ import { incompleteCheckIssue, isAbsoluteAnywhere, treeFor } from './repo-tree.js';
8
+ import { closestUniqueMatch, isMechanicalRename } from './close-match.js';
9
9
  /**
10
10
  * Command Validity Detector
11
11
  *
@@ -19,9 +19,24 @@ import { realPathWithinRoot } from './path-containment.js';
19
19
  * - Prose patterns like "run pnpm test"
20
20
  * - Multi-command lines joined with && are split and each segment validated
21
21
  *
22
+ * Every path a command names is looked up through RepoTree (pcic-x1vl, the
23
+ * dead-reference containment of pcic-xhzb): inside the scan root only, with
24
+ * exact case. A path that leaves the root is not checked, and a directory that
25
+ * cannot be listed leaves its commands unchecked and named in one "check
26
+ * incomplete" warning instead of reported as missing.
27
+ *
22
28
  * Findings are heuristic — cautious wording is intentional.
23
29
  */
24
30
  // ── Helpers ───────────────────────────────────────────────────────────────────
31
+ /** Occurrences of a plain substring in one line of text (pcic-oonz). */
32
+ function countOnLine(line, needle) {
33
+ if (!needle)
34
+ return 0;
35
+ let count = 0;
36
+ for (let at = line.indexOf(needle); at !== -1; at = line.indexOf(needle, at + needle.length))
37
+ count++;
38
+ return count;
39
+ }
25
40
  /** Stable per-finding ID based on file + command text. */
26
41
  function issueId(filePath, cmdText) {
27
42
  const hash = crypto
@@ -31,30 +46,101 @@ function issueId(filePath, cmdText) {
31
46
  .slice(0, 12);
32
47
  return `cmd-validity-${hash}`;
33
48
  }
34
- /** Returns true when a repo-relative path exists on disk. */
35
- function fileExists(repoRoot, relativePath) {
36
- try {
37
- const full = path.resolve(repoRoot, relativePath);
38
- return fs.existsSync(full);
39
- }
40
- catch {
41
- return false;
49
+ /**
50
+ * Returned when the verdict depends on a directory that could not be listed:
51
+ * the command is neither validated nor reported, and the scan says what it
52
+ * skipped (pcic-x1vl, as dead-references does since pcic-xhzb).
53
+ */
54
+ const UNJUDGED = Symbol('unjudged');
55
+ /** Separators in a documented path: `/` everywhere, `\` too where the platform treats it as one. */
56
+ const PATH_SEPARATORS = path.sep === '\\' ? /[\\/]/ : /\//;
57
+ /**
58
+ * pcic-x1vl: `rel`, a path a command names, joined to `fromDir` (inside the
59
+ * root) one segment at a time; `null` when the path cannot be placed inside
60
+ * the scan root without knowing where the root is. That is:
61
+ * - absolute in any platform's form (`/x`, `C:\x`, `C:x`, `\\host\x`), even
62
+ * when it happens to point into the root: its verdict would depend on where
63
+ * the repository is checked out;
64
+ * - computed from the environment (`~/x`, `$HOME/x`, `%APPDATA%\x`, backticks);
65
+ * - climbing above the root at any point, even when a later segment names the
66
+ * root's own directory again (`../<root name>/x`): resolving it would make
67
+ * the verdict depend on, and so reveal, the name of the directory the scan
68
+ * runs in.
69
+ */
70
+ function withinRoot(tree, fromDir, rel) {
71
+ if (isAbsoluteAnywhere(rel) || /^~|[$`%]/.test(rel))
72
+ return null;
73
+ const parts = path.relative(tree.root, fromDir).split(path.sep).filter(Boolean);
74
+ if (parts.includes('..') || path.isAbsolute(parts.join(path.sep)))
75
+ return null;
76
+ for (const segment of rel.split(PATH_SEPARATORS)) {
77
+ if (segment === '' || segment === '.')
78
+ continue;
79
+ if (segment !== '..')
80
+ parts.push(segment);
81
+ else if (parts.pop() === undefined)
82
+ return null;
42
83
  }
84
+ return path.join(tree.root, ...parts);
43
85
  }
44
- function isDirectory(repoRoot, relativePath) {
45
- try {
46
- return fs.statSync(path.resolve(repoRoot, relativePath)).isDirectory();
47
- }
48
- catch {
49
- return false;
50
- }
86
+ /**
87
+ * pcic-x1vl: what the repository holds at `rel`, a path a command names,
88
+ * resolved against the command's directory. The only way this detector asks
89
+ * the filesystem about such a path. A path {@link withinRoot} cannot place is
90
+ * `outside` before any lookup, and RepoTree never touches anything outside the
91
+ * scan root: one through a committed link out of the tree is `outside` too.
92
+ * Names match with exact case, so the verdict is the same on every platform,
93
+ * and the same as on Linux and in CI.
94
+ */
95
+ function locate(tree, baseDir, rel) {
96
+ const abs = withinRoot(tree, baseDir, rel);
97
+ return abs === null ? { state: 'outside' } : tree.lookup(abs);
51
98
  }
52
- function readPackageScripts(repoRoot, packageJsonPath) {
53
- // A manifest committed as a symlink to a file outside the repo is not read.
54
- if (!realPathWithinRoot(repoRoot, packageJsonPath))
99
+ /**
100
+ * The finding for a path a command needs, by what {@link locate} found.
101
+ * pcic-x1vl: a path outside the scan root is not judged, whether or not it
102
+ * exists. Reporting it would claim a file is missing without having looked,
103
+ * and checking it would tell whoever wrote the instruction file whether a path
104
+ * on the scanning machine exists (in the hosted product, the server's). A
105
+ * monorepo package scanned on its own can legitimately run `../../scripts/x.sh`.
106
+ */
107
+ function missingUnless(found, message) {
108
+ if (found.state === 'missing')
109
+ return message;
110
+ if (found.state === 'unreadable')
111
+ return UNJUDGED;
112
+ return null;
113
+ }
114
+ /**
115
+ * The name and scripts of a package.json; `null` when there is none to read
116
+ * (or it does not parse); `unreadable` when a directory on the way could not
117
+ * be listed, so whether it exists is unknown; `unfetched` when a caller's
118
+ * inventory says the manifest is there but its bytes were never fetched
119
+ * (pcic-csf) — nothing failed, so callers stay silent rather than warn.
120
+ */
121
+ function readPackageScripts(tree, packageJsonPath) {
122
+ // pcic-x1vl: looked up through the tree, so a manifest path that leaves the
123
+ // root (`cd` through a link out of the tree) is never touched, and a
124
+ // manifest committed as a link out of the tree is not read.
125
+ const found = tree.lookup(packageJsonPath);
126
+ if (found.state === 'unreadable')
127
+ return 'unreadable';
128
+ if (found.state !== 'found' || found.isDir)
129
+ return null;
130
+ // pcic-csf: a caller's inventory can say the manifest is there without its
131
+ // bytes being on disk. Its scripts are then unknown, not absent — reporting
132
+ // them missing is the false positive the inventory exists to remove. The two
133
+ // "cannot read" answers stay apart: `unreadable` is a real failure the scan
134
+ // should name, `unfetched` is nothing to name (see RepoTree.readText).
135
+ const read = tree.readText(packageJsonPath);
136
+ if (read.state === 'unreadable' || read.state === 'too-large')
137
+ return 'unreadable';
138
+ if (read.state === 'unfetched')
139
+ return 'unfetched';
140
+ if (read.state !== 'found')
55
141
  return null;
56
142
  try {
57
- const raw = fs.readFileSync(packageJsonPath, 'utf8');
143
+ const raw = read.content;
58
144
  const parsed = JSON.parse(raw);
59
145
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
60
146
  return null;
@@ -92,28 +178,37 @@ function normalizePnpmFilter(value) {
92
178
  .replace(/^!/, '')
93
179
  .trim();
94
180
  }
95
- function workspacePackageJsonCandidates(repoRoot, selector) {
181
+ /**
182
+ * The package.json files a workspace selector may name. `complete` is false
183
+ * when a workspace directory could not be listed (pcic-x1vl): a selector that
184
+ * matches nothing may still name a package in it.
185
+ */
186
+ function workspacePackageJsonCandidates(tree, selector) {
96
187
  const candidates = new Set();
188
+ let complete = true;
97
189
  const selectorAsPath = selector.replace(/\\/g, '/');
98
190
  if (selectorAsPath.startsWith('.') || selectorAsPath.includes('/')) {
99
- candidates.add(path.join(repoRoot, selectorAsPath, 'package.json'));
191
+ // pcic-x1vl: a selector path that leaves the root is not looked up.
192
+ const dir = withinRoot(tree, tree.root, selectorAsPath);
193
+ if (dir !== null)
194
+ candidates.add(path.join(dir, 'package.json'));
100
195
  }
101
196
  for (const dir of ['apps', 'packages', 'libs']) {
102
- const absDir = path.join(repoRoot, dir);
103
- try {
104
- for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) {
105
- if (entry.isDirectory()) {
106
- candidates.add(path.join(absDir, entry.name, 'package.json'));
107
- }
197
+ const absDir = path.join(tree.root, dir);
198
+ const entries = tree.entries(absDir);
199
+ if (entries === 'unreadable')
200
+ complete = false;
201
+ if (typeof entries === 'string')
202
+ continue; // workspace directory absent (or not inside the root)
203
+ for (const entry of entries.values()) {
204
+ if (entry.isDirectory()) {
205
+ candidates.add(path.join(absDir, entry.name, 'package.json'));
108
206
  }
109
207
  }
110
- catch {
111
- // Workspace directory absent.
112
- }
113
208
  }
114
- return [...candidates];
209
+ return { candidates: [...candidates], complete };
115
210
  }
116
- function workspaceHasScript(context, selector, scriptName) {
211
+ function workspaceHasScript(context, tree, selector, scriptName) {
117
212
  const normalizedSelector = normalizePnpmFilter(selector);
118
213
  if (!normalizedSelector)
119
214
  return false;
@@ -123,9 +218,16 @@ function workspaceHasScript(context, selector, scriptName) {
123
218
  if (context.workspaces) {
124
219
  return selectWorkspaces(context, normalizedSelector).some((ws) => !!ws.scripts[scriptName]);
125
220
  }
126
- for (const candidate of workspacePackageJsonCandidates(context.repoRoot, normalizedSelector)) {
127
- const pkg = readPackageScripts(context.repoRoot, candidate);
128
- if (!pkg)
221
+ const { candidates, complete } = workspacePackageJsonCandidates(tree, normalizedSelector);
222
+ let unknown = !complete;
223
+ let silent = false;
224
+ for (const candidate of candidates) {
225
+ const pkg = readPackageScripts(tree, candidate);
226
+ if (pkg === 'unreadable')
227
+ unknown = true;
228
+ if (pkg === 'unfetched')
229
+ silent = true;
230
+ if (!pkg || pkg === 'unreadable' || pkg === 'unfetched')
129
231
  continue;
130
232
  const packageDir = path.dirname(candidate).replace(/\\/g, '/');
131
233
  const relDir = path.relative(context.repoRoot, packageDir).replace(/\\/g, '/');
@@ -135,7 +237,10 @@ function workspaceHasScript(context, selector, scriptName) {
135
237
  if (selectorMatches && !!pkg.scripts[scriptName])
136
238
  return true;
137
239
  }
138
- return false;
240
+ if (unknown)
241
+ return 'unknown';
242
+ // A manifest that exists but was never fetched could define the script.
243
+ return silent ? 'silent' : false;
139
244
  }
140
245
  /** Returns true when a path string looks like a file reference (not a flag). */
141
246
  function isLikelyFilePath(str) {
@@ -188,9 +293,10 @@ function resolveCwd(current, target) {
188
293
  if (current === null)
189
294
  return null;
190
295
  const cleaned = target.replace(/^['"]|['"]$/g, '').trim();
191
- if (!cleaned || cleaned.startsWith('/') || cleaned.startsWith('~') || /^[a-zA-Z]:[\\/]/.test(cleaned)) {
192
- return null; // absolute or home path — not resolvable to a repo-relative dir
193
- }
296
+ // Absolute in any platform's form (`/x`, `C:\x`, drive-relative `C:x`, `\\host\x`)
297
+ // or home: not resolvable to a repo-relative dir (pcic-x1vl: on every platform alike).
298
+ if (!cleaned || isAbsoluteAnywhere(cleaned) || cleaned.startsWith('~'))
299
+ return null;
194
300
  // `cd -`, `cd $DIR`, `cd "$(git rev-parse --show-toplevel)"`, `cd %TEMP%`,
195
301
  // `cd <dir>`: computed or placeholder targets name no knowable directory.
196
302
  if (/^-|[$`%<>{}*?]/.test(cleaned))
@@ -415,7 +521,7 @@ function cwdOverride(parts) {
415
521
  }
416
522
  return undefined;
417
523
  }
418
- function findMissingScript(context, cwd, parts, scriptName, builtins, tool, lookup, onScript) {
524
+ function findMissingScript(context, tree, cwd, parts, scriptName, builtins, tool, lookup, onScript, suggest) {
419
525
  const names = scriptName.includes('/')
420
526
  ? scriptName.split('/').map((s) => s.trim()).filter(Boolean)
421
527
  : [scriptName];
@@ -435,17 +541,45 @@ function findMissingScript(context, cwd, parts, scriptName, builtins, tool, look
435
541
  }
436
542
  const selector = lookup.workspaceName ?? workspaceSelector(tool, parts);
437
543
  const allWorkspaces = runsInAllWorkspaces(tool, parts);
438
- const inWorkspaceScope = (name) => allWorkspaces ? anyWorkspaceHasScript(context, name) : workspaceHasScript(context, selector, name);
544
+ const inWorkspaceScope = (name) => allWorkspaces ? anyWorkspaceHasScript(context, name) : workspaceHasScript(context, tree, selector, name);
439
545
  // With discovered workspaces, an explicit selector means ONLY the selected
440
546
  // packages: the root manifest's same-named script is not what runs, and a
441
547
  // selector that matches no package is itself the error.
442
548
  const selectedWorkspaces = selector && context.workspaces && !allWorkspaces
443
549
  ? selectWorkspaces(context, selector.replace(/^['"]|['"]$/g, '').replace(/^\.\.\./, '').replace(/\.\.\.$/, '').trim())
444
550
  : undefined;
551
+ /**
552
+ * pcic-oonz: offers the one close match for a name that is about to be
553
+ * reported missing, from the manifest(s) that would actually have run it.
554
+ *
555
+ * Deliberately silent for the slash shorthand (`pnpm lint/typecheck`): the
556
+ * rewrite would have to edit one component of a token that documents several
557
+ * scripts, which is not the mechanical single-token substitution the fix
558
+ * engine applies. Silent too whenever a workspace selector is in play
559
+ * without discovered workspaces to resolve it against — the candidate pool
560
+ * would then be the root manifest, which is not where the script would run.
561
+ */
562
+ const propose = (name, pool) => {
563
+ if (!suggest || names.length !== 1)
564
+ return;
565
+ const candidate = closestUniqueMatch(name, pool);
566
+ if (candidate !== undefined) {
567
+ suggest({ missing: name, candidate, mechanical: isMechanicalRename(name, candidate) });
568
+ }
569
+ };
570
+ const scopedToWorkspaces = selector !== undefined || allWorkspaces;
445
571
  const inSubdir = cwd !== '.' && cwd !== '';
446
572
  const dirPkg = inSubdir
447
- ? readPackageScripts(context.repoRoot, path.join(context.repoRoot, cwd, 'package.json'))
573
+ ? readPackageScripts(tree, path.join(tree.root, cwd, 'package.json'))
448
574
  : null;
575
+ // pcic-csf: the manifest is there but was never fetched. Nothing failed and
576
+ // there is no location to name, so say nothing at all — a "check incomplete"
577
+ // warning here would name zero locations and tell the reader nothing.
578
+ if (dirPkg === 'unfetched')
579
+ return null;
580
+ // pcic-x1vl: a directory on the way could not be listed — say so, don't guess.
581
+ if (dirPkg === 'unreadable')
582
+ return UNJUDGED;
449
583
  // cd'd into a directory whose package.json we can't read — don't guess.
450
584
  if (inSubdir && !dirPkg)
451
585
  return null;
@@ -471,6 +605,13 @@ function findMissingScript(context, cwd, parts, scriptName, builtins, tool, look
471
605
  const target = selectedWorkspaces.length === 1
472
606
  ? `${selectedWorkspaces[0].name ?? selectedWorkspaces[0].dir} (${selectedWorkspaces[0].dir}/package.json)`
473
607
  : `the ${selectedWorkspaces.length} workspaces selected by "${selector}"`;
608
+ // The selected packages ARE the manifests that would run the script — all
609
+ // of them, so a candidate only SOME of them define would still leave the
610
+ // command broken where it is missing. The pool is therefore the
611
+ // intersection of their script names (which for one selected package is
612
+ // simply that package's scripts).
613
+ const pools = selectedWorkspaces.map((ws) => Object.keys(ws.scripts));
614
+ propose(name, pools.slice(1).reduce((shared, pool) => shared.filter((n) => pool.includes(n)), pools[0] ?? []));
474
615
  return `${tool} script "${name}" does not appear in ${target} scripts`;
475
616
  }
476
617
  if (dirPkg) {
@@ -481,15 +622,26 @@ function findMissingScript(context, cwd, parts, scriptName, builtins, tool, look
481
622
  // script.
482
623
  if (dirPkg.scripts[name])
483
624
  continue;
484
- if (inWorkspaceScope(name))
625
+ const inWorkspace = inWorkspaceScope(name);
626
+ if (inWorkspace === true || inWorkspace === 'silent')
485
627
  continue;
628
+ if (inWorkspace === 'unknown')
629
+ return UNJUDGED;
630
+ if (!scopedToWorkspaces)
631
+ propose(name, Object.keys(dirPkg.scripts));
486
632
  return `${tool} script "${name}" does not appear in ${cwd}/package.json scripts`;
487
633
  }
488
634
  // Repo-root context: the workspace target(s) or the root manifest.
489
- if (inWorkspaceScope(name))
635
+ const inWorkspace = inWorkspaceScope(name);
636
+ if (inWorkspace === true || inWorkspace === 'silent')
490
637
  continue;
491
638
  if (context.packageJson.scripts[name])
492
639
  continue;
640
+ // pcic-x1vl: a selected workspace may define it in a directory that could not be read.
641
+ if (inWorkspace === 'unknown')
642
+ return UNJUDGED;
643
+ if (!scopedToWorkspaces)
644
+ propose(name, Object.keys(context.packageJson.scripts));
493
645
  return `${tool} script "${name}" does not appear in package.json scripts`;
494
646
  }
495
647
  return null;
@@ -509,7 +661,7 @@ const BUILTINS_BY_TOOL = {
509
661
  * to an installed binary), `yarn workspace <name> <script>`, and workspace or
510
662
  * directory selectors.
511
663
  */
512
- function validatePackageManagerScript(tool, parts, context, cwd, baseDir, onScript) {
664
+ function validatePackageManagerScript(tool, parts, context, tree, cwd, baseDir, onScript, suggest) {
513
665
  const subIdx = firstNonFlagArgIndex(parts);
514
666
  const sub = parts[subIdx];
515
667
  if (!sub)
@@ -540,15 +692,28 @@ function validatePackageManagerScript(tool, parts, context, cwd, baseDir, onScri
540
692
  if (!explicitRun && BUN_BUILTINS.has(scriptName))
541
693
  return null;
542
694
  if (isLikelyFilePath(scriptName)) {
543
- if (baseDir && /\.[cm]?[jt]sx?$/.test(scriptName) && !fileExists(baseDir, scriptName)) {
544
- return `File "${scriptName}" referenced by "bun" does not appear to exist in the repository`;
695
+ if (baseDir && /\.[cm]?[jt]sx?$/.test(scriptName)) {
696
+ return missingUnless(locate(tree, baseDir, scriptName), `File "${scriptName}" referenced by "bun" does not appear to exist in the repository`);
545
697
  }
546
698
  return null;
547
699
  }
548
700
  }
549
- return findMissingScript(context, cwd, parts, scriptName, BUILTINS_BY_TOOL[tool], tool,
701
+ // pcic-oonz: the rewrite keeps everything up to and including the script
702
+ // name, so the text looked for on the line is the command as documented
703
+ // (`npm run bulid`, `pnpm --filter web lnit`) rather than a bare word.
704
+ const prefix = parts.slice(0, nameIdx).join(' ');
705
+ const onSuggestion = suggest
706
+ ? ({ missing, candidate, mechanical }) => suggest({
707
+ find: prefix ? `${prefix} ${missing}` : missing,
708
+ replace: prefix ? `${prefix} ${candidate}` : candidate,
709
+ missing,
710
+ candidate,
711
+ mechanical,
712
+ })
713
+ : undefined;
714
+ return findMissingScript(context, tree, cwd, parts, scriptName, BUILTINS_BY_TOOL[tool], tool,
550
715
  // npm/pnpm `run` only run scripts; every other form can fall through to a binary.
551
- { allowBinary: !(explicitRun && (tool === 'npm' || tool === 'pnpm')), workspaceName }, onScript);
716
+ { allowBinary: !(explicitRun && (tool === 'npm' || tool === 'pnpm')), workspaceName }, onScript, onSuggestion);
552
717
  }
553
718
  // ── make targets ──────────────────────────────────────────────────────────────
554
719
  /** make options whose value is the NEXT token (unless attached: `-Cdir`, `-j4`). */
@@ -571,7 +736,7 @@ const MAKE_FLAGS_WITH_OPTIONAL_NUMBER = new Set(['-j', '-l', '--jobs', '--load-a
571
736
  * names make could build from a same-stem source via its built-in implicit
572
737
  * rules (`make hello` next to `hello.c`) are never reported.
573
738
  */
574
- function validateMakeTargets(parts, context, cwd, makefileFor) {
739
+ function validateMakeTargets(parts, tree, cwd, makefileFor) {
575
740
  let dir = cwd;
576
741
  const goals = [];
577
742
  for (let i = 1; i < parts.length; i++) {
@@ -608,7 +773,6 @@ function validateMakeTargets(parts, context, cwd, makefileFor) {
608
773
  const facts = makefileFor(dir);
609
774
  if (!facts || facts.unverifiable)
610
775
  return null;
611
- const baseDir = dir === '.' ? context.repoRoot : path.join(context.repoRoot, dir);
612
776
  let entries;
613
777
  for (const goal of goals) {
614
778
  const target = goal.replace(/^['"]|['"]$/g, '');
@@ -617,11 +781,13 @@ function validateMakeTargets(parts, context, cwd, makefileFor) {
617
781
  if (makefileDefines(facts, target))
618
782
  continue;
619
783
  // make can build `target` from `target.c` etc. with its built-in rules.
620
- try {
621
- entries ??= fs.readdirSync(baseDir);
622
- }
623
- catch {
624
- entries = [];
784
+ // Listed through the tree (pcic-x1vl), like the Makefile it found there;
785
+ // a directory it cannot list leaves those rules unknowable, so stay silent.
786
+ if (entries === undefined) {
787
+ const listing = tree.entries(dir);
788
+ if (typeof listing === 'string')
789
+ return null;
790
+ entries = [...listing.keys()];
625
791
  }
626
792
  if (entries.some((e) => e === target || e.startsWith(`${target}.`)))
627
793
  continue;
@@ -643,15 +809,16 @@ function validateSegment(seg, context, cwd = '.', onScript, opts = {}) {
643
809
  if (parts.length === 0)
644
810
  return null;
645
811
  const tool = parts[0];
812
+ const tree = opts.tree ?? treeFor(context);
646
813
  // Base directory for file-existence checks — respects a `cd`-established cwd.
647
814
  const baseDir = cwd === null
648
815
  ? null
649
816
  : cwd !== '.' && cwd !== ''
650
- ? path.join(context.repoRoot, cwd)
651
- : context.repoRoot;
817
+ ? path.join(tree.root, cwd)
818
+ : tree.root;
652
819
  // ── Package manager scripts ────────────────────────────────────────────────
653
820
  if (tool === 'pnpm' || tool === 'npm' || tool === 'yarn' || tool === 'bun') {
654
- return validatePackageManagerScript(tool, parts, context, cwd, baseDir, onScript);
821
+ return validatePackageManagerScript(tool, parts, context, tree, cwd, baseDir, onScript, opts.suggestScript);
655
822
  }
656
823
  // ── File-executing commands ────────────────────────────────────────────────
657
824
  const FILE_EXECUTORS = ['node', 'tsx', 'ts-node', 'bash', 'sh', 'python', 'python3'];
@@ -659,8 +826,10 @@ function validateSegment(seg, context, cwd = '.', onScript, opts = {}) {
659
826
  // Skip flags (and known flags' values, e.g. `--loader ts-node/esm`) to
660
827
  // find the actual script/file argument.
661
828
  const filePart = parts[firstNonFlagArgIndex(parts)];
662
- if (filePart && isLikelyFilePath(filePart) && !fileExists(baseDir, filePart)) {
663
- return `File "${filePart}" referenced by "${tool}" does not appear to exist in the repository`;
829
+ if (filePart && isLikelyFilePath(filePart)) {
830
+ const verdict = missingUnless(locate(tree, baseDir, filePart), `File "${filePart}" referenced by "${tool}" does not appear to exist in the repository`);
831
+ if (verdict !== null)
832
+ return verdict;
664
833
  }
665
834
  }
666
835
  // ── make <target> ──────────────────────────────────────────────────────────
@@ -672,39 +841,47 @@ function validateSegment(seg, context, cwd = '.', onScript, opts = {}) {
672
841
  // A code span like `make sure` is English, not a make invocation.
673
842
  if (!opts.fenced && parts.length === 2 && MAKE_PROSE_WORDS.has(parts[1].toLowerCase()))
674
843
  return null;
675
- return validateMakeTargets(parts, context, makeCwd, opts.makefileFor);
844
+ return validateMakeTargets(parts, tree, makeCwd, opts.makefileFor);
676
845
  }
677
846
  // ── Direct script invocations: ./scripts/foo.sh ───────────────────────────
678
847
  if (baseDir && (tool.startsWith('./') || tool.startsWith('../'))) {
848
+ const found = locate(tree, baseDir, tool);
679
849
  // pcic-2b6.8: a bare `./src` or `./docs/` in an inline code span is a
680
850
  // directory mention, not a script invocation — it is dead-references' to
681
851
  // judge. Only a shell-fence line (meant to be run) still reports an absent
682
852
  // extensionless `./gradlew`; an existing directory is never a missing script.
683
853
  if (parts.length === 1 && path.extname(tool.replace(/\/+$/, '')) === '') {
684
- if (isDirectory(baseDir, tool))
854
+ if (found.state === 'found' && found.isDir)
685
855
  return null;
686
856
  if (!opts.fenced || tool.endsWith('/'))
687
857
  return null;
688
858
  }
689
- if (!fileExists(baseDir, tool)) {
690
- return `Script "${tool}" does not appear to exist in the repository`;
691
- }
859
+ const verdict = missingUnless(found, `Script "${tool}" does not appear to exist in the repository`);
860
+ if (verdict !== null)
861
+ return verdict;
692
862
  }
693
863
  // ── docker compose -f <file> ───────────────────────────────────────────────
694
864
  if (baseDir && tool === 'docker' && parts[1] === 'compose' && parts.includes('-f')) {
695
865
  const fIdx = parts.indexOf('-f');
696
866
  const composeFile = parts[fIdx + 1];
697
- if (composeFile && isLikelyFilePath(composeFile) && !fileExists(baseDir, composeFile)) {
698
- return `Docker Compose file "${composeFile}" does not appear to exist in the repository`;
867
+ if (composeFile && isLikelyFilePath(composeFile)) {
868
+ const verdict = missingUnless(locate(tree, baseDir, composeFile), `Docker Compose file "${composeFile}" does not appear to exist in the repository`);
869
+ if (verdict !== null)
870
+ return verdict;
699
871
  }
700
872
  }
701
873
  // ── dotnet test <project-or-solution> ─────────────────────────────────────
702
874
  if (baseDir && tool === 'dotnet' && parts[1] === 'test' && parts[2]) {
703
875
  const target = parts[2];
704
876
  if (isLikelyFilePath(target)) {
705
- if (!fileExists(baseDir, target)) {
706
- return `dotnet test target "${target}" does not appear to exist`;
707
- }
877
+ const found = locate(tree, baseDir, target);
878
+ // Outside the root: not judged at all, so the extension check below
879
+ // cannot tell an existing outside file from a missing one either.
880
+ if (found.state === 'outside')
881
+ return null;
882
+ const verdict = missingUnless(found, `dotnet test target "${target}" does not appear to exist`);
883
+ if (verdict !== null)
884
+ return verdict;
708
885
  if (!/\.(sln|csproj|fsproj)$/.test(target)) {
709
886
  return `dotnet test target "${target}" should be a .sln, .csproj, or .fsproj file`;
710
887
  }
@@ -742,6 +919,9 @@ function computeCommandAnalysis(context) {
742
919
  const issues = [];
743
920
  const evaluatedScripts = new Map();
744
921
  const reported = new Set();
922
+ const tree = treeFor(context);
923
+ // pcic-x1vl: commands left unchecked because part of the tree could not be listed.
924
+ const unchecked = new Map();
745
925
  // Makefiles by repo-relative directory: the root one comes from the context,
746
926
  // subdirectory ones (`make -C dir`, `cd dir && make`) are read once each.
747
927
  const makefiles = new Map();
@@ -749,11 +929,14 @@ function computeCommandAnalysis(context) {
749
929
  if (dir === '.' || dir === '')
750
930
  return context.makefile;
751
931
  if (!makefiles.has(dir))
752
- makefiles.set(dir, readMakefile(context.repoRoot, dir));
932
+ makefiles.set(dir, readMakefile(context.repoRoot, dir, tree));
753
933
  return makefiles.get(dir);
754
934
  };
755
935
  for (const file of context.files) {
756
936
  const commands = extractCommands(file.content);
937
+ // pcic-oonz: the file's own lines, to confirm a rewrite's `find` text is
938
+ // unambiguous there before the finding claims to be auto-applicable.
939
+ const contentLines = file.content.split(/\r?\n/);
757
940
  const evaluated = new Set();
758
941
  const fileDir = path.relative(context.repoRoot, path.dirname(file.path)).replace(/\\/g, '/') || '.';
759
942
  evaluatedScripts.set(file.path, evaluated);
@@ -764,33 +947,83 @@ function computeCommandAnalysis(context) {
764
947
  // (prose, which never carries a `cd`) runs from the root; null stays
765
948
  // unverifiable.
766
949
  const cwd = cmd.cwd === undefined ? '.' : cmd.cwd;
950
+ let rewrite;
767
951
  const error = validateSegment(cmd.text, context, cwd, (name) => evaluated.add(`${cmd.line}:${name}`), {
768
952
  fenced: cmd.fenced === true,
769
953
  makefileFor,
770
954
  fileDir: fileDir.startsWith('..') ? '.' : fileDir,
955
+ tree,
956
+ suggestScript: (r) => { rewrite = r; },
771
957
  });
772
958
  if (!error)
773
959
  continue;
960
+ if (error === UNJUDGED) {
961
+ const evidence = `Not checked: \`${cmd.text}\``;
962
+ const entry = unchecked.get(evidence) ?? { filePaths: new Set(), fileLines: new Map(), evidence };
963
+ entry.filePaths.add(file.path);
964
+ const seen = entry.fileLines.get(file.path);
965
+ if (seen === undefined || cmd.line < seen)
966
+ entry.fileLines.set(file.path, cmd.line);
967
+ unchecked.set(evidence, entry);
968
+ continue;
969
+ }
774
970
  // Deduplicate same command text appearing twice in the same file
775
971
  const dedupeKey = `${file.path}|${cmd.text}`;
776
972
  if (reported.has(dedupeKey))
777
973
  continue;
778
974
  reported.add(dedupeKey);
975
+ // pcic-oonz: a MISSPELLED script name with exactly one close match in the
976
+ // manifest that would have run it is a mechanical rewrite — provided the
977
+ // difference is a typo rather than a reference to a real sibling script
978
+ // (`isMechanicalRename`; `test:api` vs `test:app` is not), and the text
979
+ // to replace appears exactly once on the line, so the edit cannot land on
980
+ // another command that shares it.
981
+ const fix = rewrite?.mechanical && countOnLine(contentLines[cmd.line - 1] ?? '', rewrite.find) === 1
982
+ ? rewrite
983
+ : undefined;
984
+ // A candidate too close to a different script to apply is still worth
985
+ // naming — cautiously, and in the recommendation rather than the summary,
986
+ // so nothing reads as a verdict.
987
+ const hint = rewrite && !fix
988
+ ? ` The manifest defines "${rewrite.candidate}", which is close to "${rewrite.missing}"; ` +
989
+ 'it may be what was meant, but it is near enough to be a different script, so check before changing it.'
990
+ : '';
779
991
  issues.push({
780
992
  id: issueId(fileIdPath(file), cmd.text),
781
993
  severity: 'warning',
782
994
  category: 'command_validity',
783
995
  title: `Possible reference to missing command or file: \`${cmd.text.split(/\s+/).slice(0, 3).join(' ')}\``,
784
- summary: error,
996
+ summary: fix
997
+ ? `${error}. The closest script name the manifest defines is "${fix.candidate}".`
998
+ : error,
785
999
  filePaths: [file.path],
786
1000
  locations: [{ filePath: file.path, startLine: cmd.line, endLine: cmd.line }],
787
1001
  evidence: [`Command: \`${cmd.text}\``, `In file: ${file.path}`],
788
1002
  recommendation: 'Verify the command. If the script was renamed, update the instruction file. ' +
789
- 'If it is an illustrative example, wrap it in a placeholder like <script-name> to suppress this finding.',
1003
+ 'If it is an illustrative example, wrap it in a placeholder like <script-name> to suppress this finding.' +
1004
+ hint,
1005
+ ...(fix
1006
+ ? {
1007
+ fixRecipe: `Rename the script in this command: \`${fix.find}\` -> \`${fix.replace}\`.`,
1008
+ // `applyFixRecipe` replaces that one occurrence on that one line.
1009
+ autoApplySafe: true,
1010
+ fixEdits: [{ filePath: file.path, line: cmd.line, find: fix.find, replace: fix.replace }],
1011
+ }
1012
+ : {}),
790
1013
  confidence: 0.8,
791
1014
  });
792
1015
  }
793
1016
  }
1017
+ if (unchecked.size > 0) {
1018
+ issues.push(incompleteCheckIssue(tree, [...unchecked.values()], {
1019
+ id: issueId('', '(command check incomplete)'),
1020
+ category: 'command_validity',
1021
+ title: 'Command check incomplete: part of the repository could not be read',
1022
+ unit: 'command',
1023
+ unknown: 'The files they name may or may not exist',
1024
+ finding: 'missing-command',
1025
+ }));
1026
+ }
794
1027
  return { issues, evaluatedScripts };
795
1028
  }
796
1029
  //# sourceMappingURL=command-validity.js.map