@blundergoat/gruff-ts 0.1.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/CHANGELOG.md +16 -0
- package/CONTRIBUTING.md +87 -0
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/SECURITY.md +45 -0
- package/bin/gruff-ts +25 -0
- package/docs/CONFIGURATION.md +220 -0
- package/docs/RELEASING.md +103 -0
- package/docs/REPORTS_AND_CI.md +156 -0
- package/fixtures/sample.ts +21 -0
- package/package.json +56 -0
- package/scripts/bump-version.sh +145 -0
- package/scripts/check.sh +4 -0
- package/scripts/npm-publish.sh +258 -0
- package/scripts/preflight-checks.sh +357 -0
- package/scripts/start-dev.sh +8 -0
- package/scripts/test-performance.sh +695 -0
- package/src/analyser.ts +461 -0
- package/src/baseline.ts +90 -0
- package/src/blocks.ts +687 -0
- package/src/class-rules.ts +326 -0
- package/src/cli-program.ts +326 -0
- package/src/cli.ts +19 -0
- package/src/comment-rules.ts +605 -0
- package/src/comment-scanner.ts +357 -0
- package/src/config.ts +622 -0
- package/src/constants.ts +4 -0
- package/src/context-doc-rules.ts +241 -0
- package/src/dashboard.ts +114 -0
- package/src/dead-code-rules.ts +183 -0
- package/src/discovery.ts +508 -0
- package/src/doc-rules.ts +368 -0
- package/src/findings-helpers.ts +108 -0
- package/src/findings.ts +45 -0
- package/src/fixture-purpose-rules.ts +334 -0
- package/src/fixtures/rule-catalogue-security-doctrine.ts +132 -0
- package/src/github-actions-rules.ts +413 -0
- package/src/line-rules.ts +538 -0
- package/src/naming-pushers.ts +191 -0
- package/src/project-config-rules.ts +555 -0
- package/src/project-rules.ts +545 -0
- package/src/report-renderers.ts +691 -0
- package/src/rule-list.ts +179 -0
- package/src/rules.ts +135 -0
- package/src/safety-rules.ts +355 -0
- package/src/scoring.ts +74 -0
- package/src/security-flow-rules.ts +112 -0
- package/src/sensitive-data-rules.ts +288 -0
- package/src/source-text.ts +722 -0
- package/src/test-block-rules.ts +347 -0
- package/src/test-fixtures.ts +621 -0
- package/src/text-scans.ts +193 -0
- package/src/types.ts +113 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
// Package and tsconfig health checks that emit deterministic project-config findings from JSON files.
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, dirname as dirnamePath, isAbsolute, join } from "node:path";
|
|
4
|
+
import { platform } from "node:process";
|
|
5
|
+
import { isString, objectValue } from "./config.ts";
|
|
6
|
+
import { makeFinding } from "./findings.ts";
|
|
7
|
+
import { firstLine } from "./text-scans.ts";
|
|
8
|
+
import type { Finding } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
// Both paths are required: `displayPath` anchors findings, `absolutePath` resolves bin targets
|
|
11
|
+
// against the owning package.json. Diverging them would break bin-existence checks on Windows.
|
|
12
|
+
interface ConfigSourceFile {
|
|
13
|
+
absolutePath: string;
|
|
14
|
+
displayPath: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
* Dispatcher for package.json / tsconfig.json health rules. Only these two filenames are inspected;
|
|
19
|
+
* any other JSON files in the project are out of scope to keep the rule surface bounded. The
|
|
20
|
+
* stable, deterministic Finding[] emission order is what makes baselines reproducible.
|
|
21
|
+
*/
|
|
22
|
+
function analyseProjectConfigRules(file: ConfigSourceFile, source: string, findings: Finding[]): void {
|
|
23
|
+
const name = basename(file.displayPath);
|
|
24
|
+
if (name !== "package.json" && !isTsconfigFileName(name)) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const configObject = parseJsonObject(source);
|
|
28
|
+
if (!configObject) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (name === "package.json") {
|
|
32
|
+
analysePackageJson(file, source, configObject, findings);
|
|
33
|
+
} else {
|
|
34
|
+
analyseTsconfigJson(file, source, configObject, findings);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Accepts the default tsconfig plus named variants such as `tsconfig.base.json`. This keeps
|
|
39
|
+
// workspace/shared config files inside the same strictness rule surface as the root config.
|
|
40
|
+
function isTsconfigFileName(name: string): boolean {
|
|
41
|
+
return name === "tsconfig.json" || /^tsconfig\.[^.]+(?:\.[^.]+)*\.json$/.test(name);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Three sub-pillars in fixed order (scripts → dependencies → bins). This deterministic order is
|
|
45
|
+
// the stable emission contract that lets fingerprints survive cosmetic reorderings of package.json.
|
|
46
|
+
function analysePackageJson(file: ConfigSourceFile, source: string, pkg: Record<string, unknown>, findings: Finding[]): void {
|
|
47
|
+
analysePackageScripts(file, source, objectValue(pkg.scripts), findings);
|
|
48
|
+
analysePackageDependencies(file, source, pkg, findings);
|
|
49
|
+
analysePackageBins(file, source, pkg, findings);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/*
|
|
53
|
+
* Iterates `package.json#scripts` in declaration order - the stable Finding[] emission contract
|
|
54
|
+
* relies on this. Each script is funnelled through both the remote-installer and lifecycle-script
|
|
55
|
+
* checks because one script can match both.
|
|
56
|
+
*/
|
|
57
|
+
function analysePackageScripts(file: ConfigSourceFile, source: string, scripts: Record<string, unknown> | undefined, findings: Finding[]): void {
|
|
58
|
+
if (!scripts) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
for (const [scriptName, scriptCommand] of Object.entries(scripts)) {
|
|
62
|
+
if (!isString(scriptCommand)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
pushRemoteInstallScriptFinding(file, source, scriptName, scriptCommand, findings);
|
|
66
|
+
pushLifecycleScriptFinding(file, source, scriptName, findings);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/*
|
|
71
|
+
* Reports `security.remote-install-script` for `curl|wget … | sh` patterns. Severity is `error`
|
|
72
|
+
* because remote shell execution at install time is a contract red flag in the modern supply-chain
|
|
73
|
+
* landscape.
|
|
74
|
+
*/
|
|
75
|
+
function pushRemoteInstallScriptFinding(file: ConfigSourceFile, source: string, scriptName: string, scriptCommand: string, findings: Finding[]): void {
|
|
76
|
+
if (!isRemoteInstallScript(scriptCommand)) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
findings.push(
|
|
80
|
+
makeFinding({
|
|
81
|
+
ruleId: "security.remote-install-script",
|
|
82
|
+
message: `Package script \`${scriptName}\` downloads and executes remote shell content.`,
|
|
83
|
+
filePath: file.displayPath,
|
|
84
|
+
line: jsonKeyLine(source, scriptName),
|
|
85
|
+
severity: "error",
|
|
86
|
+
pillar: "security",
|
|
87
|
+
confidence: "medium",
|
|
88
|
+
symbol: scriptName,
|
|
89
|
+
remediation: "Vendor the installer, pin an audited package, or remove remote shell execution.",
|
|
90
|
+
metadata: { scriptName },
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/*
|
|
96
|
+
* Reports the stable `security.risky-lifecycle-script` finding for any preinstall/install/
|
|
97
|
+
* postinstall/prepare/prepublish/prepublishOnly hook - these run automatically and even disabling
|
|
98
|
+
* install scripts in npm config is not universally honoured. Flagged as `warning` rather than
|
|
99
|
+
* `error` because some packages legitimately need them.
|
|
100
|
+
*/
|
|
101
|
+
function pushLifecycleScriptFinding(file: ConfigSourceFile, source: string, scriptName: string, findings: Finding[]): void {
|
|
102
|
+
if (!isLifecycleScript(scriptName)) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
findings.push(
|
|
106
|
+
makeFinding({
|
|
107
|
+
ruleId: "security.risky-lifecycle-script",
|
|
108
|
+
message: `Package lifecycle script \`${scriptName}\` runs automatically during install or publish flows.`,
|
|
109
|
+
filePath: file.displayPath,
|
|
110
|
+
line: jsonKeyLine(source, scriptName),
|
|
111
|
+
severity: "warning",
|
|
112
|
+
pillar: "security",
|
|
113
|
+
confidence: "medium",
|
|
114
|
+
symbol: scriptName,
|
|
115
|
+
remediation: "Move setup behind an explicit command unless lifecycle execution is required.",
|
|
116
|
+
metadata: { scriptName },
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/*
|
|
122
|
+
* Walks every dependency section in a stable, deterministic order. `runtimeDependency` flag
|
|
123
|
+
* separates devDependencies from the rest because the broad-version rule should only fire for
|
|
124
|
+
* runtime drift.
|
|
125
|
+
*/
|
|
126
|
+
function analysePackageDependencies(file: ConfigSourceFile, source: string, pkg: Record<string, unknown>, findings: Finding[]): void {
|
|
127
|
+
for (const section of ["dependencies", "optionalDependencies", "peerDependencies", "devDependencies"]) {
|
|
128
|
+
const dependencies = objectValue(pkg[section]);
|
|
129
|
+
if (!dependencies) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const runtimeDependency = section === "dependencies" || section === "optionalDependencies";
|
|
133
|
+
for (const [packageName, value] of Object.entries(dependencies)) {
|
|
134
|
+
if (isString(value)) {
|
|
135
|
+
analysePackageDependency(file, source, section, packageName, value, runtimeDependency, findings);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function analysePackageDependency(
|
|
142
|
+
file: ConfigSourceFile,
|
|
143
|
+
source: string,
|
|
144
|
+
section: string,
|
|
145
|
+
packageName: string,
|
|
146
|
+
versionSpec: string,
|
|
147
|
+
runtimeDependency: boolean,
|
|
148
|
+
findings: Finding[],
|
|
149
|
+
): void {
|
|
150
|
+
pushUrlDependencyFinding(file, source, section, packageName, versionSpec, runtimeDependency, findings);
|
|
151
|
+
pushBroadRuntimeDependencyFinding(file, source, section, packageName, versionSpec, runtimeDependency, findings);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function pushUrlDependencyFinding(
|
|
155
|
+
file: ConfigSourceFile,
|
|
156
|
+
source: string,
|
|
157
|
+
section: string,
|
|
158
|
+
packageName: string,
|
|
159
|
+
versionSpec: string,
|
|
160
|
+
runtimeDependency: boolean,
|
|
161
|
+
findings: Finding[],
|
|
162
|
+
): void {
|
|
163
|
+
if (!isUrlDependency(versionSpec)) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
findings.push(
|
|
167
|
+
makeFinding({
|
|
168
|
+
ruleId: "security.url-dependency",
|
|
169
|
+
message: `Dependency \`${packageName}\` in \`${section}\` installs from a URL or git spec.`,
|
|
170
|
+
filePath: file.displayPath,
|
|
171
|
+
line: jsonKeyLine(source, packageName),
|
|
172
|
+
severity: "warning",
|
|
173
|
+
pillar: "security",
|
|
174
|
+
confidence: "medium",
|
|
175
|
+
symbol: packageName,
|
|
176
|
+
remediation: "Prefer a registry package version that can be locked and audited.",
|
|
177
|
+
metadata: { packageName, section, runtimeDependency },
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function pushBroadRuntimeDependencyFinding(
|
|
183
|
+
file: ConfigSourceFile,
|
|
184
|
+
source: string,
|
|
185
|
+
section: string,
|
|
186
|
+
packageName: string,
|
|
187
|
+
versionSpec: string,
|
|
188
|
+
runtimeDependency: boolean,
|
|
189
|
+
findings: Finding[],
|
|
190
|
+
): void {
|
|
191
|
+
if (!runtimeDependency || !isBroadRuntimeVersion(versionSpec)) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
findings.push(
|
|
195
|
+
makeFinding({
|
|
196
|
+
ruleId: "waste.broad-runtime-version",
|
|
197
|
+
message: `Runtime dependency \`${packageName}\` uses overly broad version spec \`${versionSpec}\`.`,
|
|
198
|
+
filePath: file.displayPath,
|
|
199
|
+
line: jsonKeyLine(source, packageName),
|
|
200
|
+
severity: "advisory",
|
|
201
|
+
pillar: "waste",
|
|
202
|
+
confidence: "medium",
|
|
203
|
+
symbol: packageName,
|
|
204
|
+
remediation: "Use a bounded semver range and rely on the lockfile for repeatable installs.",
|
|
205
|
+
metadata: { packageName, section, versionSpec },
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/*
|
|
211
|
+
* Expands `bin` into (command, target) pairs (handling both string and object forms), then checks
|
|
212
|
+
* each one in a deterministic, stable order. Returns nothing when `bin` is absent - the rule does
|
|
213
|
+
* not require packages to ship CLIs.
|
|
214
|
+
*/
|
|
215
|
+
function analysePackageBins(file: ConfigSourceFile, source: string, pkg: Record<string, unknown>, findings: Finding[]): void {
|
|
216
|
+
for (const [command, target] of packageBinEntries(pkg)) {
|
|
217
|
+
analysePackageBin(file, source, command, target, findings);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/*
|
|
222
|
+
* Reads the bin target from disk. The stable mapping: missing → `package-bin-missing`;
|
|
223
|
+
* non-executable → `package-bin-not-executable`. Executable files pass silently so the rule cannot
|
|
224
|
+
* fail a healthy install pipeline.
|
|
225
|
+
*/
|
|
226
|
+
function analysePackageBin(file: ConfigSourceFile, source: string, command: string, target: string, findings: Finding[]): void {
|
|
227
|
+
const absolute = packageBinPath(file, target);
|
|
228
|
+
if (!existsSync(absolute)) {
|
|
229
|
+
pushMissingPackageBinFinding(file, source, command, target, findings);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const stats = statSync(absolute);
|
|
233
|
+
if (platform === "win32") {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (!stats.isFile() || (stats.mode & 0o111) === 0) {
|
|
237
|
+
pushNonExecutablePackageBinFinding(file, source, command, target, findings);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Bin targets are resolved against the directory of the owning package.json, not the project root.
|
|
242
|
+
// Required because workspace packages can live in subdirectories and have their own bins.
|
|
243
|
+
function packageBinPath(file: ConfigSourceFile, target: string): string {
|
|
244
|
+
return isAbsolute(target) ? target : join(dirnamePath(file.absolutePath), target);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/*
|
|
248
|
+
* Reports the `design.package-bin-missing` finding for a declared bin whose file does not exist
|
|
249
|
+
* on disk - emitted with stable command + target metadata that other tooling can key off.
|
|
250
|
+
*/
|
|
251
|
+
function pushMissingPackageBinFinding(file: ConfigSourceFile, source: string, command: string, target: string, findings: Finding[]): void {
|
|
252
|
+
findings.push(
|
|
253
|
+
packageBinFinding({
|
|
254
|
+
file,
|
|
255
|
+
source,
|
|
256
|
+
ruleId: "design.package-bin-missing",
|
|
257
|
+
message: `Package bin \`${command}\` points to missing file \`${target}\`.`,
|
|
258
|
+
command,
|
|
259
|
+
target,
|
|
260
|
+
remediation: "Update the bin path or add the executable file.",
|
|
261
|
+
}),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/*
|
|
266
|
+
* Reports the stable `design.package-bin-not-executable` finding - the file exists but lacks the
|
|
267
|
+
* execute bit. Common on Windows checkouts; the remediation message reminds maintainers to also
|
|
268
|
+
* keep the shebang valid.
|
|
269
|
+
*/
|
|
270
|
+
function pushNonExecutablePackageBinFinding(file: ConfigSourceFile, source: string, command: string, target: string, findings: Finding[]): void {
|
|
271
|
+
findings.push(
|
|
272
|
+
packageBinFinding({
|
|
273
|
+
file,
|
|
274
|
+
source,
|
|
275
|
+
ruleId: "design.package-bin-not-executable",
|
|
276
|
+
message: `Package bin \`${command}\` points to a file that is not executable.`,
|
|
277
|
+
command,
|
|
278
|
+
target,
|
|
279
|
+
remediation: "Make the bin target executable and keep its shebang valid.",
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Argument bundle for `packageBinFinding`. Grouped into a struct because the helper accepts seven
|
|
285
|
+
// fields and an inline parameter list would silently break call sites on field shuffles.
|
|
286
|
+
interface PackageBinFindingInput {
|
|
287
|
+
file: ConfigSourceFile;
|
|
288
|
+
source: string;
|
|
289
|
+
ruleId: string;
|
|
290
|
+
message: string;
|
|
291
|
+
command: string;
|
|
292
|
+
target: string;
|
|
293
|
+
remediation: string;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Single makeFinding call site for both bin-missing and bin-not-executable. The `command` symbol
|
|
297
|
+
// is what the fingerprint anchors on, so renaming a bin entry intentionally invalidates the baseline.
|
|
298
|
+
function packageBinFinding(input: PackageBinFindingInput): Finding {
|
|
299
|
+
return makeFinding({
|
|
300
|
+
ruleId: input.ruleId,
|
|
301
|
+
message: input.message,
|
|
302
|
+
filePath: input.file.displayPath,
|
|
303
|
+
line: jsonKeyLine(input.source, input.command),
|
|
304
|
+
severity: "warning",
|
|
305
|
+
pillar: "design",
|
|
306
|
+
confidence: "high",
|
|
307
|
+
symbol: input.command,
|
|
308
|
+
remediation: input.remediation,
|
|
309
|
+
metadata: { command: input.command, target: input.target },
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/*
|
|
314
|
+
* Three TypeScript strictness flags whose absence is a documentation-worthy compromise: `strict`,
|
|
315
|
+
* `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`. Reports each missing flag as its own
|
|
316
|
+
* finding; the list is intentionally short - adding new flags here changes the rule surface and
|
|
317
|
+
* warrants a schema discussion.
|
|
318
|
+
*/
|
|
319
|
+
function analyseTsconfigJson(file: ConfigSourceFile, source: string, tsconfigData: Record<string, unknown>, findings: Finding[]): void {
|
|
320
|
+
const compilerOptions = objectValue(tsconfigData.compilerOptions) ?? {};
|
|
321
|
+
const hasExtends = typeof tsconfigData.extends === "string" || Array.isArray(tsconfigData.extends);
|
|
322
|
+
const checks: Array<[string, string, string]> = [
|
|
323
|
+
["strict", "modernisation.tsconfig-strict-disabled", "`strict` is disabled, reducing TypeScript's baseline safety checks."],
|
|
324
|
+
["noUncheckedIndexedAccess", "modernisation.tsconfig-index-safety-disabled", "`noUncheckedIndexedAccess` is disabled, so indexed reads can silently ignore undefined."],
|
|
325
|
+
["exactOptionalPropertyTypes", "modernisation.tsconfig-exact-optional-disabled", "`exactOptionalPropertyTypes` is disabled, weakening optional property contracts."],
|
|
326
|
+
];
|
|
327
|
+
for (const [optionName, ruleId, message] of checks) {
|
|
328
|
+
const optionValue = compilerOptions[optionName];
|
|
329
|
+
if (optionValue === true) {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
// When the tsconfig `extends` another file we can't see the resolved compilerOptions, so an
|
|
333
|
+
// absent flag may already be `true` in the base. Only an explicit `false` is reportable.
|
|
334
|
+
if (optionValue === undefined && hasExtends) {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
findings.push(
|
|
338
|
+
tsconfigFinding({
|
|
339
|
+
file,
|
|
340
|
+
source,
|
|
341
|
+
ruleId,
|
|
342
|
+
message,
|
|
343
|
+
optionName,
|
|
344
|
+
currentValue: optionValue ?? null,
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Argument bundle for `tsconfigFinding`. `currentValue` is preserved as-is (could be `false`, a
|
|
351
|
+
// non-`true` truthy value, or `null` for missing) so consumers can distinguish opt-outs from omissions.
|
|
352
|
+
interface TsconfigFindingInput {
|
|
353
|
+
file: ConfigSourceFile;
|
|
354
|
+
source: string;
|
|
355
|
+
ruleId: string;
|
|
356
|
+
message: string;
|
|
357
|
+
optionName: string;
|
|
358
|
+
currentValue: unknown;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Single makeFinding site for tsconfig strictness findings. `optionName` is the symbol anchor and
|
|
362
|
+
// `currentValue` is preserved verbatim in metadata for downstream tooling - both are part of the stable contract.
|
|
363
|
+
function tsconfigFinding(input: TsconfigFindingInput): Finding {
|
|
364
|
+
return makeFinding({
|
|
365
|
+
ruleId: input.ruleId,
|
|
366
|
+
message: input.message,
|
|
367
|
+
filePath: input.file.displayPath,
|
|
368
|
+
line: jsonKeyLine(input.source, input.optionName),
|
|
369
|
+
severity: "warning",
|
|
370
|
+
pillar: "modernisation",
|
|
371
|
+
confidence: "high",
|
|
372
|
+
symbol: input.optionName,
|
|
373
|
+
remediation: `Set compilerOptions.${input.optionName} to true unless a documented migration blocker exists.`,
|
|
374
|
+
metadata: { optionName: input.optionName, currentValue: input.currentValue },
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/*
|
|
379
|
+
* Swallows parse errors and returns undefined as a fallback so a malformed package.json doesn't
|
|
380
|
+
* fail the whole analysis run - the rest of the file scan continues.
|
|
381
|
+
*/
|
|
382
|
+
function parseJsonObject(source: string): Record<string, unknown> | undefined {
|
|
383
|
+
try {
|
|
384
|
+
return objectValue(JSON.parse(jsonParseSource(source)));
|
|
385
|
+
} catch {
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Approximate line lookup - finds the first `"key":` occurrence. JSON allows the same key in
|
|
391
|
+
// nested objects, but for package.json/tsconfig the top-level keys we report on are unique.
|
|
392
|
+
function jsonKeyLine(source: string, key: string): number {
|
|
393
|
+
const escapedKey = escapeRegex(key);
|
|
394
|
+
return firstLine(source, new RegExp(`"${escapedKey}"\\s*:`));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Pattern matches `curl|wget … | sh` or its inline-pipe variants. The negative lookaheads for `|`,
|
|
398
|
+
// `;`, `&` prevent overlong matches that would span multiple shell commands.
|
|
399
|
+
function isRemoteInstallScript(command: string): boolean {
|
|
400
|
+
return /\b(?:curl|wget)\b[^\n|;&]*https?:\/\/[^\n|;&]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh)\b/i.test(command);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// The closed list of npm/yarn/pnpm install-time hooks. Adding entries here expands rule coverage.
|
|
404
|
+
function isLifecycleScript(scriptName: string): boolean {
|
|
405
|
+
return ["preinstall", "install", "postinstall", "prepare", "prepublish", "prepublishOnly"].includes(scriptName);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Recognises non-registry installs: full URLs, git+ssh, and the github:/gitlab:/bitbucket: shortcuts
|
|
409
|
+
// npm supports. These specs cannot be reproducibly locked the way registry versions can.
|
|
410
|
+
function isUrlDependency(versionSpec: string): boolean {
|
|
411
|
+
return /^(?:https?:\/\/|git(?:\+https?|\+ssh)?:\/\/|ssh:\/\/|github:|gitlab:|bitbucket:|git@[^:]+:[^/]+\/|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:#.+)?$)/i.test(versionSpec);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Catches `*`, `x`, `latest`, unbounded `>=` ranges, and OR-joined ranges. All let dependency
|
|
415
|
+
// resolution drift arbitrarily - lockfile or not, the declared intent is "anything goes".
|
|
416
|
+
function isBroadRuntimeVersion(versionSpec: string): boolean {
|
|
417
|
+
const normalized = versionSpec.trim().toLowerCase();
|
|
418
|
+
return normalized === "*" || normalized === "x" || normalized === "latest" || (/^>=\s*\d/.test(normalized) && !normalized.includes("<")) || normalized.includes("||");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// JSONC normalization used for tsconfig files: strip BOM, comments, then trailing commas while
|
|
422
|
+
// preserving line breaks so `jsonKeyLine` still points at the original source location.
|
|
423
|
+
function jsonParseSource(source: string): string {
|
|
424
|
+
return stripJsonTrailingCommas(stripJsonComments(source.replace(/^\uFEFF/, "")));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Removes `//` and `/* */` comments outside strings, replacing comment bytes with spaces/newlines
|
|
428
|
+
// so later parse errors and line lookups remain aligned with the original file.
|
|
429
|
+
function stripJsonComments(source: string): string {
|
|
430
|
+
let result = "";
|
|
431
|
+
const quoteState: JsonQuoteState = { quote: "", isEscaped: false };
|
|
432
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
433
|
+
const character = source[index] ?? "";
|
|
434
|
+
const next = source[index + 1] ?? "";
|
|
435
|
+
if (!quoteState.quote && character === "/" && next === "/") {
|
|
436
|
+
const comment = lineCommentSpan(source, index);
|
|
437
|
+
result += comment.replacement;
|
|
438
|
+
index = comment.endIndex;
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (!quoteState.quote && character === "/" && next === "*") {
|
|
442
|
+
const comment = blockCommentSpan(source, index);
|
|
443
|
+
result += comment.replacement;
|
|
444
|
+
index = comment.endIndex;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
updateJsonQuoteState(character, quoteState);
|
|
448
|
+
result += character;
|
|
449
|
+
}
|
|
450
|
+
return result;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Replacement text for a line comment. Spaces keep column alignment; the end index lets the caller
|
|
454
|
+
// resume at the newline without losing the line break.
|
|
455
|
+
function lineCommentSpan(source: string, startIndex: number): { replacement: string; endIndex: number } {
|
|
456
|
+
let endIndex = startIndex;
|
|
457
|
+
while (endIndex < source.length && source[endIndex] !== "\n") {
|
|
458
|
+
endIndex += 1;
|
|
459
|
+
}
|
|
460
|
+
return { replacement: " ".repeat(endIndex - startIndex), endIndex: endIndex - 1 };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Replacement text for a block comment, preserving every newline encountered. The character loop
|
|
464
|
+
// exists because block comments can span lines; fallback behaviour turns unterminated comments into
|
|
465
|
+
// spaces through EOF because malformed JSONC should simply reach JSON.parse failure.
|
|
466
|
+
function blockCommentSpan(source: string, startIndex: number): { replacement: string; endIndex: number } {
|
|
467
|
+
let endIndex = startIndex + 2;
|
|
468
|
+
let replacement = " ";
|
|
469
|
+
while (endIndex < source.length) {
|
|
470
|
+
const character = source[endIndex] ?? "";
|
|
471
|
+
const next = source[endIndex + 1] ?? "";
|
|
472
|
+
if (character === "*" && next === "/") {
|
|
473
|
+
return { replacement: `${replacement} `, endIndex: endIndex + 1 };
|
|
474
|
+
}
|
|
475
|
+
replacement += character === "\n" ? "\n" : " ";
|
|
476
|
+
endIndex += 1;
|
|
477
|
+
}
|
|
478
|
+
return { replacement, endIndex: source.length - 1 };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Removes commas that are followed only by whitespace and a JSON object/array closer. Quote state
|
|
482
|
+
// prevents commas inside string literals from being treated as structural punctuation.
|
|
483
|
+
function stripJsonTrailingCommas(source: string): string {
|
|
484
|
+
let result = "";
|
|
485
|
+
const quoteState: JsonQuoteState = { quote: "", isEscaped: false };
|
|
486
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
487
|
+
const character = source[index] ?? "";
|
|
488
|
+
updateJsonQuoteState(character, quoteState);
|
|
489
|
+
if (!quoteState.quote && character === "," && isJsonClosingTokenAhead(source, index + 1)) {
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
result += character;
|
|
493
|
+
}
|
|
494
|
+
return result;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Looks past whitespace to decide whether a comma is trailing before `}` or `]`.
|
|
498
|
+
function isJsonClosingTokenAhead(source: string, startIndex: number): boolean {
|
|
499
|
+
for (let index = startIndex; index < source.length; index += 1) {
|
|
500
|
+
const character = source[index] ?? "";
|
|
501
|
+
if (!/\s/.test(character)) {
|
|
502
|
+
return character === "}" || character === "]";
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Minimal JSON string lexer state shared by comment and trailing-comma stripping passes.
|
|
509
|
+
interface JsonQuoteState {
|
|
510
|
+
quote: string;
|
|
511
|
+
isEscaped: boolean;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Tracks whether the cursor is inside a JSON string and whether the prior character was a
|
|
515
|
+
// backslash. Callers use this to avoid rewriting comment/comma-like text inside string values.
|
|
516
|
+
function updateJsonQuoteState(character: string, quoteState: JsonQuoteState): void {
|
|
517
|
+
if (!quoteState.quote && character === "\"") {
|
|
518
|
+
quoteState.quote = character;
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (!quoteState.quote) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (quoteState.isEscaped) {
|
|
525
|
+
quoteState.isEscaped = false;
|
|
526
|
+
} else if (character === "\\") {
|
|
527
|
+
quoteState.isEscaped = true;
|
|
528
|
+
} else if (character === quoteState.quote) {
|
|
529
|
+
quoteState.quote = "";
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Normalises both npm bin forms: a string (uses the package name as the command) and an object
|
|
534
|
+
// (each key is a command name). Non-string entries are silently dropped because nothing valid can
|
|
535
|
+
// be done with them.
|
|
536
|
+
function packageBinEntries(pkg: Record<string, unknown>): Array<[string, string]> {
|
|
537
|
+
const bin = pkg.bin;
|
|
538
|
+
if (isString(bin)) {
|
|
539
|
+
const name = isString(pkg.name) ? pkg.name : "bin";
|
|
540
|
+
return [[name, bin]];
|
|
541
|
+
}
|
|
542
|
+
const bins = objectValue(bin);
|
|
543
|
+
if (!bins) {
|
|
544
|
+
return [];
|
|
545
|
+
}
|
|
546
|
+
return Object.entries(bins).filter((entry): entry is [string, string] => isString(entry[1]));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Local copy of the regex-escape helper - `discovery.ts` has its own copy because this module
|
|
550
|
+
// is intentionally a leaf with no cross-module dependency on path helpers.
|
|
551
|
+
function escapeRegex(rawText: string): string {
|
|
552
|
+
return rawText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export { analyseProjectConfigRules };
|