@promptci/core 0.2.2 → 0.4.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 (181) hide show
  1. package/dist/action-pinning.d.ts.map +1 -1
  2. package/dist/action-pinning.js +86 -55
  3. package/dist/action-pinning.js.map +1 -1
  4. package/dist/agent-practices.d.ts +5 -1
  5. package/dist/agent-practices.d.ts.map +1 -1
  6. package/dist/agent-practices.js +59 -34
  7. package/dist/agent-practices.js.map +1 -1
  8. package/dist/ai-config.d.ts +13 -6
  9. package/dist/ai-config.d.ts.map +1 -1
  10. package/dist/ai-config.js +25 -78
  11. package/dist/ai-config.js.map +1 -1
  12. package/dist/baseline.d.ts +8 -0
  13. package/dist/baseline.d.ts.map +1 -1
  14. package/dist/baseline.js +15 -0
  15. package/dist/baseline.js.map +1 -1
  16. package/dist/ci-alignment.d.ts +3 -1
  17. package/dist/ci-alignment.d.ts.map +1 -1
  18. package/dist/ci-alignment.js +8 -1
  19. package/dist/ci-alignment.js.map +1 -1
  20. package/dist/close-match.d.ts +97 -0
  21. package/dist/close-match.d.ts.map +1 -0
  22. package/dist/close-match.js +215 -0
  23. package/dist/close-match.js.map +1 -0
  24. package/dist/command-context.d.ts +67 -0
  25. package/dist/command-context.d.ts.map +1 -0
  26. package/dist/command-context.js +291 -0
  27. package/dist/command-context.js.map +1 -0
  28. package/dist/command-validity.d.ts.map +1 -1
  29. package/dist/command-validity.js +510 -32
  30. package/dist/command-validity.js.map +1 -1
  31. package/dist/context-optimizer.d.ts.map +1 -1
  32. package/dist/context-optimizer.js +9 -2
  33. package/dist/context-optimizer.js.map +1 -1
  34. package/dist/dead-references.d.ts +14 -1
  35. package/dist/dead-references.d.ts.map +1 -1
  36. package/dist/dead-references.js +264 -17
  37. package/dist/dead-references.js.map +1 -1
  38. package/dist/dependency-staleness.d.ts.map +1 -1
  39. package/dist/dependency-staleness.js +24 -38
  40. package/dist/dependency-staleness.js.map +1 -1
  41. package/dist/detectors.js +1 -1
  42. package/dist/detectors.js.map +1 -1
  43. package/dist/discovery.d.ts +138 -0
  44. package/dist/discovery.d.ts.map +1 -0
  45. package/dist/discovery.js +569 -0
  46. package/dist/discovery.js.map +1 -0
  47. package/dist/duplicates.d.ts +24 -2
  48. package/dist/duplicates.d.ts.map +1 -1
  49. package/dist/duplicates.js +313 -9
  50. package/dist/duplicates.js.map +1 -1
  51. package/dist/fix-engine.d.ts.map +1 -1
  52. package/dist/fix-engine.js +75 -1
  53. package/dist/fix-engine.js.map +1 -1
  54. package/dist/frameworks/dotnet.d.ts.map +1 -1
  55. package/dist/frameworks/dotnet.js +10 -11
  56. package/dist/frameworks/dotnet.js.map +1 -1
  57. package/dist/frameworks/go.d.ts.map +1 -1
  58. package/dist/frameworks/go.js +8 -4
  59. package/dist/frameworks/go.js.map +1 -1
  60. package/dist/frameworks/nextjs.d.ts.map +1 -1
  61. package/dist/frameworks/nextjs.js +8 -17
  62. package/dist/frameworks/nextjs.js.map +1 -1
  63. package/dist/frameworks/python.d.ts.map +1 -1
  64. package/dist/frameworks/python.js +5 -3
  65. package/dist/frameworks/python.js.map +1 -1
  66. package/dist/frameworks/rust.d.ts.map +1 -1
  67. package/dist/frameworks/rust.js +3 -3
  68. package/dist/frameworks/rust.js.map +1 -1
  69. package/dist/frameworks/unity.d.ts.map +1 -1
  70. package/dist/frameworks/unity.js +6 -5
  71. package/dist/frameworks/unity.js.map +1 -1
  72. package/dist/gitignore.d.ts +31 -2
  73. package/dist/gitignore.d.ts.map +1 -1
  74. package/dist/gitignore.js +33 -8
  75. package/dist/gitignore.js.map +1 -1
  76. package/dist/health-score.d.ts +15 -1
  77. package/dist/health-score.d.ts.map +1 -1
  78. package/dist/health-score.js +25 -2
  79. package/dist/health-score.js.map +1 -1
  80. package/dist/hooks-settings-detector.d.ts.map +1 -1
  81. package/dist/hooks-settings-detector.js +5 -1
  82. package/dist/hooks-settings-detector.js.map +1 -1
  83. package/dist/index.d.ts +10 -2
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +16 -2
  86. package/dist/index.js.map +1 -1
  87. package/dist/known-paths.d.ts +82 -0
  88. package/dist/known-paths.d.ts.map +1 -0
  89. package/dist/known-paths.js +152 -0
  90. package/dist/known-paths.js.map +1 -0
  91. package/dist/makefile.d.ts.map +1 -1
  92. package/dist/makefile.js +17 -13
  93. package/dist/makefile.js.map +1 -1
  94. package/dist/manifest-consistency.d.ts +1 -1
  95. package/dist/manifest-consistency.d.ts.map +1 -1
  96. package/dist/manifest-consistency.js +18 -8
  97. package/dist/manifest-consistency.js.map +1 -1
  98. package/dist/markdown-fences.d.ts.map +1 -1
  99. package/dist/markdown-fences.js +7 -3
  100. package/dist/markdown-fences.js.map +1 -1
  101. package/dist/mcp-config-detector.d.ts.map +1 -1
  102. package/dist/mcp-config-detector.js +5 -1
  103. package/dist/mcp-config-detector.js.map +1 -1
  104. package/dist/project-type.d.ts +2 -1
  105. package/dist/project-type.d.ts.map +1 -1
  106. package/dist/project-type.js +36 -48
  107. package/dist/project-type.js.map +1 -1
  108. package/dist/prompt-cache.d.ts.map +1 -1
  109. package/dist/prompt-cache.js +13 -2
  110. package/dist/prompt-cache.js.map +1 -1
  111. package/dist/provenance/check.d.ts +40 -0
  112. package/dist/provenance/check.d.ts.map +1 -0
  113. package/dist/provenance/check.js +157 -0
  114. package/dist/provenance/check.js.map +1 -0
  115. package/dist/provenance/index.d.ts +13 -0
  116. package/dist/provenance/index.d.ts.map +1 -0
  117. package/dist/provenance/index.js +21 -0
  118. package/dist/provenance/index.js.map +1 -0
  119. package/dist/provenance/instruction-advice.d.ts +3 -0
  120. package/dist/provenance/instruction-advice.d.ts.map +1 -0
  121. package/dist/provenance/instruction-advice.js +570 -0
  122. package/dist/provenance/instruction-advice.js.map +1 -0
  123. package/dist/provenance/repo-checks.d.ts +3 -0
  124. package/dist/provenance/repo-checks.d.ts.map +1 -0
  125. package/dist/provenance/repo-checks.js +644 -0
  126. package/dist/provenance/repo-checks.js.map +1 -0
  127. package/dist/provenance/tool-config.d.ts +3 -0
  128. package/dist/provenance/tool-config.d.ts.map +1 -0
  129. package/dist/provenance/tool-config.js +480 -0
  130. package/dist/provenance/tool-config.js.map +1 -0
  131. package/dist/provenance/types.d.ts +116 -0
  132. package/dist/provenance/types.d.ts.map +1 -0
  133. package/dist/provenance/types.js +17 -0
  134. package/dist/provenance/types.js.map +1 -0
  135. package/dist/repo-context.d.ts +9 -0
  136. package/dist/repo-context.d.ts.map +1 -1
  137. package/dist/repo-context.js +35 -41
  138. package/dist/repo-context.js.map +1 -1
  139. package/dist/repo-tree.d.ts +163 -9
  140. package/dist/repo-tree.d.ts.map +1 -1
  141. package/dist/repo-tree.js +226 -34
  142. package/dist/repo-tree.js.map +1 -1
  143. package/dist/report.d.ts.map +1 -1
  144. package/dist/report.js +6 -0
  145. package/dist/report.js.map +1 -1
  146. package/dist/sample-text.d.ts +36 -0
  147. package/dist/sample-text.d.ts.map +1 -0
  148. package/dist/sample-text.js +72 -0
  149. package/dist/sample-text.js.map +1 -0
  150. package/dist/sarif.d.ts +27 -0
  151. package/dist/sarif.d.ts.map +1 -0
  152. package/dist/sarif.js +357 -0
  153. package/dist/sarif.js.map +1 -0
  154. package/dist/scan.d.ts.map +1 -1
  155. package/dist/scan.js +8 -2
  156. package/dist/scan.js.map +1 -1
  157. package/dist/scanner.d.ts +4 -3
  158. package/dist/scanner.d.ts.map +1 -1
  159. package/dist/scanner.js +36 -77
  160. package/dist/scanner.js.map +1 -1
  161. package/dist/security-pack.d.ts.map +1 -1
  162. package/dist/security-pack.js +117 -35
  163. package/dist/security-pack.js.map +1 -1
  164. package/dist/skill-supply-chain.d.ts.map +1 -1
  165. package/dist/skill-supply-chain.js +29 -19
  166. package/dist/skill-supply-chain.js.map +1 -1
  167. package/dist/skills-detector.d.ts.map +1 -1
  168. package/dist/skills-detector.js +11 -3
  169. package/dist/skills-detector.js.map +1 -1
  170. package/dist/token-estimates.d.ts +42 -0
  171. package/dist/token-estimates.d.ts.map +1 -0
  172. package/dist/token-estimates.js +200 -0
  173. package/dist/token-estimates.js.map +1 -0
  174. package/dist/types.d.ts +99 -0
  175. package/dist/types.d.ts.map +1 -1
  176. package/dist/types.js.map +1 -1
  177. package/dist/workspace-manifests.d.ts +6 -0
  178. package/dist/workspace-manifests.d.ts.map +1 -1
  179. package/dist/workspace-manifests.js +13 -8
  180. package/dist/workspace-manifests.js.map +1 -1
  181. package/package.json +5 -2
@@ -1,11 +1,13 @@
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
- import { anyWorkspaceHasScript, isKnownBinary, selectWorkspaces } from './workspace-manifests.js';
5
+ import { anyWorkspaceHasScript, hasScript, isKnownBinary, selectWorkspaces } from './workspace-manifests.js';
7
6
  import { makefileDefines, readMakefile } from './makefile.js';
8
- import { RepoTree, incompleteCheckIssue, isAbsoluteAnywhere } from './repo-tree.js';
7
+ import { incompleteCheckIssue, isAbsoluteAnywhere, treeFor } from './repo-tree.js';
8
+ import { closestUniqueMatch, isMechanicalRename } from './close-match.js';
9
+ import { htmlCommentLines } from './sample-text.js';
10
+ import { commandContext, directoryTokens, lineStructure } from './command-context.js';
9
11
  /**
10
12
  * Command Validity Detector
11
13
  *
@@ -28,6 +30,51 @@ import { RepoTree, incompleteCheckIssue, isAbsoluteAnywhere } from './repo-tree.
28
30
  * Findings are heuristic — cautious wording is intentional.
29
31
  */
30
32
  // ── Helpers ───────────────────────────────────────────────────────────────────
33
+ /**
34
+ * pcic-1ik0.2: why a script rewrite must NOT be applied unseen, as a clause that
35
+ * completes "it may be what was meant, but ...": `undefined` only when every
36
+ * condition for an unattended edit holds. An edit lands without a person looking,
37
+ * on a repository that is not ours: a missed fix costs nothing, a wrong one lands
38
+ * in a stranger's repo. Heuristics over prose do not converge, so the rule is
39
+ * default-deny and structural first:
40
+ * - RULE 1: the repository is a single package (see {@link singlePackageRepo}), so
41
+ * there is exactly one manifest the command can mean, whatever the prose says;
42
+ * and the closest name is in that root manifest;
43
+ * - RULE 2: nothing cautionary or negating frames the command, in a deliberately
44
+ * wide scope (the whole heading chain, block, paragraph, enclosing list items,
45
+ * table, blockquote and `<details>` summary, and a trailing comment);
46
+ * - the command is not in a comment or an indented block, and runs from the root
47
+ * with no `cd` before it in its block and no `--cwd`/`--prefix`/`-C`.
48
+ */
49
+ function editBlocker(cmd, lines, inComment, context, earlierCd, singlePackage, viaRoot) {
50
+ if (inComment)
51
+ return 'this command is inside an HTML comment';
52
+ if (!cmd.fenced && /^(?: {4}|\t)/.test(lines[cmd.line - 1] ?? ''))
53
+ return 'this command is in an indented block';
54
+ if (typeof cmd.cwd === 'string' && cmd.cwd !== '.' && cmd.cwd !== '')
55
+ return 'this command runs from another directory';
56
+ // `-C site`, `-Csite`, `--cwd site`, `--cwd=site`.
57
+ if (/\s(?:--cwd|--dir|--prefix)(?:\s|=)|\s-C/.test(cmd.text))
58
+ return 'this command runs from another directory';
59
+ if (earlierCd)
60
+ return 'an earlier line of its block changes directory';
61
+ if (context.cautionary)
62
+ return 'the text around this command reads as an example not to follow, or as a warning';
63
+ if (!singlePackage)
64
+ return 'this repository has more than one package, which makes the manifest the command runs against uncertain';
65
+ if (!viaRoot)
66
+ return 'the closest name is not in the root manifest';
67
+ return undefined;
68
+ }
69
+ /** Occurrences of a plain substring in one line of text (pcic-oonz). */
70
+ function countOnLine(line, needle) {
71
+ if (!needle)
72
+ return 0;
73
+ let count = 0;
74
+ for (let at = line.indexOf(needle); at !== -1; at = line.indexOf(needle, at + needle.length))
75
+ count++;
76
+ return count;
77
+ }
31
78
  /** Stable per-finding ID based on file + command text. */
32
79
  function issueId(filePath, cmdText) {
33
80
  const hash = crypto
@@ -103,10 +150,16 @@ function missingUnless(found, message) {
103
150
  return null;
104
151
  }
105
152
  /**
106
- * The name and scripts of a package.json; `null` when there is none to read
107
- * (or it does not parse); `unreadable` when a directory on the way could not
108
- * be listed, so whether it exists is unknown.
153
+ * The name and scripts of a package.json; `null` when there is none to read;
154
+ * `unreadable` when a directory on the way could not be listed, so whether the
155
+ * script exists is unknown; `corrupt` when the manifest is not a JSON object (the
156
+ * detector says so in one info finding when it relied on that manifest, and
157
+ * checks nothing against it); `unfetched` when a caller's inventory says the
158
+ * manifest is there but its bytes were never fetched (pcic-csf) — nothing failed,
159
+ * so callers stay silent rather than warn.
109
160
  */
161
+ /** A package.json larger than this is not read: a hostile or generated manifest must not stall a scan. */
162
+ const MAX_MANIFEST_BYTES = 512 * 1024;
110
163
  function readPackageScripts(tree, packageJsonPath) {
111
164
  // pcic-x1vl: looked up through the tree, so a manifest path that leaves the
112
165
  // root (`cd` through a link out of the tree) is never touched, and a
@@ -116,11 +169,26 @@ function readPackageScripts(tree, packageJsonPath) {
116
169
  return 'unreadable';
117
170
  if (found.state !== 'found' || found.isDir)
118
171
  return null;
172
+ // pcic-csf: a caller's inventory can say the manifest is there without its
173
+ // bytes being on disk. Its scripts are then unknown, not absent — reporting
174
+ // them missing is the false positive the inventory exists to remove. The two
175
+ // "cannot read" answers stay apart: `unreadable` is a real failure the scan
176
+ // should name, `unfetched` is nothing to name (see RepoTree.readText).
177
+ const read = tree.readText(packageJsonPath, { maxBytes: MAX_MANIFEST_BYTES });
178
+ if (read.state === 'unreadable')
179
+ return 'unreadable';
180
+ // Oversized is treated like listed-but-not-downloaded (pcic-1ik0.2): the scripts are unknown,
181
+ // not absent, and there is nothing a user could fix, so no finding and no warning.
182
+ if (read.state === 'unfetched' || read.state === 'too-large')
183
+ return 'unfetched';
184
+ if (read.state !== 'found')
185
+ return null;
119
186
  try {
120
- const raw = fs.readFileSync(found.path, 'utf8');
187
+ const raw = read.content;
121
188
  const parsed = JSON.parse(raw);
189
+ // A manifest that is not a JSON object is corrupt, not absent.
122
190
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
123
- return null;
191
+ return 'corrupt';
124
192
  const scripts = typeof parsed.scripts === 'object' && parsed.scripts !== null && !Array.isArray(parsed.scripts)
125
193
  ? Object.fromEntries(Object.entries(parsed.scripts).filter((entry) => typeof entry[1] === 'string'))
126
194
  : {};
@@ -130,9 +198,173 @@ function readPackageScripts(tree, packageJsonPath) {
130
198
  };
131
199
  }
132
200
  catch {
133
- return null;
201
+ // Corrupt JSON is not "no scripts": whether the script exists is unknown.
202
+ return 'corrupt';
134
203
  }
135
204
  }
205
+ /**
206
+ * A script token that is a glob or placeholder (`build*`, `test:<name>`,
207
+ * `$SCRIPT`, `${NAME}`, `%NAME%`), never a misspelled name.
208
+ */
209
+ const SCRIPT_PATTERN_RE = /[*?<>{}[\]$`]|%\w+%/;
210
+ /** Directories walked looking for package.json files, and how deep: bounded so a huge repo cannot stall a scan. */
211
+ const MAX_MANIFEST_DIRS = 2000;
212
+ const MAX_MANIFEST_DEPTH = 12;
213
+ /** Manifests a finding relied on (named by the text, or the directory a `cd` entered) that turned out to be corrupt. */
214
+ const corruptUsed = new WeakMap();
215
+ function noteCorrupt(tree, manifest) {
216
+ let set = corruptUsed.get(tree);
217
+ if (!set)
218
+ corruptUsed.set(tree, (set = new Set()));
219
+ set.add(manifest);
220
+ }
221
+ const packageIndexCache = new WeakMap();
222
+ /**
223
+ * Scripts of every package.json under the root outside `.git` and `node_modules`
224
+ * (dot directories are walked: `.github/actions/release/package.json` is a package).
225
+ * Breadth-first and sorted, so the same repository always yields the same index;
226
+ * and it says when it is not the whole picture, so that a caller can tell "defined
227
+ * nowhere" from "not seen".
228
+ */
229
+ function packageIndex(tree) {
230
+ const cached = packageIndexCache.get(tree);
231
+ if (cached)
232
+ return cached;
233
+ const index = { scripts: new Map(), unfetched: [], incomplete: false, foreign: false };
234
+ const queue = [{ dir: '', depth: 0 }];
235
+ for (let next = 0; next < queue.length; next++) {
236
+ if (next >= MAX_MANIFEST_DIRS) {
237
+ index.incomplete = true;
238
+ break;
239
+ }
240
+ const { dir, depth } = queue[next];
241
+ const abs = dir ? path.join(tree.root, dir) : tree.root;
242
+ const listing = tree.entries(abs);
243
+ if (listing === 'unreadable')
244
+ index.incomplete = true;
245
+ if (typeof listing === 'string')
246
+ continue;
247
+ const names = [...listing.keys()].sort();
248
+ if (names.includes('package.json')) {
249
+ const pkg = readPackageScripts(tree, path.join(abs, 'package.json'));
250
+ if (pkg === 'unfetched')
251
+ index.unfetched.push(dir || '.');
252
+ else if (pkg === 'unreadable' || pkg === 'corrupt')
253
+ index.incomplete = true;
254
+ else if (pkg)
255
+ index.scripts.set(dir || '.', pkg.scripts);
256
+ }
257
+ if (names.some((n) => FOREIGN_MANIFESTS.has(n)))
258
+ index.foreign = true;
259
+ for (const deno of ['deno.json', 'deno.jsonc']) {
260
+ if (!names.includes(deno))
261
+ continue;
262
+ const text = tree.readText(path.join(abs, deno), { maxBytes: MAX_MANIFEST_BYTES });
263
+ // Tasks are deno's scripts. A file that cannot be read may hold them.
264
+ if (text.state !== 'found' || /"tasks"\s*:/.test(text.content))
265
+ index.foreign = true;
266
+ }
267
+ for (const name of names) {
268
+ const entry = listing.get(name);
269
+ if (entry.isSymbolicLink()) {
270
+ // A link that leaves the repository points at content this scan never reads.
271
+ const target = tree.lookup(path.join(abs, name)).state;
272
+ if (target === 'outside')
273
+ index.foreign = true;
274
+ else if (target === 'unreadable')
275
+ index.incomplete = true;
276
+ continue;
277
+ }
278
+ if (!entry.isDirectory())
279
+ continue;
280
+ if (name === 'node_modules' || name === '.git')
281
+ continue;
282
+ // A directory below the depth bound could hold a manifest nobody read.
283
+ if (depth >= MAX_MANIFEST_DEPTH) {
284
+ index.incomplete = true;
285
+ continue;
286
+ }
287
+ queue.push({ dir: dir ? `${dir}/${name}` : name, depth: depth + 1 });
288
+ }
289
+ }
290
+ packageIndexCache.set(tree, index);
291
+ return index;
292
+ }
293
+ /** Repo-relative directories (never the root) of the packages that define `script`, sorted. */
294
+ function packagesDefining(context, tree, script) {
295
+ const dirs = new Set();
296
+ for (const ws of context.workspaces ?? [])
297
+ if (ws.dir !== '.' && hasScript(ws.scripts, script))
298
+ dirs.add(ws.dir);
299
+ for (const [dir, scripts] of packageIndex(tree).scripts) {
300
+ if (dir !== '.' && !dir.split('/').some((segment) => segment.startsWith('.')) && hasScript(scripts, script))
301
+ dirs.add(dir);
302
+ }
303
+ return [...dirs].sort();
304
+ }
305
+ /** Files at the repository root that declare a multi-package layout. */
306
+ const WORKSPACE_FILES = ['pnpm-workspace.yaml', 'lerna.json', 'nx.json', 'turbo.json', 'rush.json', '.gitmodules'];
307
+ /** Manifests of other package managers: a repository holding one is not certainly a single npm package. */
308
+ const FOREIGN_MANIFESTS = new Set(['package.yaml', 'package.json5', 'rush.json']);
309
+ const singlePackageCache = new WeakMap();
310
+ /**
311
+ * pcic-1ik0.2 (RULE 1, structural): true only when the repository is certainly ONE
312
+ * package: the inventory is complete and fully fetched (no unread manifest, no
313
+ * unlisted directory, no partial caller inventory), the root package.json is the
314
+ * only package.json, and nothing declares workspaces (`workspaces` field,
315
+ * pnpm-workspace.yaml, lerna.json, nx.json, turbo.json). The unattended typo edit
316
+ * is offered only then: in any other repository which manifest a command runs
317
+ * against depends on prose, and prose heuristics do not converge.
318
+ */
319
+ function singlePackageRepo(context, tree) {
320
+ const cached = singlePackageCache.get(tree);
321
+ if (cached !== undefined)
322
+ return cached;
323
+ const index = packageIndex(tree);
324
+ let single = !index.incomplete &&
325
+ !index.foreign &&
326
+ index.unfetched.length === 0 &&
327
+ index.scripts.size === 1 &&
328
+ index.scripts.has('.') &&
329
+ (tree.inventory === undefined || tree.inventory.complete);
330
+ if (single && /"workspaces"\s*:/.test(context.packageJson.raw ?? ''))
331
+ single = false;
332
+ if (single) {
333
+ for (const file of WORKSPACE_FILES) {
334
+ if (tree.lookup(path.join(tree.root, file)).state !== 'missing')
335
+ single = false;
336
+ }
337
+ }
338
+ singlePackageCache.set(tree, single);
339
+ return single;
340
+ }
341
+ /**
342
+ * The existing directories among the tokens the text around a command names,
343
+ * as repo-relative POSIX paths: resolved from the repository root and, failing
344
+ * that, from the instruction file's own directory. Whether a named directory
345
+ * defines the script is the caller's check, so a stray mention can only silence
346
+ * a finding when that directory really holds the script.
347
+ */
348
+ function resolveHintDirs(tokens, fileDir, tree) {
349
+ const dirs = [];
350
+ const seen = new Set();
351
+ for (const token of tokens) {
352
+ for (const base of fileDir === '.' ? ['.'] : ['.', fileDir]) {
353
+ const abs = withinRoot(tree, tree.root, base === '.' ? token : `${base}/${token}`);
354
+ if (abs === null)
355
+ continue;
356
+ const rel = path.relative(tree.root, abs).split(path.sep).join('/');
357
+ if (!rel || seen.has(rel))
358
+ continue;
359
+ const found = tree.lookup(abs);
360
+ if (found.state !== 'found' || !found.isDir)
361
+ continue;
362
+ seen.add(rel);
363
+ dirs.push(rel);
364
+ }
365
+ }
366
+ return dirs;
367
+ }
136
368
  function pnpmFilterValue(parts) {
137
369
  for (let i = 1; i < parts.length; i++) {
138
370
  const part = parts[i];
@@ -185,10 +417,6 @@ function workspacePackageJsonCandidates(tree, selector) {
185
417
  }
186
418
  return { candidates: [...candidates], complete };
187
419
  }
188
- /**
189
- * True when the workspace(s) a selector names define the script; `unknown`
190
- * when none found does but a manifest or workspace directory could not be read.
191
- */
192
420
  function workspaceHasScript(context, tree, selector, scriptName) {
193
421
  const normalizedSelector = normalizePnpmFilter(selector);
194
422
  if (!normalizedSelector)
@@ -197,25 +425,31 @@ function workspaceHasScript(context, tree, selector, scriptName) {
197
425
  // `workspaces` globs, any layout). The path-based lookup below is only for
198
426
  // contexts built without them.
199
427
  if (context.workspaces) {
200
- return selectWorkspaces(context, normalizedSelector).some((ws) => !!ws.scripts[scriptName]);
428
+ return selectWorkspaces(context, normalizedSelector).some((ws) => hasScript(ws.scripts, scriptName));
201
429
  }
202
430
  const { candidates, complete } = workspacePackageJsonCandidates(tree, normalizedSelector);
203
431
  let unknown = !complete;
432
+ let silent = false;
204
433
  for (const candidate of candidates) {
205
434
  const pkg = readPackageScripts(tree, candidate);
206
435
  if (pkg === 'unreadable')
207
436
  unknown = true;
208
- if (!pkg || pkg === 'unreadable')
437
+ if (pkg === 'unfetched' || pkg === 'corrupt')
438
+ silent = true;
439
+ if (!pkg || pkg === 'unreadable' || pkg === 'unfetched' || pkg === 'corrupt')
209
440
  continue;
210
441
  const packageDir = path.dirname(candidate).replace(/\\/g, '/');
211
442
  const relDir = path.relative(context.repoRoot, packageDir).replace(/\\/g, '/');
212
443
  const selectorMatches = pkg.name === normalizedSelector ||
213
444
  relDir === normalizedSelector ||
214
445
  relDir.endsWith(`/${normalizedSelector}`);
215
- if (selectorMatches && !!pkg.scripts[scriptName])
446
+ if (selectorMatches && hasScript(pkg.scripts, scriptName))
216
447
  return true;
217
448
  }
218
- return unknown ? 'unknown' : false;
449
+ if (unknown)
450
+ return 'unknown';
451
+ // A manifest that exists but was never fetched could define the script.
452
+ return silent ? 'silent' : false;
219
453
  }
220
454
  /** Returns true when a path string looks like a file reference (not a flag). */
221
455
  function isLikelyFilePath(str) {
@@ -291,6 +525,37 @@ function resolveCwd(current, target) {
291
525
  }
292
526
  return parts.length === 0 ? '.' : parts.join('/');
293
527
  }
528
+ /**
529
+ * pcic-1ik0.2: what the earlier lines of the fenced block holding line `line` do to
530
+ * the working directory: whether any `cd`, `pushd` or `popd` came first, and the
531
+ * directory they leave (`null` when it cannot be known). The detector still judges
532
+ * each line from the root (CV6); this only feeds the text-names-a-directory logic
533
+ * and withholds edits.
534
+ */
535
+ function earlierCd(s, line) {
536
+ const open = s.opener.get(line);
537
+ let dir = '.';
538
+ let any = false;
539
+ if (open === undefined)
540
+ return { any, dir };
541
+ for (let n = open + 1; n < line; n++) {
542
+ const text = (s.lines[n - 1] ?? '').trim().replace(/^\$\s*/, '');
543
+ if (!text || text.startsWith('#'))
544
+ continue;
545
+ for (const segment of splitOnAmpersand(text)) {
546
+ const change = /^(?:cd|pushd)\s+(.+)$/.exec(segment);
547
+ if (change) {
548
+ any = true;
549
+ dir = resolveCwd(dir, change[1].trim());
550
+ }
551
+ else if (/^(?:cd|pushd|popd)$/.test(segment) || segment.startsWith('popd ')) {
552
+ any = true;
553
+ dir = null;
554
+ }
555
+ }
556
+ }
557
+ return { any, dir };
558
+ }
294
559
  /**
295
560
  * Splits a command string on `&&`, `||`, `;`, and newlines, yielding
296
561
  * individual segments. This allows a single `run:` block or backtick
@@ -305,9 +570,18 @@ function resolveCwd(current, target) {
305
570
  function splitOnAmpersand(cmd) {
306
571
  return cmd
307
572
  .split(/&&|\|\||;|\n/)
308
- .map((s) => s.trim())
573
+ .map((s) => unwrapSubshell(s.trim()))
309
574
  .filter(Boolean);
310
575
  }
576
+ /** `(cd site` and `npm run dev)`: the parentheses of a subshell belong to the shell, not to a word of the command. */
577
+ function unwrapSubshell(segment) {
578
+ let out = segment;
579
+ while (out.startsWith('('))
580
+ out = out.slice(1).trim();
581
+ while (out.endsWith(')') && (out.match(/\)/g)?.length ?? 0) > (out.match(/\(/g)?.length ?? 0))
582
+ out = out.slice(0, -1).trim();
583
+ return out;
584
+ }
311
585
  /** Returns true if a text looks like it could be a shell command to validate. */
312
586
  function isPotentialCommand(text) {
313
587
  const KNOWN_PREFIXES = [
@@ -496,7 +770,7 @@ function cwdOverride(parts) {
496
770
  }
497
771
  return undefined;
498
772
  }
499
- function findMissingScript(context, tree, cwd, parts, scriptName, builtins, tool, lookup, onScript) {
773
+ function findMissingScript(context, tree, cwd, parts, scriptName, builtins, tool, lookup, onScript, suggest, scope) {
500
774
  const names = scriptName.includes('/')
501
775
  ? scriptName.split('/').map((s) => s.trim()).filter(Boolean)
502
776
  : [scriptName];
@@ -523,10 +797,40 @@ function findMissingScript(context, tree, cwd, parts, scriptName, builtins, tool
523
797
  const selectedWorkspaces = selector && context.workspaces && !allWorkspaces
524
798
  ? selectWorkspaces(context, selector.replace(/^['"]|['"]$/g, '').replace(/^\.\.\./, '').replace(/\.\.\.$/, '').trim())
525
799
  : undefined;
800
+ /**
801
+ * pcic-oonz: offers the one close match for a name that is about to be
802
+ * reported missing, from the manifest(s) that would actually have run it.
803
+ *
804
+ * Deliberately silent for the slash shorthand (`pnpm lint/typecheck`): the
805
+ * rewrite would have to edit one component of a token that documents several
806
+ * scripts, which is not the mechanical single-token substitution the fix
807
+ * engine applies. Silent too whenever a workspace selector is in play
808
+ * without discovered workspaces to resolve it against — the candidate pool
809
+ * would then be the root manifest, which is not where the script would run.
810
+ */
811
+ const propose = (name, pool, viaRoot = false) => {
812
+ if (!suggest || names.length !== 1)
813
+ return;
814
+ const candidate = closestUniqueMatch(name, pool);
815
+ if (candidate !== undefined) {
816
+ suggest({ missing: name, candidate, mechanical: isMechanicalRename(name, candidate), viaRoot });
817
+ }
818
+ };
819
+ const scopedToWorkspaces = selector !== undefined || allWorkspaces;
526
820
  const inSubdir = cwd !== '.' && cwd !== '';
527
821
  const dirPkg = inSubdir
528
822
  ? readPackageScripts(tree, path.join(tree.root, cwd, 'package.json'))
529
823
  : null;
824
+ // pcic-csf: the manifest is there but was never fetched. Nothing failed and
825
+ // there is no location to name, so say nothing at all — a "check incomplete"
826
+ // warning here would name zero locations and tell the reader nothing.
827
+ if (dirPkg === 'unfetched')
828
+ return null;
829
+ // A corrupt manifest in the directory the command names: nothing to judge it against.
830
+ if (dirPkg === 'corrupt') {
831
+ noteCorrupt(tree, path.join(tree.root, cwd, 'package.json'));
832
+ return null;
833
+ }
530
834
  // pcic-x1vl: a directory on the way could not be listed — say so, don't guess.
531
835
  if (dirPkg === 'unreadable')
532
836
  return UNJUDGED;
@@ -537,6 +841,7 @@ function findMissingScript(context, tree, cwd, parts, scriptName, builtins, tool
537
841
  // No manifest we can check anywhere — stay silent.
538
842
  if (!dirPkg && !rootScriptsLoaded)
539
843
  return null;
844
+ let pendingElsewhere;
540
845
  for (const name of names) {
541
846
  if (builtins.has(name))
542
847
  continue;
@@ -550,11 +855,18 @@ function findMissingScript(context, tree, cwd, parts, scriptName, builtins, tool
550
855
  continue; // only a sample of the workspace was read
551
856
  return `no workspace matches "${selector}" for ${tool} script "${name}"`;
552
857
  }
553
- if (selectedWorkspaces.some((ws) => !!ws.scripts[name]))
858
+ if (selectedWorkspaces.some((ws) => hasScript(ws.scripts, name)))
554
859
  continue;
555
860
  const target = selectedWorkspaces.length === 1
556
861
  ? `${selectedWorkspaces[0].name ?? selectedWorkspaces[0].dir} (${selectedWorkspaces[0].dir}/package.json)`
557
862
  : `the ${selectedWorkspaces.length} workspaces selected by "${selector}"`;
863
+ // The selected packages ARE the manifests that would run the script — all
864
+ // of them, so a candidate only SOME of them define would still leave the
865
+ // command broken where it is missing. The pool is therefore the
866
+ // intersection of their script names (which for one selected package is
867
+ // simply that package's scripts).
868
+ const pools = selectedWorkspaces.map((ws) => Object.keys(ws.scripts));
869
+ propose(name, pools.slice(1).reduce((shared, pool) => shared.filter((n) => pool.includes(n)), pools[0] ?? []));
558
870
  return `${tool} script "${name}" does not appear in ${target} scripts`;
559
871
  }
560
872
  if (dirPkg) {
@@ -563,26 +875,67 @@ function findMissingScript(context, tree, cwd, parts, scriptName, builtins, tool
563
875
  // from an explicit --filter workspace target. A same-named root script is
564
876
  // NOT reachable here, so it must not mask a genuinely missing subdir
565
877
  // script.
566
- if (dirPkg.scripts[name])
878
+ if (hasScript(dirPkg.scripts, name))
567
879
  continue;
568
880
  const inWorkspace = inWorkspaceScope(name);
569
- if (inWorkspace === true)
881
+ if (inWorkspace === true || inWorkspace === 'silent')
570
882
  continue;
571
883
  if (inWorkspace === 'unknown')
572
884
  return UNJUDGED;
885
+ if (!scopedToWorkspaces)
886
+ propose(name, Object.keys(dirPkg.scripts));
573
887
  return `${tool} script "${name}" does not appear in ${cwd}/package.json scripts`;
574
888
  }
575
889
  // Repo-root context: the workspace target(s) or the root manifest.
576
890
  const inWorkspace = inWorkspaceScope(name);
577
- if (inWorkspace === true)
891
+ if (inWorkspace === true || inWorkspace === 'silent')
578
892
  continue;
579
- if (context.packageJson.scripts[name])
893
+ if (hasScript(context.packageJson.scripts, name))
580
894
  continue;
581
895
  // pcic-x1vl: a selected workspace may define it in a directory that could not be read.
582
896
  if (inWorkspace === 'unknown')
583
897
  return UNJUDGED;
898
+ if (!scopedToWorkspaces) {
899
+ // pcic-1ik0.2: the root manifest is not the only place a documented
900
+ // script can live. A directory the text names that defines it settles the
901
+ // command; a package elsewhere that defines it turns the finding into a
902
+ // note and withdraws the typo suggestion (the name is not misspelled).
903
+ //
904
+ // Each name of a slash-joined list is judged on its own: one that is
905
+ // settled or only elsewhere must not hide a sibling that is missing.
906
+ // Text that says "at the repo root" is an explicit root: no hint applies.
907
+ let settled = false;
908
+ if (!scope?.explicitRoot) {
909
+ for (const dir of scope?.hintDirs?.() ?? []) {
910
+ const pkg = readPackageScripts(tree, path.join(tree.root, dir, 'package.json'));
911
+ // Listed by a caller's inventory but never fetched: the script may be there. Say nothing.
912
+ if (pkg === 'unfetched')
913
+ settled = true;
914
+ else if (pkg === 'corrupt') {
915
+ noteCorrupt(tree, path.join(tree.root, dir, 'package.json'));
916
+ settled = true;
917
+ }
918
+ else if (pkg === 'unreadable')
919
+ return UNJUDGED;
920
+ else if (pkg && hasScript(pkg.scripts, name))
921
+ settled = true;
922
+ }
923
+ if (settled)
924
+ continue;
925
+ const elsewhere = packagesDefining(context, tree, name);
926
+ if (elsewhere.length > 0) {
927
+ pendingElsewhere ??= { name, dirs: elsewhere };
928
+ continue;
929
+ }
930
+ }
931
+ propose(name, Object.keys(context.packageJson.scripts), true);
932
+ }
584
933
  return `${tool} script "${name}" does not appear in package.json scripts`;
585
934
  }
935
+ if (pendingElsewhere) {
936
+ scope?.elsewhere?.(pendingElsewhere.name, pendingElsewhere.dirs);
937
+ return `${tool} script "${pendingElsewhere.name}" does not appear in package.json scripts`;
938
+ }
586
939
  return null;
587
940
  }
588
941
  const BUILTINS_BY_TOOL = {
@@ -600,7 +953,7 @@ const BUILTINS_BY_TOOL = {
600
953
  * to an installed binary), `yarn workspace <name> <script>`, and workspace or
601
954
  * directory selectors.
602
955
  */
603
- function validatePackageManagerScript(tool, parts, context, tree, cwd, baseDir, onScript) {
956
+ function validatePackageManagerScript(tool, parts, context, tree, cwd, baseDir, onScript, suggest, scope) {
604
957
  const subIdx = firstNonFlagArgIndex(parts);
605
958
  const sub = parts[subIdx];
606
959
  if (!sub)
@@ -626,6 +979,14 @@ function validatePackageManagerScript(tool, parts, context, tree, cwd, baseDir,
626
979
  const scriptName = parts[nameIdx];
627
980
  if (!scriptName || scriptName.startsWith('-'))
628
981
  return null;
982
+ // pcic-qc33: `pnpm build*` means every script that starts with `build`, and
983
+ // `npm run test:<name>` is a template. A pattern is never a misspelling, so
984
+ // there is nothing to report and nothing to rewrite. Recorded as judged so
985
+ // manifest-consistency does not read the same token as a missing script.
986
+ if (SCRIPT_PATTERN_RE.test(scriptName)) {
987
+ onScript?.(scriptName);
988
+ return null;
989
+ }
629
990
  if (tool === 'bun') {
630
991
  // `bun <x>` / `bun run <x>` also run files — a path is not a script name.
631
992
  if (!explicitRun && BUN_BUILTINS.has(scriptName))
@@ -637,9 +998,23 @@ function validatePackageManagerScript(tool, parts, context, tree, cwd, baseDir,
637
998
  return null;
638
999
  }
639
1000
  }
1001
+ // pcic-oonz: the rewrite keeps everything up to and including the script
1002
+ // name, so the text looked for on the line is the command as documented
1003
+ // (`npm run bulid`, `pnpm --filter web lnit`) rather than a bare word.
1004
+ const prefix = parts.slice(0, nameIdx).join(' ');
1005
+ const onSuggestion = suggest
1006
+ ? ({ missing, candidate, mechanical, viaRoot }) => suggest({
1007
+ find: prefix ? `${prefix} ${missing}` : missing,
1008
+ replace: prefix ? `${prefix} ${candidate}` : candidate,
1009
+ missing,
1010
+ candidate,
1011
+ mechanical,
1012
+ viaRoot,
1013
+ })
1014
+ : undefined;
640
1015
  return findMissingScript(context, tree, cwd, parts, scriptName, BUILTINS_BY_TOOL[tool], tool,
641
1016
  // npm/pnpm `run` only run scripts; every other form can fall through to a binary.
642
- { allowBinary: !(explicitRun && (tool === 'npm' || tool === 'pnpm')), workspaceName }, onScript);
1017
+ { allowBinary: !(explicitRun && (tool === 'npm' || tool === 'pnpm')), workspaceName }, onScript, onSuggestion, scope);
643
1018
  }
644
1019
  // ── make targets ──────────────────────────────────────────────────────────────
645
1020
  /** make options whose value is the NEXT token (unless attached: `-Cdir`, `-j4`). */
@@ -735,7 +1110,7 @@ function validateSegment(seg, context, cwd = '.', onScript, opts = {}) {
735
1110
  if (parts.length === 0)
736
1111
  return null;
737
1112
  const tool = parts[0];
738
- const tree = opts.tree ?? new RepoTree(context.repoRoot);
1113
+ const tree = opts.tree ?? treeFor(context);
739
1114
  // Base directory for file-existence checks — respects a `cd`-established cwd.
740
1115
  const baseDir = cwd === null
741
1116
  ? null
@@ -744,7 +1119,7 @@ function validateSegment(seg, context, cwd = '.', onScript, opts = {}) {
744
1119
  : tree.root;
745
1120
  // ── Package manager scripts ────────────────────────────────────────────────
746
1121
  if (tool === 'pnpm' || tool === 'npm' || tool === 'yarn' || tool === 'bun') {
747
- return validatePackageManagerScript(tool, parts, context, tree, cwd, baseDir, onScript);
1122
+ return validatePackageManagerScript(tool, parts, context, tree, cwd, baseDir, onScript, opts.suggestScript, opts.scope);
748
1123
  }
749
1124
  // ── File-executing commands ────────────────────────────────────────────────
750
1125
  const FILE_EXECUTORS = ['node', 'tsx', 'ts-node', 'bash', 'sh', 'python', 'python3'];
@@ -845,7 +1220,7 @@ function computeCommandAnalysis(context) {
845
1220
  const issues = [];
846
1221
  const evaluatedScripts = new Map();
847
1222
  const reported = new Set();
848
- const tree = new RepoTree(context.repoRoot);
1223
+ const tree = treeFor(context);
849
1224
  // pcic-x1vl: commands left unchecked because part of the tree could not be listed.
850
1225
  const unchecked = new Map();
851
1226
  // Makefiles by repo-relative directory: the root one comes from the context,
@@ -860,8 +1235,14 @@ function computeCommandAnalysis(context) {
860
1235
  };
861
1236
  for (const file of context.files) {
862
1237
  const commands = extractCommands(file.content);
1238
+ // pcic-oonz: the file's own lines, to confirm a rewrite's `find` text is
1239
+ // unambiguous there before the finding claims to be auto-applicable.
1240
+ const contentLines = file.content.split(/\r?\n/);
1241
+ const commentLines = htmlCommentLines(file.content);
1242
+ const structure = lineStructure(file.content);
863
1243
  const evaluated = new Set();
864
1244
  const fileDir = path.relative(context.repoRoot, path.dirname(file.path)).replace(/\\/g, '/') || '.';
1245
+ const nearDir = fileDir.startsWith('..') ? '.' : fileDir;
865
1246
  evaluatedScripts.set(file.path, evaluated);
866
1247
  for (const cmd of commands) {
867
1248
  // pcic-2b6.13: `cmd.cwd ?? '.'` used to turn an unknown directory (null,
@@ -870,14 +1251,38 @@ function computeCommandAnalysis(context) {
870
1251
  // (prose, which never carries a `cd`) runs from the root; null stays
871
1252
  // unverifiable.
872
1253
  const cwd = cmd.cwd === undefined ? '.' : cmd.cwd;
1254
+ let rewrite;
1255
+ let elsewhere;
1256
+ // pcic-1ik0.2: what the text around the command says, read only when it is needed.
1257
+ const namesDirectory = (text) => resolveHintDirs(directoryTokens([text]), nearDir, tree).length > 0;
1258
+ let framing;
1259
+ const framingOf = () => (framing ??= commandContext(structure, cmd.line, cmd.text, namesDirectory));
1260
+ const blockCd = earlierCd(structure, cmd.line);
1261
+ const blockDirs = () => blockCd.dir !== null && blockCd.dir !== '.' ? resolveHintDirs([blockCd.dir], nearDir, tree) : [];
1262
+ let hints;
1263
+ const hintDirs = () => (hints ??=
1264
+ cwd === '.'
1265
+ ? [...new Set([...blockDirs(), ...resolveHintDirs(directoryTokens(framingOf().text), nearDir, tree)])]
1266
+ : []);
873
1267
  const error = validateSegment(cmd.text, context, cwd, (name) => evaluated.add(`${cmd.line}:${name}`), {
874
1268
  fenced: cmd.fenced === true,
875
1269
  makefileFor,
876
- fileDir: fileDir.startsWith('..') ? '.' : fileDir,
1270
+ fileDir: nearDir,
877
1271
  tree,
1272
+ suggestScript: (r) => { rewrite = r; },
1273
+ scope: {
1274
+ // Only a command run from the repo root is placed by the text around it.
1275
+ hintDirs,
1276
+ get explicitRoot() { return cwd === '.' && framingOf().explicitRoot; },
1277
+ elsewhere: (script, dirs) => { elsewhere = { script, dirs }; },
1278
+ },
878
1279
  });
879
1280
  if (!error)
880
1281
  continue;
1282
+ // A command in an HTML comment is text no reader sees: judged (so no other
1283
+ // detector re-reads it), never reported.
1284
+ if (commentLines[cmd.line - 1] === true)
1285
+ continue;
881
1286
  if (error === UNJUDGED) {
882
1287
  const evidence = `Not checked: \`${cmd.text}\``;
883
1288
  const entry = unchecked.get(evidence) ?? { filePaths: new Set(), fileLines: new Map(), evidence };
@@ -893,21 +1298,94 @@ function computeCommandAnalysis(context) {
893
1298
  if (reported.has(dedupeKey))
894
1299
  continue;
895
1300
  reported.add(dedupeKey);
1301
+ // pcic-oonz: a MISSPELLED script name with exactly one close match in the
1302
+ // manifest that would have run it is a mechanical rewrite — provided the
1303
+ // difference is a typo rather than a reference to a real sibling script
1304
+ // (`isMechanicalRename`; `test:api` vs `test:app` is not), and the text
1305
+ // to replace appears exactly once on the line, so the edit cannot land on
1306
+ // another command that shares it.
1307
+ //
1308
+ // Only where the edit cannot change what the sentence means (editBlocker):
1309
+ // an edit is applied unseen in a fix PR on someone else's repository.
1310
+ const mechanical = rewrite?.mechanical === true && countOnLine(contentLines[cmd.line - 1] ?? '', rewrite.find) === 1;
1311
+ const editContext = mechanical
1312
+ ? editBlocker(cmd, contentLines, commentLines[cmd.line - 1] === true, framingOf(), blockCd.any, singlePackageRepo(context, tree), rewrite?.viaRoot === true)
1313
+ : undefined;
1314
+ const fix = mechanical && editContext === undefined ? rewrite : undefined;
1315
+ // A candidate too close to a different script to apply is still worth
1316
+ // naming — cautiously, and in the recommendation rather than the summary,
1317
+ // so nothing reads as a verdict.
1318
+ const hint = rewrite && !fix
1319
+ ? ` The manifest defines "${rewrite.candidate}", which is close to "${rewrite.missing}"; ` +
1320
+ (editContext
1321
+ ? `it may be what was meant, but ${editContext}, so it was left unedited; check before changing it.`
1322
+ : 'it may be what was meant, but it is near enough to be a different script, so check before changing it.')
1323
+ : '';
1324
+ if (elsewhere) {
1325
+ // pcic-1ik0.2: the script exists, in a package the text does not point at.
1326
+ const named = elsewhere.dirs.slice(0, 3).map((d) => `${d}/package.json`);
1327
+ const more = elsewhere.dirs.length - named.length;
1328
+ const where = `${named.join(', ')}${more > 0 ? ` and ${more} more` : ''}`;
1329
+ const dir = elsewhere.dirs[0];
1330
+ issues.push({
1331
+ id: issueId(fileIdPath(file), cmd.text),
1332
+ severity: 'info',
1333
+ category: 'command_validity',
1334
+ title: `Command may need a working directory: \`${cmd.text.split(/\s+/).slice(0, 3).join(' ')}\``,
1335
+ summary: `${error}, but ${where} defines "${elsewhere.script}". ` +
1336
+ 'Nothing near the command names that directory, so it may be meant to run from there.',
1337
+ filePaths: [file.path],
1338
+ locations: [{ filePath: file.path, startLine: cmd.line, endLine: cmd.line }],
1339
+ evidence: [`Command: \`${cmd.text}\``, `In file: ${file.path}`, `Defined in: ${where}`],
1340
+ recommendation: `If the command runs from \`${dir}/\`, say so next to it (for example "from \`${dir}/\`", or \`cd ${dir} && ${cmd.text}\`) ` +
1341
+ 'so a reader or an agent runs it in the right place. If the script was renamed or removed, update the instruction file.',
1342
+ confidence: 0.6,
1343
+ });
1344
+ continue;
1345
+ }
896
1346
  issues.push({
897
1347
  id: issueId(fileIdPath(file), cmd.text),
898
1348
  severity: 'warning',
899
1349
  category: 'command_validity',
900
1350
  title: `Possible reference to missing command or file: \`${cmd.text.split(/\s+/).slice(0, 3).join(' ')}\``,
901
- summary: error,
1351
+ summary: fix
1352
+ ? `${error}. The closest script name the manifest defines is "${fix.candidate}".`
1353
+ : error,
902
1354
  filePaths: [file.path],
903
1355
  locations: [{ filePath: file.path, startLine: cmd.line, endLine: cmd.line }],
904
1356
  evidence: [`Command: \`${cmd.text}\``, `In file: ${file.path}`],
905
1357
  recommendation: 'Verify the command. If the script was renamed, update the instruction file. ' +
906
- 'If it is an illustrative example, wrap it in a placeholder like <script-name> to suppress this finding.',
1358
+ 'If it is an illustrative example, wrap it in a placeholder like <script-name> to suppress this finding.' +
1359
+ hint,
1360
+ ...(fix
1361
+ ? {
1362
+ fixRecipe: `Rename the script in this command: \`${fix.find}\` -> \`${fix.replace}\`.`,
1363
+ // `applyFixRecipe` replaces that one occurrence on that one line.
1364
+ autoApplySafe: true,
1365
+ fixEdits: [{ filePath: file.path, line: cmd.line, find: fix.find, replace: fix.replace }],
1366
+ }
1367
+ : {}),
907
1368
  confidence: 0.8,
908
1369
  });
909
1370
  }
910
1371
  }
1372
+ // One info finding per manifest the check relied on and could not parse: about the manifest, not
1373
+ // about anyone's instructions, so it never carries the weight of a warning.
1374
+ for (const manifest of [...(corruptUsed.get(tree) ?? [])].sort()) {
1375
+ const rel = path.relative(tree.root, manifest).split(path.sep).join('/');
1376
+ issues.push({
1377
+ id: issueId('', `(unparsable ${rel})`),
1378
+ severity: 'info',
1379
+ category: 'command_validity',
1380
+ title: `Could not parse ${rel}`,
1381
+ summary: `${rel} is not a valid JSON object, so the commands that depend on its scripts were not checked.`,
1382
+ filePaths: [manifest],
1383
+ locations: [{ filePath: manifest }],
1384
+ evidence: [`File: ${rel}`],
1385
+ recommendation: `Fix the syntax of ${rel}; commands documented to run there are checked once it parses.`,
1386
+ confidence: 0.9,
1387
+ });
1388
+ }
911
1389
  if (unchecked.size > 0) {
912
1390
  issues.push(incompleteCheckIssue(tree, [...unchecked.values()], {
913
1391
  id: issueId('', '(command check incomplete)'),