@nifrajs/cli 2.12.1 → 2.14.1
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/capabilities-tool.d.ts +54 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +146 -18
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +11 -11
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +235 -47
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +96 -406
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +244 -0
- package/dist/command-catalog.d.ts.map +1 -0
- package/dist/command-catalog.js +1014 -0
- package/dist/command-catalog.js.map +1 -0
- package/dist/contracts.d.ts +9 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +22 -6
- package/dist/contracts.js.map +1 -1
- package/dist/doctor.d.ts +18 -11
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +30 -230
- package/dist/doctor.js.map +1 -1
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +22 -5
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts +15 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +23 -9
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp.d.ts +8 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +59 -349
- package/dist/mcp.js.map +1 -1
- package/dist/project-facts.d.ts +40 -0
- package/dist/project-facts.d.ts.map +1 -0
- package/dist/project-facts.js +36 -0
- package/dist/project-facts.js.map +1 -0
- package/dist/release-verification.d.ts +45 -0
- package/dist/release-verification.d.ts.map +1 -0
- package/dist/release-verification.js +303 -0
- package/dist/release-verification.js.map +1 -0
- package/dist/rules/comment-markers.d.ts +8 -0
- package/dist/rules/comment-markers.d.ts.map +1 -1
- package/dist/rules/comment-markers.js +36 -0
- package/dist/rules/comment-markers.js.map +1 -1
- package/dist/rules/index.d.ts +2 -3
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +12 -14
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/security.js +14 -14
- package/dist/rules/security.js.map +1 -1
- package/docs/types.json +28 -15
- package/package.json +9 -9
- package/src/capabilities-tool.ts +229 -21
- package/src/check.ts +286 -64
- package/src/cli.ts +106 -399
- package/src/command-catalog.ts +1415 -0
- package/src/contracts.ts +28 -7
- package/src/doctor.ts +56 -264
- package/src/load.ts +25 -6
- package/src/manifest-tool.ts +43 -12
- package/src/mcp-http.ts +1 -1
- package/src/mcp.ts +82 -414
- package/src/project-facts.ts +86 -0
- package/src/release-verification.ts +377 -0
- package/src/rules/comment-markers.ts +36 -0
- package/src/rules/index.ts +3 -4
- package/src/rules/routes.ts +13 -14
- package/src/rules/security.ts +14 -14
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The repository release verification gate.
|
|
3
|
+
*
|
|
4
|
+
* This module is intentionally independent from app loading. The root package scripts and the
|
|
5
|
+
* `nifra verify` command both call this runner, so the local and CI paths share one gate plan.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"
|
|
9
|
+
import { tmpdir } from "node:os"
|
|
10
|
+
import { dirname, join, relative, resolve, sep } from "node:path"
|
|
11
|
+
|
|
12
|
+
export type ReleaseVerificationMode = "default" | "release"
|
|
13
|
+
export type ReleaseGateStatus = "pass" | "fail" | "skipped"
|
|
14
|
+
|
|
15
|
+
export interface ReleaseGateResult {
|
|
16
|
+
readonly id: string
|
|
17
|
+
readonly status: ReleaseGateStatus
|
|
18
|
+
readonly commands: readonly string[]
|
|
19
|
+
readonly exitCode?: number
|
|
20
|
+
readonly message?: string
|
|
21
|
+
readonly remediation: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ReleaseVerificationResult {
|
|
25
|
+
readonly ok: boolean
|
|
26
|
+
readonly mode: ReleaseVerificationMode
|
|
27
|
+
readonly gates: readonly ReleaseGateResult[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ReleaseCommandResult {
|
|
31
|
+
readonly exitCode: number
|
|
32
|
+
readonly stdout?: string
|
|
33
|
+
readonly stderr?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ReleaseCommandSpec {
|
|
37
|
+
readonly args: readonly string[]
|
|
38
|
+
readonly cwd: string
|
|
39
|
+
readonly env: Record<string, string | undefined>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ReleaseVerificationOptions {
|
|
43
|
+
readonly mode?: ReleaseVerificationMode
|
|
44
|
+
readonly runCommand?: (spec: ReleaseCommandSpec) => Promise<ReleaseCommandResult>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface ReleaseGateSpec {
|
|
48
|
+
readonly id: string
|
|
49
|
+
readonly commands: readonly (readonly string[])[]
|
|
50
|
+
readonly remediation: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DEFAULT_GATES: readonly ReleaseGateSpec[] = [
|
|
54
|
+
{
|
|
55
|
+
id: "lint",
|
|
56
|
+
commands: [["run", "lint"]],
|
|
57
|
+
remediation: "Run `bun run lint` and fix the reported lint findings.",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "typecheck",
|
|
61
|
+
commands: [["run", "typecheck"]],
|
|
62
|
+
remediation: "Run `bun run typecheck` and fix the reported TypeScript errors.",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "tests",
|
|
66
|
+
commands: [["run", "test"]],
|
|
67
|
+
remediation: "Run `bun run test` and fix the first failing test.",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: "docs",
|
|
71
|
+
commands: [["run", "check:docs"]],
|
|
72
|
+
remediation: "Run `bun run check:docs` and update the failing documentation example.",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "api-corpus",
|
|
76
|
+
commands: [["run", "check:api"]],
|
|
77
|
+
remediation: "Run `bun run gen:api` and review the generated API reference.",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "cards-corpus",
|
|
81
|
+
commands: [["run", "check:cards"]],
|
|
82
|
+
remediation: "Run `bun run gen:cards` and review the generated package cards.",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "node-outcome-corpus",
|
|
86
|
+
commands: [["run", "check:node-outcome"]],
|
|
87
|
+
remediation: "Run `bun run gen:node-outcome` and review the generated Node outcome contract.",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "sitemap",
|
|
91
|
+
commands: [["run", "check:sitemap"]],
|
|
92
|
+
remediation: "Run `bun run gen:sitemap` and review the generated sitemap.",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "public-boundary",
|
|
96
|
+
commands: [["run", "check:public-boundary"]],
|
|
97
|
+
remediation:
|
|
98
|
+
"Run `bun run check:public-boundary` and remove the reported public-boundary violation.",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: "size",
|
|
102
|
+
commands: [["run", "check:size"]],
|
|
103
|
+
remediation:
|
|
104
|
+
"Run `bun run check:size` and either reduce the bundle or update the reviewed budget.",
|
|
105
|
+
},
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
const RELEASE_GATES: readonly ReleaseGateSpec[] = [
|
|
109
|
+
{
|
|
110
|
+
id: "build",
|
|
111
|
+
commands: [["run", "build"]],
|
|
112
|
+
remediation: "Run `bun run build` and fix the first package build failure.",
|
|
113
|
+
},
|
|
114
|
+
...DEFAULT_GATES.slice(0, 3),
|
|
115
|
+
{
|
|
116
|
+
id: "coverage",
|
|
117
|
+
commands: [
|
|
118
|
+
["run", "test:coverage"],
|
|
119
|
+
["run", "check:coverage"],
|
|
120
|
+
],
|
|
121
|
+
remediation:
|
|
122
|
+
"Run `bun run test:coverage` first, then `bun run check:coverage`, and fix the reported coverage regression.",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: "corpus",
|
|
126
|
+
commands: [["run", "check:corpus"]],
|
|
127
|
+
remediation:
|
|
128
|
+
"Run `bun run gen:llms`, `bun run gen:api`, and `bun run gen:cards`, then rerun the corpus gate.",
|
|
129
|
+
},
|
|
130
|
+
DEFAULT_GATES[3] as ReleaseGateSpec,
|
|
131
|
+
{
|
|
132
|
+
id: "public-boundary",
|
|
133
|
+
commands: [["run", "check:public-boundary"]],
|
|
134
|
+
remediation:
|
|
135
|
+
"Run `bun run check:public-boundary` and remove the reported public-boundary violation.",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: "size",
|
|
139
|
+
commands: [["run", "check:size"]],
|
|
140
|
+
remediation:
|
|
141
|
+
"Run `bun run check:size` and either reduce the bundle or update the reviewed budget.",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: "core-performance",
|
|
145
|
+
commands: [["run", "check:core-performance"]],
|
|
146
|
+
remediation:
|
|
147
|
+
"Run `bun run check:core-performance` and investigate the measured performance regression.",
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: "publish",
|
|
151
|
+
commands: [["run", "check:publish"]],
|
|
152
|
+
remediation:
|
|
153
|
+
"Run `bun run check:publish` and fix the publish-consumer metadata or type-surface failure.",
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: "consumer",
|
|
157
|
+
commands: [["run", "check:consumers"]],
|
|
158
|
+
remediation: "Run `bun run check:consumers` and fix the isolated consumer failure.",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: "cold-start",
|
|
162
|
+
commands: [["run", "check:cold-start"]],
|
|
163
|
+
remediation:
|
|
164
|
+
"Run `bun run check:cold-start` and fix the fresh scaffold install or build failure.",
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "cross-runtime-deno",
|
|
168
|
+
commands: [
|
|
169
|
+
["run", "test:deno"],
|
|
170
|
+
["run", "check:deno-tarball"],
|
|
171
|
+
],
|
|
172
|
+
remediation:
|
|
173
|
+
"Run `bun run test:deno` and `bun run check:deno-tarball`, then fix the first Deno compatibility failure.",
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: "cross-runtime-node",
|
|
177
|
+
commands: [["run", "test:node"]],
|
|
178
|
+
remediation: "Run `bun run test:node` and fix the Node runtime adapter failure.",
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: "pipeline-parity",
|
|
182
|
+
commands: [["run", "check:pipeline-parity"]],
|
|
183
|
+
remediation:
|
|
184
|
+
"Run `bun run check:pipeline-parity` and fix the development and production manifest drift.",
|
|
185
|
+
},
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
const runBunCommand = async (spec: ReleaseCommandSpec): Promise<ReleaseCommandResult> => {
|
|
189
|
+
const child = Bun.spawn([process.execPath, ...spec.args], {
|
|
190
|
+
cwd: spec.cwd,
|
|
191
|
+
env: spec.env,
|
|
192
|
+
stdout: "pipe",
|
|
193
|
+
stderr: "pipe",
|
|
194
|
+
})
|
|
195
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
196
|
+
child.exited,
|
|
197
|
+
new Response(child.stdout).text(),
|
|
198
|
+
new Response(child.stderr).text(),
|
|
199
|
+
])
|
|
200
|
+
return { exitCode, stdout, stderr }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const parsePackage = (path: string): Record<string, unknown> | undefined => {
|
|
204
|
+
try {
|
|
205
|
+
const value: unknown = JSON.parse(readFileSync(path, "utf8"))
|
|
206
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
207
|
+
? (value as Record<string, unknown>)
|
|
208
|
+
: undefined
|
|
209
|
+
} catch {
|
|
210
|
+
return undefined
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const workspacePatterns = (pkg: Record<string, unknown>): readonly string[] => {
|
|
215
|
+
const workspaces = pkg.workspaces
|
|
216
|
+
if (Array.isArray(workspaces))
|
|
217
|
+
return workspaces.filter((item): item is string => typeof item === "string")
|
|
218
|
+
if (workspaces !== null && typeof workspaces === "object") {
|
|
219
|
+
const packages = (workspaces as Record<string, unknown>).packages
|
|
220
|
+
if (Array.isArray(packages))
|
|
221
|
+
return packages.filter((item): item is string => typeof item === "string")
|
|
222
|
+
}
|
|
223
|
+
return []
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const workspaceContains = async (
|
|
227
|
+
root: string,
|
|
228
|
+
patterns: readonly string[],
|
|
229
|
+
target: string,
|
|
230
|
+
): Promise<boolean> => {
|
|
231
|
+
if (root === target) return patterns.length > 0
|
|
232
|
+
for (const pattern of patterns) {
|
|
233
|
+
const packagePattern = `${pattern.replace(/\/$/, "")}/package.json`
|
|
234
|
+
for await (const match of new Bun.Glob(packagePattern).scan({ cwd: root, dot: false })) {
|
|
235
|
+
if (match.split(/[\\/]/).includes("node_modules")) continue
|
|
236
|
+
const packageRoot = resolve(root, dirname(match))
|
|
237
|
+
const targetRelative = relative(packageRoot, target)
|
|
238
|
+
if (
|
|
239
|
+
targetRelative === "" ||
|
|
240
|
+
(!targetRelative.startsWith(`..${sep}`) && targetRelative !== "..")
|
|
241
|
+
)
|
|
242
|
+
return true
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return false
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Resolve the repository root even when the command starts in a workspace package. */
|
|
249
|
+
export async function resolveVerificationRoot(start: string): Promise<string> {
|
|
250
|
+
const original = realpathSync(resolve(start))
|
|
251
|
+
let current = original
|
|
252
|
+
for (;;) {
|
|
253
|
+
const pkgPath = join(current, "package.json")
|
|
254
|
+
const pkg = parsePackage(pkgPath)
|
|
255
|
+
if (pkg !== undefined && (await workspaceContains(current, workspacePatterns(pkg), original))) {
|
|
256
|
+
return current
|
|
257
|
+
}
|
|
258
|
+
const parent = dirname(current)
|
|
259
|
+
if (parent === current) return original
|
|
260
|
+
current = parent
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const gatePlan = (mode: ReleaseVerificationMode): readonly ReleaseGateSpec[] =>
|
|
265
|
+
mode === "release" ? RELEASE_GATES : DEFAULT_GATES
|
|
266
|
+
|
|
267
|
+
const commandLabel = (args: readonly string[]): string => `bun ${args.join(" ")}`
|
|
268
|
+
|
|
269
|
+
const runGate = async (
|
|
270
|
+
root: string,
|
|
271
|
+
gate: ReleaseGateSpec,
|
|
272
|
+
runCommand: (spec: ReleaseCommandSpec) => Promise<ReleaseCommandResult>,
|
|
273
|
+
fixtureRoot: string,
|
|
274
|
+
): Promise<ReleaseGateResult> => {
|
|
275
|
+
let lastExitCode: number | undefined
|
|
276
|
+
for (const [index, args] of gate.commands.entries()) {
|
|
277
|
+
const fixture = mkdtempSync(join(fixtureRoot, `${gate.id}-${index}-`))
|
|
278
|
+
const result = await runCommand({
|
|
279
|
+
args,
|
|
280
|
+
cwd: root,
|
|
281
|
+
env: {
|
|
282
|
+
...process.env,
|
|
283
|
+
TMPDIR: fixture,
|
|
284
|
+
NIFRA_VERIFY_GATE: gate.id,
|
|
285
|
+
},
|
|
286
|
+
})
|
|
287
|
+
lastExitCode = result.exitCode
|
|
288
|
+
if (result.exitCode !== 0) {
|
|
289
|
+
return {
|
|
290
|
+
id: gate.id,
|
|
291
|
+
status: "fail",
|
|
292
|
+
commands: gate.commands.map(commandLabel),
|
|
293
|
+
exitCode: result.exitCode,
|
|
294
|
+
message: `${commandLabel(args)} exited with code ${result.exitCode}`,
|
|
295
|
+
remediation: gate.remediation,
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
id: gate.id,
|
|
301
|
+
status: "pass",
|
|
302
|
+
commands: gate.commands.map(commandLabel),
|
|
303
|
+
...(lastExitCode === undefined ? {} : { exitCode: lastExitCode }),
|
|
304
|
+
remediation: gate.remediation,
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const skippedGate = (gate: ReleaseGateSpec, failedId: string): ReleaseGateResult => ({
|
|
309
|
+
id: gate.id,
|
|
310
|
+
status: "skipped",
|
|
311
|
+
commands: gate.commands.map(commandLabel),
|
|
312
|
+
message: `not run because the ${failedId} gate failed`,
|
|
313
|
+
remediation: `Fix the ${failedId} gate first, then rerun verification.`,
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
/** Collect the same gate result rendered by the CLI and used by repository scripts. */
|
|
317
|
+
export async function collectReleaseVerification(
|
|
318
|
+
start: string,
|
|
319
|
+
options: ReleaseVerificationOptions = {},
|
|
320
|
+
): Promise<ReleaseVerificationResult> {
|
|
321
|
+
const mode = options.mode ?? "default"
|
|
322
|
+
const root = await resolveVerificationRoot(start)
|
|
323
|
+
const runCommand = options.runCommand ?? runBunCommand
|
|
324
|
+
const fixtureRoot = mkdtempSync(join(realpathSync(tmpdir()), "nifra-verify-"))
|
|
325
|
+
const gates: ReleaseGateResult[] = []
|
|
326
|
+
try {
|
|
327
|
+
let failedId: string | undefined
|
|
328
|
+
for (const gate of gatePlan(mode)) {
|
|
329
|
+
if (failedId !== undefined) {
|
|
330
|
+
gates.push(skippedGate(gate, failedId))
|
|
331
|
+
continue
|
|
332
|
+
}
|
|
333
|
+
const result = await runGate(root, gate, runCommand, fixtureRoot)
|
|
334
|
+
gates.push(result)
|
|
335
|
+
if (result.status === "fail") failedId = result.id
|
|
336
|
+
}
|
|
337
|
+
} finally {
|
|
338
|
+
rmSync(fixtureRoot, { recursive: true, force: true })
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
ok: gates.every((gate) => gate.status === "pass"),
|
|
342
|
+
mode,
|
|
343
|
+
gates,
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function renderReleaseVerification(result: ReleaseVerificationResult): string {
|
|
348
|
+
const lines = [`nifra verify --${result.mode}`, ""]
|
|
349
|
+
for (const gate of result.gates) {
|
|
350
|
+
const marker = gate.status === "pass" ? "✓" : gate.status === "fail" ? "✗" : "-"
|
|
351
|
+
lines.push(`${marker} ${gate.id}: ${gate.status}`)
|
|
352
|
+
if (gate.message !== undefined) lines.push(` ${gate.message}`)
|
|
353
|
+
if (gate.status !== "pass") lines.push(` fix: ${gate.remediation}`)
|
|
354
|
+
}
|
|
355
|
+
lines.push("", result.ok ? "✓ verification passed" : "✗ verification failed")
|
|
356
|
+
return lines.join("\n")
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Run verification and render either the human or machine interface. */
|
|
360
|
+
export async function runReleaseVerification(
|
|
361
|
+
cwd: string,
|
|
362
|
+
options: ReleaseVerificationOptions & { readonly json?: boolean } = {},
|
|
363
|
+
): Promise<boolean> {
|
|
364
|
+
const result = await collectReleaseVerification(cwd, options)
|
|
365
|
+
if (options.json) console.log(JSON.stringify(result, null, 2))
|
|
366
|
+
else console.log(renderReleaseVerification(result))
|
|
367
|
+
return result.ok
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (import.meta.main) {
|
|
371
|
+
const mode: ReleaseVerificationMode = process.argv.includes("--release") ? "release" : "default"
|
|
372
|
+
const ok = await runReleaseVerification(process.cwd(), {
|
|
373
|
+
mode,
|
|
374
|
+
json: process.argv.includes("--json"),
|
|
375
|
+
})
|
|
376
|
+
if (!ok) process.exitCode = 1
|
|
377
|
+
}
|
|
@@ -24,3 +24,39 @@ export function commentBlockHasMarker(
|
|
|
24
24
|
}
|
|
25
25
|
return false
|
|
26
26
|
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The justification a reason-carrying marker (`<marker>: <reason>`) supplies, or `undefined` when the
|
|
30
|
+
* marker is absent or written with no reason after its colon. Searches the same trailing-comment /
|
|
31
|
+
* comment-block-above region as {@link commentBlockHasMarker}, so a hatch that demands a written reason
|
|
32
|
+
* can require this to be non-empty before it silences anything - an empty `<marker>:` never suppresses,
|
|
33
|
+
* which is how "reason mandatory" is enforced without a second diagnostic.
|
|
34
|
+
*/
|
|
35
|
+
export function commentBlockMarkerReason(
|
|
36
|
+
lines: readonly string[],
|
|
37
|
+
line: number,
|
|
38
|
+
marker: string,
|
|
39
|
+
): string | undefined {
|
|
40
|
+
const reasonOn = (text: string): string | undefined => {
|
|
41
|
+
const at = text.indexOf(marker)
|
|
42
|
+
if (at < 0) return undefined
|
|
43
|
+
const rest = text.slice(at + marker.length)
|
|
44
|
+
const colon = rest.indexOf(":")
|
|
45
|
+
if (colon < 0) return undefined
|
|
46
|
+
// A block comment's `*/` closer is not part of the reason.
|
|
47
|
+
const reason = rest
|
|
48
|
+
.slice(colon + 1)
|
|
49
|
+
.replace(/\*\/\s*$/, "")
|
|
50
|
+
.trim()
|
|
51
|
+
return reason.length > 0 ? reason : undefined
|
|
52
|
+
}
|
|
53
|
+
const onLine = reasonOn(lines[line - 1] ?? "")
|
|
54
|
+
if (onLine !== undefined) return onLine
|
|
55
|
+
for (let i = line - 2; i >= 0; i--) {
|
|
56
|
+
const text = lines[i] ?? ""
|
|
57
|
+
if (!COMMENT_LINE.test(text)) return undefined
|
|
58
|
+
const reason = reasonOn(text)
|
|
59
|
+
if (reason !== undefined) return reason
|
|
60
|
+
}
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
package/src/rules/index.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { Diagnostic } from "../diagnostics.ts"
|
|
2
|
+
import type { ProjectFacts } from "../project-facts.ts"
|
|
2
3
|
import { isBuiltInCode } from "./codes.ts"
|
|
3
4
|
|
|
5
|
+
export type { ProjectFacts } from "../project-facts.ts"
|
|
6
|
+
|
|
4
7
|
export interface SourceFile {
|
|
5
8
|
readonly file: string
|
|
6
9
|
readonly content: string
|
|
@@ -11,10 +14,6 @@ export interface SourceIndex {
|
|
|
11
14
|
read(file: string): string | undefined
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
export interface ProjectFacts {
|
|
15
|
-
readonly [key: string]: unknown
|
|
16
|
-
}
|
|
17
|
-
|
|
18
17
|
export interface RuleContext {
|
|
19
18
|
readonly root: string
|
|
20
19
|
readonly sources: SourceIndex
|
package/src/rules/routes.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { commentBlockHasMarker } from "./comment-markers.ts"
|
|
|
3
3
|
import type { CheckRule, RuleContext } from "./index.ts"
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Route-table lints over the statically collected route registrations (`
|
|
7
|
-
*
|
|
6
|
+
* Route-table lints over the statically collected route registrations (`ProjectFacts.routes` is built
|
|
7
|
+
* once before the registry runs - no rule re-walks source).
|
|
8
8
|
*
|
|
9
9
|
* NF-C018 exists because the typed client's proxy intercepts a fixed set of property names before
|
|
10
10
|
* path resolution (`resolveSegment` + the thenable guard in @nifrajs/client): the seven HTTP verbs
|
|
@@ -41,20 +41,19 @@ interface StaticRouteFact {
|
|
|
41
41
|
|
|
42
42
|
/** Parse-don't-cast over the project fact: a malformed entry is dropped, never trusted. */
|
|
43
43
|
function routeFacts(ctx: RuleContext): StaticRouteFact[] {
|
|
44
|
-
const raw = ctx.project.
|
|
45
|
-
if (!Array.isArray(raw)) return []
|
|
44
|
+
const raw: readonly unknown[] = ctx.project.routes
|
|
46
45
|
const out: StaticRouteFact[] = []
|
|
47
46
|
for (const item of raw) {
|
|
48
|
-
if (typeof item !== "object" || item === null) continue
|
|
49
|
-
const record = item
|
|
47
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) continue
|
|
48
|
+
const record = Object.fromEntries(Object.entries(item))
|
|
50
49
|
if (
|
|
51
|
-
typeof record.file
|
|
52
|
-
typeof record.line
|
|
53
|
-
typeof record.method
|
|
54
|
-
typeof record.path
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
}
|
|
50
|
+
typeof record.file !== "string" ||
|
|
51
|
+
typeof record.line !== "number" ||
|
|
52
|
+
typeof record.method !== "string" ||
|
|
53
|
+
typeof record.path !== "string"
|
|
54
|
+
)
|
|
55
|
+
continue
|
|
56
|
+
out.push({ file: record.file, line: record.line, method: record.method, path: record.path })
|
|
58
57
|
}
|
|
59
58
|
return out
|
|
60
59
|
}
|
|
@@ -91,7 +90,7 @@ export const reservedSegmentRule: CheckRule = {
|
|
|
91
90
|
if (collision === undefined) continue
|
|
92
91
|
let lines = linesByFile.get(route.file)
|
|
93
92
|
if (lines === undefined) {
|
|
94
|
-
lines = (ctx.
|
|
93
|
+
lines = (ctx.project.source.read(route.file) ?? "").split("\n")
|
|
95
94
|
linesByFile.set(route.file, lines)
|
|
96
95
|
}
|
|
97
96
|
if (commentBlockHasMarker(lines, route.line, RESERVED_SEGMENT_PRAGMA)) continue
|
package/src/rules/security.ts
CHANGED
|
@@ -108,8 +108,8 @@ export const secretComparisonRule: CheckRule = {
|
|
|
108
108
|
const ts = await importProjectTypeScript(ctx.root)
|
|
109
109
|
if (ts === undefined) return didNotRun("NF-S002", "Non-constant-time secret comparison scan")
|
|
110
110
|
const findings: Diagnostic[] = []
|
|
111
|
-
for (const file of ctx.
|
|
112
|
-
const parsed = parsedFile(ts, ctx.
|
|
111
|
+
for (const file of ctx.project.source.files) {
|
|
112
|
+
const parsed = parsedFile(ts, ctx.project.source, file)
|
|
113
113
|
if (parsed === undefined) continue
|
|
114
114
|
const { tree, lines } = parsed
|
|
115
115
|
const visit = (node: TSApi.Node): void => {
|
|
@@ -169,8 +169,8 @@ export const piiLogRule: CheckRule = {
|
|
|
169
169
|
const ts = await importProjectTypeScript(ctx.root)
|
|
170
170
|
if (ts === undefined) return didNotRun("NF-S003", "Sensitive-value-in-log scan")
|
|
171
171
|
const findings: Diagnostic[] = []
|
|
172
|
-
for (const file of ctx.
|
|
173
|
-
const parsed = parsedFile(ts, ctx.
|
|
172
|
+
for (const file of ctx.project.source.files) {
|
|
173
|
+
const parsed = parsedFile(ts, ctx.project.source, file)
|
|
174
174
|
if (parsed === undefined) continue
|
|
175
175
|
const { tree, lines } = parsed
|
|
176
176
|
const visit = (node: TSApi.Node): void => {
|
|
@@ -217,8 +217,8 @@ export const failOpenGateRule: CheckRule = {
|
|
|
217
217
|
const ts = await importProjectTypeScript(ctx.root)
|
|
218
218
|
if (ts === undefined) return didNotRun("NF-S001", "Fail-open gate scan")
|
|
219
219
|
const findings: Diagnostic[] = []
|
|
220
|
-
for (const file of ctx.
|
|
221
|
-
const parsed = parsedFile(ts, ctx.
|
|
220
|
+
for (const file of ctx.project.source.files) {
|
|
221
|
+
const parsed = parsedFile(ts, ctx.project.source, file)
|
|
222
222
|
if (parsed === undefined) continue
|
|
223
223
|
const { tree, lines } = parsed
|
|
224
224
|
const visit = (node: TSApi.Node): void => {
|
|
@@ -284,8 +284,8 @@ export const corsOriginPredicateRule: CheckRule = {
|
|
|
284
284
|
const ts = await importProjectTypeScript(ctx.root)
|
|
285
285
|
if (ts === undefined) return didNotRun("NF-S004", "CORS origin predicate scan")
|
|
286
286
|
const findings: Diagnostic[] = []
|
|
287
|
-
for (const file of ctx.
|
|
288
|
-
const parsed = parsedFile(ts, ctx.
|
|
287
|
+
for (const file of ctx.project.source.files) {
|
|
288
|
+
const parsed = parsedFile(ts, ctx.project.source, file)
|
|
289
289
|
if (parsed === undefined) continue
|
|
290
290
|
const { tree, lines } = parsed
|
|
291
291
|
const visit = (node: TSApi.Node): void => {
|
|
@@ -345,8 +345,8 @@ export const externalRedirectRule: CheckRule = {
|
|
|
345
345
|
const ts = await importProjectTypeScript(ctx.root)
|
|
346
346
|
if (ts === undefined) return didNotRun("NF-S005", "External redirect opt-out scan")
|
|
347
347
|
const findings: Diagnostic[] = []
|
|
348
|
-
for (const file of ctx.
|
|
349
|
-
const parsed = parsedFile(ts, ctx.
|
|
348
|
+
for (const file of ctx.project.source.files) {
|
|
349
|
+
const parsed = parsedFile(ts, ctx.project.source, file)
|
|
350
350
|
if (parsed === undefined) continue
|
|
351
351
|
const { tree, lines } = parsed
|
|
352
352
|
const visit = (node: TSApi.Node): void => {
|
|
@@ -402,8 +402,8 @@ export const assuranceEscapeHatchRule: CheckRule = {
|
|
|
402
402
|
const ts = await importProjectTypeScript(ctx.root)
|
|
403
403
|
if (ts === undefined) return didNotRun("NF-S006", "Security escape hatch scan")
|
|
404
404
|
const findings: Diagnostic[] = []
|
|
405
|
-
for (const file of ctx.
|
|
406
|
-
const parsed = parsedFile(ts, ctx.
|
|
405
|
+
for (const file of ctx.project.source.files) {
|
|
406
|
+
const parsed = parsedFile(ts, ctx.project.source, file)
|
|
407
407
|
if (parsed === undefined) continue
|
|
408
408
|
const { tree, lines } = parsed
|
|
409
409
|
const visit = (node: TSApi.Node): void => {
|
|
@@ -445,8 +445,8 @@ export const unprefixedSecureCookieRule: CheckRule = {
|
|
|
445
445
|
const ts = await importProjectTypeScript(ctx.root)
|
|
446
446
|
if (ts === undefined) return didNotRun("NF-S007", "Secure cookie prefix scan")
|
|
447
447
|
const findings: Diagnostic[] = []
|
|
448
|
-
for (const file of ctx.
|
|
449
|
-
const parsed = parsedFile(ts, ctx.
|
|
448
|
+
for (const file of ctx.project.source.files) {
|
|
449
|
+
const parsed = parsedFile(ts, ctx.project.source, file)
|
|
450
450
|
if (parsed === undefined) continue
|
|
451
451
|
const { tree } = parsed
|
|
452
452
|
const visit = (node: TSApi.Node): void => {
|