@nifrajs/cli 2.12.0 → 2.13.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/check.d.ts.map +1 -1
- package/dist/check.js +2 -5
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +13 -1
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts +5 -11
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +12 -228
- package/dist/doctor.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- 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/docs/types.json +3 -3
- package/package.json +9 -9
- package/src/check.ts +2 -6
- package/src/cli.ts +12 -1
- package/src/doctor.ts +21 -260
- package/src/mcp-http.ts +1 -1
- package/src/release-verification.ts +377 -0
|
@@ -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
|
+
}
|