@promptci/core 0.3.0 → 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 (89) 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/baseline.d.ts +8 -0
  9. package/dist/baseline.d.ts.map +1 -1
  10. package/dist/baseline.js +15 -0
  11. package/dist/baseline.js.map +1 -1
  12. package/dist/ci-alignment.d.ts +3 -1
  13. package/dist/ci-alignment.d.ts.map +1 -1
  14. package/dist/ci-alignment.js +8 -1
  15. package/dist/ci-alignment.js.map +1 -1
  16. package/dist/command-context.d.ts +67 -0
  17. package/dist/command-context.d.ts.map +1 -0
  18. package/dist/command-context.js +291 -0
  19. package/dist/command-context.js.map +1 -0
  20. package/dist/command-validity.d.ts.map +1 -1
  21. package/dist/command-validity.js +404 -32
  22. package/dist/command-validity.js.map +1 -1
  23. package/dist/dead-references.d.ts +6 -0
  24. package/dist/dead-references.d.ts.map +1 -1
  25. package/dist/dead-references.js +120 -7
  26. package/dist/dead-references.js.map +1 -1
  27. package/dist/duplicates.d.ts +24 -2
  28. package/dist/duplicates.d.ts.map +1 -1
  29. package/dist/duplicates.js +313 -9
  30. package/dist/duplicates.js.map +1 -1
  31. package/dist/health-score.d.ts +15 -1
  32. package/dist/health-score.d.ts.map +1 -1
  33. package/dist/health-score.js +25 -2
  34. package/dist/health-score.js.map +1 -1
  35. package/dist/index.d.ts +3 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +4 -2
  38. package/dist/index.js.map +1 -1
  39. package/dist/manifest-consistency.d.ts.map +1 -1
  40. package/dist/manifest-consistency.js +6 -0
  41. package/dist/manifest-consistency.js.map +1 -1
  42. package/dist/markdown-fences.d.ts.map +1 -1
  43. package/dist/markdown-fences.js +7 -3
  44. package/dist/markdown-fences.js.map +1 -1
  45. package/dist/provenance/check.d.ts +40 -0
  46. package/dist/provenance/check.d.ts.map +1 -0
  47. package/dist/provenance/check.js +157 -0
  48. package/dist/provenance/check.js.map +1 -0
  49. package/dist/provenance/index.d.ts +13 -0
  50. package/dist/provenance/index.d.ts.map +1 -0
  51. package/dist/provenance/index.js +21 -0
  52. package/dist/provenance/index.js.map +1 -0
  53. package/dist/provenance/instruction-advice.d.ts +3 -0
  54. package/dist/provenance/instruction-advice.d.ts.map +1 -0
  55. package/dist/provenance/instruction-advice.js +570 -0
  56. package/dist/provenance/instruction-advice.js.map +1 -0
  57. package/dist/provenance/repo-checks.d.ts +3 -0
  58. package/dist/provenance/repo-checks.d.ts.map +1 -0
  59. package/dist/provenance/repo-checks.js +644 -0
  60. package/dist/provenance/repo-checks.js.map +1 -0
  61. package/dist/provenance/tool-config.d.ts +3 -0
  62. package/dist/provenance/tool-config.d.ts.map +1 -0
  63. package/dist/provenance/tool-config.js +480 -0
  64. package/dist/provenance/tool-config.js.map +1 -0
  65. package/dist/provenance/types.d.ts +116 -0
  66. package/dist/provenance/types.d.ts.map +1 -0
  67. package/dist/provenance/types.js +17 -0
  68. package/dist/provenance/types.js.map +1 -0
  69. package/dist/repo-context.d.ts.map +1 -1
  70. package/dist/repo-context.js +3 -6
  71. package/dist/repo-context.js.map +1 -1
  72. package/dist/report.d.ts.map +1 -1
  73. package/dist/report.js +3 -0
  74. package/dist/report.js.map +1 -1
  75. package/dist/sample-text.d.ts +36 -0
  76. package/dist/sample-text.d.ts.map +1 -0
  77. package/dist/sample-text.js +72 -0
  78. package/dist/sample-text.js.map +1 -0
  79. package/dist/security-pack.d.ts.map +1 -1
  80. package/dist/security-pack.js +83 -0
  81. package/dist/security-pack.js.map +1 -1
  82. package/dist/types.d.ts +10 -0
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/types.js.map +1 -1
  85. package/dist/workspace-manifests.d.ts +6 -0
  86. package/dist/workspace-manifests.d.ts.map +1 -1
  87. package/dist/workspace-manifests.js +13 -8
  88. package/dist/workspace-manifests.js.map +1 -1
  89. package/package.json +1 -1
@@ -2,10 +2,12 @@ import * as path from 'node:path';
2
2
  import * as crypto from 'node:crypto';
3
3
  import { fencedBlocks, scanFencedLines } from './markdown-fences.js';
4
4
  import { fileIdPath } from './finding-id.js';
5
- import { anyWorkspaceHasScript, isKnownBinary, selectWorkspaces } from './workspace-manifests.js';
5
+ import { anyWorkspaceHasScript, hasScript, isKnownBinary, selectWorkspaces } from './workspace-manifests.js';
6
6
  import { makefileDefines, readMakefile } from './makefile.js';
7
7
  import { incompleteCheckIssue, isAbsoluteAnywhere, treeFor } from './repo-tree.js';
8
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,42 @@ import { closestUniqueMatch, isMechanicalRename } from './close-match.js';
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
+ }
31
69
  /** Occurrences of a plain substring in one line of text (pcic-oonz). */
32
70
  function countOnLine(line, needle) {
33
71
  if (!needle)
@@ -112,12 +150,16 @@ function missingUnless(found, message) {
112
150
  return null;
113
151
  }
114
152
  /**
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.
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.
120
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;
121
163
  function readPackageScripts(tree, packageJsonPath) {
122
164
  // pcic-x1vl: looked up through the tree, so a manifest path that leaves the
123
165
  // root (`cd` through a link out of the tree) is never touched, and a
@@ -132,18 +174,21 @@ function readPackageScripts(tree, packageJsonPath) {
132
174
  // them missing is the false positive the inventory exists to remove. The two
133
175
  // "cannot read" answers stay apart: `unreadable` is a real failure the scan
134
176
  // 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')
177
+ const read = tree.readText(packageJsonPath, { maxBytes: MAX_MANIFEST_BYTES });
178
+ if (read.state === 'unreadable')
137
179
  return 'unreadable';
138
- if (read.state === 'unfetched')
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')
139
183
  return 'unfetched';
140
184
  if (read.state !== 'found')
141
185
  return null;
142
186
  try {
143
187
  const raw = read.content;
144
188
  const parsed = JSON.parse(raw);
189
+ // A manifest that is not a JSON object is corrupt, not absent.
145
190
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
146
- return null;
191
+ return 'corrupt';
147
192
  const scripts = typeof parsed.scripts === 'object' && parsed.scripts !== null && !Array.isArray(parsed.scripts)
148
193
  ? Object.fromEntries(Object.entries(parsed.scripts).filter((entry) => typeof entry[1] === 'string'))
149
194
  : {};
@@ -153,8 +198,172 @@ function readPackageScripts(tree, packageJsonPath) {
153
198
  };
154
199
  }
155
200
  catch {
156
- return null;
201
+ // Corrupt JSON is not "no scripts": whether the script exists is unknown.
202
+ return 'corrupt';
203
+ }
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
+ }
157
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;
158
367
  }
159
368
  function pnpmFilterValue(parts) {
160
369
  for (let i = 1; i < parts.length; i++) {
@@ -216,7 +425,7 @@ function workspaceHasScript(context, tree, selector, scriptName) {
216
425
  // `workspaces` globs, any layout). The path-based lookup below is only for
217
426
  // contexts built without them.
218
427
  if (context.workspaces) {
219
- return selectWorkspaces(context, normalizedSelector).some((ws) => !!ws.scripts[scriptName]);
428
+ return selectWorkspaces(context, normalizedSelector).some((ws) => hasScript(ws.scripts, scriptName));
220
429
  }
221
430
  const { candidates, complete } = workspacePackageJsonCandidates(tree, normalizedSelector);
222
431
  let unknown = !complete;
@@ -225,16 +434,16 @@ function workspaceHasScript(context, tree, selector, scriptName) {
225
434
  const pkg = readPackageScripts(tree, candidate);
226
435
  if (pkg === 'unreadable')
227
436
  unknown = true;
228
- if (pkg === 'unfetched')
437
+ if (pkg === 'unfetched' || pkg === 'corrupt')
229
438
  silent = true;
230
- if (!pkg || pkg === 'unreadable' || pkg === 'unfetched')
439
+ if (!pkg || pkg === 'unreadable' || pkg === 'unfetched' || pkg === 'corrupt')
231
440
  continue;
232
441
  const packageDir = path.dirname(candidate).replace(/\\/g, '/');
233
442
  const relDir = path.relative(context.repoRoot, packageDir).replace(/\\/g, '/');
234
443
  const selectorMatches = pkg.name === normalizedSelector ||
235
444
  relDir === normalizedSelector ||
236
445
  relDir.endsWith(`/${normalizedSelector}`);
237
- if (selectorMatches && !!pkg.scripts[scriptName])
446
+ if (selectorMatches && hasScript(pkg.scripts, scriptName))
238
447
  return true;
239
448
  }
240
449
  if (unknown)
@@ -316,6 +525,37 @@ function resolveCwd(current, target) {
316
525
  }
317
526
  return parts.length === 0 ? '.' : parts.join('/');
318
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
+ }
319
559
  /**
320
560
  * Splits a command string on `&&`, `||`, `;`, and newlines, yielding
321
561
  * individual segments. This allows a single `run:` block or backtick
@@ -330,9 +570,18 @@ function resolveCwd(current, target) {
330
570
  function splitOnAmpersand(cmd) {
331
571
  return cmd
332
572
  .split(/&&|\|\||;|\n/)
333
- .map((s) => s.trim())
573
+ .map((s) => unwrapSubshell(s.trim()))
334
574
  .filter(Boolean);
335
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
+ }
336
585
  /** Returns true if a text looks like it could be a shell command to validate. */
337
586
  function isPotentialCommand(text) {
338
587
  const KNOWN_PREFIXES = [
@@ -521,7 +770,7 @@ function cwdOverride(parts) {
521
770
  }
522
771
  return undefined;
523
772
  }
524
- function findMissingScript(context, tree, cwd, parts, scriptName, builtins, tool, lookup, onScript, suggest) {
773
+ function findMissingScript(context, tree, cwd, parts, scriptName, builtins, tool, lookup, onScript, suggest, scope) {
525
774
  const names = scriptName.includes('/')
526
775
  ? scriptName.split('/').map((s) => s.trim()).filter(Boolean)
527
776
  : [scriptName];
@@ -559,12 +808,12 @@ function findMissingScript(context, tree, cwd, parts, scriptName, builtins, tool
559
808
  * without discovered workspaces to resolve it against — the candidate pool
560
809
  * would then be the root manifest, which is not where the script would run.
561
810
  */
562
- const propose = (name, pool) => {
811
+ const propose = (name, pool, viaRoot = false) => {
563
812
  if (!suggest || names.length !== 1)
564
813
  return;
565
814
  const candidate = closestUniqueMatch(name, pool);
566
815
  if (candidate !== undefined) {
567
- suggest({ missing: name, candidate, mechanical: isMechanicalRename(name, candidate) });
816
+ suggest({ missing: name, candidate, mechanical: isMechanicalRename(name, candidate), viaRoot });
568
817
  }
569
818
  };
570
819
  const scopedToWorkspaces = selector !== undefined || allWorkspaces;
@@ -577,6 +826,11 @@ function findMissingScript(context, tree, cwd, parts, scriptName, builtins, tool
577
826
  // warning here would name zero locations and tell the reader nothing.
578
827
  if (dirPkg === 'unfetched')
579
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
+ }
580
834
  // pcic-x1vl: a directory on the way could not be listed — say so, don't guess.
581
835
  if (dirPkg === 'unreadable')
582
836
  return UNJUDGED;
@@ -587,6 +841,7 @@ function findMissingScript(context, tree, cwd, parts, scriptName, builtins, tool
587
841
  // No manifest we can check anywhere — stay silent.
588
842
  if (!dirPkg && !rootScriptsLoaded)
589
843
  return null;
844
+ let pendingElsewhere;
590
845
  for (const name of names) {
591
846
  if (builtins.has(name))
592
847
  continue;
@@ -600,7 +855,7 @@ function findMissingScript(context, tree, cwd, parts, scriptName, builtins, tool
600
855
  continue; // only a sample of the workspace was read
601
856
  return `no workspace matches "${selector}" for ${tool} script "${name}"`;
602
857
  }
603
- if (selectedWorkspaces.some((ws) => !!ws.scripts[name]))
858
+ if (selectedWorkspaces.some((ws) => hasScript(ws.scripts, name)))
604
859
  continue;
605
860
  const target = selectedWorkspaces.length === 1
606
861
  ? `${selectedWorkspaces[0].name ?? selectedWorkspaces[0].dir} (${selectedWorkspaces[0].dir}/package.json)`
@@ -620,7 +875,7 @@ function findMissingScript(context, tree, cwd, parts, scriptName, builtins, tool
620
875
  // from an explicit --filter workspace target. A same-named root script is
621
876
  // NOT reachable here, so it must not mask a genuinely missing subdir
622
877
  // script.
623
- if (dirPkg.scripts[name])
878
+ if (hasScript(dirPkg.scripts, name))
624
879
  continue;
625
880
  const inWorkspace = inWorkspaceScope(name);
626
881
  if (inWorkspace === true || inWorkspace === 'silent')
@@ -635,15 +890,52 @@ function findMissingScript(context, tree, cwd, parts, scriptName, builtins, tool
635
890
  const inWorkspace = inWorkspaceScope(name);
636
891
  if (inWorkspace === true || inWorkspace === 'silent')
637
892
  continue;
638
- if (context.packageJson.scripts[name])
893
+ if (hasScript(context.packageJson.scripts, name))
639
894
  continue;
640
895
  // pcic-x1vl: a selected workspace may define it in a directory that could not be read.
641
896
  if (inWorkspace === 'unknown')
642
897
  return UNJUDGED;
643
- if (!scopedToWorkspaces)
644
- propose(name, Object.keys(context.packageJson.scripts));
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
+ }
645
933
  return `${tool} script "${name}" does not appear in package.json scripts`;
646
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
+ }
647
939
  return null;
648
940
  }
649
941
  const BUILTINS_BY_TOOL = {
@@ -661,7 +953,7 @@ const BUILTINS_BY_TOOL = {
661
953
  * to an installed binary), `yarn workspace <name> <script>`, and workspace or
662
954
  * directory selectors.
663
955
  */
664
- function validatePackageManagerScript(tool, parts, context, tree, cwd, baseDir, onScript, suggest) {
956
+ function validatePackageManagerScript(tool, parts, context, tree, cwd, baseDir, onScript, suggest, scope) {
665
957
  const subIdx = firstNonFlagArgIndex(parts);
666
958
  const sub = parts[subIdx];
667
959
  if (!sub)
@@ -687,6 +979,14 @@ function validatePackageManagerScript(tool, parts, context, tree, cwd, baseDir,
687
979
  const scriptName = parts[nameIdx];
688
980
  if (!scriptName || scriptName.startsWith('-'))
689
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
+ }
690
990
  if (tool === 'bun') {
691
991
  // `bun <x>` / `bun run <x>` also run files — a path is not a script name.
692
992
  if (!explicitRun && BUN_BUILTINS.has(scriptName))
@@ -703,17 +1003,18 @@ function validatePackageManagerScript(tool, parts, context, tree, cwd, baseDir,
703
1003
  // (`npm run bulid`, `pnpm --filter web lnit`) rather than a bare word.
704
1004
  const prefix = parts.slice(0, nameIdx).join(' ');
705
1005
  const onSuggestion = suggest
706
- ? ({ missing, candidate, mechanical }) => suggest({
1006
+ ? ({ missing, candidate, mechanical, viaRoot }) => suggest({
707
1007
  find: prefix ? `${prefix} ${missing}` : missing,
708
1008
  replace: prefix ? `${prefix} ${candidate}` : candidate,
709
1009
  missing,
710
1010
  candidate,
711
1011
  mechanical,
1012
+ viaRoot,
712
1013
  })
713
1014
  : undefined;
714
1015
  return findMissingScript(context, tree, cwd, parts, scriptName, BUILTINS_BY_TOOL[tool], tool,
715
1016
  // npm/pnpm `run` only run scripts; every other form can fall through to a binary.
716
- { allowBinary: !(explicitRun && (tool === 'npm' || tool === 'pnpm')), workspaceName }, onScript, onSuggestion);
1017
+ { allowBinary: !(explicitRun && (tool === 'npm' || tool === 'pnpm')), workspaceName }, onScript, onSuggestion, scope);
717
1018
  }
718
1019
  // ── make targets ──────────────────────────────────────────────────────────────
719
1020
  /** make options whose value is the NEXT token (unless attached: `-Cdir`, `-j4`). */
@@ -818,7 +1119,7 @@ function validateSegment(seg, context, cwd = '.', onScript, opts = {}) {
818
1119
  : tree.root;
819
1120
  // ── Package manager scripts ────────────────────────────────────────────────
820
1121
  if (tool === 'pnpm' || tool === 'npm' || tool === 'yarn' || tool === 'bun') {
821
- return validatePackageManagerScript(tool, parts, context, tree, cwd, baseDir, onScript, opts.suggestScript);
1122
+ return validatePackageManagerScript(tool, parts, context, tree, cwd, baseDir, onScript, opts.suggestScript, opts.scope);
822
1123
  }
823
1124
  // ── File-executing commands ────────────────────────────────────────────────
824
1125
  const FILE_EXECUTORS = ['node', 'tsx', 'ts-node', 'bash', 'sh', 'python', 'python3'];
@@ -937,8 +1238,11 @@ function computeCommandAnalysis(context) {
937
1238
  // pcic-oonz: the file's own lines, to confirm a rewrite's `find` text is
938
1239
  // unambiguous there before the finding claims to be auto-applicable.
939
1240
  const contentLines = file.content.split(/\r?\n/);
1241
+ const commentLines = htmlCommentLines(file.content);
1242
+ const structure = lineStructure(file.content);
940
1243
  const evaluated = new Set();
941
1244
  const fileDir = path.relative(context.repoRoot, path.dirname(file.path)).replace(/\\/g, '/') || '.';
1245
+ const nearDir = fileDir.startsWith('..') ? '.' : fileDir;
942
1246
  evaluatedScripts.set(file.path, evaluated);
943
1247
  for (const cmd of commands) {
944
1248
  // pcic-2b6.13: `cmd.cwd ?? '.'` used to turn an unknown directory (null,
@@ -948,15 +1252,37 @@ function computeCommandAnalysis(context) {
948
1252
  // unverifiable.
949
1253
  const cwd = cmd.cwd === undefined ? '.' : cmd.cwd;
950
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
+ : []);
951
1267
  const error = validateSegment(cmd.text, context, cwd, (name) => evaluated.add(`${cmd.line}:${name}`), {
952
1268
  fenced: cmd.fenced === true,
953
1269
  makefileFor,
954
- fileDir: fileDir.startsWith('..') ? '.' : fileDir,
1270
+ fileDir: nearDir,
955
1271
  tree,
956
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
+ },
957
1279
  });
958
1280
  if (!error)
959
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;
960
1286
  if (error === UNJUDGED) {
961
1287
  const evidence = `Not checked: \`${cmd.text}\``;
962
1288
  const entry = unchecked.get(evidence) ?? { filePaths: new Set(), fileLines: new Map(), evidence };
@@ -978,16 +1304,45 @@ function computeCommandAnalysis(context) {
978
1304
  // (`isMechanicalRename`; `test:api` vs `test:app` is not), and the text
979
1305
  // to replace appears exactly once on the line, so the edit cannot land on
980
1306
  // another command that shares it.
981
- const fix = rewrite?.mechanical && countOnLine(contentLines[cmd.line - 1] ?? '', rewrite.find) === 1
982
- ? rewrite
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)
983
1313
  : undefined;
1314
+ const fix = mechanical && editContext === undefined ? rewrite : undefined;
984
1315
  // A candidate too close to a different script to apply is still worth
985
1316
  // naming — cautiously, and in the recommendation rather than the summary,
986
1317
  // so nothing reads as a verdict.
987
1318
  const hint = rewrite && !fix
988
1319
  ? ` 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.'
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.')
990
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
+ }
991
1346
  issues.push({
992
1347
  id: issueId(fileIdPath(file), cmd.text),
993
1348
  severity: 'warning',
@@ -1014,6 +1369,23 @@ function computeCommandAnalysis(context) {
1014
1369
  });
1015
1370
  }
1016
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
+ }
1017
1389
  if (unchecked.size > 0) {
1018
1390
  issues.push(incompleteCheckIssue(tree, [...unchecked.values()], {
1019
1391
  id: issueId('', '(command check incomplete)'),