@nifrajs/cli 2.13.0 → 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.
Files changed (69) hide show
  1. package/dist/capabilities-tool.d.ts +54 -1
  2. package/dist/capabilities-tool.d.ts.map +1 -1
  3. package/dist/capabilities-tool.js +146 -18
  4. package/dist/capabilities-tool.js.map +1 -1
  5. package/dist/check.d.ts +11 -11
  6. package/dist/check.d.ts.map +1 -1
  7. package/dist/check.js +234 -43
  8. package/dist/check.js.map +1 -1
  9. package/dist/cli.d.ts +11 -0
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +91 -413
  12. package/dist/cli.js.map +1 -1
  13. package/dist/command-catalog.d.ts +244 -0
  14. package/dist/command-catalog.d.ts.map +1 -0
  15. package/dist/command-catalog.js +1014 -0
  16. package/dist/command-catalog.js.map +1 -0
  17. package/dist/contracts.d.ts +9 -0
  18. package/dist/contracts.d.ts.map +1 -1
  19. package/dist/contracts.js +22 -6
  20. package/dist/contracts.js.map +1 -1
  21. package/dist/doctor.d.ts +13 -0
  22. package/dist/doctor.d.ts.map +1 -1
  23. package/dist/doctor.js +18 -2
  24. package/dist/doctor.js.map +1 -1
  25. package/dist/load.d.ts.map +1 -1
  26. package/dist/load.js +22 -5
  27. package/dist/load.js.map +1 -1
  28. package/dist/manifest-tool.d.ts +15 -1
  29. package/dist/manifest-tool.d.ts.map +1 -1
  30. package/dist/manifest-tool.js +23 -9
  31. package/dist/manifest-tool.js.map +1 -1
  32. package/dist/mcp-http.js +1 -1
  33. package/dist/mcp.d.ts +8 -0
  34. package/dist/mcp.d.ts.map +1 -1
  35. package/dist/mcp.js +59 -349
  36. package/dist/mcp.js.map +1 -1
  37. package/dist/project-facts.d.ts +40 -0
  38. package/dist/project-facts.d.ts.map +1 -0
  39. package/dist/project-facts.js +36 -0
  40. package/dist/project-facts.js.map +1 -0
  41. package/dist/rules/comment-markers.d.ts +8 -0
  42. package/dist/rules/comment-markers.d.ts.map +1 -1
  43. package/dist/rules/comment-markers.js +36 -0
  44. package/dist/rules/comment-markers.js.map +1 -1
  45. package/dist/rules/index.d.ts +2 -3
  46. package/dist/rules/index.d.ts.map +1 -1
  47. package/dist/rules/index.js.map +1 -1
  48. package/dist/rules/routes.d.ts.map +1 -1
  49. package/dist/rules/routes.js +12 -14
  50. package/dist/rules/routes.js.map +1 -1
  51. package/dist/rules/security.js +14 -14
  52. package/dist/rules/security.js.map +1 -1
  53. package/docs/types.json +27 -14
  54. package/package.json +9 -9
  55. package/src/capabilities-tool.ts +229 -21
  56. package/src/check.ts +285 -59
  57. package/src/cli.ts +100 -404
  58. package/src/command-catalog.ts +1415 -0
  59. package/src/contracts.ts +28 -7
  60. package/src/doctor.ts +35 -4
  61. package/src/load.ts +25 -6
  62. package/src/manifest-tool.ts +43 -12
  63. package/src/mcp-http.ts +1 -1
  64. package/src/mcp.ts +82 -414
  65. package/src/project-facts.ts +86 -0
  66. package/src/rules/comment-markers.ts +36 -0
  67. package/src/rules/index.ts +3 -4
  68. package/src/rules/routes.ts +13 -14
  69. package/src/rules/security.ts +14 -14
@@ -0,0 +1,1415 @@
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
+ readonly diagnostics: readonly unknown[]
380
+ }
381
+ interface SnapshotCommandOutput {
382
+ readonly ok: true
383
+ readonly file: string
384
+ readonly routes: number
385
+ readonly snapshot: unknown
386
+ }
387
+ interface DiffCommandOutput {
388
+ readonly ok: boolean
389
+ readonly hasBreaking: boolean
390
+ readonly changes: readonly unknown[]
391
+ }
392
+ interface ContractsCommandOutput {
393
+ readonly ok: boolean
394
+ readonly lock?: ContractsLock
395
+ readonly diagnostics?: readonly unknown[]
396
+ readonly present?: boolean
397
+ readonly vacuous?: boolean
398
+ }
399
+ interface SyncCommandOutput {
400
+ readonly ok: true
401
+ readonly results: readonly unknown[]
402
+ }
403
+
404
+ const CHECK_SCHEMA = input<CheckInput>(
405
+ objectSchema({
406
+ lintsOnly: { type: "boolean" },
407
+ json: { type: "boolean" },
408
+ maxDiagnostics: { type: "integer", minimum: 1 },
409
+ dir: { type: "string" },
410
+ }),
411
+ (value) => {
412
+ const raw = withDir(record(value))
413
+ const maxDiagnostics = optionalNumber(raw.maxDiagnostics, "maxDiagnostics")
414
+ if (maxDiagnostics !== undefined && (!Number.isInteger(maxDiagnostics) || maxDiagnostics < 1))
415
+ throw new TypeError("maxDiagnostics must be a positive integer")
416
+ return {
417
+ ...parseBooleanFlags(raw, ["lintsOnly", "json"]),
418
+ ...(maxDiagnostics === undefined ? {} : { maxDiagnostics }),
419
+ ...(raw.dir === undefined ? {} : { dir: raw.dir }),
420
+ } as CheckInput
421
+ },
422
+ )
423
+
424
+ const ASSURE_SCHEMA = input<AssureInput>(
425
+ objectSchema({
426
+ config: { type: "string" },
427
+ bundle: { type: "boolean" },
428
+ strict: { type: "boolean" },
429
+ hydration: { type: "boolean" },
430
+ interact: { type: "boolean" },
431
+ out: { type: "string" },
432
+ json: { type: "boolean" },
433
+ dir: { type: "string" },
434
+ }),
435
+ (value) => {
436
+ const raw = withDir(record(value))
437
+ return {
438
+ ...parseBooleanFlags(raw, ["bundle", "strict", "hydration", "interact", "json"]),
439
+ config: optionalString(raw.config, "config"),
440
+ out: optionalString(raw.out, "out"),
441
+ ...(raw.dir === undefined ? {} : { dir: raw.dir }),
442
+ }
443
+ },
444
+ )
445
+
446
+ const LEVELS_SCHEMA = input<LevelsInput>(
447
+ objectSchema({
448
+ config: { type: "string" },
449
+ seed: { type: "number" },
450
+ min: { type: "integer", minimum: 0, maximum: 4 },
451
+ json: { type: "boolean" },
452
+ dir: { type: "string" },
453
+ }),
454
+ (value) => {
455
+ const raw = withDir(record(value))
456
+ const seed = optionalNumber(raw.seed, "seed")
457
+ const min = optionalNumber(raw.min, "min")
458
+ if (seed !== undefined && !Number.isSafeInteger(seed))
459
+ throw new TypeError("seed must be a safe integer")
460
+ if (min !== undefined && (!Number.isInteger(min) || min < 0 || min > 4))
461
+ throw new TypeError("min must be an integer from 0 to 4")
462
+ return {
463
+ ...parseBooleanFlags(raw, ["json"]),
464
+ config: optionalString(raw.config, "config"),
465
+ ...(seed === undefined ? {} : { seed }),
466
+ ...(min === undefined ? {} : { min }),
467
+ ...(raw.dir === undefined ? {} : { dir: raw.dir }),
468
+ }
469
+ },
470
+ )
471
+
472
+ const CAPABILITIES_SCHEMA = input<CapabilitiesInput>(
473
+ objectSchema(
474
+ {
475
+ action: { type: "string", enum: ["snapshot", "check", "explain"] },
476
+ config: { type: "string" },
477
+ out: { type: "string" },
478
+ lockfile: { type: "string" },
479
+ method: { type: "string" },
480
+ path: { type: "string" },
481
+ json: { type: "boolean" },
482
+ dir: { type: "string" },
483
+ },
484
+ ["action"],
485
+ ),
486
+ (value) => {
487
+ const raw = withDir(record(value))
488
+ if (raw.action !== "snapshot" && raw.action !== "check" && raw.action !== "explain")
489
+ throw new TypeError("action must be snapshot, check, or explain")
490
+ return {
491
+ action: raw.action,
492
+ config: optionalString(raw.config, "config"),
493
+ out: optionalString(raw.out, "out"),
494
+ lockfile: optionalString(raw.lockfile, "lockfile"),
495
+ method: optionalString(raw.method, "method"),
496
+ path: optionalString(raw.path, "path"),
497
+ ...parseBooleanFlags(raw, ["json"]),
498
+ ...(raw.dir === undefined ? {} : { dir: raw.dir }),
499
+ }
500
+ },
501
+ )
502
+
503
+ const MANIFEST_SCHEMA = input<ManifestInput>(
504
+ objectSchema(
505
+ {
506
+ action: { type: "string", enum: ["emit", "diff"] },
507
+ config: { type: "string" },
508
+ out: { type: "string" },
509
+ sign: { type: "string" },
510
+ before: { type: "string" },
511
+ after: { type: "string" },
512
+ json: { type: "boolean" },
513
+ dir: { type: "string" },
514
+ },
515
+ ["action"],
516
+ ),
517
+ (value) => {
518
+ const raw = withDir(record(value))
519
+ if (raw.action !== "emit" && raw.action !== "diff")
520
+ throw new TypeError("action must be emit or diff")
521
+ return {
522
+ action: raw.action,
523
+ config: optionalString(raw.config, "config"),
524
+ out: optionalString(raw.out, "out"),
525
+ sign: optionalString(raw.sign, "sign"),
526
+ before: optionalString(raw.before, "before"),
527
+ after: optionalString(raw.after, "after"),
528
+ ...parseBooleanFlags(raw, ["json"]),
529
+ ...(raw.dir === undefined ? {} : { dir: raw.dir }),
530
+ }
531
+ },
532
+ )
533
+
534
+ const ROUTES_SCHEMA = input<RoutesInput>(
535
+ objectSchema({
536
+ json: { type: "boolean" },
537
+ graph: { type: "boolean" },
538
+ modes: { type: "boolean" },
539
+ target: { type: "string" },
540
+ dir: { type: "string" },
541
+ }),
542
+ (value) => {
543
+ const raw = withDir(record(value))
544
+ if (raw.graph === true && raw.modes === true)
545
+ throw new TypeError("graph and modes cannot be combined")
546
+ return {
547
+ ...parseBooleanFlags(raw, ["json", "graph", "modes"]),
548
+ target: optionalString(raw.target, "target"),
549
+ ...(raw.dir === undefined ? {} : { dir: raw.dir }),
550
+ }
551
+ },
552
+ )
553
+
554
+ const CONTEXT_SCHEMA = input<ContextInput>(
555
+ objectSchema({
556
+ path: { type: "string" },
557
+ kind: { type: "string", enum: ["api", "pages"] },
558
+ dir: { type: "string" },
559
+ }),
560
+ (value) => {
561
+ const raw = withDir(record(value))
562
+ if (raw.kind !== undefined && raw.kind !== "api" && raw.kind !== "pages")
563
+ throw new TypeError("kind must be api or pages")
564
+ return {
565
+ path: optionalString(raw.path, "path"),
566
+ kind: raw.kind as "api" | "pages" | undefined,
567
+ ...(raw.dir === undefined ? {} : { dir: raw.dir }),
568
+ }
569
+ },
570
+ )
571
+
572
+ const DOCTOR_SCHEMA = input<DoctorInput>(
573
+ objectSchema({
574
+ json: { type: "boolean" },
575
+ autoFix: { type: "boolean" },
576
+ strict: { type: "boolean" },
577
+ target: { type: "string" },
578
+ dir: { type: "string" },
579
+ }),
580
+ (value) => {
581
+ const raw = withDir(record(value))
582
+ return {
583
+ ...parseBooleanFlags(raw, ["json", "autoFix", "strict"]),
584
+ target: optionalString(raw.target, "target"),
585
+ ...(raw.dir === undefined ? {} : { dir: raw.dir }),
586
+ }
587
+ },
588
+ )
589
+
590
+ const FIX_SCHEMA = input<FixInput>(
591
+ objectSchema({ code: { type: "string" }, json: { type: "boolean" }, dir: { type: "string" } }),
592
+ (value) => {
593
+ const raw = withDir(record(value))
594
+ return {
595
+ code: optionalString(raw.code, "code"),
596
+ ...parseBooleanFlags(raw, ["json"]),
597
+ ...(raw.dir === undefined ? {} : { dir: raw.dir }),
598
+ }
599
+ },
600
+ )
601
+
602
+ const SNAPSHOT_SCHEMA = input<SnapshotInput>(
603
+ objectSchema({ out: { type: "string" }, json: { type: "boolean" }, dir: { type: "string" } }),
604
+ (value) => {
605
+ const raw = withDir(record(value))
606
+ return {
607
+ out: optionalString(raw.out, "out"),
608
+ ...parseBooleanFlags(raw, ["json"]),
609
+ ...(raw.dir === undefined ? {} : { dir: raw.dir }),
610
+ }
611
+ },
612
+ )
613
+
614
+ const DIFF_SCHEMA = input<DiffInput>(
615
+ objectSchema({
616
+ baseline: { type: "string" },
617
+ json: { type: "boolean" },
618
+ dir: { type: "string" },
619
+ }),
620
+ (value) => {
621
+ const raw = withDir(record(value))
622
+ return {
623
+ baseline: optionalString(raw.baseline, "baseline"),
624
+ ...parseBooleanFlags(raw, ["json"]),
625
+ ...(raw.dir === undefined ? {} : { dir: raw.dir }),
626
+ }
627
+ },
628
+ )
629
+
630
+ const CONTRACTS_SCHEMA = input<ContractsInput>(
631
+ objectSchema(
632
+ {
633
+ action: { type: "string", enum: ["snapshot", "check"] },
634
+ out: { type: "string" },
635
+ json: { type: "boolean" },
636
+ dir: { type: "string" },
637
+ },
638
+ ["action"],
639
+ ),
640
+ (value) => {
641
+ const raw = withDir(record(value))
642
+ if (raw.action !== "snapshot" && raw.action !== "check")
643
+ throw new TypeError("action must be snapshot or check")
644
+ return {
645
+ action: raw.action,
646
+ out: optionalString(raw.out, "out"),
647
+ ...parseBooleanFlags(raw, ["json"]),
648
+ ...(raw.dir === undefined ? {} : { dir: raw.dir }),
649
+ }
650
+ },
651
+ )
652
+
653
+ const SYNC_SCHEMA = input<SyncInput>(
654
+ objectSchema({ json: { type: "boolean" }, dir: { type: "string" } }),
655
+ (value) => {
656
+ const raw = withDir(record(value))
657
+ return {
658
+ ...parseBooleanFlags(raw, ["json"]),
659
+ ...(raw.dir === undefined ? {} : { dir: raw.dir }),
660
+ }
661
+ },
662
+ )
663
+
664
+ const PROVE_SCHEMA = input<ProveInput>(
665
+ objectSchema({
666
+ files: { type: "array", items: { type: "string" } },
667
+ minLevel: { type: "integer", minimum: 0, maximum: 4 },
668
+ json: { type: "boolean" },
669
+ dir: { type: "string" },
670
+ }),
671
+ (value) => {
672
+ const raw = withDir(record(value))
673
+ let files: readonly string[] | undefined
674
+ if (raw.files !== undefined) {
675
+ if (!Array.isArray(raw.files) || raw.files.some((entry) => typeof entry !== "string"))
676
+ throw new TypeError("files must be an array of strings")
677
+ files = raw.files as readonly string[]
678
+ }
679
+ const minLevel = optionalNumber(raw.minLevel, "minLevel")
680
+ if (minLevel !== undefined && (!Number.isInteger(minLevel) || minLevel < 0 || minLevel > 4))
681
+ throw new TypeError("minLevel must be an integer from 0 to 4")
682
+ return {
683
+ ...(files === undefined ? {} : { files }),
684
+ ...(minLevel === undefined ? {} : { minLevel }),
685
+ ...parseBooleanFlags(raw, ["json"]),
686
+ ...(raw.dir === undefined ? {} : { dir: raw.dir }),
687
+ }
688
+ },
689
+ )
690
+
691
+ const REPLAY_SCHEMA = input<ReplayInput>(
692
+ objectSchema({ file: { type: "string" }, dir: { type: "string" } }, ["file"]),
693
+ (value) => {
694
+ const raw = withDir(record(value))
695
+ const file = optionalString(raw.file, "file")
696
+ if (file === undefined || file.trim() === "") throw new TypeError("file is required")
697
+ return { file, ...(raw.dir === undefined ? {} : { dir: raw.dir }) }
698
+ },
699
+ )
700
+
701
+ const PORT_SCHEMA = input<PortInput>(
702
+ objectSchema({
703
+ target: { type: "string" },
704
+ json: { type: "boolean" },
705
+ ci: { type: "boolean" },
706
+ strict: { type: "boolean" },
707
+ dir: { type: "string" },
708
+ }),
709
+ (value) => {
710
+ const raw = withDir(record(value))
711
+ return {
712
+ target: optionalString(raw.target, "target"),
713
+ ...parseBooleanFlags(raw, ["json", "ci", "strict"]),
714
+ ...(raw.dir === undefined ? {} : { dir: raw.dir }),
715
+ }
716
+ },
717
+ )
718
+
719
+ function loadAppFor(ctx: CommandCtx): Promise<LoadedApp> {
720
+ if (ctx.loadApp !== undefined) return ctx.loadApp()
721
+ return import("./load.ts").then(({ loadApp }) => loadApp(ctx.cwd))
722
+ }
723
+
724
+ function checkJson(out: CheckCommandOutput): unknown {
725
+ return out.structuredDiagnostics === undefined
726
+ ? out
727
+ : { ...out, diagnostics: out.structuredDiagnostics }
728
+ }
729
+
730
+ const checkSpec: CommandSpec<CheckInput, CheckCommandOutput> = {
731
+ name: "check",
732
+ summary: "Run the typed-contract, source-lint, and dependency drift gate.",
733
+ input: CHECK_SCHEMA,
734
+ output: output<CheckCommandOutput>({
735
+ type: "object",
736
+ properties: { ok: { type: "boolean" }, diagnostics: { type: "array" } },
737
+ required: ["ok", "diagnostics"],
738
+ }),
739
+ transports: ["cli", "mcp"],
740
+ stability: "stable",
741
+ argv: {
742
+ flags: [
743
+ { name: "lints-only", field: "lintsOnly", type: "boolean" },
744
+ { name: "json", field: "json", type: "boolean" },
745
+ ],
746
+ },
747
+ async run(value, ctx) {
748
+ const { collectProjectVerification } = await import("./verification.ts")
749
+ return (
750
+ await collectProjectVerification(ctx.cwd, {
751
+ lintsOnly: value.lintsOnly === true,
752
+ ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
753
+ ...(value.maxDiagnostics === undefined ? {} : { maxDiagnostics: value.maxDiagnostics }),
754
+ })
755
+ ).check()
756
+ },
757
+ render: (out) => renderCheckReport(out),
758
+ success: (out) => out.ok,
759
+ json: (out) => checkJson(out),
760
+ }
761
+
762
+ const assureSpec: CommandSpec<AssureInput, AssureCommandOutput> = {
763
+ name: "assure",
764
+ summary: "Evaluate route classification and fail-closed enforcement evidence.",
765
+ input: ASSURE_SCHEMA,
766
+ output: output({ type: "object" }),
767
+ transports: ["cli", "mcp"],
768
+ stability: "stable",
769
+ argv: {
770
+ flags: [
771
+ { name: "config", field: "config", type: "string" },
772
+ { name: "out", field: "out", type: "string" },
773
+ { name: "json", field: "json", type: "boolean" },
774
+ { name: "bundle", field: "bundle", type: "boolean" },
775
+ { name: "strict", field: "strict", type: "boolean" },
776
+ { name: "hydration", field: "hydration", type: "boolean" },
777
+ { name: "interact", field: "interact", type: "boolean" },
778
+ ],
779
+ },
780
+ async run(value, ctx) {
781
+ const { collectAssuranceReport, collectAssureBundle } = await import("./assure.ts")
782
+ const bundle =
783
+ value.bundle === true ||
784
+ value.strict === true ||
785
+ value.hydration === true ||
786
+ value.interact === true
787
+ if (bundle) {
788
+ const result = await collectAssureBundle(ctx.cwd, {
789
+ ...(value.config === undefined ? {} : { config: value.config }),
790
+ ...(value.strict === undefined ? {} : { strict: value.strict }),
791
+ ...(value.hydration === undefined ? {} : { hydration: value.hydration }),
792
+ ...(value.interact === undefined ? {} : { interact: value.interact }),
793
+ })
794
+ if (value.out !== undefined)
795
+ await Bun.write(resolve(ctx.cwd, value.out), `${JSON.stringify(result, null, 2)}\n`)
796
+ return { bundle: result, ok: result.verdict === "green" }
797
+ }
798
+ const report = await collectAssuranceReport(ctx.cwd, value.config)
799
+ return { report, ok: report.ok }
800
+ },
801
+ render: (out) => [
802
+ out.bundle === undefined
803
+ ? formatAssuranceReport(out.report as never)
804
+ : JSON.stringify(out.bundle, null, 2),
805
+ ],
806
+ success: (out) => out.ok,
807
+ json: (out) => out.bundle ?? out.report,
808
+ }
809
+
810
+ const levelsSpec: CommandSpec<LevelsInput, LevelsCommandOutput> = {
811
+ name: "levels",
812
+ summary: "Compute the cumulative L0 typed-contract through L4 invariant verification ladder.",
813
+ input: LEVELS_SCHEMA,
814
+ output: output({
815
+ type: "object",
816
+ properties: { achieved: { type: "integer" }, levels: { type: "array" } },
817
+ required: ["achieved", "levels"],
818
+ }),
819
+ transports: ["cli", "mcp"],
820
+ stability: "stable",
821
+ argv: {
822
+ flags: [
823
+ { name: "config", field: "config", type: "string" },
824
+ { name: "seed", field: "seed", type: "number" },
825
+ { name: "min", field: "min", type: "number" },
826
+ { name: "json", field: "json", type: "boolean" },
827
+ ],
828
+ },
829
+ async run(value, ctx) {
830
+ return import("./levels-tool.ts").then(({ collectVerificationLevels }) =>
831
+ collectVerificationLevels(ctx.cwd, {
832
+ ...(value.config === undefined ? {} : { config: value.config }),
833
+ ...(value.seed === undefined ? {} : { seed: value.seed }),
834
+ }),
835
+ )
836
+ },
837
+ render: (out) => [
838
+ ...out.levels.flatMap((s) => [
839
+ `${s.ok ? "✓" : "✖"} L${s.level} ${s.name}`,
840
+ ...s.reasons.map((r) => ` - ${r}`),
841
+ ]),
842
+ out.achieved < 0
843
+ ? "[nifra] verification level: none (L0 failing)"
844
+ : `[nifra] verification level: L${out.achieved}`,
845
+ ],
846
+ success: (out, value) => out.achieved >= (value.min ?? 0),
847
+ }
848
+
849
+ const capabilitiesSpec: CommandSpec<
850
+ CapabilitiesInput,
851
+ CapabilitySnapshotCommandResult | CapabilityCheckCommandResult | CapabilityExplainCommandResult
852
+ > = {
853
+ name: "capabilities",
854
+ summary: "Snapshot, check, or explain token-only capability provenance and lockfile drift.",
855
+ input: CAPABILITIES_SCHEMA,
856
+ output: output({ type: "object" }),
857
+ transports: ["cli", "mcp"],
858
+ stability: "stable",
859
+ argv: {
860
+ positionals: ["action", "method", "path"],
861
+ flags: [
862
+ { name: "config", field: "config", type: "string" },
863
+ { name: "out", field: "out", type: "string" },
864
+ { name: "lockfile", field: "lockfile", type: "string" },
865
+ { name: "json", field: "json", type: "boolean" },
866
+ ],
867
+ },
868
+ async run(value, ctx) {
869
+ if (value.action === "snapshot")
870
+ return import("./capabilities-tool.ts").then(({ collectCapabilitySnapshot }) =>
871
+ collectCapabilitySnapshot(ctx.cwd, {
872
+ ...(value.config === undefined ? {} : { config: value.config }),
873
+ ...(value.out === undefined ? {} : { out: value.out }),
874
+ }),
875
+ )
876
+ if (value.action === "check")
877
+ return import("./capabilities-tool.ts").then(({ collectCapabilityCheck }) =>
878
+ collectCapabilityCheck(ctx.cwd, {
879
+ ...(value.config === undefined ? {} : { config: value.config }),
880
+ ...(value.lockfile === undefined ? {} : { lockfile: value.lockfile }),
881
+ }),
882
+ )
883
+ if (value.method === undefined || value.path === undefined)
884
+ throw new Error("capabilities explain needs <METHOD> <path>")
885
+ return import("./capabilities-tool.ts").then(({ collectCapabilityExplanation }) =>
886
+ collectCapabilityExplanation(
887
+ ctx.cwd,
888
+ value.method!,
889
+ value.path!,
890
+ value.config === undefined ? {} : { config: value.config },
891
+ ),
892
+ )
893
+ },
894
+ render: (out) => [JSON.stringify(out, null, 2)],
895
+ success: (out) => out.ok,
896
+ }
897
+
898
+ const manifestSpec: CommandSpec<ManifestInput, ManifestEmitCommandResult | DiffCommandOutput> = {
899
+ name: "manifest",
900
+ summary: "Emit or diff the hash-verified route trust manifest.",
901
+ input: MANIFEST_SCHEMA,
902
+ output: output({ type: "object" }),
903
+ transports: ["cli", "mcp"],
904
+ stability: "stable",
905
+ argv: {
906
+ positionals: ["action", "before", "after"],
907
+ flags: [
908
+ { name: "config", field: "config", type: "string" },
909
+ { name: "out", field: "out", type: "string" },
910
+ { name: "sign", field: "sign", type: "string" },
911
+ { name: "json", field: "json", type: "boolean" },
912
+ ],
913
+ },
914
+ async run(value, ctx) {
915
+ if (value.action === "emit")
916
+ return import("./manifest-tool.ts").then(({ collectManifestEmit }) =>
917
+ collectManifestEmit(ctx.cwd, {
918
+ ...(value.config === undefined ? {} : { config: value.config }),
919
+ ...(value.out === undefined ? {} : { out: value.out }),
920
+ ...(value.sign === undefined ? {} : { sign: value.sign }),
921
+ }),
922
+ )
923
+ if (value.before === undefined || value.after === undefined)
924
+ throw new Error("manifest diff needs <before> <after>")
925
+ const diff = await import("./manifest-tool.ts").then(({ collectManifestDiff }) =>
926
+ collectManifestDiff(ctx.cwd, value.before!, value.after!),
927
+ )
928
+ return { ok: !diff.hasBreaking, hasBreaking: diff.hasBreaking, changes: diff.changes }
929
+ },
930
+ render: (out) =>
931
+ "changes" in out
932
+ ? [
933
+ out.ok ? "✓ no breaking manifest changes" : "✖ breaking manifest changes",
934
+ ...(out.changes as readonly { method: string; path: string; message: string }[]).map(
935
+ (c) => `${c.method} ${c.path}: ${c.message}`,
936
+ ),
937
+ ]
938
+ : [
939
+ out.ok
940
+ ? `✓ wrote manifest to ${out.path}`
941
+ : "✖ refusing to emit a manifest from failing assurance",
942
+ ],
943
+ success: (out) => out.ok,
944
+ json: (out) => out,
945
+ }
946
+
947
+ const routesSpec: CommandSpec<RoutesInput, RoutesCommandOutput> = {
948
+ name: "routes",
949
+ summary: "List, graph, or target-check every page and API route the app serves.",
950
+ input: ROUTES_SCHEMA,
951
+ output: output({
952
+ type: "object",
953
+ properties: { ok: { type: "boolean" }, mode: { type: "string" }, data: {} },
954
+ required: ["ok", "mode", "data"],
955
+ }),
956
+ transports: ["cli", "mcp"],
957
+ stability: "stable",
958
+ argv: {
959
+ flags: [
960
+ { name: "json", field: "json", type: "boolean" },
961
+ { name: "graph", field: "graph", type: "boolean" },
962
+ { name: "modes", field: "modes", type: "boolean" },
963
+ { name: "target", field: "target", type: "string" },
964
+ ],
965
+ },
966
+ async run(value, ctx) {
967
+ const app = await loadAppFor(ctx)
968
+ if (value.modes === true) {
969
+ const { buildRouteManifest, renderRouteManifest } = await import(
970
+ "@nifrajs/web/route-manifest"
971
+ )
972
+ const { discoverRoutes } = await import("@nifrajs/web/fs")
973
+ const data = await buildRouteManifest(
974
+ discoverRoutes(app.routesDir),
975
+ value.target === undefined ? {} : { target: value.target },
976
+ )
977
+ return {
978
+ ok: data.conflicts.length === 0,
979
+ mode: "modes",
980
+ data,
981
+ text: renderRouteManifest(data),
982
+ }
983
+ }
984
+ const { describeRoutes, describeRouteGraph } = await import("./introspect.ts")
985
+ const mode = value.graph === true ? "graph" : "default"
986
+ const text = value.graph === true ? await describeRouteGraph(app) : await describeRoutes(app)
987
+ const jsonText =
988
+ value.graph === true
989
+ ? await describeRouteGraph(app, { json: true })
990
+ : await describeRoutes(app, { json: true })
991
+ return { ok: true, mode, data: JSON.parse(jsonText) as unknown, text }
992
+ },
993
+ render: (out) => [out.text],
994
+ success: (out) => out.ok,
995
+ json: (out) => out.data,
996
+ }
997
+
998
+ const contextSpec: CommandSpec<ContextInput, ContextCommandOutput> = {
999
+ name: "context",
1000
+ summary: "Print the project route index and framework conventions for an agent.",
1001
+ input: CONTEXT_SCHEMA,
1002
+ output: output({
1003
+ type: "object",
1004
+ properties: { ok: { type: "boolean" }, text: { type: "string" } },
1005
+ required: ["ok", "text"],
1006
+ }),
1007
+ transports: ["cli", "mcp"],
1008
+ stability: "stable",
1009
+ argv: {
1010
+ flags: [
1011
+ { name: "path", field: "path", type: "string" },
1012
+ { name: "kind", field: "kind", type: "string" },
1013
+ ],
1014
+ },
1015
+ async run(value, ctx) {
1016
+ return {
1017
+ ok: true,
1018
+ text: (await import("./introspect.ts")).describeProject(await loadAppFor(ctx), {
1019
+ ...(value.path === undefined ? {} : { path: value.path }),
1020
+ ...(value.kind === undefined ? {} : { kind: value.kind }),
1021
+ }),
1022
+ }
1023
+ },
1024
+ render: (out) => [out.text],
1025
+ }
1026
+
1027
+ const doctorSpec: CommandSpec<DoctorInput, DoctorResult> = {
1028
+ name: "doctor",
1029
+ summary: "Find undeclared imports, duplicate identity installs, and pipeline readiness drift.",
1030
+ input: DOCTOR_SCHEMA,
1031
+ output: output({
1032
+ type: "object",
1033
+ properties: { ok: { type: "boolean" }, findings: { type: "array" } },
1034
+ required: ["ok"],
1035
+ }),
1036
+ transports: ["cli", "mcp"],
1037
+ stability: "stable",
1038
+ argv: {
1039
+ flags: [
1040
+ { name: "json", field: "json", type: "boolean" },
1041
+ { name: "auto-fix", field: "autoFix", type: "boolean" },
1042
+ { name: "fix", field: "autoFix", type: "boolean" },
1043
+ { name: "strict", field: "strict", type: "boolean" },
1044
+ { name: "target", field: "target", type: "string" },
1045
+ ],
1046
+ },
1047
+ async run(value, ctx) {
1048
+ const mod = await import("./doctor.ts")
1049
+ const options = {
1050
+ ...(value.target === undefined ? {} : { target: value.target }),
1051
+ ...(value.strict === undefined ? {} : { strict: value.strict }),
1052
+ ...(ctx.cliVersion === undefined ? {} : { cliVersion: ctx.cliVersion }),
1053
+ }
1054
+ return value.autoFix === true
1055
+ ? mod.applyDoctorAutoFix(ctx.cwd, options)
1056
+ : mod.collectDoctorResult(ctx.cwd, options)
1057
+ },
1058
+ render: (out) => [
1059
+ out.ok ? "✓ doctor passed" : "✖ doctor found issues",
1060
+ ...out.findings.map((f) => ` ${f.file}:${f.line} ${f.package}`),
1061
+ ],
1062
+ success: (out) => out.ok,
1063
+ }
1064
+
1065
+ const fixSpec: CommandSpec<FixInput, FixCommandOutput> = {
1066
+ name: "fix",
1067
+ summary: "Apply registered mechanical diagnostic recipes and return remaining findings.",
1068
+ input: FIX_SCHEMA,
1069
+ output: output({
1070
+ type: "object",
1071
+ properties: {
1072
+ ok: { type: "boolean" },
1073
+ changed: { type: "array" },
1074
+ diagnostics: { type: "array" },
1075
+ },
1076
+ required: ["ok", "diagnostics"],
1077
+ }),
1078
+ transports: ["cli", "mcp"],
1079
+ stability: "stable",
1080
+ argv: {
1081
+ flags: [
1082
+ { name: "code", field: "code", type: "string" },
1083
+ { name: "json", field: "json", type: "boolean" },
1084
+ ],
1085
+ },
1086
+ async run(value, ctx) {
1087
+ const { collectCheckResult } = await import("./check.ts")
1088
+ const { applyDiagnosticRecipe } = await import("./fix-recipes.ts")
1089
+ const result = await collectCheckResult(ctx.cwd, {
1090
+ lintsOnly: true,
1091
+ ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
1092
+ })
1093
+ const changed: string[] = []
1094
+ for (const diagnostic of result.structuredDiagnostics ?? [])
1095
+ if (value.code === undefined || diagnostic.code === value.code)
1096
+ changed.push(...(await applyDiagnosticRecipe(ctx.cwd, diagnostic)))
1097
+ const final = await collectCheckResult(ctx.cwd, {
1098
+ lintsOnly: true,
1099
+ ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
1100
+ })
1101
+ return { ok: final.ok, changed, diagnostics: final.structuredDiagnostics ?? final.diagnostics }
1102
+ },
1103
+ render: (out) => [
1104
+ out.ok ? "✓ fixes applied" : "✖ unresolved diagnostics remain",
1105
+ ...out.changed.map((file) => ` changed ${file}`),
1106
+ ...out.diagnostics.map((d) => ` ${String((d as { code?: unknown }).code ?? "diagnostic")}`),
1107
+ ],
1108
+ success: (out) => out.ok,
1109
+ }
1110
+
1111
+ const snapshotSpec: CommandSpec<SnapshotInput, SnapshotCommandOutput> = {
1112
+ name: "snapshot",
1113
+ summary: "Write the backend API contract as a versioned JSON baseline.",
1114
+ input: SNAPSHOT_SCHEMA,
1115
+ output: output({
1116
+ type: "object",
1117
+ properties: {
1118
+ ok: { type: "boolean" },
1119
+ file: { type: "string" },
1120
+ routes: { type: "integer" },
1121
+ snapshot: { type: "object" },
1122
+ },
1123
+ required: ["ok", "file", "routes"],
1124
+ }),
1125
+ transports: ["cli", "mcp"],
1126
+ stability: "stable",
1127
+ argv: {
1128
+ flags: [
1129
+ { name: "out", field: "out", type: "string" },
1130
+ { name: "json", field: "json", type: "boolean" },
1131
+ ],
1132
+ },
1133
+ async run(value, ctx) {
1134
+ const mod = await import("./diff-tool.ts")
1135
+ const routes = await mod.snapshotBackend(ctx.cwd)
1136
+ const file = value.out ?? mod.DEFAULT_SNAPSHOT_FILE
1137
+ const snapshot = { nifraSnapshot: 1 as const, routes }
1138
+ await Bun.write(resolve(ctx.cwd, file), `${JSON.stringify(snapshot, null, 2)}\n`)
1139
+ return { ok: true, file, routes: routes.length, snapshot }
1140
+ },
1141
+ render: (out) => [
1142
+ `[nifra] wrote ${out.routes} route${out.routes === 1 ? "" : "s"} to ${out.file}`,
1143
+ ],
1144
+ }
1145
+
1146
+ const diffSpec: CommandSpec<DiffInput, DiffCommandOutput> = {
1147
+ name: "diff",
1148
+ summary: "Compare the current backend contract with a baseline and fail on breaking changes.",
1149
+ input: DIFF_SCHEMA,
1150
+ output: output({
1151
+ type: "object",
1152
+ properties: {
1153
+ ok: { type: "boolean" },
1154
+ hasBreaking: { type: "boolean" },
1155
+ changes: { type: "array" },
1156
+ },
1157
+ required: ["ok", "hasBreaking", "changes"],
1158
+ }),
1159
+ transports: ["cli", "mcp"],
1160
+ stability: "stable",
1161
+ argv: { positionals: ["baseline"], flags: [{ name: "json", field: "json", type: "boolean" }] },
1162
+ async run(value, ctx) {
1163
+ const mod = await import("./diff-tool.ts")
1164
+ const baseline = value.baseline ?? mod.DEFAULT_SNAPSHOT_FILE
1165
+ const file = await Bun.file(resolve(ctx.cwd, baseline)).text()
1166
+ const before = mod.parseSnapshotFile(file, baseline)
1167
+ const current = await mod.snapshotBackend(ctx.cwd)
1168
+ const { diffRouteSnapshots } = await import("@nifrajs/core/diff")
1169
+ const diff = diffRouteSnapshots(before.routes, current)
1170
+ return { ok: !diff.hasBreaking, hasBreaking: diff.hasBreaking, changes: diff.changes }
1171
+ },
1172
+ render: (out) => [
1173
+ out.ok ? "✓ no breaking changes" : "✖ breaking changes",
1174
+ ...(out.changes as readonly { method: string; path: string; message: string }[]).map(
1175
+ (c) => `${c.method} ${c.path}: ${c.message}`,
1176
+ ),
1177
+ ],
1178
+ success: (out) => out.ok,
1179
+ }
1180
+
1181
+ const contractsSpec: CommandSpec<ContractsInput, ContractsCommandOutput> = {
1182
+ name: "contracts",
1183
+ summary: "Snapshot or check the deterministic route contract lock.",
1184
+ input: CONTRACTS_SCHEMA,
1185
+ output: output({ type: "object" }),
1186
+ transports: ["cli", "mcp"],
1187
+ stability: "stable",
1188
+ argv: {
1189
+ positionals: ["action"],
1190
+ flags: [
1191
+ { name: "out", field: "out", type: "string" },
1192
+ { name: "json", field: "json", type: "boolean" },
1193
+ ],
1194
+ },
1195
+ async run(value, ctx) {
1196
+ const mod = await import("./contracts.ts")
1197
+ if (value.action === "snapshot")
1198
+ return { ok: true, lock: await mod.snapshotContracts(ctx.cwd, value.out) }
1199
+ const result = await mod.checkContractsLock(ctx.cwd)
1200
+ return { ok: result.present && result.diagnostics.length === 0, ...result }
1201
+ },
1202
+ render: (out) => [
1203
+ out.ok ? "✓ contracts are current" : "✖ contract drift",
1204
+ ...(out.diagnostics ?? []).map((d) => ` ${(d as { message: string }).message}`),
1205
+ ],
1206
+ success: (out) => out.ok,
1207
+ }
1208
+
1209
+ const syncManifestSpec: CommandSpec<SyncInput, SyncCommandOutput> = {
1210
+ name: "sync-manifest",
1211
+ summary: "Regenerate generated server-manifest route tables without a full build.",
1212
+ input: SYNC_SCHEMA,
1213
+ output: output({
1214
+ type: "object",
1215
+ properties: { ok: { type: "boolean" }, results: { type: "array" } },
1216
+ required: ["ok", "results"],
1217
+ }),
1218
+ transports: ["cli", "mcp"],
1219
+ stability: "stable",
1220
+ argv: { flags: [{ name: "json", field: "json", type: "boolean" }] },
1221
+ async run(_value, ctx) {
1222
+ return {
1223
+ ok: true,
1224
+ results: await (await import("./sync-manifest.ts")).syncServerManifests(ctx.cwd),
1225
+ }
1226
+ },
1227
+ render: (out) => [
1228
+ out.results.length === 0
1229
+ ? "nifra sync-manifest: no generated server-manifest.ts found under this directory."
1230
+ : `✓ checked ${out.results.length} generated server manifest${out.results.length === 1 ? "" : "s"}`,
1231
+ ],
1232
+ }
1233
+
1234
+ const syncRoutesSpec: CommandSpec<SyncInput, SyncCommandOutput> = {
1235
+ name: "sync-routes",
1236
+ summary: "Regenerate nifra-routes.d.ts so typed navigation follows route search schemas.",
1237
+ input: SYNC_SCHEMA,
1238
+ output: output({
1239
+ type: "object",
1240
+ properties: { ok: { type: "boolean" }, results: { type: "array" } },
1241
+ required: ["ok", "results"],
1242
+ }),
1243
+ transports: ["cli", "mcp"],
1244
+ stability: "stable",
1245
+ argv: { flags: [{ name: "json", field: "json", type: "boolean" }] },
1246
+ async run(_value, ctx) {
1247
+ const result = await (await import("./sync-routes.ts")).syncRouteTypes(ctx.cwd)
1248
+ return { ok: true, results: result === null ? [] : [result] }
1249
+ },
1250
+ render: (out) => [
1251
+ out.results.length === 0
1252
+ ? "nifra sync-routes: no routes/ directory found. Run from your project root."
1253
+ : `✓ checked ${out.results.length} route type output`,
1254
+ ],
1255
+ }
1256
+
1257
+ const proveSpec: CommandSpec<ProveInput, ProjectWorkGraphResult> = {
1258
+ name: "prove",
1259
+ summary:
1260
+ "Build the static verification work graph, plan the cheapest proofs for the changed files, and report a machine-checkable stop condition.",
1261
+ input: PROVE_SCHEMA,
1262
+ output: output({ type: "object" }),
1263
+ transports: ["cli", "mcp"],
1264
+ stability: "stable",
1265
+ argv: {
1266
+ flags: [
1267
+ { name: "file", field: "files", type: "string[]" },
1268
+ { name: "min", field: "minLevel", type: "number" },
1269
+ { name: "json", field: "json", type: "boolean" },
1270
+ ],
1271
+ },
1272
+ async run(value, ctx) {
1273
+ return collectProjectWorkGraph(ctx.cwd, {
1274
+ ...(value.files === undefined ? {} : { changedFiles: value.files }),
1275
+ ...(value.minLevel === undefined ? {} : { minLevel: value.minLevel }),
1276
+ })
1277
+ },
1278
+ render: (out) => [renderWorkGraphText(out)],
1279
+ success: (out) => out.evidence.stop.done,
1280
+ }
1281
+
1282
+ const replaySpec: CommandSpec<ReplayInput, ReplayResult> = {
1283
+ name: "replay",
1284
+ summary: "Validate a token-only verification metadata file and dispatch it against its gate.",
1285
+ input: REPLAY_SCHEMA,
1286
+ output: output({ type: "object" }),
1287
+ transports: ["cli", "mcp"],
1288
+ stability: "stable",
1289
+ argv: { positionals: ["file"] },
1290
+ async run(value, ctx) {
1291
+ const { runReplay } = await import("./replay.ts")
1292
+ return runReplay(ctx.cwd, value.file)
1293
+ },
1294
+ render: (out) => [JSON.stringify(out, null, 2)],
1295
+ success: (out) => out.ok,
1296
+ }
1297
+
1298
+ const portSpec: CommandSpec<PortInput, PortResult> = {
1299
+ name: "port",
1300
+ summary:
1301
+ "Print a feature by deploy-target portability matrix with file:line evidence and gate against an unsupported target.",
1302
+ input: PORT_SCHEMA,
1303
+ output: output({ type: "object" }),
1304
+ transports: ["cli", "mcp"],
1305
+ stability: "stable",
1306
+ argv: {
1307
+ flags: [
1308
+ { name: "target", field: "target", type: "string" },
1309
+ { name: "json", field: "json", type: "boolean" },
1310
+ { name: "ci", field: "ci", type: "boolean" },
1311
+ { name: "strict", field: "strict", type: "boolean" },
1312
+ ],
1313
+ },
1314
+ async run(value, ctx) {
1315
+ return collectPortResult(ctx.cwd, {
1316
+ ...(value.target === undefined ? {} : { target: value.target }),
1317
+ ...(value.strict === true ? { strict: true } : {}),
1318
+ })
1319
+ },
1320
+ render: (out, input) => {
1321
+ const report = renderReport(out, { strict: input?.strict === true })
1322
+ const gating = input?.ci === true || input?.target !== undefined
1323
+ if (gating && out.resolved === undefined)
1324
+ return [
1325
+ report,
1326
+ "",
1327
+ "[nifra] --ci needs a deploy target to gate against, and none was detected. Pass --target <bun|node|deno|cf-pages|vercel>.",
1328
+ ]
1329
+ return [report]
1330
+ },
1331
+ success: (out, input) => {
1332
+ const gating = input.ci === true || input.target !== undefined
1333
+ if (!gating) return true
1334
+ if (out.resolved === undefined) return false
1335
+ return out.json.blocked.length === 0
1336
+ },
1337
+ json: (out) => out.json,
1338
+ }
1339
+
1340
+ export const commandSpecs = Object.freeze([
1341
+ checkSpec,
1342
+ assureSpec,
1343
+ levelsSpec,
1344
+ capabilitiesSpec,
1345
+ manifestSpec,
1346
+ routesSpec,
1347
+ contextSpec,
1348
+ doctorSpec,
1349
+ fixSpec,
1350
+ snapshotSpec,
1351
+ diffSpec,
1352
+ contractsSpec,
1353
+ syncManifestSpec,
1354
+ syncRoutesSpec,
1355
+ proveSpec,
1356
+ replaySpec,
1357
+ portSpec,
1358
+ ] as const)
1359
+
1360
+ const commandByName = new Map(commandSpecs.map((spec) => [spec.name, spec]))
1361
+
1362
+ export function findCommandSpec(name: string): CommandSpec<unknown, unknown> | undefined {
1363
+ return commandByName.get(name) as CommandSpec<unknown, unknown> | undefined
1364
+ }
1365
+
1366
+ export const commandCatalog = Object.freeze(
1367
+ commandSpecs.map((spec) => toCommandCatalogEntry(spec as CommandSpec<unknown, unknown>)),
1368
+ )
1369
+
1370
+ export function commandMcpName(name: string): string {
1371
+ return `nifra_${name.replaceAll("-", "_")}`
1372
+ }
1373
+
1374
+ export function commandUsage<Input, Output>(spec: CommandSpec<Input, Output>): string {
1375
+ const positionals = (spec.argv?.positionals ?? []).map((field) => `<${field}>`).join(" ")
1376
+ const flags = (spec.argv?.flags ?? [])
1377
+ .filter((flag, index, all) => all.findIndex((x) => x.field === flag.field) === index)
1378
+ .map((flag) => (flag.type === "boolean" ? `[--${flag.name}]` : `[--${flag.name} <value>]`))
1379
+ .join(" ")
1380
+ return [positionals, flags].filter(Boolean).join(" ")
1381
+ }
1382
+
1383
+ /** Stable one-line help/card projection; command-specific prose lives only in the catalog. */
1384
+ export function renderCommandCatalogLines(prefix = "nifra"): readonly string[] {
1385
+ return commandCatalog.map((entry) => {
1386
+ const spec = findCommandSpec(entry.name) as CommandSpec<unknown, unknown>
1387
+ const usage = commandUsage(spec)
1388
+ return `${prefix} ${entry.name}${usage === "" ? "" : ` ${usage}`} - ${entry.summary}`
1389
+ })
1390
+ }
1391
+
1392
+ export function renderCommandCatalogHelp(): string {
1393
+ return ["", "Stable project command catalog:", ...renderCommandCatalogLines(" nifra")].join("\n")
1394
+ }
1395
+
1396
+ export function parseCommandOutput<Input, Output>(
1397
+ spec: CommandSpec<Input, Output>,
1398
+ value: unknown,
1399
+ ): Output {
1400
+ const raw = record(value)
1401
+ if (raw.nifraCommand !== undefined && raw.version !== undefined && raw.result !== undefined) {
1402
+ if (raw.version !== spec.output.version)
1403
+ throw new Error(`unsupported ${spec.name} output version: ${String(raw.version)}`)
1404
+ return spec.output.parse(raw.result)
1405
+ }
1406
+ return spec.output.parse(value)
1407
+ }
1408
+
1409
+ /** Serialize a result as an envelope without forcing existing raw --json consumers to migrate. */
1410
+ export function envelopeCommandOutput<Input, Output>(
1411
+ spec: CommandSpec<Input, Output>,
1412
+ value: Output,
1413
+ ): { readonly nifraCommand: string; readonly version: number; readonly result: Output } {
1414
+ return { nifraCommand: spec.name, version: spec.output.version, result: value }
1415
+ }