@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.
- package/dist/action-pinning.d.ts.map +1 -1
- package/dist/action-pinning.js +86 -55
- package/dist/action-pinning.js.map +1 -1
- package/dist/agent-practices.d.ts +5 -1
- package/dist/agent-practices.d.ts.map +1 -1
- package/dist/agent-practices.js +59 -34
- package/dist/agent-practices.js.map +1 -1
- package/dist/ai-config.d.ts +13 -6
- package/dist/ai-config.d.ts.map +1 -1
- package/dist/ai-config.js +25 -78
- package/dist/ai-config.js.map +1 -1
- package/dist/baseline.d.ts +8 -0
- package/dist/baseline.d.ts.map +1 -1
- package/dist/baseline.js +15 -0
- package/dist/baseline.js.map +1 -1
- package/dist/ci-alignment.d.ts +3 -1
- package/dist/ci-alignment.d.ts.map +1 -1
- package/dist/ci-alignment.js +8 -1
- package/dist/ci-alignment.js.map +1 -1
- package/dist/close-match.d.ts +97 -0
- package/dist/close-match.d.ts.map +1 -0
- package/dist/close-match.js +215 -0
- package/dist/close-match.js.map +1 -0
- package/dist/command-context.d.ts +67 -0
- package/dist/command-context.d.ts.map +1 -0
- package/dist/command-context.js +291 -0
- package/dist/command-context.js.map +1 -0
- package/dist/command-validity.d.ts.map +1 -1
- package/dist/command-validity.js +510 -32
- package/dist/command-validity.js.map +1 -1
- package/dist/context-optimizer.d.ts.map +1 -1
- package/dist/context-optimizer.js +9 -2
- package/dist/context-optimizer.js.map +1 -1
- package/dist/dead-references.d.ts +14 -1
- package/dist/dead-references.d.ts.map +1 -1
- package/dist/dead-references.js +264 -17
- package/dist/dead-references.js.map +1 -1
- package/dist/dependency-staleness.d.ts.map +1 -1
- package/dist/dependency-staleness.js +24 -38
- package/dist/dependency-staleness.js.map +1 -1
- package/dist/detectors.js +1 -1
- package/dist/detectors.js.map +1 -1
- package/dist/discovery.d.ts +138 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +569 -0
- package/dist/discovery.js.map +1 -0
- package/dist/duplicates.d.ts +24 -2
- package/dist/duplicates.d.ts.map +1 -1
- package/dist/duplicates.js +313 -9
- package/dist/duplicates.js.map +1 -1
- package/dist/fix-engine.d.ts.map +1 -1
- package/dist/fix-engine.js +75 -1
- package/dist/fix-engine.js.map +1 -1
- package/dist/frameworks/dotnet.d.ts.map +1 -1
- package/dist/frameworks/dotnet.js +10 -11
- package/dist/frameworks/dotnet.js.map +1 -1
- package/dist/frameworks/go.d.ts.map +1 -1
- package/dist/frameworks/go.js +8 -4
- package/dist/frameworks/go.js.map +1 -1
- package/dist/frameworks/nextjs.d.ts.map +1 -1
- package/dist/frameworks/nextjs.js +8 -17
- package/dist/frameworks/nextjs.js.map +1 -1
- package/dist/frameworks/python.d.ts.map +1 -1
- package/dist/frameworks/python.js +5 -3
- package/dist/frameworks/python.js.map +1 -1
- package/dist/frameworks/rust.d.ts.map +1 -1
- package/dist/frameworks/rust.js +3 -3
- package/dist/frameworks/rust.js.map +1 -1
- package/dist/frameworks/unity.d.ts.map +1 -1
- package/dist/frameworks/unity.js +6 -5
- package/dist/frameworks/unity.js.map +1 -1
- package/dist/gitignore.d.ts +31 -2
- package/dist/gitignore.d.ts.map +1 -1
- package/dist/gitignore.js +33 -8
- package/dist/gitignore.js.map +1 -1
- package/dist/health-score.d.ts +15 -1
- package/dist/health-score.d.ts.map +1 -1
- package/dist/health-score.js +25 -2
- package/dist/health-score.js.map +1 -1
- package/dist/hooks-settings-detector.d.ts.map +1 -1
- package/dist/hooks-settings-detector.js +5 -1
- package/dist/hooks-settings-detector.js.map +1 -1
- package/dist/index.d.ts +10 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -2
- package/dist/index.js.map +1 -1
- package/dist/known-paths.d.ts +82 -0
- package/dist/known-paths.d.ts.map +1 -0
- package/dist/known-paths.js +152 -0
- package/dist/known-paths.js.map +1 -0
- package/dist/makefile.d.ts.map +1 -1
- package/dist/makefile.js +17 -13
- package/dist/makefile.js.map +1 -1
- package/dist/manifest-consistency.d.ts +1 -1
- package/dist/manifest-consistency.d.ts.map +1 -1
- package/dist/manifest-consistency.js +18 -8
- package/dist/manifest-consistency.js.map +1 -1
- package/dist/markdown-fences.d.ts.map +1 -1
- package/dist/markdown-fences.js +7 -3
- package/dist/markdown-fences.js.map +1 -1
- package/dist/mcp-config-detector.d.ts.map +1 -1
- package/dist/mcp-config-detector.js +5 -1
- package/dist/mcp-config-detector.js.map +1 -1
- package/dist/project-type.d.ts +2 -1
- package/dist/project-type.d.ts.map +1 -1
- package/dist/project-type.js +36 -48
- package/dist/project-type.js.map +1 -1
- package/dist/prompt-cache.d.ts.map +1 -1
- package/dist/prompt-cache.js +13 -2
- package/dist/prompt-cache.js.map +1 -1
- package/dist/provenance/check.d.ts +40 -0
- package/dist/provenance/check.d.ts.map +1 -0
- package/dist/provenance/check.js +157 -0
- package/dist/provenance/check.js.map +1 -0
- package/dist/provenance/index.d.ts +13 -0
- package/dist/provenance/index.d.ts.map +1 -0
- package/dist/provenance/index.js +21 -0
- package/dist/provenance/index.js.map +1 -0
- package/dist/provenance/instruction-advice.d.ts +3 -0
- package/dist/provenance/instruction-advice.d.ts.map +1 -0
- package/dist/provenance/instruction-advice.js +570 -0
- package/dist/provenance/instruction-advice.js.map +1 -0
- package/dist/provenance/repo-checks.d.ts +3 -0
- package/dist/provenance/repo-checks.d.ts.map +1 -0
- package/dist/provenance/repo-checks.js +644 -0
- package/dist/provenance/repo-checks.js.map +1 -0
- package/dist/provenance/tool-config.d.ts +3 -0
- package/dist/provenance/tool-config.d.ts.map +1 -0
- package/dist/provenance/tool-config.js +480 -0
- package/dist/provenance/tool-config.js.map +1 -0
- package/dist/provenance/types.d.ts +116 -0
- package/dist/provenance/types.d.ts.map +1 -0
- package/dist/provenance/types.js +17 -0
- package/dist/provenance/types.js.map +1 -0
- package/dist/repo-context.d.ts +9 -0
- package/dist/repo-context.d.ts.map +1 -1
- package/dist/repo-context.js +35 -41
- package/dist/repo-context.js.map +1 -1
- package/dist/repo-tree.d.ts +163 -9
- package/dist/repo-tree.d.ts.map +1 -1
- package/dist/repo-tree.js +226 -34
- package/dist/repo-tree.js.map +1 -1
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +6 -0
- package/dist/report.js.map +1 -1
- package/dist/sample-text.d.ts +36 -0
- package/dist/sample-text.d.ts.map +1 -0
- package/dist/sample-text.js +72 -0
- package/dist/sample-text.js.map +1 -0
- package/dist/sarif.d.ts +27 -0
- package/dist/sarif.d.ts.map +1 -0
- package/dist/sarif.js +357 -0
- package/dist/sarif.js.map +1 -0
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +8 -2
- package/dist/scan.js.map +1 -1
- package/dist/scanner.d.ts +4 -3
- package/dist/scanner.d.ts.map +1 -1
- package/dist/scanner.js +36 -77
- package/dist/scanner.js.map +1 -1
- package/dist/security-pack.d.ts.map +1 -1
- package/dist/security-pack.js +117 -35
- package/dist/security-pack.js.map +1 -1
- package/dist/skill-supply-chain.d.ts.map +1 -1
- package/dist/skill-supply-chain.js +29 -19
- package/dist/skill-supply-chain.js.map +1 -1
- package/dist/skills-detector.d.ts.map +1 -1
- package/dist/skills-detector.js +11 -3
- package/dist/skills-detector.js.map +1 -1
- package/dist/token-estimates.d.ts +42 -0
- package/dist/token-estimates.d.ts.map +1 -0
- package/dist/token-estimates.js +200 -0
- package/dist/token-estimates.js.map +1 -0
- package/dist/types.d.ts +99 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/workspace-manifests.d.ts +6 -0
- package/dist/workspace-manifests.d.ts.map +1 -1
- package/dist/workspace-manifests.js +13 -8
- package/dist/workspace-manifests.js.map +1 -1
- package/package.json +5 -2
package/dist/command-validity.js
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
108
|
-
*
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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 (
|
|
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 &&
|
|
446
|
+
if (selectorMatches && hasScript(pkg.scripts, scriptName))
|
|
216
447
|
return true;
|
|
217
448
|
}
|
|
218
|
-
|
|
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) =>
|
|
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
|
|
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
|
|
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 ??
|
|
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 =
|
|
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:
|
|
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:
|
|
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)'),
|