@promptci/core 0.0.1 → 0.2.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 (253) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +29 -30
  3. package/dist/action-pinning.d.ts +37 -0
  4. package/dist/action-pinning.d.ts.map +1 -0
  5. package/dist/action-pinning.js +277 -0
  6. package/dist/action-pinning.js.map +1 -0
  7. package/dist/agent-practices.d.ts +3 -1
  8. package/dist/agent-practices.d.ts.map +1 -1
  9. package/dist/agent-practices.js +198 -94
  10. package/dist/agent-practices.js.map +1 -1
  11. package/dist/ai-config.d.ts +178 -0
  12. package/dist/ai-config.d.ts.map +1 -0
  13. package/dist/ai-config.js +721 -0
  14. package/dist/ai-config.js.map +1 -0
  15. package/dist/alias-files.d.ts +33 -0
  16. package/dist/alias-files.d.ts.map +1 -0
  17. package/dist/alias-files.js +60 -0
  18. package/dist/alias-files.js.map +1 -0
  19. package/dist/baseline.d.ts +17 -1
  20. package/dist/baseline.d.ts.map +1 -1
  21. package/dist/baseline.js +5 -1
  22. package/dist/baseline.js.map +1 -1
  23. package/dist/buried-critical.d.ts +17 -0
  24. package/dist/buried-critical.d.ts.map +1 -0
  25. package/dist/buried-critical.js +107 -0
  26. package/dist/buried-critical.js.map +1 -0
  27. package/dist/canonical-owner.d.ts.map +1 -1
  28. package/dist/canonical-owner.js +60 -30
  29. package/dist/canonical-owner.js.map +1 -1
  30. package/dist/ci-alignment.d.ts.map +1 -1
  31. package/dist/ci-alignment.js +3 -2
  32. package/dist/ci-alignment.js.map +1 -1
  33. package/dist/command-validity.d.ts +16 -0
  34. package/dist/command-validity.d.ts.map +1 -1
  35. package/dist/command-validity.js +387 -134
  36. package/dist/command-validity.js.map +1 -1
  37. package/dist/conflicts.d.ts.map +1 -1
  38. package/dist/conflicts.js +4 -44
  39. package/dist/conflicts.js.map +1 -1
  40. package/dist/context-analysis.d.ts.map +1 -1
  41. package/dist/context-analysis.js +13 -2
  42. package/dist/context-analysis.js.map +1 -1
  43. package/dist/context-bloat.d.ts +20 -1
  44. package/dist/context-bloat.d.ts.map +1 -1
  45. package/dist/context-bloat.js +99 -15
  46. package/dist/context-bloat.js.map +1 -1
  47. package/dist/context-optimizer.d.ts.map +1 -1
  48. package/dist/context-optimizer.js +18 -54
  49. package/dist/context-optimizer.js.map +1 -1
  50. package/dist/copilot-instructions-detector.d.ts +24 -0
  51. package/dist/copilot-instructions-detector.d.ts.map +1 -0
  52. package/dist/copilot-instructions-detector.js +145 -0
  53. package/dist/copilot-instructions-detector.js.map +1 -0
  54. package/dist/cursor-rules-detector.d.ts +17 -0
  55. package/dist/cursor-rules-detector.d.ts.map +1 -0
  56. package/dist/cursor-rules-detector.js +110 -0
  57. package/dist/cursor-rules-detector.js.map +1 -0
  58. package/dist/custom-rules.d.ts +52 -0
  59. package/dist/custom-rules.d.ts.map +1 -0
  60. package/dist/custom-rules.js +303 -0
  61. package/dist/custom-rules.js.map +1 -0
  62. package/dist/dead-references.d.ts +8 -2
  63. package/dist/dead-references.d.ts.map +1 -1
  64. package/dist/dead-references.js +315 -68
  65. package/dist/dead-references.js.map +1 -1
  66. package/dist/dependency-staleness.d.ts +35 -0
  67. package/dist/dependency-staleness.d.ts.map +1 -0
  68. package/dist/dependency-staleness.js +1001 -0
  69. package/dist/dependency-staleness.js.map +1 -0
  70. package/dist/detectors.d.ts +1 -3
  71. package/dist/detectors.d.ts.map +1 -1
  72. package/dist/detectors.js +74 -56
  73. package/dist/detectors.js.map +1 -1
  74. package/dist/duplicates.d.ts.map +1 -1
  75. package/dist/duplicates.js +15 -9
  76. package/dist/duplicates.js.map +1 -1
  77. package/dist/evidence.d.ts +39 -0
  78. package/dist/evidence.d.ts.map +1 -0
  79. package/dist/evidence.js +78 -0
  80. package/dist/evidence.js.map +1 -0
  81. package/dist/finding-id.d.ts +21 -0
  82. package/dist/finding-id.d.ts.map +1 -0
  83. package/dist/finding-id.js +31 -0
  84. package/dist/finding-id.js.map +1 -0
  85. package/dist/fix-engine.d.ts +11 -0
  86. package/dist/fix-engine.d.ts.map +1 -1
  87. package/dist/fix-engine.js +88 -71
  88. package/dist/fix-engine.js.map +1 -1
  89. package/dist/frameworks/dotnet.js +2 -2
  90. package/dist/frameworks/dotnet.js.map +1 -1
  91. package/dist/frameworks/python.d.ts.map +1 -1
  92. package/dist/frameworks/python.js +5 -2
  93. package/dist/frameworks/python.js.map +1 -1
  94. package/dist/frameworks/rust.d.ts.map +1 -1
  95. package/dist/frameworks/rust.js +2 -1
  96. package/dist/frameworks/rust.js.map +1 -1
  97. package/dist/git-symlinks.d.ts +30 -0
  98. package/dist/git-symlinks.d.ts.map +1 -0
  99. package/dist/git-symlinks.js +161 -0
  100. package/dist/git-symlinks.js.map +1 -0
  101. package/dist/gitignore.d.ts +22 -0
  102. package/dist/gitignore.d.ts.map +1 -0
  103. package/dist/gitignore.js +157 -0
  104. package/dist/gitignore.js.map +1 -0
  105. package/dist/health-score.d.ts.map +1 -1
  106. package/dist/health-score.js +35 -5
  107. package/dist/health-score.js.map +1 -1
  108. package/dist/hooks-settings-detector.d.ts +17 -0
  109. package/dist/hooks-settings-detector.d.ts.map +1 -0
  110. package/dist/hooks-settings-detector.js +228 -0
  111. package/dist/hooks-settings-detector.js.map +1 -0
  112. package/dist/index.d.ts +29 -8
  113. package/dist/index.d.ts.map +1 -1
  114. package/dist/index.js +21 -6
  115. package/dist/index.js.map +1 -1
  116. package/dist/makefile.d.ts +37 -0
  117. package/dist/makefile.d.ts.map +1 -0
  118. package/dist/makefile.js +156 -0
  119. package/dist/makefile.js.map +1 -0
  120. package/dist/manifest-consistency.d.ts +6 -0
  121. package/dist/manifest-consistency.d.ts.map +1 -1
  122. package/dist/manifest-consistency.js +75 -10
  123. package/dist/manifest-consistency.js.map +1 -1
  124. package/dist/markdown-fences.d.ts +112 -0
  125. package/dist/markdown-fences.d.ts.map +1 -0
  126. package/dist/markdown-fences.js +174 -0
  127. package/dist/markdown-fences.js.map +1 -0
  128. package/dist/mcp-config-detector.d.ts +20 -0
  129. package/dist/mcp-config-detector.d.ts.map +1 -0
  130. package/dist/mcp-config-detector.js +237 -0
  131. package/dist/mcp-config-detector.js.map +1 -0
  132. package/dist/missing-context.d.ts.map +1 -1
  133. package/dist/missing-context.js +2 -52
  134. package/dist/missing-context.js.map +1 -1
  135. package/dist/model-budgets.d.ts +69 -0
  136. package/dist/model-budgets.d.ts.map +1 -0
  137. package/dist/model-budgets.js +110 -0
  138. package/dist/model-budgets.js.map +1 -0
  139. package/dist/negative-instructions.d.ts +26 -0
  140. package/dist/negative-instructions.d.ts.map +1 -0
  141. package/dist/negative-instructions.js +169 -0
  142. package/dist/negative-instructions.js.map +1 -0
  143. package/dist/path-containment.d.ts +41 -0
  144. package/dist/path-containment.d.ts.map +1 -0
  145. package/dist/path-containment.js +123 -0
  146. package/dist/path-containment.js.map +1 -0
  147. package/dist/project-type.d.ts +12 -2
  148. package/dist/project-type.d.ts.map +1 -1
  149. package/dist/project-type.js +25 -12
  150. package/dist/project-type.js.map +1 -1
  151. package/dist/prompt-cache.d.ts.map +1 -1
  152. package/dist/prompt-cache.js +33 -24
  153. package/dist/prompt-cache.js.map +1 -1
  154. package/dist/promptci-gitignore.d.ts +34 -0
  155. package/dist/promptci-gitignore.d.ts.map +1 -0
  156. package/dist/promptci-gitignore.js +42 -0
  157. package/dist/promptci-gitignore.js.map +1 -0
  158. package/dist/repo-context.d.ts +80 -0
  159. package/dist/repo-context.d.ts.map +1 -1
  160. package/dist/repo-context.js +170 -16
  161. package/dist/repo-context.js.map +1 -1
  162. package/dist/report.d.ts +8 -0
  163. package/dist/report.d.ts.map +1 -1
  164. package/dist/report.js +163 -63
  165. package/dist/report.js.map +1 -1
  166. package/dist/scan.d.ts.map +1 -1
  167. package/dist/scan.js +47 -4
  168. package/dist/scan.js.map +1 -1
  169. package/dist/scanner.d.ts +61 -1
  170. package/dist/scanner.d.ts.map +1 -1
  171. package/dist/scanner.js +367 -70
  172. package/dist/scanner.js.map +1 -1
  173. package/dist/security-pack.d.ts +20 -0
  174. package/dist/security-pack.d.ts.map +1 -1
  175. package/dist/security-pack.js +33 -14
  176. package/dist/security-pack.js.map +1 -1
  177. package/dist/skill-supply-chain.d.ts +132 -0
  178. package/dist/skill-supply-chain.d.ts.map +1 -0
  179. package/dist/skill-supply-chain.js +2954 -0
  180. package/dist/skill-supply-chain.js.map +1 -0
  181. package/dist/skills-detector.d.ts +23 -0
  182. package/dist/skills-detector.d.ts.map +1 -0
  183. package/dist/skills-detector.js +279 -0
  184. package/dist/skills-detector.js.map +1 -0
  185. package/dist/stale-instructions.d.ts +4 -1
  186. package/dist/stale-instructions.d.ts.map +1 -1
  187. package/dist/stale-instructions.js +53 -55
  188. package/dist/stale-instructions.js.map +1 -1
  189. package/dist/subagents-detector.d.ts +19 -0
  190. package/dist/subagents-detector.d.ts.map +1 -0
  191. package/dist/subagents-detector.js +155 -0
  192. package/dist/subagents-detector.js.map +1 -0
  193. package/dist/suppression.d.ts +2 -0
  194. package/dist/suppression.d.ts.map +1 -1
  195. package/dist/suppression.js +16 -47
  196. package/dist/suppression.js.map +1 -1
  197. package/dist/types.d.ts +62 -4
  198. package/dist/types.d.ts.map +1 -1
  199. package/dist/types.js +47 -1
  200. package/dist/types.js.map +1 -1
  201. package/dist/vague-guidance.d.ts +15 -2
  202. package/dist/vague-guidance.d.ts.map +1 -1
  203. package/dist/vague-guidance.js +44 -41
  204. package/dist/vague-guidance.js.map +1 -1
  205. package/dist/windsurf-detector.d.ts +22 -0
  206. package/dist/windsurf-detector.d.ts.map +1 -0
  207. package/dist/windsurf-detector.js +96 -0
  208. package/dist/windsurf-detector.js.map +1 -0
  209. package/dist/within-section-dedup.d.ts +16 -0
  210. package/dist/within-section-dedup.d.ts.map +1 -0
  211. package/dist/within-section-dedup.js +148 -0
  212. package/dist/within-section-dedup.js.map +1 -0
  213. package/dist/workspace-manifests.d.ts +80 -0
  214. package/dist/workspace-manifests.d.ts.map +1 -0
  215. package/dist/workspace-manifests.js +333 -0
  216. package/dist/workspace-manifests.js.map +1 -0
  217. package/package.json +11 -8
  218. package/dist/cheap-model-routing.d.ts +0 -4
  219. package/dist/cheap-model-routing.d.ts.map +0 -1
  220. package/dist/cheap-model-routing.js +0 -43
  221. package/dist/cheap-model-routing.js.map +0 -1
  222. package/dist/cli-output-filter.d.ts +0 -16
  223. package/dist/cli-output-filter.d.ts.map +0 -1
  224. package/dist/cli-output-filter.js +0 -88
  225. package/dist/cli-output-filter.js.map +0 -1
  226. package/dist/context-cost.d.ts +0 -4
  227. package/dist/context-cost.d.ts.map +0 -1
  228. package/dist/context-cost.js +0 -70
  229. package/dist/context-cost.js.map +0 -1
  230. package/dist/context-recommender.d.ts +0 -3
  231. package/dist/context-recommender.d.ts.map +0 -1
  232. package/dist/context-recommender.js +0 -329
  233. package/dist/context-recommender.js.map +0 -1
  234. package/dist/llm.d.ts +0 -6
  235. package/dist/llm.d.ts.map +0 -1
  236. package/dist/llm.js +0 -67
  237. package/dist/llm.js.map +0 -1
  238. package/dist/mcp-detector.d.ts +0 -4
  239. package/dist/mcp-detector.d.ts.map +0 -1
  240. package/dist/mcp-detector.js +0 -131
  241. package/dist/mcp-detector.js.map +0 -1
  242. package/dist/native-tool-preference.d.ts +0 -20
  243. package/dist/native-tool-preference.d.ts.map +0 -1
  244. package/dist/native-tool-preference.js +0 -94
  245. package/dist/native-tool-preference.js.map +0 -1
  246. package/dist/output-verbosity.d.ts +0 -3
  247. package/dist/output-verbosity.d.ts.map +0 -1
  248. package/dist/output-verbosity.js +0 -83
  249. package/dist/output-verbosity.js.map +0 -1
  250. package/dist/product-boundary.d.ts +0 -11
  251. package/dist/product-boundary.d.ts.map +0 -1
  252. package/dist/product-boundary.js +0 -301
  253. package/dist/product-boundary.js.map +0 -1
@@ -1,6 +1,11 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import * as crypto from 'node:crypto';
4
+ import { fencedBlocks, scanFencedLines } from './markdown-fences.js';
5
+ import { fileIdPath } from './finding-id.js';
6
+ import { anyWorkspaceHasScript, isKnownBinary, selectWorkspaces } from './workspace-manifests.js';
7
+ import { makefileDefines, readMakefile } from './makefile.js';
8
+ import { realPathWithinRoot } from './path-containment.js';
4
9
  /**
5
10
  * Command Validity Detector
6
11
  *
@@ -36,7 +41,18 @@ function fileExists(repoRoot, relativePath) {
36
41
  return false;
37
42
  }
38
43
  }
39
- function readPackageScripts(packageJsonPath) {
44
+ function isDirectory(repoRoot, relativePath) {
45
+ try {
46
+ return fs.statSync(path.resolve(repoRoot, relativePath)).isDirectory();
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
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))
55
+ return null;
40
56
  try {
41
57
  const raw = fs.readFileSync(packageJsonPath, 'utf8');
42
58
  const parsed = JSON.parse(raw);
@@ -101,8 +117,14 @@ function workspaceHasScript(context, selector, scriptName) {
101
117
  const normalizedSelector = normalizePnpmFilter(selector);
102
118
  if (!normalizedSelector)
103
119
  return false;
120
+ // Workspace manifests discovered while building the context (declared
121
+ // `workspaces` globs, any layout). The path-based lookup below is only for
122
+ // contexts built without them.
123
+ if (context.workspaces) {
124
+ return selectWorkspaces(context, normalizedSelector).some((ws) => !!ws.scripts[scriptName]);
125
+ }
104
126
  for (const candidate of workspacePackageJsonCandidates(context.repoRoot, normalizedSelector)) {
105
- const pkg = readPackageScripts(candidate);
127
+ const pkg = readPackageScripts(context.repoRoot, candidate);
106
128
  if (!pkg)
107
129
  continue;
108
130
  const packageDir = path.dirname(candidate).replace(/\\/g, '/');
@@ -129,17 +151,32 @@ const PNPM_BUILTINS = new Set([
129
151
  'install', 'i', 'add', 'remove', 'uninstall', 'test', 'it', 'init',
130
152
  'create', 'publish', 'link', 'unlink', 'outdated', 'audit', 'update',
131
153
  'up', 'exec', 'dlx', 'run', 'store', 'list', 'ls', 'why', 'pack',
132
- 'recursive', 'm', 'multi', 'prune',
154
+ 'recursive', 'm', 'multi', 'prune', 'version', 'run-script',
155
+ 'env', 'config', 'fetch', 'rebuild', 'rb', 'patch', 'patch-commit', 'deploy',
156
+ 'import', 'licenses', 'root', 'bin', 'setup', 'self-update', 'dedupe', 'doctor',
157
+ 'approve-builds', 'sbom', 'cache',
133
158
  ]);
134
159
  const NPM_BUILTINS = new Set([
135
160
  'install', 'i', 'ci', 'uninstall', 'remove', 'update', 'outdated',
136
161
  'audit', 'start', 'stop', 'restart', 'test', 'it', 'init', 'create',
137
- 'publish', 'link', 'unlink', 'pack', 'exec', 'run',
162
+ 'publish', 'link', 'unlink', 'pack', 'exec', 'run', 'run-script', 'version',
138
163
  ]);
139
164
  const YARN_BUILTINS = new Set([
140
165
  'install', 'add', 'remove', 'upgrade', 'outdated', 'audit', 'init',
141
- 'create', 'publish', 'link', 'unlink', 'why', 'pack', 'run',
166
+ 'create', 'publish', 'link', 'unlink', 'why', 'pack', 'run', 'version',
167
+ 'run-script', 'workspaces', 'exec', 'dlx', 'up', 'info', 'config', 'cache',
168
+ 'bin', 'set', 'node', 'global', 'dedupe', 'patch', 'rebuild', 'plugin',
169
+ 'constraints', 'unplug', 'npm',
170
+ ]);
171
+ /** `bun <name>` runs a script (or file) only when <name> is not one of bun's own commands. */
172
+ export const BUN_BUILTINS = new Set([
173
+ 'install', 'i', 'add', 'a', 'remove', 'rm', 'update', 'test', 'build', 'init',
174
+ 'create', 'c', 'x', 'bunx', 'pm', 'link', 'unlink', 'upgrade', 'repl', 'outdated',
175
+ 'publish', 'patch', 'audit', 'info', 'why', 'completions', 'help', 'feedback',
176
+ 'run', 'exec',
142
177
  ]);
178
+ /** npm and pnpm treat `run-script` as an alias of `run`. */
179
+ const RUN_ALIASES = new Set(['run', 'run-script']);
143
180
  /**
144
181
  * CV6: Resolves a `cd <target>` against the current repo-relative directory so
145
182
  * that a `cd apps/web && pnpm test:e2e` line validates `test:e2e` against
@@ -154,6 +191,10 @@ function resolveCwd(current, target) {
154
191
  if (!cleaned || cleaned.startsWith('/') || cleaned.startsWith('~') || /^[a-zA-Z]:[\\/]/.test(cleaned)) {
155
192
  return null; // absolute or home path — not resolvable to a repo-relative dir
156
193
  }
194
+ // `cd -`, `cd $DIR`, `cd "$(git rev-parse --show-toplevel)"`, `cd %TEMP%`,
195
+ // `cd <dir>`: computed or placeholder targets name no knowable directory.
196
+ if (/^-|[$`%<>{}*?]/.test(cleaned))
197
+ return null;
157
198
  const parts = current === '.' ? [] : current.split('/');
158
199
  for (const seg of cleaned.replace(/\\/g, '/').split('/')) {
159
200
  if (seg === '' || seg === '.')
@@ -214,80 +255,57 @@ const SHELL_FENCE_LANGS = new Set([
214
255
  /** Extracts commands from a single instruction-file content string. */
215
256
  function extractCommands(content) {
216
257
  const commands = [];
217
- const lines = content.split(/\r?\n/);
218
- // CV1: track fence char + length (matching scanner.ts/vague-guidance.ts/
219
- // stale-instructions.ts/conflicts.ts) so we know we're inside SOME fence
220
- // even for languages we don't scan for commands. The old regex only
221
- // recognized bare ``` or a fixed shell-language list as an OPENER — a
222
- // ```json fence's opener didn't match, so its bare ``` CLOSER was misread
223
- // as a NEW opener, inverting inBlock state for the rest of the file.
224
- let inBlock = false;
225
- let inShellBlock = false;
226
- let fenceChar = null;
227
- let fenceLen = 0;
228
- let blockStartLine = -1;
229
- // CV2: keep the block's raw (untrimmed) lines so each entry's index still
230
- // corresponds 1:1 to its physical line offset from blockStartLine. The old
231
- // code joined the block into one string and called `.trim()` on it before
232
- // re-splitting — that silently dropped leading/trailing blank lines, so
233
- // blockLines[j] no longer matched the physical line blockStartLine + j + 1
234
- // whenever the fenced block had leading blank lines.
235
- let blockContent = [];
236
- for (let i = 0; i < lines.length; i++) {
237
- const line = lines[i];
238
- if (!inBlock) {
239
- const openMatch = /^ {0,3}([`~]{3,})\s*([a-zA-Z0-9_-]*)/.exec(line);
240
- if (openMatch) {
241
- inBlock = true;
242
- fenceChar = openMatch[1][0] ?? null;
243
- fenceLen = openMatch[1].length;
244
- const lang = (openMatch[2] ?? '').toLowerCase();
245
- inShellBlock = lang === '' || SHELL_FENCE_LANGS.has(lang);
246
- blockStartLine = i + 1;
247
- blockContent = [];
258
+ // Fenced blocks first, prose second, then sorted back into document order
259
+ // below — findings quote the first occurrence of a command, so the order the
260
+ // two passes produce has to match the order a single interleaved pass did.
261
+ //
262
+ // Only CLOSED blocks contribute: an unclosed fence is a malformed document,
263
+ // and its tail was never meant to read as example commands. Non-shell fences
264
+ // (```json, ```ts, …) are skipped by language — tracking them at all is what
265
+ // keeps a ```json block's bare ``` closer from being misread as an opener,
266
+ // which used to invert the in-block state for the rest of the file.
267
+ for (const block of fencedBlocks(content)) {
268
+ // The shared scanner reports the full info string ('{.bash}', 'json,');
269
+ // classify on its leading identifier run, matching the historical opener
270
+ // regex `([a-zA-Z0-9_-]*)`. That keeps a pandoc-attribute fence like
271
+ // ```{.bash} classified as bare-and-therefore-shell (identifier run is
272
+ // empty) instead of being silently skipped as an unknown language.
273
+ const lang = /^[a-zA-Z0-9_-]*/.exec(block.lang)[0];
274
+ const isShell = lang === '' || SHELL_FENCE_LANGS.has(lang);
275
+ if (!isShell || !block.closed)
276
+ continue;
277
+ // Each entry's index corresponds 1:1 to its physical line offset from the
278
+ // block's first content line, so blank leading lines cannot shift the
279
+ // reported line number.
280
+ for (let j = 0; j < block.lines.length; j++) {
281
+ const rawTrimmed = block.lines[j].trim();
282
+ if (rawTrimmed.startsWith('#'))
248
283
  continue;
249
- }
250
- }
251
- else {
252
- const closeMatch = /^ {0,3}([`~]{3,})\s*$/.exec(line);
253
- if (closeMatch && closeMatch[1][0] === fenceChar && closeMatch[1].length >= fenceLen) {
254
- inBlock = false;
255
- if (inShellBlock) {
256
- for (let j = 0; j < blockContent.length; j++) {
257
- const raw = blockContent[j];
258
- const rawTrimmed = raw.trim();
259
- if (rawTrimmed.startsWith('#'))
260
- continue;
261
- const trimmed = rawTrimmed.replace(/^\$\s*/, '');
262
- if (!trimmed)
263
- continue;
264
- // CV6: each documented line is treated as an independent example run
265
- // from the repo root, so `cd` only affects commands chained after it
266
- // on the SAME line (e.g. `cd apps/web && pnpm test:e2e`). Setup blocks
267
- // are written this way — repeating `cd apps/web` on separate lines —
268
- // so a standalone `cd` must not bleed into later lines.
269
- let lineCwd = '.';
270
- for (const seg of splitOnAmpersand(trimmed)) {
271
- const cdMatch = /^cd\s+(.+)$/.exec(seg);
272
- if (cdMatch) {
273
- lineCwd = resolveCwd(lineCwd, cdMatch[1].trim());
274
- }
275
- commands.push({ text: seg, line: blockStartLine + j + 1, cwd: lineCwd });
276
- }
277
- }
278
- }
279
- inShellBlock = false;
280
- fenceChar = null;
281
- fenceLen = 0;
284
+ const trimmed = rawTrimmed.replace(/^\$\s*/, '');
285
+ if (!trimmed)
282
286
  continue;
287
+ // CV6: each documented line is treated as an independent example run
288
+ // from the repo root, so `cd` only affects commands chained after it
289
+ // on the SAME line (e.g. `cd apps/web && pnpm test:e2e`). Setup blocks
290
+ // are written this way — repeating `cd apps/web` on separate lines —
291
+ // so a standalone `cd` must not bleed into later lines.
292
+ let lineCwd = '.';
293
+ for (const seg of splitOnAmpersand(trimmed)) {
294
+ const cdMatch = /^cd\s+(.+)$/.exec(seg);
295
+ if (cdMatch) {
296
+ lineCwd = resolveCwd(lineCwd, cdMatch[1].trim());
297
+ }
298
+ commands.push({ text: seg, line: block.contentStartLine + j, cwd: lineCwd, fenced: true });
283
299
  }
284
- // Still inside the fence — accumulate only if it's a shell-ish block;
285
- // non-shell fences (```json, ```ts, …) are tracked for state but their
286
- // content is never scanned for commands.
287
- if (inShellBlock)
288
- blockContent.push(line);
289
- continue;
290
300
  }
301
+ }
302
+ for (const fenceLine of scanFencedLines(content)) {
303
+ if (fenceLine.inFence)
304
+ continue;
305
+ const i = fenceLine.lineNumber - 1;
306
+ // A trailing CR would block the prose patterns below: `.` does not match
307
+ // it, so the end-of-line lookahead never fires on a CRLF file.
308
+ const line = fenceLine.text.replace(/\r$/, '');
291
309
  // Inline backticks
292
310
  for (const m of line.matchAll(/`([^`]+)`/g)) {
293
311
  const inner = m[1].trim();
@@ -312,7 +330,8 @@ function extractCommands(content) {
312
330
  commands.push({ text: fullCmd, line: i + 1 });
313
331
  }
314
332
  }
315
- return commands;
333
+ // Stable sort: segments split from one line keep their left-to-right order.
334
+ return commands.sort((a, b) => a.line - b.line);
316
335
  }
317
336
  // ── Command validation ────────────────────────────────────────────────────────
318
337
  /** Placeholder patterns that indicate a command is intentionally abstract. */
@@ -324,10 +343,15 @@ const PLACEHOLDER_RE = /[<{]|\.{3,}|your-file|your-script|example|placeholder/i;
324
343
  * like `--filter` or `--loader` gets mistaken for the subcommand, and its
325
344
  * VALUE (a package name or loader module) gets mistaken for a file path.
326
345
  */
327
- const FLAGS_WITH_VALUE = new Set(['--filter', '-F', '--cwd', '-C', '--loader', '--require', '-r']);
346
+ const FLAGS_WITH_VALUE = new Set([
347
+ '--filter', '-F', '--cwd', '-C', '--dir', '--loader', '--require', '-r', '--workspace', '--prefix',
348
+ ]);
328
349
  function flagTakesValue(tool, flag) {
329
350
  if (tool === 'pnpm' && flag === '-r')
330
351
  return false;
352
+ // npm `-w <workspace>` takes a value; pnpm's `-w` (workspace root) does not.
353
+ if (flag === '-w')
354
+ return tool === 'npm';
331
355
  return FLAGS_WITH_VALUE.has(flag);
332
356
  }
333
357
  /**
@@ -341,8 +365,8 @@ function flagTakesValue(tool, flag) {
341
365
  * "ts-node/esm" (the loader flag's VALUE, which happens to contain a slash)
342
366
  * as the file being executed, instead of the real target "src/x.ts".
343
367
  */
344
- function firstNonFlagArgIndex(parts) {
345
- let idx = 1;
368
+ function firstNonFlagArgIndex(parts, startIdx = 1) {
369
+ let idx = startIdx;
346
370
  const tool = parts[0] ?? '';
347
371
  while (idx < parts.length) {
348
372
  const p = parts[idx];
@@ -355,30 +379,72 @@ function firstNonFlagArgIndex(parts) {
355
379
  }
356
380
  return idx;
357
381
  }
358
- /**
359
- * CV6/CV7: Validates one or more package-manager script names, honoring both a
360
- * `cd`-established working directory and slash-joined shorthand.
361
- *
362
- * CV6 (cwd): when a command runs after `cd <subdir>`, its scripts live in
363
- * `<subdir>/package.json`, NOT the repo-root manifest — so
364
- * `cd apps/web && pnpm test:e2e` is valid whenever apps/web defines `test:e2e`.
365
- *
366
- * CV7 (slash shorthand): `pnpm lint/typecheck/test/build` is a documentation
367
- * shorthand for four separate scripts, not one literal script named
368
- * "lint/typecheck/test/build". Each `/`-separated part is validated on its own.
369
- *
370
- * Returns an error string for the first genuinely-missing script, or null.
371
- */
372
- function findMissingScript(context, cwd, parts, scriptName, builtins, tool) {
373
- // Unknown working directory (unresolvable `cd`) — cannot verify.
374
- if (cwd === null)
375
- return null;
382
+ /** The `--filter`/`-F` (pnpm, bun) or `-w`/`--workspace` (npm) selector, if any. */
383
+ function workspaceSelector(tool, parts) {
384
+ const filter = pnpmFilterValue(parts);
385
+ if (filter)
386
+ return filter;
387
+ if (tool !== 'npm')
388
+ return undefined;
389
+ for (let i = 1; i < parts.length; i++) {
390
+ const part = parts[i];
391
+ if (part === '-w' || part === '--workspace')
392
+ return parts[i + 1];
393
+ if (part.startsWith('--workspace='))
394
+ return part.slice('--workspace='.length);
395
+ }
396
+ return undefined;
397
+ }
398
+ /** `pnpm -r` / `pnpm --recursive` / `npm --workspaces`: the script may live in any workspace. */
399
+ function runsInAllWorkspaces(tool, parts) {
400
+ if (tool === 'pnpm')
401
+ return parts.some((p) => p === '-r' || p === '--recursive');
402
+ if (tool === 'npm')
403
+ return parts.some((p) => p === '--workspaces' || p === '-ws');
404
+ return false;
405
+ }
406
+ /** A directory override (`--cwd`, `-C`, `--prefix`) re-roots the command like a `cd` would. */
407
+ function cwdOverride(parts) {
408
+ for (let i = 1; i < parts.length; i++) {
409
+ const part = parts[i];
410
+ if (part === '--cwd' || part === '-C' || part === '--dir' || part === '--prefix')
411
+ return parts[i + 1];
412
+ const eq = /^(?:--cwd|--dir|--prefix)=(.+)$/.exec(part);
413
+ if (eq)
414
+ return eq[1];
415
+ }
416
+ return undefined;
417
+ }
418
+ function findMissingScript(context, cwd, parts, scriptName, builtins, tool, lookup, onScript) {
376
419
  const names = scriptName.includes('/')
377
420
  ? scriptName.split('/').map((s) => s.trim()).filter(Boolean)
378
421
  : [scriptName];
422
+ // Recorded before any early return so callers know this command was judged —
423
+ // including when it could not be checked (unknown working directory).
424
+ for (const name of names)
425
+ onScript?.(name);
426
+ // Unknown working directory (unresolvable `cd`) — cannot verify.
427
+ if (cwd === null)
428
+ return null;
429
+ const override = cwdOverride(parts);
430
+ if (override !== undefined) {
431
+ const resolved = resolveCwd(cwd, override);
432
+ if (resolved === null)
433
+ return null;
434
+ cwd = resolved;
435
+ }
436
+ const selector = lookup.workspaceName ?? workspaceSelector(tool, parts);
437
+ const allWorkspaces = runsInAllWorkspaces(tool, parts);
438
+ const inWorkspaceScope = (name) => allWorkspaces ? anyWorkspaceHasScript(context, name) : workspaceHasScript(context, selector, name);
439
+ // With discovered workspaces, an explicit selector means ONLY the selected
440
+ // packages: the root manifest's same-named script is not what runs, and a
441
+ // selector that matches no package is itself the error.
442
+ const selectedWorkspaces = selector && context.workspaces && !allWorkspaces
443
+ ? selectWorkspaces(context, selector.replace(/^['"]|['"]$/g, '').replace(/^\.\.\./, '').replace(/\.\.\.$/, '').trim())
444
+ : undefined;
379
445
  const inSubdir = cwd !== '.' && cwd !== '';
380
446
  const dirPkg = inSubdir
381
- ? readPackageScripts(path.join(context.repoRoot, cwd, 'package.json'))
447
+ ? readPackageScripts(context.repoRoot, path.join(context.repoRoot, cwd, 'package.json'))
382
448
  : null;
383
449
  // cd'd into a directory whose package.json we can't read — don't guess.
384
450
  if (inSubdir && !dirPkg)
@@ -390,6 +456,23 @@ function findMissingScript(context, cwd, parts, scriptName, builtins, tool) {
390
456
  for (const name of names) {
391
457
  if (builtins.has(name))
392
458
  continue;
459
+ // `pnpm vitest run`, `yarn tsc --noEmit`: no script of that name, but it is
460
+ // an installed dependency's binary — the package manager runs that instead.
461
+ if (lookup.allowBinary && isKnownBinary(context, name))
462
+ continue;
463
+ if (selectedWorkspaces) {
464
+ if (selectedWorkspaces.length === 0) {
465
+ if (context.workspacesTruncated)
466
+ continue; // only a sample of the workspace was read
467
+ return `no workspace matches "${selector}" for ${tool} script "${name}"`;
468
+ }
469
+ if (selectedWorkspaces.some((ws) => !!ws.scripts[name]))
470
+ continue;
471
+ const target = selectedWorkspaces.length === 1
472
+ ? `${selectedWorkspaces[0].name ?? selectedWorkspaces[0].dir} (${selectedWorkspaces[0].dir}/package.json)`
473
+ : `the ${selectedWorkspaces.length} workspaces selected by "${selector}"`;
474
+ return `${tool} script "${name}" does not appear in ${target} scripts`;
475
+ }
393
476
  if (dirPkg) {
394
477
  // A command that runs inside a subdir resolves scripts ONLY from that
395
478
  // subdir's manifest (pnpm/npm/yarn do not search parent directories) or
@@ -398,12 +481,12 @@ function findMissingScript(context, cwd, parts, scriptName, builtins, tool) {
398
481
  // script.
399
482
  if (dirPkg.scripts[name])
400
483
  continue;
401
- if (workspaceHasScript(context, pnpmFilterValue(parts), name))
484
+ if (inWorkspaceScope(name))
402
485
  continue;
403
486
  return `${tool} script "${name}" does not appear in ${cwd}/package.json scripts`;
404
487
  }
405
- // Repo-root context: the pnpm --filter workspace target or the root manifest.
406
- if (workspaceHasScript(context, pnpmFilterValue(parts), name))
488
+ // Repo-root context: the workspace target(s) or the root manifest.
489
+ if (inWorkspaceScope(name))
407
490
  continue;
408
491
  if (context.packageJson.scripts[name])
409
492
  continue;
@@ -411,6 +494,141 @@ function findMissingScript(context, cwd, parts, scriptName, builtins, tool) {
411
494
  }
412
495
  return null;
413
496
  }
497
+ const BUILTINS_BY_TOOL = {
498
+ pnpm: PNPM_BUILTINS,
499
+ npm: NPM_BUILTINS,
500
+ yarn: YARN_BUILTINS,
501
+ // bun's own commands are filtered before this lookup (bare form only), so
502
+ // `bun run test` is judged against the manifest like any other script.
503
+ bun: new Set(),
504
+ };
505
+ /**
506
+ * Resolves which script (or file, for bun) a `pnpm`/`npm`/`yarn`/`bun` command
507
+ * invokes and checks it. Handles the `run` / `run-script` forms, the bare
508
+ * `pnpm <script>` / `yarn <script>` / `bun <script>` forms (which fall through
509
+ * to an installed binary), `yarn workspace <name> <script>`, and workspace or
510
+ * directory selectors.
511
+ */
512
+ function validatePackageManagerScript(tool, parts, context, cwd, baseDir, onScript) {
513
+ const subIdx = firstNonFlagArgIndex(parts);
514
+ const sub = parts[subIdx];
515
+ if (!sub)
516
+ return null;
517
+ let explicitRun = false;
518
+ let nameIdx = subIdx;
519
+ let workspaceName;
520
+ if (RUN_ALIASES.has(sub)) {
521
+ explicitRun = true;
522
+ nameIdx = firstNonFlagArgIndex(parts, subIdx + 1);
523
+ }
524
+ else if (tool === 'npm') {
525
+ return null; // a bare `npm <x>` is an npm command, never a script
526
+ }
527
+ else if (tool === 'yarn' && sub === 'workspace') {
528
+ workspaceName = parts[subIdx + 1];
529
+ nameIdx = subIdx + 2;
530
+ if (RUN_ALIASES.has(parts[nameIdx] ?? '')) {
531
+ explicitRun = true;
532
+ nameIdx++;
533
+ }
534
+ }
535
+ const scriptName = parts[nameIdx];
536
+ if (!scriptName || scriptName.startsWith('-'))
537
+ return null;
538
+ if (tool === 'bun') {
539
+ // `bun <x>` / `bun run <x>` also run files — a path is not a script name.
540
+ if (!explicitRun && BUN_BUILTINS.has(scriptName))
541
+ return null;
542
+ 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`;
545
+ }
546
+ return null;
547
+ }
548
+ }
549
+ return findMissingScript(context, cwd, parts, scriptName, BUILTINS_BY_TOOL[tool], tool,
550
+ // npm/pnpm `run` only run scripts; every other form can fall through to a binary.
551
+ { allowBinary: !(explicitRun && (tool === 'npm' || tool === 'pnpm')), workspaceName }, onScript);
552
+ }
553
+ // ── make targets ──────────────────────────────────────────────────────────────
554
+ /** make options whose value is the NEXT token (unless attached: `-Cdir`, `-j4`). */
555
+ const MAKE_FLAGS_WITH_VALUE = new Set([
556
+ '-C', '-f', '-o', '-W', '-I', '-E', '--directory', '--file', '--makefile', '--old-file', '--what-if',
557
+ '--include-dir', '--new-file', '--assume-old', '--assume-new', '--eval',
558
+ ]);
559
+ /** Words that follow "make" in English prose (`make sure`), never checked as targets in a code span. */
560
+ const MAKE_PROSE_WORDS = new Set([
561
+ 'sure', 'it', 'this', 'that', 'them', 'these', 'those', 'certain', 'sense', 'changes', 'room', 'use', 'way',
562
+ ]);
563
+ /** make options with an OPTIONAL numeric value (`-j`, `-j 4`, `-l 2.5`). */
564
+ const MAKE_FLAGS_WITH_OPTIONAL_NUMBER = new Set(['-j', '-l', '--jobs', '--load-average', '--max-load']);
565
+ /**
566
+ * pcic-2b6.10: `make <target>` against the Makefile make would read. Reports a
567
+ * target only when a Makefile exists in the command's directory, this reader
568
+ * could enumerate every target it defines (no `include`, no computed names),
569
+ * and neither an explicit target, a `.PHONY` name nor a pattern rule covers it.
570
+ * Variable assignments (`make X=1`), file-path goals (`make build/app.o`) and
571
+ * names make could build from a same-stem source via its built-in implicit
572
+ * rules (`make hello` next to `hello.c`) are never reported.
573
+ */
574
+ function validateMakeTargets(parts, context, cwd, makefileFor) {
575
+ let dir = cwd;
576
+ const goals = [];
577
+ for (let i = 1; i < parts.length; i++) {
578
+ const p = parts[i];
579
+ // Shell plumbing ends the make invocation.
580
+ if (/^(?:[|&<>#]|\d*>)/.test(p))
581
+ break;
582
+ if (p.startsWith('-')) {
583
+ const eq = p.indexOf('=');
584
+ const name = eq === -1 ? p : p.slice(0, eq);
585
+ if (name === '-f' || name === '--file' || name === '--makefile' || /^-f./.test(p))
586
+ return null; // another file
587
+ let value;
588
+ if (MAKE_FLAGS_WITH_VALUE.has(name)) {
589
+ value = eq !== -1 ? p.slice(eq + 1) : parts[++i];
590
+ }
591
+ else if (/^-C./.test(p)) {
592
+ value = p.slice(2);
593
+ }
594
+ else if (MAKE_FLAGS_WITH_OPTIONAL_NUMBER.has(p) && /^\d+(?:\.\d+)?$/.test(parts[i + 1] ?? '')) {
595
+ i++;
596
+ }
597
+ if ((name === '-C' || name === '--directory' || /^-C./.test(p)) && value !== undefined) {
598
+ dir = resolveCwd(dir, value);
599
+ }
600
+ continue;
601
+ }
602
+ if (p.includes('='))
603
+ continue; // `make CC=clang build`
604
+ goals.push(p);
605
+ }
606
+ if (dir === null || goals.length === 0)
607
+ return null;
608
+ const facts = makefileFor(dir);
609
+ if (!facts || facts.unverifiable)
610
+ return null;
611
+ const baseDir = dir === '.' ? context.repoRoot : path.join(context.repoRoot, dir);
612
+ let entries;
613
+ for (const goal of goals) {
614
+ const target = goal.replace(/^['"]|['"]$/g, '');
615
+ if (!target || isLikelyFilePath(target) || !/^[A-Za-z0-9_.+-]+$/.test(target))
616
+ continue;
617
+ if (makefileDefines(facts, target))
618
+ continue;
619
+ // 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 = [];
625
+ }
626
+ if (entries.some((e) => e === target || e.startsWith(`${target}.`)))
627
+ continue;
628
+ return `make target "${target}" does not appear in ${facts.file}`;
629
+ }
630
+ return null;
631
+ }
414
632
  /**
415
633
  * Validates a single command segment against the repo context.
416
634
  * Returns a human-readable error string, or null if the command looks valid.
@@ -418,7 +636,7 @@ function findMissingScript(context, cwd, parts, scriptName, builtins, tool) {
418
636
  * @param cwd Repo-relative directory the command runs in ('.' = root, null =
419
637
  * unknown after an unresolvable `cd` — validation is skipped).
420
638
  */
421
- function validateSegment(seg, context, cwd = '.') {
639
+ function validateSegment(seg, context, cwd = '.', onScript, opts = {}) {
422
640
  if (PLACEHOLDER_RE.test(seg))
423
641
  return null;
424
642
  const parts = seg.trim().split(/\s+/);
@@ -432,35 +650,8 @@ function validateSegment(seg, context, cwd = '.') {
432
650
  ? path.join(context.repoRoot, cwd)
433
651
  : context.repoRoot;
434
652
  // ── Package manager scripts ────────────────────────────────────────────────
435
- if (tool === 'pnpm') {
436
- const subIdx = firstNonFlagArgIndex(parts);
437
- const sub = parts[subIdx];
438
- if (!sub)
439
- return null;
440
- const scriptName = sub === 'run' ? parts[subIdx + 1] : sub;
441
- if (!scriptName)
442
- return null;
443
- return findMissingScript(context, cwd, parts, scriptName, PNPM_BUILTINS, 'pnpm');
444
- }
445
- if (tool === 'npm') {
446
- const subIdx = firstNonFlagArgIndex(parts);
447
- if (parts[subIdx] === 'run' && parts[subIdx + 1]) {
448
- return findMissingScript(context, cwd, parts, parts[subIdx + 1], NPM_BUILTINS, 'npm');
449
- }
450
- }
451
- if (tool === 'yarn') {
452
- const subIdx = firstNonFlagArgIndex(parts);
453
- const sub = parts[subIdx];
454
- if (!sub)
455
- return null;
456
- const scriptName = sub === 'run'
457
- ? parts[subIdx + 1]
458
- : sub === 'workspace'
459
- ? parts[subIdx + 2]
460
- : sub;
461
- if (!scriptName)
462
- return null;
463
- return findMissingScript(context, cwd, parts, scriptName, YARN_BUILTINS, 'yarn');
653
+ if (tool === 'pnpm' || tool === 'npm' || tool === 'yarn' || tool === 'bun') {
654
+ return validatePackageManagerScript(tool, parts, context, cwd, baseDir, onScript);
464
655
  }
465
656
  // ── File-executing commands ────────────────────────────────────────────────
466
657
  const FILE_EXECUTORS = ['node', 'tsx', 'ts-node', 'bash', 'sh', 'python', 'python3'];
@@ -472,8 +663,29 @@ function validateSegment(seg, context, cwd = '.') {
472
663
  return `File "${filePart}" referenced by "${tool}" does not appear to exist in the repository`;
473
664
  }
474
665
  }
666
+ // ── make <target> ──────────────────────────────────────────────────────────
667
+ if (tool === 'make' && cwd !== null && opts.makefileFor) {
668
+ // An instruction file that sits next to its own Makefile (`tools/AGENTS.md`
669
+ // beside `tools/Makefile`) documents that one, not the root's.
670
+ const fileDir = opts.fileDir ?? '.';
671
+ const makeCwd = cwd === '.' && fileDir !== '.' && opts.makefileFor(fileDir) ? fileDir : cwd;
672
+ // A code span like `make sure` is English, not a make invocation.
673
+ if (!opts.fenced && parts.length === 2 && MAKE_PROSE_WORDS.has(parts[1].toLowerCase()))
674
+ return null;
675
+ return validateMakeTargets(parts, context, makeCwd, opts.makefileFor);
676
+ }
475
677
  // ── Direct script invocations: ./scripts/foo.sh ───────────────────────────
476
678
  if (baseDir && (tool.startsWith('./') || tool.startsWith('../'))) {
679
+ // pcic-2b6.8: a bare `./src` or `./docs/` in an inline code span is a
680
+ // directory mention, not a script invocation — it is dead-references' to
681
+ // judge. Only a shell-fence line (meant to be run) still reports an absent
682
+ // extensionless `./gradlew`; an existing directory is never a missing script.
683
+ if (parts.length === 1 && path.extname(tool.replace(/\/+$/, '')) === '') {
684
+ if (isDirectory(baseDir, tool))
685
+ return null;
686
+ if (!opts.fenced || tool.endsWith('/'))
687
+ return null;
688
+ }
477
689
  if (!fileExists(baseDir, tool)) {
478
690
  return `Script "${tool}" does not appear to exist in the repository`;
479
691
  }
@@ -510,12 +722,53 @@ function validateSegment(seg, context, cwd = '.') {
510
722
  * out of scope.
511
723
  */
512
724
  export function detectCommandValidity(context) {
725
+ // Copies: the cached analysis is shared with manifest-consistency.
726
+ return analyzeCommands(context).issues.map((issue) => ({ ...issue }));
727
+ }
728
+ /**
729
+ * Both command-validity and manifest-consistency need the analysis; compute it
730
+ * once per context.
731
+ */
732
+ const analysisCache = new WeakMap();
733
+ export function analyzeCommands(context) {
734
+ let analysis = analysisCache.get(context);
735
+ if (!analysis) {
736
+ analysis = computeCommandAnalysis(context);
737
+ analysisCache.set(context, analysis);
738
+ }
739
+ return analysis;
740
+ }
741
+ function computeCommandAnalysis(context) {
513
742
  const issues = [];
743
+ const evaluatedScripts = new Map();
514
744
  const reported = new Set();
745
+ // Makefiles by repo-relative directory: the root one comes from the context,
746
+ // subdirectory ones (`make -C dir`, `cd dir && make`) are read once each.
747
+ const makefiles = new Map();
748
+ const makefileFor = (dir) => {
749
+ if (dir === '.' || dir === '')
750
+ return context.makefile;
751
+ if (!makefiles.has(dir))
752
+ makefiles.set(dir, readMakefile(context.repoRoot, dir));
753
+ return makefiles.get(dir);
754
+ };
515
755
  for (const file of context.files) {
516
756
  const commands = extractCommands(file.content);
757
+ const evaluated = new Set();
758
+ const fileDir = path.relative(context.repoRoot, path.dirname(file.path)).replace(/\\/g, '/') || '.';
759
+ evaluatedScripts.set(file.path, evaluated);
517
760
  for (const cmd of commands) {
518
- const error = validateSegment(cmd.text, context, cmd.cwd ?? '.');
761
+ // pcic-2b6.13: `cmd.cwd ?? '.'` used to turn an unknown directory (null,
762
+ // after `cd /srv/app` or `cd $DIR`) into the repo root and validate the
763
+ // command against the root manifest. Only a command with no cwd at all
764
+ // (prose, which never carries a `cd`) runs from the root; null stays
765
+ // unverifiable.
766
+ const cwd = cmd.cwd === undefined ? '.' : cmd.cwd;
767
+ const error = validateSegment(cmd.text, context, cwd, (name) => evaluated.add(`${cmd.line}:${name}`), {
768
+ fenced: cmd.fenced === true,
769
+ makefileFor,
770
+ fileDir: fileDir.startsWith('..') ? '.' : fileDir,
771
+ });
519
772
  if (!error)
520
773
  continue;
521
774
  // Deduplicate same command text appearing twice in the same file
@@ -524,7 +777,7 @@ export function detectCommandValidity(context) {
524
777
  continue;
525
778
  reported.add(dedupeKey);
526
779
  issues.push({
527
- id: issueId(file.path, cmd.text),
780
+ id: issueId(fileIdPath(file), cmd.text),
528
781
  severity: 'warning',
529
782
  category: 'command_validity',
530
783
  title: `Possible reference to missing command or file: \`${cmd.text.split(/\s+/).slice(0, 3).join(' ')}\``,
@@ -538,6 +791,6 @@ export function detectCommandValidity(context) {
538
791
  });
539
792
  }
540
793
  }
541
- return issues;
794
+ return { issues, evaluatedScripts };
542
795
  }
543
796
  //# sourceMappingURL=command-validity.js.map