@nifrajs/cli 2.13.0 → 3.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/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 +260 -49
- 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 +130 -414
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +250 -0
- package/dist/command-catalog.d.ts.map +1 -0
- package/dist/command-catalog.js +1032 -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 +55 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +82 -9
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +17 -0
- package/dist/env-file.d.ts.map +1 -0
- package/dist/env-file.js +113 -0
- package/dist/env-file.js.map +1 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +91 -15
- package/dist/fix-recipes.js.map +1 -1
- package/dist/internal/reserved-segment-codemod.d.ts +36 -0
- package/dist/internal/reserved-segment-codemod.d.ts.map +1 -0
- package/dist/internal/reserved-segment-codemod.js +134 -0
- package/dist/internal/reserved-segment-codemod.js.map +1 -0
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +62 -11
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +26 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +74 -24
- package/dist/introspect.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-http.js.map +1 -1
- package/dist/mcp.d.ts +16 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +127 -355
- 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/recipes/3.0.0.d.ts +10 -0
- package/dist/recipes/3.0.0.d.ts.map +1 -0
- package/dist/recipes/3.0.0.js +23 -0
- package/dist/recipes/3.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +15 -0
- package/dist/release-verification.js.map +1 -1
- 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 +29 -44
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/security.js +14 -14
- package/dist/rules/security.js.map +1 -1
- package/dist/workspace-link.d.ts +37 -0
- package/dist/workspace-link.d.ts.map +1 -0
- package/dist/workspace-link.js +72 -0
- package/dist/workspace-link.js.map +1 -0
- package/docs/examples.json +1 -1
- package/docs/llms-full.txt +362 -20
- package/docs/types.json +956 -56
- package/package.json +9 -9
- package/src/capabilities-tool.ts +229 -21
- package/src/check.ts +312 -65
- package/src/cli.ts +140 -405
- package/src/command-catalog.ts +1435 -0
- package/src/contracts.ts +28 -7
- package/src/doctor.ts +134 -12
- package/src/env-file.ts +121 -0
- package/src/fix-recipes.ts +101 -15
- package/src/internal/reserved-segment-codemod.ts +150 -0
- package/src/internal/typescript-import.ts +61 -11
- package/src/introspect.ts +82 -21
- package/src/load.ts +25 -6
- package/src/manifest-tool.ts +43 -12
- package/src/mcp-http.ts +1 -1
- package/src/mcp.ts +163 -420
- package/src/project-facts.ts +86 -0
- package/src/recipes/3.0.0.ts +24 -0
- package/src/recipes/index.ts +2 -0
- package/src/release-verification.ts +17 -0
- package/src/rules/comment-markers.ts +36 -0
- package/src/rules/index.ts +3 -4
- package/src/rules/routes.ts +30 -44
- package/src/rules/security.ts +14 -14
- package/src/workspace-link.ts +80 -0
|
@@ -0,0 +1,1435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public command contract for the stable, project-facing nifra commands.
|
|
3
|
+
*
|
|
4
|
+
* This module is deliberately descriptive at the projection boundary: the catalog entry does not run
|
|
5
|
+
* a command, validate permissions, or decide whether a result is safe. Those policies remain in the
|
|
6
|
+
* command implementations and in the adapters (the same split used by `@nifrajs/core/tool-catalog`).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { resolve } from "node:path"
|
|
10
|
+
import { formatAssuranceReport } from "./assure.ts"
|
|
11
|
+
import type {
|
|
12
|
+
CapabilityCheckCommandResult,
|
|
13
|
+
CapabilityExplainCommandResult,
|
|
14
|
+
CapabilitySnapshotCommandResult,
|
|
15
|
+
} from "./capabilities-tool.ts"
|
|
16
|
+
import { type CheckResult, renderCheckReport } from "./check.ts"
|
|
17
|
+
import type { ContractsLock } from "./contracts.ts"
|
|
18
|
+
import type { DoctorResult } from "./doctor.ts"
|
|
19
|
+
import type { VerificationLevelsResult } from "./levels-tool.ts"
|
|
20
|
+
import type { LoadedApp } from "./load.ts"
|
|
21
|
+
import type { ManifestEmitCommandResult } from "./manifest-tool.ts"
|
|
22
|
+
import { collectPortResult, type PortResult, renderReport } from "./port.ts"
|
|
23
|
+
import type { ReplayResult } from "./replay.ts"
|
|
24
|
+
import {
|
|
25
|
+
collectProjectWorkGraph,
|
|
26
|
+
type ProjectWorkGraphResult,
|
|
27
|
+
renderWorkGraphText,
|
|
28
|
+
} from "./work-graph.ts"
|
|
29
|
+
|
|
30
|
+
export type CommandTransport = "cli" | "mcp"
|
|
31
|
+
export type CommandStability = "stable" | "experimental"
|
|
32
|
+
export type CommandJsonSchema = Readonly<Record<string, unknown>>
|
|
33
|
+
|
|
34
|
+
export interface CommandInputSchema<Input> {
|
|
35
|
+
readonly jsonSchema: CommandJsonSchema
|
|
36
|
+
readonly parse: (value: unknown) => Input
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CommandOutputContract<Output> {
|
|
40
|
+
/** Increment when the structured result changes incompatibly. */
|
|
41
|
+
readonly version: number
|
|
42
|
+
readonly jsonSchema: CommandJsonSchema
|
|
43
|
+
/** Accepts the current result and, where possible, the prior un-enveloped result shape. */
|
|
44
|
+
readonly parse: (value: unknown) => Output
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type CommandFlagType = "boolean" | "string" | "number" | "string[]"
|
|
48
|
+
|
|
49
|
+
export interface CommandFlag<Input> {
|
|
50
|
+
readonly name: string
|
|
51
|
+
readonly field: keyof Input & string
|
|
52
|
+
readonly type: CommandFlagType
|
|
53
|
+
readonly aliases?: readonly string[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The small CLI-only binding layer between argv and the same JSON input object MCP receives. */
|
|
57
|
+
export interface CommandArgvBinding<Input> {
|
|
58
|
+
readonly positionals?: readonly (keyof Input & string)[]
|
|
59
|
+
readonly flags?: readonly CommandFlag<Input>[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CommandCtx {
|
|
63
|
+
readonly cwd: string
|
|
64
|
+
readonly signal?: AbortSignal
|
|
65
|
+
readonly progress?: (message: string) => void
|
|
66
|
+
readonly cliVersion?: string
|
|
67
|
+
/** Adapter-provided cache for commands that need the loaded web app. */
|
|
68
|
+
readonly loadApp?: () => Promise<LoadedApp>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CommandSpec<Input, Output> {
|
|
72
|
+
readonly name: string
|
|
73
|
+
readonly summary: string
|
|
74
|
+
readonly input: CommandInputSchema<Input>
|
|
75
|
+
readonly output: CommandOutputContract<Output>
|
|
76
|
+
readonly transports: readonly CommandTransport[]
|
|
77
|
+
readonly stability: CommandStability
|
|
78
|
+
readonly argv?: CommandArgvBinding<Input>
|
|
79
|
+
readonly run: (input: Input, ctx: CommandCtx) => Promise<Output>
|
|
80
|
+
readonly render: (out: Output, input?: Input) => readonly string[]
|
|
81
|
+
readonly success?: (out: Output, input: Input) => boolean
|
|
82
|
+
/** JSON-compatible view. Defaults to the raw command result. */
|
|
83
|
+
readonly json?: (out: Output, input: Input) => unknown
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface CommandCatalogEntry {
|
|
87
|
+
readonly name: string
|
|
88
|
+
readonly summary: string
|
|
89
|
+
readonly inputSchema: CommandJsonSchema
|
|
90
|
+
readonly outputVersion: number
|
|
91
|
+
readonly transports: readonly CommandTransport[]
|
|
92
|
+
readonly stability: CommandStability
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function freezeJson<T>(value: T): T {
|
|
96
|
+
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
|
97
|
+
for (const child of Object.values(value as Record<string, unknown>)) freezeJson(child)
|
|
98
|
+
Object.freeze(value)
|
|
99
|
+
}
|
|
100
|
+
return value
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Build the single descriptive projection shared by CLI help, MCP tools, and generated cards. */
|
|
104
|
+
export function toCommandCatalogEntry<Input, Output>(
|
|
105
|
+
spec: CommandSpec<Input, Output>,
|
|
106
|
+
): CommandCatalogEntry {
|
|
107
|
+
return Object.freeze({
|
|
108
|
+
name: spec.name,
|
|
109
|
+
summary: spec.summary,
|
|
110
|
+
inputSchema: freezeJson({ ...spec.input.jsonSchema }),
|
|
111
|
+
outputVersion: spec.output.version,
|
|
112
|
+
transports: Object.freeze([...spec.transports]),
|
|
113
|
+
stability: spec.stability,
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function record(value: unknown): Record<string, unknown> {
|
|
118
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
119
|
+
throw new TypeError("command input must be an object")
|
|
120
|
+
return value as Record<string, unknown>
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function optionalString(value: unknown, field: string): string | undefined {
|
|
124
|
+
if (value === undefined) return undefined
|
|
125
|
+
if (typeof value !== "string") throw new TypeError(`${field} must be a string`)
|
|
126
|
+
return value
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function optionalBoolean(value: unknown, field: string): boolean | undefined {
|
|
130
|
+
if (value === undefined) return undefined
|
|
131
|
+
if (typeof value !== "boolean") throw new TypeError(`${field} must be a boolean`)
|
|
132
|
+
return value
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function optionalNumber(value: unknown, field: string): number | undefined {
|
|
136
|
+
if (value === undefined) return undefined
|
|
137
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
138
|
+
throw new TypeError(`${field} must be a finite number`)
|
|
139
|
+
return value
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function objectSchema(
|
|
143
|
+
properties: Record<string, unknown>,
|
|
144
|
+
required: readonly string[] = [],
|
|
145
|
+
): CommandJsonSchema {
|
|
146
|
+
return {
|
|
147
|
+
type: "object",
|
|
148
|
+
properties,
|
|
149
|
+
...(required.length > 0 ? { required } : {}),
|
|
150
|
+
additionalProperties: false,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function input<Input>(
|
|
155
|
+
jsonSchema: CommandJsonSchema,
|
|
156
|
+
parse: (value: unknown) => Input,
|
|
157
|
+
): CommandInputSchema<Input> {
|
|
158
|
+
return Object.freeze({ jsonSchema: freezeJson({ ...jsonSchema }), parse })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function output<Output>(
|
|
162
|
+
jsonSchema: CommandJsonSchema,
|
|
163
|
+
parse: (value: unknown) => Output = (value) => value as Output,
|
|
164
|
+
): CommandOutputContract<Output> {
|
|
165
|
+
return Object.freeze({ version: 1, jsonSchema: freezeJson({ ...jsonSchema }), parse })
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function withDir<T extends Record<string, unknown>>(value: T): T & { dir?: string } {
|
|
169
|
+
const raw = record(value)
|
|
170
|
+
const dir = optionalString(raw.dir, "dir")
|
|
171
|
+
return { ...(value as T), ...(dir === undefined ? {} : { dir }) }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function valueAfterFlag(argv: readonly string[], index: number, flag: string): string {
|
|
175
|
+
const value = argv[index + 1]
|
|
176
|
+
if (value === undefined || value.startsWith("-")) throw new Error(`${flag} needs a value`)
|
|
177
|
+
return value
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Bind CLI positionals and flags to the JSON object passed to a command's schema parser. */
|
|
181
|
+
export function bindCommandArgv<Input, Output>(
|
|
182
|
+
spec: CommandSpec<Input, Output>,
|
|
183
|
+
argv: readonly string[],
|
|
184
|
+
): Input {
|
|
185
|
+
const raw: Record<string, unknown> = {}
|
|
186
|
+
const binding = spec.argv
|
|
187
|
+
const flags = binding?.flags ?? []
|
|
188
|
+
const byName = new Map<string, CommandFlag<Input>>()
|
|
189
|
+
for (const flag of flags) {
|
|
190
|
+
byName.set(`--${flag.name}`, flag)
|
|
191
|
+
for (const alias of flag.aliases ?? []) byName.set(alias, flag)
|
|
192
|
+
}
|
|
193
|
+
const positionals = binding?.positionals ?? []
|
|
194
|
+
let positionalIndex = 0
|
|
195
|
+
for (let index = 0; index < argv.length; index++) {
|
|
196
|
+
const token = argv[index]!
|
|
197
|
+
if (token.startsWith("-")) {
|
|
198
|
+
const equals = token.indexOf("=")
|
|
199
|
+
const flagName = equals === -1 ? token : token.slice(0, equals)
|
|
200
|
+
const flag = byName.get(flagName)
|
|
201
|
+
if (flag === undefined) throw new Error(`unknown option: ${token}`)
|
|
202
|
+
if (flag.type === "boolean") {
|
|
203
|
+
const value = equals === -1 ? undefined : token.slice(equals + 1)
|
|
204
|
+
if (value !== undefined && value !== "true" && value !== "false")
|
|
205
|
+
throw new Error(`${flagName} must be a boolean`)
|
|
206
|
+
raw[flag.field] = value !== "false"
|
|
207
|
+
} else {
|
|
208
|
+
const value =
|
|
209
|
+
equals === -1 ? valueAfterFlag(argv, index, flagName) : token.slice(equals + 1)
|
|
210
|
+
if (equals === -1) index++
|
|
211
|
+
if (flag.type === "number") {
|
|
212
|
+
const number = Number(value)
|
|
213
|
+
if (!Number.isFinite(number)) throw new Error(`${flagName} must be a number`)
|
|
214
|
+
raw[flag.field] = number
|
|
215
|
+
} else if (flag.type === "string[]") {
|
|
216
|
+
const values = (raw[flag.field] as string[] | undefined) ?? []
|
|
217
|
+
values.push(value)
|
|
218
|
+
raw[flag.field] = values
|
|
219
|
+
} else raw[flag.field] = value
|
|
220
|
+
}
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
const field = positionals[positionalIndex++]
|
|
224
|
+
if (field === undefined) throw new Error(`unexpected positional argument: ${token}`)
|
|
225
|
+
raw[field] = token
|
|
226
|
+
}
|
|
227
|
+
return spec.input.parse(raw)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function parseBooleanFlags(
|
|
231
|
+
raw: Record<string, unknown>,
|
|
232
|
+
fields: readonly string[],
|
|
233
|
+
): Record<string, unknown> {
|
|
234
|
+
const out: Record<string, unknown> = {}
|
|
235
|
+
for (const field of fields) out[field] = optionalBoolean(raw[field], field)
|
|
236
|
+
return out
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
interface CheckInput {
|
|
240
|
+
readonly lintsOnly?: boolean | undefined
|
|
241
|
+
readonly json?: boolean | undefined
|
|
242
|
+
readonly maxDiagnostics?: number | undefined
|
|
243
|
+
readonly dir?: string | undefined
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
interface AssureInput {
|
|
247
|
+
readonly config?: string | undefined
|
|
248
|
+
readonly bundle?: boolean | undefined
|
|
249
|
+
readonly strict?: boolean | undefined
|
|
250
|
+
readonly hydration?: boolean | undefined
|
|
251
|
+
readonly interact?: boolean | undefined
|
|
252
|
+
readonly out?: string | undefined
|
|
253
|
+
readonly json?: boolean | undefined
|
|
254
|
+
readonly dir?: string | undefined
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
interface LevelsInput {
|
|
258
|
+
readonly config?: string | undefined
|
|
259
|
+
readonly seed?: number | undefined
|
|
260
|
+
readonly min?: number | undefined
|
|
261
|
+
readonly json?: boolean | undefined
|
|
262
|
+
readonly dir?: string | undefined
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
interface CapabilitiesInput {
|
|
266
|
+
readonly action: "snapshot" | "check" | "explain"
|
|
267
|
+
readonly config?: string | undefined
|
|
268
|
+
readonly out?: string | undefined
|
|
269
|
+
readonly lockfile?: string | undefined
|
|
270
|
+
readonly method?: string | undefined
|
|
271
|
+
readonly path?: string | undefined
|
|
272
|
+
readonly json?: boolean | undefined
|
|
273
|
+
readonly dir?: string | undefined
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
interface ManifestInput {
|
|
277
|
+
readonly action: "emit" | "diff"
|
|
278
|
+
readonly config?: string | undefined
|
|
279
|
+
readonly out?: string | undefined
|
|
280
|
+
readonly sign?: string | undefined
|
|
281
|
+
readonly before?: string | undefined
|
|
282
|
+
readonly after?: string | undefined
|
|
283
|
+
readonly json?: boolean | undefined
|
|
284
|
+
readonly dir?: string | undefined
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
interface RoutesInput {
|
|
288
|
+
readonly json?: boolean | undefined
|
|
289
|
+
readonly graph?: boolean | undefined
|
|
290
|
+
readonly modes?: boolean | undefined
|
|
291
|
+
readonly target?: string | undefined
|
|
292
|
+
readonly dir?: string | undefined
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
interface ContextInput {
|
|
296
|
+
readonly path?: string | undefined
|
|
297
|
+
readonly kind?: "api" | "pages" | undefined
|
|
298
|
+
readonly dir?: string | undefined
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
interface DoctorInput {
|
|
302
|
+
readonly json?: boolean | undefined
|
|
303
|
+
readonly autoFix?: boolean | undefined
|
|
304
|
+
readonly strict?: boolean | undefined
|
|
305
|
+
readonly target?: string | undefined
|
|
306
|
+
readonly dir?: string | undefined
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
interface FixInput {
|
|
310
|
+
readonly code?: string | undefined
|
|
311
|
+
readonly json?: boolean | undefined
|
|
312
|
+
readonly dir?: string | undefined
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
interface SnapshotInput {
|
|
316
|
+
readonly out?: string | undefined
|
|
317
|
+
readonly json?: boolean | undefined
|
|
318
|
+
readonly dir?: string | undefined
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
interface DiffInput {
|
|
322
|
+
readonly baseline?: string | undefined
|
|
323
|
+
readonly json?: boolean | undefined
|
|
324
|
+
readonly dir?: string | undefined
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
interface ContractsInput {
|
|
328
|
+
readonly action: "snapshot" | "check"
|
|
329
|
+
readonly out?: string | undefined
|
|
330
|
+
readonly json?: boolean | undefined
|
|
331
|
+
readonly dir?: string | undefined
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
interface SyncInput {
|
|
335
|
+
readonly json?: boolean | undefined
|
|
336
|
+
readonly dir?: string | undefined
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
interface ProveInput {
|
|
340
|
+
readonly files?: readonly string[] | undefined
|
|
341
|
+
readonly minLevel?: number | undefined
|
|
342
|
+
readonly json?: boolean | undefined
|
|
343
|
+
readonly dir?: string | undefined
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
interface ReplayInput {
|
|
347
|
+
readonly file: string
|
|
348
|
+
readonly dir?: string | undefined
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
interface PortInput {
|
|
352
|
+
readonly target?: string | undefined
|
|
353
|
+
readonly json?: boolean | undefined
|
|
354
|
+
readonly ci?: boolean | undefined
|
|
355
|
+
readonly strict?: boolean | undefined
|
|
356
|
+
readonly dir?: string | undefined
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
interface CheckCommandOutput extends CheckResult {}
|
|
360
|
+
interface AssureCommandOutput {
|
|
361
|
+
readonly report?: unknown
|
|
362
|
+
readonly bundle?: unknown
|
|
363
|
+
readonly ok: boolean
|
|
364
|
+
}
|
|
365
|
+
interface LevelsCommandOutput extends VerificationLevelsResult {}
|
|
366
|
+
interface RoutesCommandOutput {
|
|
367
|
+
readonly ok: boolean
|
|
368
|
+
readonly mode: "default" | "graph" | "modes"
|
|
369
|
+
readonly data: unknown
|
|
370
|
+
readonly text: string
|
|
371
|
+
}
|
|
372
|
+
interface ContextCommandOutput {
|
|
373
|
+
readonly ok: true
|
|
374
|
+
readonly text: string
|
|
375
|
+
}
|
|
376
|
+
interface FixCommandOutput {
|
|
377
|
+
readonly ok: boolean
|
|
378
|
+
readonly changed: readonly string[]
|
|
379
|
+
/** Recipes that refused, with the reason. A recipe that cannot act says so here instead of
|
|
380
|
+
* aborting the run or - worse - reporting nothing at all. */
|
|
381
|
+
readonly failed: readonly { readonly code: string; readonly reason: string }[]
|
|
382
|
+
readonly diagnostics: readonly unknown[]
|
|
383
|
+
}
|
|
384
|
+
interface SnapshotCommandOutput {
|
|
385
|
+
readonly ok: true
|
|
386
|
+
readonly file: string
|
|
387
|
+
readonly routes: number
|
|
388
|
+
readonly snapshot: unknown
|
|
389
|
+
}
|
|
390
|
+
interface DiffCommandOutput {
|
|
391
|
+
readonly ok: boolean
|
|
392
|
+
readonly hasBreaking: boolean
|
|
393
|
+
readonly changes: readonly unknown[]
|
|
394
|
+
}
|
|
395
|
+
interface ContractsCommandOutput {
|
|
396
|
+
readonly ok: boolean
|
|
397
|
+
readonly lock?: ContractsLock
|
|
398
|
+
readonly diagnostics?: readonly unknown[]
|
|
399
|
+
readonly present?: boolean
|
|
400
|
+
readonly vacuous?: boolean
|
|
401
|
+
}
|
|
402
|
+
interface SyncCommandOutput {
|
|
403
|
+
readonly ok: true
|
|
404
|
+
readonly results: readonly unknown[]
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const CHECK_SCHEMA = input<CheckInput>(
|
|
408
|
+
objectSchema({
|
|
409
|
+
lintsOnly: { type: "boolean" },
|
|
410
|
+
json: { type: "boolean" },
|
|
411
|
+
maxDiagnostics: { type: "integer", minimum: 1 },
|
|
412
|
+
dir: { type: "string" },
|
|
413
|
+
}),
|
|
414
|
+
(value) => {
|
|
415
|
+
const raw = withDir(record(value))
|
|
416
|
+
const maxDiagnostics = optionalNumber(raw.maxDiagnostics, "maxDiagnostics")
|
|
417
|
+
if (maxDiagnostics !== undefined && (!Number.isInteger(maxDiagnostics) || maxDiagnostics < 1))
|
|
418
|
+
throw new TypeError("maxDiagnostics must be a positive integer")
|
|
419
|
+
return {
|
|
420
|
+
...parseBooleanFlags(raw, ["lintsOnly", "json"]),
|
|
421
|
+
...(maxDiagnostics === undefined ? {} : { maxDiagnostics }),
|
|
422
|
+
...(raw.dir === undefined ? {} : { dir: raw.dir }),
|
|
423
|
+
} as CheckInput
|
|
424
|
+
},
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
const ASSURE_SCHEMA = input<AssureInput>(
|
|
428
|
+
objectSchema({
|
|
429
|
+
config: { type: "string" },
|
|
430
|
+
bundle: { type: "boolean" },
|
|
431
|
+
strict: { type: "boolean" },
|
|
432
|
+
hydration: { type: "boolean" },
|
|
433
|
+
interact: { type: "boolean" },
|
|
434
|
+
out: { type: "string" },
|
|
435
|
+
json: { type: "boolean" },
|
|
436
|
+
dir: { type: "string" },
|
|
437
|
+
}),
|
|
438
|
+
(value) => {
|
|
439
|
+
const raw = withDir(record(value))
|
|
440
|
+
return {
|
|
441
|
+
...parseBooleanFlags(raw, ["bundle", "strict", "hydration", "interact", "json"]),
|
|
442
|
+
config: optionalString(raw.config, "config"),
|
|
443
|
+
out: optionalString(raw.out, "out"),
|
|
444
|
+
...(raw.dir === undefined ? {} : { dir: raw.dir }),
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
const LEVELS_SCHEMA = input<LevelsInput>(
|
|
450
|
+
objectSchema({
|
|
451
|
+
config: { type: "string" },
|
|
452
|
+
seed: { type: "number" },
|
|
453
|
+
min: { type: "integer", minimum: 0, maximum: 4 },
|
|
454
|
+
json: { type: "boolean" },
|
|
455
|
+
dir: { type: "string" },
|
|
456
|
+
}),
|
|
457
|
+
(value) => {
|
|
458
|
+
const raw = withDir(record(value))
|
|
459
|
+
const seed = optionalNumber(raw.seed, "seed")
|
|
460
|
+
const min = optionalNumber(raw.min, "min")
|
|
461
|
+
if (seed !== undefined && !Number.isSafeInteger(seed))
|
|
462
|
+
throw new TypeError("seed must be a safe integer")
|
|
463
|
+
if (min !== undefined && (!Number.isInteger(min) || min < 0 || min > 4))
|
|
464
|
+
throw new TypeError("min must be an integer from 0 to 4")
|
|
465
|
+
return {
|
|
466
|
+
...parseBooleanFlags(raw, ["json"]),
|
|
467
|
+
config: optionalString(raw.config, "config"),
|
|
468
|
+
...(seed === undefined ? {} : { seed }),
|
|
469
|
+
...(min === undefined ? {} : { min }),
|
|
470
|
+
...(raw.dir === undefined ? {} : { dir: raw.dir }),
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
const CAPABILITIES_SCHEMA = input<CapabilitiesInput>(
|
|
476
|
+
objectSchema(
|
|
477
|
+
{
|
|
478
|
+
action: { type: "string", enum: ["snapshot", "check", "explain"] },
|
|
479
|
+
config: { type: "string" },
|
|
480
|
+
out: { type: "string" },
|
|
481
|
+
lockfile: { type: "string" },
|
|
482
|
+
method: { type: "string" },
|
|
483
|
+
path: { type: "string" },
|
|
484
|
+
json: { type: "boolean" },
|
|
485
|
+
dir: { type: "string" },
|
|
486
|
+
},
|
|
487
|
+
["action"],
|
|
488
|
+
),
|
|
489
|
+
(value) => {
|
|
490
|
+
const raw = withDir(record(value))
|
|
491
|
+
if (raw.action !== "snapshot" && raw.action !== "check" && raw.action !== "explain")
|
|
492
|
+
throw new TypeError("action must be snapshot, check, or explain")
|
|
493
|
+
return {
|
|
494
|
+
action: raw.action,
|
|
495
|
+
config: optionalString(raw.config, "config"),
|
|
496
|
+
out: optionalString(raw.out, "out"),
|
|
497
|
+
lockfile: optionalString(raw.lockfile, "lockfile"),
|
|
498
|
+
method: optionalString(raw.method, "method"),
|
|
499
|
+
path: optionalString(raw.path, "path"),
|
|
500
|
+
...parseBooleanFlags(raw, ["json"]),
|
|
501
|
+
...(raw.dir === undefined ? {} : { dir: raw.dir }),
|
|
502
|
+
}
|
|
503
|
+
},
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
const MANIFEST_SCHEMA = input<ManifestInput>(
|
|
507
|
+
objectSchema(
|
|
508
|
+
{
|
|
509
|
+
action: { type: "string", enum: ["emit", "diff"] },
|
|
510
|
+
config: { type: "string" },
|
|
511
|
+
out: { type: "string" },
|
|
512
|
+
sign: { type: "string" },
|
|
513
|
+
before: { type: "string" },
|
|
514
|
+
after: { type: "string" },
|
|
515
|
+
json: { type: "boolean" },
|
|
516
|
+
dir: { type: "string" },
|
|
517
|
+
},
|
|
518
|
+
["action"],
|
|
519
|
+
),
|
|
520
|
+
(value) => {
|
|
521
|
+
const raw = withDir(record(value))
|
|
522
|
+
if (raw.action !== "emit" && raw.action !== "diff")
|
|
523
|
+
throw new TypeError("action must be emit or diff")
|
|
524
|
+
return {
|
|
525
|
+
action: raw.action,
|
|
526
|
+
config: optionalString(raw.config, "config"),
|
|
527
|
+
out: optionalString(raw.out, "out"),
|
|
528
|
+
sign: optionalString(raw.sign, "sign"),
|
|
529
|
+
before: optionalString(raw.before, "before"),
|
|
530
|
+
after: optionalString(raw.after, "after"),
|
|
531
|
+
...parseBooleanFlags(raw, ["json"]),
|
|
532
|
+
...(raw.dir === undefined ? {} : { dir: raw.dir }),
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
const ROUTES_SCHEMA = input<RoutesInput>(
|
|
538
|
+
objectSchema({
|
|
539
|
+
json: { type: "boolean" },
|
|
540
|
+
graph: { type: "boolean" },
|
|
541
|
+
modes: { type: "boolean" },
|
|
542
|
+
target: { type: "string" },
|
|
543
|
+
dir: { type: "string" },
|
|
544
|
+
}),
|
|
545
|
+
(value) => {
|
|
546
|
+
const raw = withDir(record(value))
|
|
547
|
+
if (raw.graph === true && raw.modes === true)
|
|
548
|
+
throw new TypeError("graph and modes cannot be combined")
|
|
549
|
+
return {
|
|
550
|
+
...parseBooleanFlags(raw, ["json", "graph", "modes"]),
|
|
551
|
+
target: optionalString(raw.target, "target"),
|
|
552
|
+
...(raw.dir === undefined ? {} : { dir: raw.dir }),
|
|
553
|
+
}
|
|
554
|
+
},
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
const CONTEXT_SCHEMA = input<ContextInput>(
|
|
558
|
+
objectSchema({
|
|
559
|
+
path: { type: "string" },
|
|
560
|
+
kind: { type: "string", enum: ["api", "pages"] },
|
|
561
|
+
dir: { type: "string" },
|
|
562
|
+
}),
|
|
563
|
+
(value) => {
|
|
564
|
+
const raw = withDir(record(value))
|
|
565
|
+
if (raw.kind !== undefined && raw.kind !== "api" && raw.kind !== "pages")
|
|
566
|
+
throw new TypeError("kind must be api or pages")
|
|
567
|
+
return {
|
|
568
|
+
path: optionalString(raw.path, "path"),
|
|
569
|
+
kind: raw.kind as "api" | "pages" | undefined,
|
|
570
|
+
...(raw.dir === undefined ? {} : { dir: raw.dir }),
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
const DOCTOR_SCHEMA = input<DoctorInput>(
|
|
576
|
+
objectSchema({
|
|
577
|
+
json: { type: "boolean" },
|
|
578
|
+
autoFix: { type: "boolean" },
|
|
579
|
+
strict: { type: "boolean" },
|
|
580
|
+
target: { type: "string" },
|
|
581
|
+
dir: { type: "string" },
|
|
582
|
+
}),
|
|
583
|
+
(value) => {
|
|
584
|
+
const raw = withDir(record(value))
|
|
585
|
+
return {
|
|
586
|
+
...parseBooleanFlags(raw, ["json", "autoFix", "strict"]),
|
|
587
|
+
target: optionalString(raw.target, "target"),
|
|
588
|
+
...(raw.dir === undefined ? {} : { dir: raw.dir }),
|
|
589
|
+
}
|
|
590
|
+
},
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
const FIX_SCHEMA = input<FixInput>(
|
|
594
|
+
objectSchema({ code: { type: "string" }, json: { type: "boolean" }, dir: { type: "string" } }),
|
|
595
|
+
(value) => {
|
|
596
|
+
const raw = withDir(record(value))
|
|
597
|
+
return {
|
|
598
|
+
code: optionalString(raw.code, "code"),
|
|
599
|
+
...parseBooleanFlags(raw, ["json"]),
|
|
600
|
+
...(raw.dir === undefined ? {} : { dir: raw.dir }),
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
const SNAPSHOT_SCHEMA = input<SnapshotInput>(
|
|
606
|
+
objectSchema({ out: { type: "string" }, json: { type: "boolean" }, dir: { type: "string" } }),
|
|
607
|
+
(value) => {
|
|
608
|
+
const raw = withDir(record(value))
|
|
609
|
+
return {
|
|
610
|
+
out: optionalString(raw.out, "out"),
|
|
611
|
+
...parseBooleanFlags(raw, ["json"]),
|
|
612
|
+
...(raw.dir === undefined ? {} : { dir: raw.dir }),
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
const DIFF_SCHEMA = input<DiffInput>(
|
|
618
|
+
objectSchema({
|
|
619
|
+
baseline: { type: "string" },
|
|
620
|
+
json: { type: "boolean" },
|
|
621
|
+
dir: { type: "string" },
|
|
622
|
+
}),
|
|
623
|
+
(value) => {
|
|
624
|
+
const raw = withDir(record(value))
|
|
625
|
+
return {
|
|
626
|
+
baseline: optionalString(raw.baseline, "baseline"),
|
|
627
|
+
...parseBooleanFlags(raw, ["json"]),
|
|
628
|
+
...(raw.dir === undefined ? {} : { dir: raw.dir }),
|
|
629
|
+
}
|
|
630
|
+
},
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
const CONTRACTS_SCHEMA = input<ContractsInput>(
|
|
634
|
+
objectSchema(
|
|
635
|
+
{
|
|
636
|
+
action: { type: "string", enum: ["snapshot", "check"] },
|
|
637
|
+
out: { type: "string" },
|
|
638
|
+
json: { type: "boolean" },
|
|
639
|
+
dir: { type: "string" },
|
|
640
|
+
},
|
|
641
|
+
["action"],
|
|
642
|
+
),
|
|
643
|
+
(value) => {
|
|
644
|
+
const raw = withDir(record(value))
|
|
645
|
+
if (raw.action !== "snapshot" && raw.action !== "check")
|
|
646
|
+
throw new TypeError("action must be snapshot or check")
|
|
647
|
+
return {
|
|
648
|
+
action: raw.action,
|
|
649
|
+
out: optionalString(raw.out, "out"),
|
|
650
|
+
...parseBooleanFlags(raw, ["json"]),
|
|
651
|
+
...(raw.dir === undefined ? {} : { dir: raw.dir }),
|
|
652
|
+
}
|
|
653
|
+
},
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
const SYNC_SCHEMA = input<SyncInput>(
|
|
657
|
+
objectSchema({ json: { type: "boolean" }, dir: { type: "string" } }),
|
|
658
|
+
(value) => {
|
|
659
|
+
const raw = withDir(record(value))
|
|
660
|
+
return {
|
|
661
|
+
...parseBooleanFlags(raw, ["json"]),
|
|
662
|
+
...(raw.dir === undefined ? {} : { dir: raw.dir }),
|
|
663
|
+
}
|
|
664
|
+
},
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
const PROVE_SCHEMA = input<ProveInput>(
|
|
668
|
+
objectSchema({
|
|
669
|
+
files: { type: "array", items: { type: "string" } },
|
|
670
|
+
minLevel: { type: "integer", minimum: 0, maximum: 4 },
|
|
671
|
+
json: { type: "boolean" },
|
|
672
|
+
dir: { type: "string" },
|
|
673
|
+
}),
|
|
674
|
+
(value) => {
|
|
675
|
+
const raw = withDir(record(value))
|
|
676
|
+
let files: readonly string[] | undefined
|
|
677
|
+
if (raw.files !== undefined) {
|
|
678
|
+
if (!Array.isArray(raw.files) || raw.files.some((entry) => typeof entry !== "string"))
|
|
679
|
+
throw new TypeError("files must be an array of strings")
|
|
680
|
+
files = raw.files as readonly string[]
|
|
681
|
+
}
|
|
682
|
+
const minLevel = optionalNumber(raw.minLevel, "minLevel")
|
|
683
|
+
if (minLevel !== undefined && (!Number.isInteger(minLevel) || minLevel < 0 || minLevel > 4))
|
|
684
|
+
throw new TypeError("minLevel must be an integer from 0 to 4")
|
|
685
|
+
return {
|
|
686
|
+
...(files === undefined ? {} : { files }),
|
|
687
|
+
...(minLevel === undefined ? {} : { minLevel }),
|
|
688
|
+
...parseBooleanFlags(raw, ["json"]),
|
|
689
|
+
...(raw.dir === undefined ? {} : { dir: raw.dir }),
|
|
690
|
+
}
|
|
691
|
+
},
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
const REPLAY_SCHEMA = input<ReplayInput>(
|
|
695
|
+
objectSchema({ file: { type: "string" }, dir: { type: "string" } }, ["file"]),
|
|
696
|
+
(value) => {
|
|
697
|
+
const raw = withDir(record(value))
|
|
698
|
+
const file = optionalString(raw.file, "file")
|
|
699
|
+
if (file === undefined || file.trim() === "") throw new TypeError("file is required")
|
|
700
|
+
return { file, ...(raw.dir === undefined ? {} : { dir: raw.dir }) }
|
|
701
|
+
},
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
const PORT_SCHEMA = input<PortInput>(
|
|
705
|
+
objectSchema({
|
|
706
|
+
target: { type: "string" },
|
|
707
|
+
json: { type: "boolean" },
|
|
708
|
+
ci: { type: "boolean" },
|
|
709
|
+
strict: { type: "boolean" },
|
|
710
|
+
dir: { type: "string" },
|
|
711
|
+
}),
|
|
712
|
+
(value) => {
|
|
713
|
+
const raw = withDir(record(value))
|
|
714
|
+
return {
|
|
715
|
+
target: optionalString(raw.target, "target"),
|
|
716
|
+
...parseBooleanFlags(raw, ["json", "ci", "strict"]),
|
|
717
|
+
...(raw.dir === undefined ? {} : { dir: raw.dir }),
|
|
718
|
+
}
|
|
719
|
+
},
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
function loadAppFor(ctx: CommandCtx): Promise<LoadedApp> {
|
|
723
|
+
if (ctx.loadApp !== undefined) return ctx.loadApp()
|
|
724
|
+
return import("./load.ts").then(({ loadApp }) => loadApp(ctx.cwd))
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function checkJson(out: CheckCommandOutput): unknown {
|
|
728
|
+
return out.structuredDiagnostics === undefined
|
|
729
|
+
? out
|
|
730
|
+
: { ...out, diagnostics: out.structuredDiagnostics }
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const checkSpec: CommandSpec<CheckInput, CheckCommandOutput> = {
|
|
734
|
+
name: "check",
|
|
735
|
+
summary: "Run the typed-contract, source-lint, and dependency drift gate.",
|
|
736
|
+
input: CHECK_SCHEMA,
|
|
737
|
+
output: output<CheckCommandOutput>({
|
|
738
|
+
type: "object",
|
|
739
|
+
properties: { ok: { type: "boolean" }, diagnostics: { type: "array" } },
|
|
740
|
+
required: ["ok", "diagnostics"],
|
|
741
|
+
}),
|
|
742
|
+
transports: ["cli", "mcp"],
|
|
743
|
+
stability: "stable",
|
|
744
|
+
argv: {
|
|
745
|
+
flags: [
|
|
746
|
+
{ name: "lints-only", field: "lintsOnly", type: "boolean" },
|
|
747
|
+
{ name: "json", field: "json", type: "boolean" },
|
|
748
|
+
],
|
|
749
|
+
},
|
|
750
|
+
async run(value, ctx) {
|
|
751
|
+
const { collectProjectVerification } = await import("./verification.ts")
|
|
752
|
+
return (
|
|
753
|
+
await collectProjectVerification(ctx.cwd, {
|
|
754
|
+
lintsOnly: value.lintsOnly === true,
|
|
755
|
+
...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
|
|
756
|
+
...(value.maxDiagnostics === undefined ? {} : { maxDiagnostics: value.maxDiagnostics }),
|
|
757
|
+
})
|
|
758
|
+
).check()
|
|
759
|
+
},
|
|
760
|
+
render: (out) => renderCheckReport(out),
|
|
761
|
+
success: (out) => out.ok,
|
|
762
|
+
json: (out) => checkJson(out),
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const assureSpec: CommandSpec<AssureInput, AssureCommandOutput> = {
|
|
766
|
+
name: "assure",
|
|
767
|
+
summary: "Evaluate route classification and fail-closed enforcement evidence.",
|
|
768
|
+
input: ASSURE_SCHEMA,
|
|
769
|
+
output: output({ type: "object" }),
|
|
770
|
+
transports: ["cli", "mcp"],
|
|
771
|
+
stability: "stable",
|
|
772
|
+
argv: {
|
|
773
|
+
flags: [
|
|
774
|
+
{ name: "config", field: "config", type: "string" },
|
|
775
|
+
{ name: "out", field: "out", type: "string" },
|
|
776
|
+
{ name: "json", field: "json", type: "boolean" },
|
|
777
|
+
{ name: "bundle", field: "bundle", type: "boolean" },
|
|
778
|
+
{ name: "strict", field: "strict", type: "boolean" },
|
|
779
|
+
{ name: "hydration", field: "hydration", type: "boolean" },
|
|
780
|
+
{ name: "interact", field: "interact", type: "boolean" },
|
|
781
|
+
],
|
|
782
|
+
},
|
|
783
|
+
async run(value, ctx) {
|
|
784
|
+
const { collectAssuranceReport, collectAssureBundle } = await import("./assure.ts")
|
|
785
|
+
const bundle =
|
|
786
|
+
value.bundle === true ||
|
|
787
|
+
value.strict === true ||
|
|
788
|
+
value.hydration === true ||
|
|
789
|
+
value.interact === true
|
|
790
|
+
if (bundle) {
|
|
791
|
+
const result = await collectAssureBundle(ctx.cwd, {
|
|
792
|
+
...(value.config === undefined ? {} : { config: value.config }),
|
|
793
|
+
...(value.strict === undefined ? {} : { strict: value.strict }),
|
|
794
|
+
...(value.hydration === undefined ? {} : { hydration: value.hydration }),
|
|
795
|
+
...(value.interact === undefined ? {} : { interact: value.interact }),
|
|
796
|
+
})
|
|
797
|
+
if (value.out !== undefined)
|
|
798
|
+
await Bun.write(resolve(ctx.cwd, value.out), `${JSON.stringify(result, null, 2)}\n`)
|
|
799
|
+
return { bundle: result, ok: result.verdict === "green" }
|
|
800
|
+
}
|
|
801
|
+
const report = await collectAssuranceReport(ctx.cwd, value.config)
|
|
802
|
+
return { report, ok: report.ok }
|
|
803
|
+
},
|
|
804
|
+
render: (out) => [
|
|
805
|
+
out.bundle === undefined
|
|
806
|
+
? formatAssuranceReport(out.report as never)
|
|
807
|
+
: JSON.stringify(out.bundle, null, 2),
|
|
808
|
+
],
|
|
809
|
+
success: (out) => out.ok,
|
|
810
|
+
json: (out) => out.bundle ?? out.report,
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const levelsSpec: CommandSpec<LevelsInput, LevelsCommandOutput> = {
|
|
814
|
+
name: "levels",
|
|
815
|
+
summary: "Compute the cumulative L0 typed-contract through L4 invariant verification ladder.",
|
|
816
|
+
input: LEVELS_SCHEMA,
|
|
817
|
+
output: output({
|
|
818
|
+
type: "object",
|
|
819
|
+
properties: { achieved: { type: "integer" }, levels: { type: "array" } },
|
|
820
|
+
required: ["achieved", "levels"],
|
|
821
|
+
}),
|
|
822
|
+
transports: ["cli", "mcp"],
|
|
823
|
+
stability: "stable",
|
|
824
|
+
argv: {
|
|
825
|
+
flags: [
|
|
826
|
+
{ name: "config", field: "config", type: "string" },
|
|
827
|
+
{ name: "seed", field: "seed", type: "number" },
|
|
828
|
+
{ name: "min", field: "min", type: "number" },
|
|
829
|
+
{ name: "json", field: "json", type: "boolean" },
|
|
830
|
+
],
|
|
831
|
+
},
|
|
832
|
+
async run(value, ctx) {
|
|
833
|
+
return import("./levels-tool.ts").then(({ collectVerificationLevels }) =>
|
|
834
|
+
collectVerificationLevels(ctx.cwd, {
|
|
835
|
+
...(value.config === undefined ? {} : { config: value.config }),
|
|
836
|
+
...(value.seed === undefined ? {} : { seed: value.seed }),
|
|
837
|
+
}),
|
|
838
|
+
)
|
|
839
|
+
},
|
|
840
|
+
render: (out) => [
|
|
841
|
+
...out.levels.flatMap((s) => [
|
|
842
|
+
`${s.ok ? "✓" : "✖"} L${s.level} ${s.name}`,
|
|
843
|
+
...s.reasons.map((r) => ` - ${r}`),
|
|
844
|
+
]),
|
|
845
|
+
out.achieved < 0
|
|
846
|
+
? "[nifra] verification level: none (L0 failing)"
|
|
847
|
+
: `[nifra] verification level: L${out.achieved}`,
|
|
848
|
+
],
|
|
849
|
+
success: (out, value) => out.achieved >= (value.min ?? 0),
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const capabilitiesSpec: CommandSpec<
|
|
853
|
+
CapabilitiesInput,
|
|
854
|
+
CapabilitySnapshotCommandResult | CapabilityCheckCommandResult | CapabilityExplainCommandResult
|
|
855
|
+
> = {
|
|
856
|
+
name: "capabilities",
|
|
857
|
+
summary: "Snapshot, check, or explain token-only capability provenance and lockfile drift.",
|
|
858
|
+
input: CAPABILITIES_SCHEMA,
|
|
859
|
+
output: output({ type: "object" }),
|
|
860
|
+
transports: ["cli", "mcp"],
|
|
861
|
+
stability: "stable",
|
|
862
|
+
argv: {
|
|
863
|
+
positionals: ["action", "method", "path"],
|
|
864
|
+
flags: [
|
|
865
|
+
{ name: "config", field: "config", type: "string" },
|
|
866
|
+
{ name: "out", field: "out", type: "string" },
|
|
867
|
+
{ name: "lockfile", field: "lockfile", type: "string" },
|
|
868
|
+
{ name: "json", field: "json", type: "boolean" },
|
|
869
|
+
],
|
|
870
|
+
},
|
|
871
|
+
async run(value, ctx) {
|
|
872
|
+
if (value.action === "snapshot")
|
|
873
|
+
return import("./capabilities-tool.ts").then(({ collectCapabilitySnapshot }) =>
|
|
874
|
+
collectCapabilitySnapshot(ctx.cwd, {
|
|
875
|
+
...(value.config === undefined ? {} : { config: value.config }),
|
|
876
|
+
...(value.out === undefined ? {} : { out: value.out }),
|
|
877
|
+
}),
|
|
878
|
+
)
|
|
879
|
+
if (value.action === "check")
|
|
880
|
+
return import("./capabilities-tool.ts").then(({ collectCapabilityCheck }) =>
|
|
881
|
+
collectCapabilityCheck(ctx.cwd, {
|
|
882
|
+
...(value.config === undefined ? {} : { config: value.config }),
|
|
883
|
+
...(value.lockfile === undefined ? {} : { lockfile: value.lockfile }),
|
|
884
|
+
}),
|
|
885
|
+
)
|
|
886
|
+
if (value.method === undefined || value.path === undefined)
|
|
887
|
+
throw new Error("capabilities explain needs <METHOD> <path>")
|
|
888
|
+
return import("./capabilities-tool.ts").then(({ collectCapabilityExplanation }) =>
|
|
889
|
+
collectCapabilityExplanation(
|
|
890
|
+
ctx.cwd,
|
|
891
|
+
value.method!,
|
|
892
|
+
value.path!,
|
|
893
|
+
value.config === undefined ? {} : { config: value.config },
|
|
894
|
+
),
|
|
895
|
+
)
|
|
896
|
+
},
|
|
897
|
+
render: (out) => [JSON.stringify(out, null, 2)],
|
|
898
|
+
success: (out) => out.ok,
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const manifestSpec: CommandSpec<ManifestInput, ManifestEmitCommandResult | DiffCommandOutput> = {
|
|
902
|
+
name: "manifest",
|
|
903
|
+
summary: "Emit or diff the hash-verified route trust manifest.",
|
|
904
|
+
input: MANIFEST_SCHEMA,
|
|
905
|
+
output: output({ type: "object" }),
|
|
906
|
+
transports: ["cli", "mcp"],
|
|
907
|
+
stability: "stable",
|
|
908
|
+
argv: {
|
|
909
|
+
positionals: ["action", "before", "after"],
|
|
910
|
+
flags: [
|
|
911
|
+
{ name: "config", field: "config", type: "string" },
|
|
912
|
+
{ name: "out", field: "out", type: "string" },
|
|
913
|
+
{ name: "sign", field: "sign", type: "string" },
|
|
914
|
+
{ name: "json", field: "json", type: "boolean" },
|
|
915
|
+
],
|
|
916
|
+
},
|
|
917
|
+
async run(value, ctx) {
|
|
918
|
+
if (value.action === "emit")
|
|
919
|
+
return import("./manifest-tool.ts").then(({ collectManifestEmit }) =>
|
|
920
|
+
collectManifestEmit(ctx.cwd, {
|
|
921
|
+
...(value.config === undefined ? {} : { config: value.config }),
|
|
922
|
+
...(value.out === undefined ? {} : { out: value.out }),
|
|
923
|
+
...(value.sign === undefined ? {} : { sign: value.sign }),
|
|
924
|
+
}),
|
|
925
|
+
)
|
|
926
|
+
if (value.before === undefined || value.after === undefined)
|
|
927
|
+
throw new Error("manifest diff needs <before> <after>")
|
|
928
|
+
const diff = await import("./manifest-tool.ts").then(({ collectManifestDiff }) =>
|
|
929
|
+
collectManifestDiff(ctx.cwd, value.before!, value.after!),
|
|
930
|
+
)
|
|
931
|
+
return { ok: !diff.hasBreaking, hasBreaking: diff.hasBreaking, changes: diff.changes }
|
|
932
|
+
},
|
|
933
|
+
render: (out) =>
|
|
934
|
+
"changes" in out
|
|
935
|
+
? [
|
|
936
|
+
out.ok ? "✓ no breaking manifest changes" : "✖ breaking manifest changes",
|
|
937
|
+
...(out.changes as readonly { method: string; path: string; message: string }[]).map(
|
|
938
|
+
(c) => `${c.method} ${c.path}: ${c.message}`,
|
|
939
|
+
),
|
|
940
|
+
]
|
|
941
|
+
: [
|
|
942
|
+
out.ok
|
|
943
|
+
? `✓ wrote manifest to ${out.path}`
|
|
944
|
+
: "✖ refusing to emit a manifest from failing assurance",
|
|
945
|
+
],
|
|
946
|
+
success: (out) => out.ok,
|
|
947
|
+
json: (out) => out,
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const routesSpec: CommandSpec<RoutesInput, RoutesCommandOutput> = {
|
|
951
|
+
name: "routes",
|
|
952
|
+
summary: "List, graph, or target-check every page and API route the app serves.",
|
|
953
|
+
input: ROUTES_SCHEMA,
|
|
954
|
+
output: output({
|
|
955
|
+
type: "object",
|
|
956
|
+
properties: { ok: { type: "boolean" }, mode: { type: "string" }, data: {} },
|
|
957
|
+
required: ["ok", "mode", "data"],
|
|
958
|
+
}),
|
|
959
|
+
transports: ["cli", "mcp"],
|
|
960
|
+
stability: "stable",
|
|
961
|
+
argv: {
|
|
962
|
+
flags: [
|
|
963
|
+
{ name: "json", field: "json", type: "boolean" },
|
|
964
|
+
{ name: "graph", field: "graph", type: "boolean" },
|
|
965
|
+
{ name: "modes", field: "modes", type: "boolean" },
|
|
966
|
+
{ name: "target", field: "target", type: "string" },
|
|
967
|
+
],
|
|
968
|
+
},
|
|
969
|
+
async run(value, ctx) {
|
|
970
|
+
const app = await loadAppFor(ctx)
|
|
971
|
+
if (value.modes === true) {
|
|
972
|
+
const { buildRouteManifest, renderRouteManifest } = await import(
|
|
973
|
+
"@nifrajs/web/route-manifest"
|
|
974
|
+
)
|
|
975
|
+
const { discoverRoutes } = await import("@nifrajs/web/fs")
|
|
976
|
+
const data = await buildRouteManifest(
|
|
977
|
+
discoverRoutes(app.routesDir),
|
|
978
|
+
value.target === undefined ? {} : { target: value.target },
|
|
979
|
+
)
|
|
980
|
+
return {
|
|
981
|
+
ok: data.conflicts.length === 0,
|
|
982
|
+
mode: "modes",
|
|
983
|
+
data,
|
|
984
|
+
text: renderRouteManifest(data),
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
const { describeRoutes, describeRouteGraph } = await import("./introspect.ts")
|
|
988
|
+
const mode = value.graph === true ? "graph" : "default"
|
|
989
|
+
const text = value.graph === true ? await describeRouteGraph(app) : await describeRoutes(app)
|
|
990
|
+
const jsonText =
|
|
991
|
+
value.graph === true
|
|
992
|
+
? await describeRouteGraph(app, { json: true })
|
|
993
|
+
: await describeRoutes(app, { json: true })
|
|
994
|
+
return { ok: true, mode, data: JSON.parse(jsonText) as unknown, text }
|
|
995
|
+
},
|
|
996
|
+
render: (out) => [out.text],
|
|
997
|
+
success: (out) => out.ok,
|
|
998
|
+
json: (out) => out.data,
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const contextSpec: CommandSpec<ContextInput, ContextCommandOutput> = {
|
|
1002
|
+
name: "context",
|
|
1003
|
+
summary: "Print the project route index and framework conventions for an agent.",
|
|
1004
|
+
input: CONTEXT_SCHEMA,
|
|
1005
|
+
output: output({
|
|
1006
|
+
type: "object",
|
|
1007
|
+
properties: { ok: { type: "boolean" }, text: { type: "string" } },
|
|
1008
|
+
required: ["ok", "text"],
|
|
1009
|
+
}),
|
|
1010
|
+
transports: ["cli", "mcp"],
|
|
1011
|
+
stability: "stable",
|
|
1012
|
+
argv: {
|
|
1013
|
+
flags: [
|
|
1014
|
+
{ name: "path", field: "path", type: "string" },
|
|
1015
|
+
{ name: "kind", field: "kind", type: "string" },
|
|
1016
|
+
],
|
|
1017
|
+
},
|
|
1018
|
+
async run(value, ctx) {
|
|
1019
|
+
return {
|
|
1020
|
+
ok: true,
|
|
1021
|
+
text: (await import("./introspect.ts")).describeProject(await loadAppFor(ctx), {
|
|
1022
|
+
...(value.path === undefined ? {} : { path: value.path }),
|
|
1023
|
+
...(value.kind === undefined ? {} : { kind: value.kind }),
|
|
1024
|
+
}),
|
|
1025
|
+
}
|
|
1026
|
+
},
|
|
1027
|
+
render: (out) => [out.text],
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const doctorSpec: CommandSpec<DoctorInput, DoctorResult> = {
|
|
1031
|
+
name: "doctor",
|
|
1032
|
+
summary: "Find undeclared imports, duplicate identity installs, and pipeline readiness drift.",
|
|
1033
|
+
input: DOCTOR_SCHEMA,
|
|
1034
|
+
output: output({
|
|
1035
|
+
type: "object",
|
|
1036
|
+
properties: { ok: { type: "boolean" }, findings: { type: "array" } },
|
|
1037
|
+
required: ["ok"],
|
|
1038
|
+
}),
|
|
1039
|
+
transports: ["cli", "mcp"],
|
|
1040
|
+
stability: "stable",
|
|
1041
|
+
argv: {
|
|
1042
|
+
flags: [
|
|
1043
|
+
{ name: "json", field: "json", type: "boolean" },
|
|
1044
|
+
{ name: "auto-fix", field: "autoFix", type: "boolean" },
|
|
1045
|
+
{ name: "fix", field: "autoFix", type: "boolean" },
|
|
1046
|
+
{ name: "strict", field: "strict", type: "boolean" },
|
|
1047
|
+
{ name: "target", field: "target", type: "string" },
|
|
1048
|
+
],
|
|
1049
|
+
},
|
|
1050
|
+
async run(value, ctx) {
|
|
1051
|
+
const mod = await import("./doctor.ts")
|
|
1052
|
+
const options = {
|
|
1053
|
+
...(value.target === undefined ? {} : { target: value.target }),
|
|
1054
|
+
...(value.strict === undefined ? {} : { strict: value.strict }),
|
|
1055
|
+
...(ctx.cliVersion === undefined ? {} : { cliVersion: ctx.cliVersion }),
|
|
1056
|
+
}
|
|
1057
|
+
return value.autoFix === true
|
|
1058
|
+
? mod.applyDoctorAutoFix(ctx.cwd, options)
|
|
1059
|
+
: mod.collectDoctorResult(ctx.cwd, options)
|
|
1060
|
+
},
|
|
1061
|
+
render: (out) => [
|
|
1062
|
+
out.ok ? "✓ doctor passed" : "✖ doctor found issues",
|
|
1063
|
+
...out.findings.map((f) => ` ${f.file}:${f.line} ${f.package}`),
|
|
1064
|
+
],
|
|
1065
|
+
success: (out) => out.ok,
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const fixSpec: CommandSpec<FixInput, FixCommandOutput> = {
|
|
1069
|
+
name: "fix",
|
|
1070
|
+
summary: "Apply registered mechanical diagnostic recipes and return remaining findings.",
|
|
1071
|
+
input: FIX_SCHEMA,
|
|
1072
|
+
output: output({
|
|
1073
|
+
type: "object",
|
|
1074
|
+
properties: {
|
|
1075
|
+
ok: { type: "boolean" },
|
|
1076
|
+
changed: { type: "array" },
|
|
1077
|
+
failed: { type: "array" },
|
|
1078
|
+
diagnostics: { type: "array" },
|
|
1079
|
+
},
|
|
1080
|
+
required: ["ok", "diagnostics"],
|
|
1081
|
+
}),
|
|
1082
|
+
transports: ["cli", "mcp"],
|
|
1083
|
+
stability: "stable",
|
|
1084
|
+
argv: {
|
|
1085
|
+
flags: [
|
|
1086
|
+
{ name: "code", field: "code", type: "string" },
|
|
1087
|
+
{ name: "json", field: "json", type: "boolean" },
|
|
1088
|
+
],
|
|
1089
|
+
},
|
|
1090
|
+
async run(value, ctx) {
|
|
1091
|
+
const { collectCheckResult } = await import("./check.ts")
|
|
1092
|
+
const { applyDiagnosticRecipe } = await import("./fix-recipes.ts")
|
|
1093
|
+
const result = await collectCheckResult(ctx.cwd, {
|
|
1094
|
+
lintsOnly: true,
|
|
1095
|
+
...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
|
|
1096
|
+
})
|
|
1097
|
+
const changed: string[] = []
|
|
1098
|
+
const failed: { code: string; reason: string }[] = []
|
|
1099
|
+
for (const diagnostic of result.structuredDiagnostics ?? [])
|
|
1100
|
+
if (value.code === undefined || diagnostic.code === value.code)
|
|
1101
|
+
// One recipe that cannot act must not cancel the others, and must not pass silently either:
|
|
1102
|
+
// record why and keep going, so a run over many diagnostics still reports every outcome.
|
|
1103
|
+
try {
|
|
1104
|
+
changed.push(...(await applyDiagnosticRecipe(ctx.cwd, diagnostic)))
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
failed.push({
|
|
1107
|
+
code: diagnostic.code,
|
|
1108
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
1109
|
+
})
|
|
1110
|
+
}
|
|
1111
|
+
const final = await collectCheckResult(ctx.cwd, {
|
|
1112
|
+
lintsOnly: true,
|
|
1113
|
+
...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
|
|
1114
|
+
})
|
|
1115
|
+
return {
|
|
1116
|
+
ok: final.ok,
|
|
1117
|
+
changed,
|
|
1118
|
+
failed,
|
|
1119
|
+
diagnostics: final.structuredDiagnostics ?? final.diagnostics,
|
|
1120
|
+
}
|
|
1121
|
+
},
|
|
1122
|
+
render: (out) => [
|
|
1123
|
+
out.ok ? "✓ fixes applied" : "✖ unresolved diagnostics remain",
|
|
1124
|
+
...out.changed.map((file) => ` changed ${file}`),
|
|
1125
|
+
...out.failed.map((f) => ` could not fix ${f.code}: ${f.reason}`),
|
|
1126
|
+
...out.diagnostics.map((d) => ` ${String((d as { code?: unknown }).code ?? "diagnostic")}`),
|
|
1127
|
+
],
|
|
1128
|
+
success: (out) => out.ok,
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const snapshotSpec: CommandSpec<SnapshotInput, SnapshotCommandOutput> = {
|
|
1132
|
+
name: "snapshot",
|
|
1133
|
+
summary: "Write the backend API contract as a versioned JSON baseline.",
|
|
1134
|
+
input: SNAPSHOT_SCHEMA,
|
|
1135
|
+
output: output({
|
|
1136
|
+
type: "object",
|
|
1137
|
+
properties: {
|
|
1138
|
+
ok: { type: "boolean" },
|
|
1139
|
+
file: { type: "string" },
|
|
1140
|
+
routes: { type: "integer" },
|
|
1141
|
+
snapshot: { type: "object" },
|
|
1142
|
+
},
|
|
1143
|
+
required: ["ok", "file", "routes"],
|
|
1144
|
+
}),
|
|
1145
|
+
transports: ["cli", "mcp"],
|
|
1146
|
+
stability: "stable",
|
|
1147
|
+
argv: {
|
|
1148
|
+
flags: [
|
|
1149
|
+
{ name: "out", field: "out", type: "string" },
|
|
1150
|
+
{ name: "json", field: "json", type: "boolean" },
|
|
1151
|
+
],
|
|
1152
|
+
},
|
|
1153
|
+
async run(value, ctx) {
|
|
1154
|
+
const mod = await import("./diff-tool.ts")
|
|
1155
|
+
const routes = await mod.snapshotBackend(ctx.cwd)
|
|
1156
|
+
const file = value.out ?? mod.DEFAULT_SNAPSHOT_FILE
|
|
1157
|
+
const snapshot = { nifraSnapshot: 1 as const, routes }
|
|
1158
|
+
await Bun.write(resolve(ctx.cwd, file), `${JSON.stringify(snapshot, null, 2)}\n`)
|
|
1159
|
+
return { ok: true, file, routes: routes.length, snapshot }
|
|
1160
|
+
},
|
|
1161
|
+
render: (out) => [
|
|
1162
|
+
`[nifra] wrote ${out.routes} route${out.routes === 1 ? "" : "s"} to ${out.file}`,
|
|
1163
|
+
],
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const diffSpec: CommandSpec<DiffInput, DiffCommandOutput> = {
|
|
1167
|
+
name: "diff",
|
|
1168
|
+
summary: "Compare the current backend contract with a baseline and fail on breaking changes.",
|
|
1169
|
+
input: DIFF_SCHEMA,
|
|
1170
|
+
output: output({
|
|
1171
|
+
type: "object",
|
|
1172
|
+
properties: {
|
|
1173
|
+
ok: { type: "boolean" },
|
|
1174
|
+
hasBreaking: { type: "boolean" },
|
|
1175
|
+
changes: { type: "array" },
|
|
1176
|
+
},
|
|
1177
|
+
required: ["ok", "hasBreaking", "changes"],
|
|
1178
|
+
}),
|
|
1179
|
+
transports: ["cli", "mcp"],
|
|
1180
|
+
stability: "stable",
|
|
1181
|
+
argv: { positionals: ["baseline"], flags: [{ name: "json", field: "json", type: "boolean" }] },
|
|
1182
|
+
async run(value, ctx) {
|
|
1183
|
+
const mod = await import("./diff-tool.ts")
|
|
1184
|
+
const baseline = value.baseline ?? mod.DEFAULT_SNAPSHOT_FILE
|
|
1185
|
+
const file = await Bun.file(resolve(ctx.cwd, baseline)).text()
|
|
1186
|
+
const before = mod.parseSnapshotFile(file, baseline)
|
|
1187
|
+
const current = await mod.snapshotBackend(ctx.cwd)
|
|
1188
|
+
const { diffRouteSnapshots } = await import("@nifrajs/core/diff")
|
|
1189
|
+
const diff = diffRouteSnapshots(before.routes, current)
|
|
1190
|
+
return { ok: !diff.hasBreaking, hasBreaking: diff.hasBreaking, changes: diff.changes }
|
|
1191
|
+
},
|
|
1192
|
+
render: (out) => [
|
|
1193
|
+
out.ok ? "✓ no breaking changes" : "✖ breaking changes",
|
|
1194
|
+
...(out.changes as readonly { method: string; path: string; message: string }[]).map(
|
|
1195
|
+
(c) => `${c.method} ${c.path}: ${c.message}`,
|
|
1196
|
+
),
|
|
1197
|
+
],
|
|
1198
|
+
success: (out) => out.ok,
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
const contractsSpec: CommandSpec<ContractsInput, ContractsCommandOutput> = {
|
|
1202
|
+
name: "contracts",
|
|
1203
|
+
summary: "Snapshot or check the deterministic route contract lock.",
|
|
1204
|
+
input: CONTRACTS_SCHEMA,
|
|
1205
|
+
output: output({ type: "object" }),
|
|
1206
|
+
transports: ["cli", "mcp"],
|
|
1207
|
+
stability: "stable",
|
|
1208
|
+
argv: {
|
|
1209
|
+
positionals: ["action"],
|
|
1210
|
+
flags: [
|
|
1211
|
+
{ name: "out", field: "out", type: "string" },
|
|
1212
|
+
{ name: "json", field: "json", type: "boolean" },
|
|
1213
|
+
],
|
|
1214
|
+
},
|
|
1215
|
+
async run(value, ctx) {
|
|
1216
|
+
const mod = await import("./contracts.ts")
|
|
1217
|
+
if (value.action === "snapshot")
|
|
1218
|
+
return { ok: true, lock: await mod.snapshotContracts(ctx.cwd, value.out) }
|
|
1219
|
+
const result = await mod.checkContractsLock(ctx.cwd)
|
|
1220
|
+
return { ok: result.present && result.diagnostics.length === 0, ...result }
|
|
1221
|
+
},
|
|
1222
|
+
render: (out) => [
|
|
1223
|
+
out.ok ? "✓ contracts are current" : "✖ contract drift",
|
|
1224
|
+
...(out.diagnostics ?? []).map((d) => ` ${(d as { message: string }).message}`),
|
|
1225
|
+
],
|
|
1226
|
+
success: (out) => out.ok,
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const syncManifestSpec: CommandSpec<SyncInput, SyncCommandOutput> = {
|
|
1230
|
+
name: "sync-manifest",
|
|
1231
|
+
summary: "Regenerate generated server-manifest route tables without a full build.",
|
|
1232
|
+
input: SYNC_SCHEMA,
|
|
1233
|
+
output: output({
|
|
1234
|
+
type: "object",
|
|
1235
|
+
properties: { ok: { type: "boolean" }, results: { type: "array" } },
|
|
1236
|
+
required: ["ok", "results"],
|
|
1237
|
+
}),
|
|
1238
|
+
transports: ["cli", "mcp"],
|
|
1239
|
+
stability: "stable",
|
|
1240
|
+
argv: { flags: [{ name: "json", field: "json", type: "boolean" }] },
|
|
1241
|
+
async run(_value, ctx) {
|
|
1242
|
+
return {
|
|
1243
|
+
ok: true,
|
|
1244
|
+
results: await (await import("./sync-manifest.ts")).syncServerManifests(ctx.cwd),
|
|
1245
|
+
}
|
|
1246
|
+
},
|
|
1247
|
+
render: (out) => [
|
|
1248
|
+
out.results.length === 0
|
|
1249
|
+
? "nifra sync-manifest: no generated server-manifest.ts found under this directory."
|
|
1250
|
+
: `✓ checked ${out.results.length} generated server manifest${out.results.length === 1 ? "" : "s"}`,
|
|
1251
|
+
],
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
const syncRoutesSpec: CommandSpec<SyncInput, SyncCommandOutput> = {
|
|
1255
|
+
name: "sync-routes",
|
|
1256
|
+
summary: "Regenerate nifra-routes.d.ts so typed navigation follows route search schemas.",
|
|
1257
|
+
input: SYNC_SCHEMA,
|
|
1258
|
+
output: output({
|
|
1259
|
+
type: "object",
|
|
1260
|
+
properties: { ok: { type: "boolean" }, results: { type: "array" } },
|
|
1261
|
+
required: ["ok", "results"],
|
|
1262
|
+
}),
|
|
1263
|
+
transports: ["cli", "mcp"],
|
|
1264
|
+
stability: "stable",
|
|
1265
|
+
argv: { flags: [{ name: "json", field: "json", type: "boolean" }] },
|
|
1266
|
+
async run(_value, ctx) {
|
|
1267
|
+
const result = await (await import("./sync-routes.ts")).syncRouteTypes(ctx.cwd)
|
|
1268
|
+
return { ok: true, results: result === null ? [] : [result] }
|
|
1269
|
+
},
|
|
1270
|
+
render: (out) => [
|
|
1271
|
+
out.results.length === 0
|
|
1272
|
+
? "nifra sync-routes: no routes/ directory found. Run from your project root."
|
|
1273
|
+
: `✓ checked ${out.results.length} route type output`,
|
|
1274
|
+
],
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const proveSpec: CommandSpec<ProveInput, ProjectWorkGraphResult> = {
|
|
1278
|
+
name: "prove",
|
|
1279
|
+
summary:
|
|
1280
|
+
"Build the static verification work graph, plan the cheapest proofs for the changed files, and report a machine-checkable stop condition.",
|
|
1281
|
+
input: PROVE_SCHEMA,
|
|
1282
|
+
output: output({ type: "object" }),
|
|
1283
|
+
transports: ["cli", "mcp"],
|
|
1284
|
+
stability: "stable",
|
|
1285
|
+
argv: {
|
|
1286
|
+
flags: [
|
|
1287
|
+
{ name: "file", field: "files", type: "string[]" },
|
|
1288
|
+
{ name: "min", field: "minLevel", type: "number" },
|
|
1289
|
+
{ name: "json", field: "json", type: "boolean" },
|
|
1290
|
+
],
|
|
1291
|
+
},
|
|
1292
|
+
async run(value, ctx) {
|
|
1293
|
+
return collectProjectWorkGraph(ctx.cwd, {
|
|
1294
|
+
...(value.files === undefined ? {} : { changedFiles: value.files }),
|
|
1295
|
+
...(value.minLevel === undefined ? {} : { minLevel: value.minLevel }),
|
|
1296
|
+
})
|
|
1297
|
+
},
|
|
1298
|
+
render: (out) => [renderWorkGraphText(out)],
|
|
1299
|
+
success: (out) => out.evidence.stop.done,
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const replaySpec: CommandSpec<ReplayInput, ReplayResult> = {
|
|
1303
|
+
name: "replay",
|
|
1304
|
+
summary: "Validate a token-only verification metadata file and dispatch it against its gate.",
|
|
1305
|
+
input: REPLAY_SCHEMA,
|
|
1306
|
+
output: output({ type: "object" }),
|
|
1307
|
+
transports: ["cli", "mcp"],
|
|
1308
|
+
stability: "stable",
|
|
1309
|
+
argv: { positionals: ["file"] },
|
|
1310
|
+
async run(value, ctx) {
|
|
1311
|
+
const { runReplay } = await import("./replay.ts")
|
|
1312
|
+
return runReplay(ctx.cwd, value.file)
|
|
1313
|
+
},
|
|
1314
|
+
render: (out) => [JSON.stringify(out, null, 2)],
|
|
1315
|
+
success: (out) => out.ok,
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
const portSpec: CommandSpec<PortInput, PortResult> = {
|
|
1319
|
+
name: "port",
|
|
1320
|
+
summary:
|
|
1321
|
+
"Print a feature by deploy-target portability matrix with file:line evidence and gate against an unsupported target.",
|
|
1322
|
+
input: PORT_SCHEMA,
|
|
1323
|
+
output: output({ type: "object" }),
|
|
1324
|
+
transports: ["cli", "mcp"],
|
|
1325
|
+
stability: "stable",
|
|
1326
|
+
argv: {
|
|
1327
|
+
flags: [
|
|
1328
|
+
{ name: "target", field: "target", type: "string" },
|
|
1329
|
+
{ name: "json", field: "json", type: "boolean" },
|
|
1330
|
+
{ name: "ci", field: "ci", type: "boolean" },
|
|
1331
|
+
{ name: "strict", field: "strict", type: "boolean" },
|
|
1332
|
+
],
|
|
1333
|
+
},
|
|
1334
|
+
async run(value, ctx) {
|
|
1335
|
+
return collectPortResult(ctx.cwd, {
|
|
1336
|
+
...(value.target === undefined ? {} : { target: value.target }),
|
|
1337
|
+
...(value.strict === true ? { strict: true } : {}),
|
|
1338
|
+
})
|
|
1339
|
+
},
|
|
1340
|
+
render: (out, input) => {
|
|
1341
|
+
const report = renderReport(out, { strict: input?.strict === true })
|
|
1342
|
+
const gating = input?.ci === true || input?.target !== undefined
|
|
1343
|
+
if (gating && out.resolved === undefined)
|
|
1344
|
+
return [
|
|
1345
|
+
report,
|
|
1346
|
+
"",
|
|
1347
|
+
"[nifra] --ci needs a deploy target to gate against, and none was detected. Pass --target <bun|node|deno|cf-pages|vercel>.",
|
|
1348
|
+
]
|
|
1349
|
+
return [report]
|
|
1350
|
+
},
|
|
1351
|
+
success: (out, input) => {
|
|
1352
|
+
const gating = input.ci === true || input.target !== undefined
|
|
1353
|
+
if (!gating) return true
|
|
1354
|
+
if (out.resolved === undefined) return false
|
|
1355
|
+
return out.json.blocked.length === 0
|
|
1356
|
+
},
|
|
1357
|
+
json: (out) => out.json,
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
export const commandSpecs = Object.freeze([
|
|
1361
|
+
checkSpec,
|
|
1362
|
+
assureSpec,
|
|
1363
|
+
levelsSpec,
|
|
1364
|
+
capabilitiesSpec,
|
|
1365
|
+
manifestSpec,
|
|
1366
|
+
routesSpec,
|
|
1367
|
+
contextSpec,
|
|
1368
|
+
doctorSpec,
|
|
1369
|
+
fixSpec,
|
|
1370
|
+
snapshotSpec,
|
|
1371
|
+
diffSpec,
|
|
1372
|
+
contractsSpec,
|
|
1373
|
+
syncManifestSpec,
|
|
1374
|
+
syncRoutesSpec,
|
|
1375
|
+
proveSpec,
|
|
1376
|
+
replaySpec,
|
|
1377
|
+
portSpec,
|
|
1378
|
+
] as const)
|
|
1379
|
+
|
|
1380
|
+
const commandByName = new Map(commandSpecs.map((spec) => [spec.name, spec]))
|
|
1381
|
+
|
|
1382
|
+
export function findCommandSpec(name: string): CommandSpec<unknown, unknown> | undefined {
|
|
1383
|
+
return commandByName.get(name) as CommandSpec<unknown, unknown> | undefined
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
export const commandCatalog = Object.freeze(
|
|
1387
|
+
commandSpecs.map((spec) => toCommandCatalogEntry(spec as CommandSpec<unknown, unknown>)),
|
|
1388
|
+
)
|
|
1389
|
+
|
|
1390
|
+
export function commandMcpName(name: string): string {
|
|
1391
|
+
return `nifra_${name.replaceAll("-", "_")}`
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
export function commandUsage<Input, Output>(spec: CommandSpec<Input, Output>): string {
|
|
1395
|
+
const positionals = (spec.argv?.positionals ?? []).map((field) => `<${field}>`).join(" ")
|
|
1396
|
+
const flags = (spec.argv?.flags ?? [])
|
|
1397
|
+
.filter((flag, index, all) => all.findIndex((x) => x.field === flag.field) === index)
|
|
1398
|
+
.map((flag) => (flag.type === "boolean" ? `[--${flag.name}]` : `[--${flag.name} <value>]`))
|
|
1399
|
+
.join(" ")
|
|
1400
|
+
return [positionals, flags].filter(Boolean).join(" ")
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/** Stable one-line help/card projection; command-specific prose lives only in the catalog. */
|
|
1404
|
+
export function renderCommandCatalogLines(prefix = "nifra"): readonly string[] {
|
|
1405
|
+
return commandCatalog.map((entry) => {
|
|
1406
|
+
const spec = findCommandSpec(entry.name) as CommandSpec<unknown, unknown>
|
|
1407
|
+
const usage = commandUsage(spec)
|
|
1408
|
+
return `${prefix} ${entry.name}${usage === "" ? "" : ` ${usage}`} - ${entry.summary}`
|
|
1409
|
+
})
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
export function renderCommandCatalogHelp(): string {
|
|
1413
|
+
return ["", "Stable project command catalog:", ...renderCommandCatalogLines(" nifra")].join("\n")
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
export function parseCommandOutput<Input, Output>(
|
|
1417
|
+
spec: CommandSpec<Input, Output>,
|
|
1418
|
+
value: unknown,
|
|
1419
|
+
): Output {
|
|
1420
|
+
const raw = record(value)
|
|
1421
|
+
if (raw.nifraCommand !== undefined && raw.version !== undefined && raw.result !== undefined) {
|
|
1422
|
+
if (raw.version !== spec.output.version)
|
|
1423
|
+
throw new Error(`unsupported ${spec.name} output version: ${String(raw.version)}`)
|
|
1424
|
+
return spec.output.parse(raw.result)
|
|
1425
|
+
}
|
|
1426
|
+
return spec.output.parse(value)
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/** Serialize a result as an envelope without forcing existing raw --json consumers to migrate. */
|
|
1430
|
+
export function envelopeCommandOutput<Input, Output>(
|
|
1431
|
+
spec: CommandSpec<Input, Output>,
|
|
1432
|
+
value: Output,
|
|
1433
|
+
): { readonly nifraCommand: string; readonly version: number; readonly result: Output } {
|
|
1434
|
+
return { nifraCommand: spec.name, version: spec.output.version, result: value }
|
|
1435
|
+
}
|