@nifrajs/cli 2.2.0 → 2.4.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.
Files changed (135) hide show
  1. package/README.md +25 -25
  2. package/dist/assure.d.ts +2 -2
  3. package/dist/assure.d.ts.map +1 -1
  4. package/dist/assure.js +17 -10
  5. package/dist/assure.js.map +1 -1
  6. package/dist/capabilities-tool.d.ts +7 -0
  7. package/dist/capabilities-tool.d.ts.map +1 -1
  8. package/dist/capabilities-tool.js +68 -16
  9. package/dist/capabilities-tool.js.map +1 -1
  10. package/dist/check.d.ts +61 -22
  11. package/dist/check.d.ts.map +1 -1
  12. package/dist/check.js +336 -87
  13. package/dist/check.js.map +1 -1
  14. package/dist/cli.d.ts +30 -2
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +148 -39
  17. package/dist/cli.js.map +1 -1
  18. package/dist/diff-tool.d.ts +3 -3
  19. package/dist/diff-tool.js +7 -7
  20. package/dist/docs-search.d.ts +3 -3
  21. package/dist/docs-search.js +6 -6
  22. package/dist/doctor.d.ts +3 -3
  23. package/dist/doctor.d.ts.map +1 -1
  24. package/dist/doctor.js +12 -8
  25. package/dist/doctor.js.map +1 -1
  26. package/dist/examples.d.ts +5 -5
  27. package/dist/examples.js +5 -5
  28. package/dist/init-agents.d.ts +14 -14
  29. package/dist/init-agents.js +14 -14
  30. package/dist/internal/typescript-import.d.ts +24 -0
  31. package/dist/internal/typescript-import.d.ts.map +1 -0
  32. package/dist/internal/typescript-import.js +32 -0
  33. package/dist/internal/typescript-import.js.map +1 -0
  34. package/dist/introspect.d.ts +12 -12
  35. package/dist/introspect.js +24 -24
  36. package/dist/learn.d.ts +32 -0
  37. package/dist/learn.d.ts.map +1 -0
  38. package/dist/learn.js +127 -0
  39. package/dist/learn.js.map +1 -0
  40. package/dist/levels-tool.d.ts +8 -8
  41. package/dist/levels-tool.d.ts.map +1 -1
  42. package/dist/levels-tool.js +33 -30
  43. package/dist/levels-tool.js.map +1 -1
  44. package/dist/load.d.ts +6 -6
  45. package/dist/load.d.ts.map +1 -1
  46. package/dist/load.js +15 -7
  47. package/dist/load.js.map +1 -1
  48. package/dist/manifest-tool.d.ts +1 -1
  49. package/dist/manifest-tool.d.ts.map +1 -1
  50. package/dist/manifest-tool.js +4 -2
  51. package/dist/manifest-tool.js.map +1 -1
  52. package/dist/mcp-app-tool.d.ts +14 -0
  53. package/dist/mcp-app-tool.d.ts.map +1 -0
  54. package/dist/mcp-app-tool.js +80 -0
  55. package/dist/mcp-app-tool.js.map +1 -0
  56. package/dist/mcp-docs-tools.d.ts +1 -1
  57. package/dist/mcp-docs-tools.d.ts.map +1 -1
  58. package/dist/mcp-docs-tools.js +25 -9
  59. package/dist/mcp-docs-tools.js.map +1 -1
  60. package/dist/mcp-http.d.ts +14 -10
  61. package/dist/mcp-http.d.ts.map +1 -1
  62. package/dist/mcp-http.js +30 -16
  63. package/dist/mcp-http.js.map +1 -1
  64. package/dist/mcp-protocol.d.ts +2 -2
  65. package/dist/mcp-protocol.js +2 -2
  66. package/dist/mcp-render.d.ts +3 -3
  67. package/dist/mcp-render.js +6 -6
  68. package/dist/mcp-run.d.ts +2 -2
  69. package/dist/mcp-run.js +2 -2
  70. package/dist/mcp.d.ts +28 -14
  71. package/dist/mcp.d.ts.map +1 -1
  72. package/dist/mcp.js +263 -45
  73. package/dist/mcp.js.map +1 -1
  74. package/dist/openapi-tool.d.ts +1 -1
  75. package/dist/openapi-tool.js +2 -2
  76. package/dist/pipeline-guard.d.ts +1 -1
  77. package/dist/pipeline-guard.js +1 -1
  78. package/dist/port.d.ts +9 -9
  79. package/dist/port.js +27 -27
  80. package/dist/recipes/1.8.0.d.ts +2 -2
  81. package/dist/recipes/1.8.0.js +5 -5
  82. package/dist/recipes/index.d.ts +2 -2
  83. package/dist/recipes/index.js +1 -1
  84. package/dist/scaffold.d.ts +3 -3
  85. package/dist/scaffold.js +8 -8
  86. package/dist/sync-routes.d.ts +28 -0
  87. package/dist/sync-routes.d.ts.map +1 -0
  88. package/dist/sync-routes.js +55 -0
  89. package/dist/sync-routes.js.map +1 -0
  90. package/dist/types-search.d.ts +5 -5
  91. package/dist/types-search.js +7 -7
  92. package/dist/upgrade.d.ts +20 -9
  93. package/dist/upgrade.d.ts.map +1 -1
  94. package/dist/upgrade.js +117 -19
  95. package/dist/upgrade.js.map +1 -1
  96. package/dist/verification.d.ts +51 -0
  97. package/dist/verification.d.ts.map +1 -0
  98. package/dist/verification.js +80 -0
  99. package/dist/verification.js.map +1 -0
  100. package/docs/examples.json +174 -90
  101. package/docs/llms-full.txt +1771 -748
  102. package/docs/types.json +2027 -3089
  103. package/package.json +22 -11
  104. package/src/assure.ts +17 -18
  105. package/src/capabilities-tool.ts +82 -16
  106. package/src/check.ts +410 -104
  107. package/src/cli.ts +161 -41
  108. package/src/diff-tool.ts +8 -8
  109. package/src/docs-search.ts +6 -6
  110. package/src/doctor.ts +18 -10
  111. package/src/examples.ts +7 -7
  112. package/src/init-agents.ts +17 -17
  113. package/src/internal/typescript-import.ts +33 -0
  114. package/src/introspect.ts +28 -28
  115. package/src/learn.ts +152 -0
  116. package/src/levels-tool.ts +37 -31
  117. package/src/load.ts +21 -12
  118. package/src/manifest-tool.ts +4 -2
  119. package/src/mcp-app-tool.ts +86 -0
  120. package/src/mcp-docs-tools.ts +26 -9
  121. package/src/mcp-http.ts +31 -18
  122. package/src/mcp-protocol.ts +2 -2
  123. package/src/mcp-render.ts +6 -6
  124. package/src/mcp-run.ts +2 -2
  125. package/src/mcp.ts +319 -45
  126. package/src/openapi-tool.ts +2 -2
  127. package/src/pipeline-guard.ts +1 -1
  128. package/src/port.ts +31 -31
  129. package/src/recipes/1.8.0.ts +5 -5
  130. package/src/recipes/index.ts +2 -2
  131. package/src/scaffold.ts +8 -8
  132. package/src/sync-routes.ts +69 -0
  133. package/src/types-search.ts +8 -8
  134. package/src/upgrade.ts +137 -19
  135. package/src/verification.ts +123 -0
package/src/upgrade.ts CHANGED
@@ -1,20 +1,20 @@
1
1
  /**
2
- * `nifra upgrade <version>` — an executable, per-release upgrade runner. Framework and shared-package
2
+ * `nifra upgrade <version>` - an executable, per-release upgrade runner. Framework and shared-package
3
3
  * bumps otherwise spread the same mechanical edits across every consuming app by hand; a recipe turns
4
4
  * them into `detect → transform → verify`:
5
5
  *
6
- * 1. **pin sweep** — set every matching dependency to the target version across the workspace's
6
+ * 1. **pin sweep** - set every matching dependency to the target version across the workspace's
7
7
  * package.json files, preserving the caret/tilde/exact style and skipping `workspace:`/`link:` specs.
8
- * 2. **dependency moves** — replace removed packages with their supported successor without
8
+ * 2. **dependency moves** - replace removed packages with their supported successor without
9
9
  * reserializing package.json or leaving duplicate dependency keys.
10
- * 3. **import moves** — rewrite exact import specifiers to their updated module paths.
11
- * 4. **verify** — reuse the existing `nifra check` gate; no new verification surface.
10
+ * 3. **import moves** - rewrite exact import specifiers to their updated module paths.
11
+ * 4. **verify** - reuse the existing `nifra check` gate; no new verification surface.
12
12
  *
13
13
  * Dry-run by default (prints the plan, writes nothing); `--write` applies. Fail-closed on an unknown
14
14
  * target version or a missing package.json, and deterministic (same repo + target → same edits).
15
15
  *
16
16
  * Deliberately NOT a codemod engine: transforms are string/specifier-level only. Structural (AST)
17
- * transforms are a future addition — a recipe that needs one is the signal to add the engine, not before.
17
+ * transforms are a future addition - a recipe that needs one is the signal to add the engine, not before.
18
18
  */
19
19
 
20
20
  import { readFileSync, writeFileSync } from "node:fs"
@@ -33,6 +33,8 @@ export interface UpgradeOptions {
33
33
  readonly list?: boolean
34
34
  /** After `--write`, run `nifra check` and fail the command if it fails. Default true. */
35
35
  readonly verify?: boolean
36
+ /** Permit a target BELOW the installed version (a rollback). Default false → fail-closed. */
37
+ readonly allowDowngrade?: boolean
36
38
  }
37
39
 
38
40
  export interface PinChange {
@@ -65,6 +67,8 @@ export interface UpgradePlan {
65
67
  readonly pins: readonly PinChange[]
66
68
  readonly dependencyMoves: readonly DependencyMoveChange[]
67
69
  readonly importMoves: readonly ImportChange[]
70
+ /** Pins refused because the target is BELOW the installed version (a rollback). Empty when allowed. */
71
+ readonly downgrades: readonly PinChange[]
68
72
  readonly notes: readonly string[]
69
73
  }
70
74
 
@@ -75,10 +79,10 @@ const DEP_FIELDS = [
75
79
  "optionalDependencies",
76
80
  ] as const
77
81
 
78
- // Only a bare semver spec is rewritten. Anything else — workspace:*, link:/file:, npm: aliases, git
79
- // urls, "*", "latest", or a multi-part range — is intentionally left untouched (skipped, not guessed).
82
+ // Only a bare semver spec is rewritten. Anything else - workspace:*, link:/file:, npm: aliases, git
83
+ // urls, "*", "latest", or a multi-part range - is intentionally left untouched (skipped, not guessed).
80
84
  const SEMVER_SPEC =
81
- /^([\^~]|>=|<=|>|<|=)?\s*(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
85
+ /^([\^~]|>=|<=|>|<|=)?\s*(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/
82
86
 
83
87
  /**
84
88
  * Rewrite a dependency version spec to `toVersion`, preserving the range operator (`^`, `~`, …).
@@ -92,25 +96,92 @@ export function rewriteVersionSpec(spec: string, toVersion: string): string | nu
92
96
  return next === spec ? null : next
93
97
  }
94
98
 
99
+ /** Numeric core (major, minor, patch) of a semver spec, ignoring the range operator + prerelease/build. */
100
+ export function specVersionTuple(spec: string): readonly [number, number, number] | null {
101
+ const match = SEMVER_SPEC.exec(spec.trim())
102
+ if (!match) return null
103
+ return [Number(match[2]), Number(match[3]), Number(match[4])]
104
+ }
105
+
106
+ interface ParsedSpecVersion {
107
+ readonly core: readonly [number, number, number]
108
+ readonly prerelease: readonly (number | string)[]
109
+ }
110
+
111
+ function parseSpecVersion(spec: string): ParsedSpecVersion | null {
112
+ const match = SEMVER_SPEC.exec(spec.trim())
113
+ if (!match) return null
114
+ const prerelease = (match[5] ?? "").split(".")
115
+ return {
116
+ core: [Number(match[2]), Number(match[3]), Number(match[4])],
117
+ prerelease:
118
+ match[5] === undefined
119
+ ? []
120
+ : prerelease.map((part) => (/^\d+$/.test(part) ? Number(part) : part)),
121
+ }
122
+ }
123
+
124
+ /** Compare two semver cores: <0 when a<b, 0 equal, >0 when a>b. */
125
+ export function compareSemverCore(
126
+ a: readonly [number, number, number],
127
+ b: readonly [number, number, number],
128
+ ): number {
129
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]
130
+ }
131
+
132
+ /** Compare full semver precedence, including prereleases (`2.3.0-beta` < `2.3.0`). */
133
+ export function compareSemverSpec(a: string, b: string): number {
134
+ const left = parseSpecVersion(a)
135
+ const right = parseSpecVersion(b)
136
+ if (left === null || right === null) return 0
137
+ const core = compareSemverCore(left.core, right.core)
138
+ if (core !== 0) return core
139
+ if (left.prerelease.length === 0 || right.prerelease.length === 0) {
140
+ return left.prerelease.length === right.prerelease.length
141
+ ? 0
142
+ : left.prerelease.length === 0
143
+ ? 1
144
+ : -1
145
+ }
146
+ const length = Math.max(left.prerelease.length, right.prerelease.length)
147
+ for (let i = 0; i < length; i++) {
148
+ const x = left.prerelease[i]
149
+ const y = right.prerelease[i]
150
+ if (x === undefined || y === undefined) return x === undefined ? -1 : 1
151
+ if (typeof x === "number" && typeof y === "number") {
152
+ if (x !== y) return x - y
153
+ } else if (typeof x === "number") return -1
154
+ else if (typeof y === "number") return 1
155
+ else if (x !== y) return x < y ? -1 : 1
156
+ }
157
+ return 0
158
+ }
159
+
95
160
  const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
96
161
 
97
162
  /**
98
- * Apply pin rules to one package.json's TEXT (format-preserving — targeted string replaces, never a
163
+ * Apply pin rules to one package.json's TEXT (format-preserving - targeted string replaces, never a
99
164
  * JSON re-serialize that would reorder keys or drop comments-as-formatting). Returns the new text and
100
165
  * the changes made.
101
166
  */
102
167
  export function pinSweepText(
103
168
  text: string,
104
169
  rules: readonly { match: string; to: string }[],
105
- ): { text: string; changes: Array<Omit<PinChange, "file">> } {
170
+ allowDowngrade = false,
171
+ ): {
172
+ text: string
173
+ changes: Array<Omit<PinChange, "file">>
174
+ downgrades: Array<Omit<PinChange, "file">>
175
+ } {
106
176
  let parsed: Record<string, unknown>
107
177
  try {
108
178
  parsed = JSON.parse(text) as Record<string, unknown>
109
179
  } catch {
110
- return { text, changes: [] }
180
+ return { text, changes: [], downgrades: [] }
111
181
  }
112
182
  let out = text
113
183
  const changes: Array<Omit<PinChange, "file">> = []
184
+ const downgrades: Array<Omit<PinChange, "file">> = []
114
185
  for (const field of DEP_FIELDS) {
115
186
  const deps = parsed[field]
116
187
  if (typeof deps !== "object" || deps === null) continue
@@ -122,6 +193,19 @@ export function pinSweepText(
122
193
  if (!rule) continue
123
194
  const next = rewriteVersionSpec(rawSpec, rule.to)
124
195
  if (next === null) continue
196
+ // Refuse a rollback: `upgrade <version>` pins an exact target, so an OLD target on a newer install
197
+ // walks dependencies backward (e.g. ^2.3.0 → ^2.0.0) and can break a shared-package peer range.
198
+ const current = specVersionTuple(rawSpec)
199
+ const target = specVersionTuple(rule.to)
200
+ if (
201
+ !allowDowngrade &&
202
+ current !== null &&
203
+ target !== null &&
204
+ compareSemverSpec(rawSpec, rule.to) > 0
205
+ ) {
206
+ downgrades.push({ field, name, from: rawSpec, to: next })
207
+ continue
208
+ }
125
209
  // Replace exactly `"name"<ws>:<ws>"spec"`, preserving the surrounding whitespace.
126
210
  const pattern = new RegExp(`("${escapeRegExp(name)}"\\s*:\\s*")${escapeRegExp(rawSpec)}(")`)
127
211
  const replaced = out.replace(pattern, `$1${next}$2`)
@@ -131,7 +215,7 @@ export function pinSweepText(
131
215
  }
132
216
  }
133
217
  }
134
- return { text: out, changes }
218
+ return { text: out, changes, downgrades }
135
219
  }
136
220
 
137
221
  function dependencyObjectRange(
@@ -289,10 +373,16 @@ function scan(cwd: string, pattern: string): string[] {
289
373
  }
290
374
 
291
375
  /** Compute the plan (and, when `write`, apply it) for a target recipe against `cwd`. */
292
- export function computeUpgrade(cwd: string, recipe: UpgradeRecipe, write: boolean): UpgradePlan {
376
+ export function computeUpgrade(
377
+ cwd: string,
378
+ recipe: UpgradeRecipe,
379
+ write: boolean,
380
+ allowDowngrade = false,
381
+ ): UpgradePlan {
293
382
  const pins: PinChange[] = []
294
383
  const dependencyMoves: DependencyMoveChange[] = []
295
384
  const importMoves: ImportChange[] = []
385
+ const downgrades: PinChange[] = []
296
386
 
297
387
  if ((recipe.dependencyMoves?.length ?? 0) > 0 || recipe.pins.length > 0) {
298
388
  for (const rel of scan(cwd, "**/package.json")) {
@@ -300,8 +390,9 @@ export function computeUpgrade(cwd: string, recipe: UpgradeRecipe, write: boolea
300
390
  const original = readFileSync(abs, "utf8")
301
391
  const moved = moveDependenciesText(original, recipe.dependencyMoves ?? [])
302
392
  for (const change of moved.changes) dependencyMoves.push({ file: rel, ...change })
303
- const pinned = pinSweepText(moved.text, recipe.pins)
393
+ const pinned = pinSweepText(moved.text, recipe.pins, allowDowngrade)
304
394
  for (const change of pinned.changes) pins.push({ file: rel, ...change })
395
+ for (const d of pinned.downgrades) downgrades.push({ file: rel, ...d })
305
396
  if (write && pinned.text !== original) writeFileSync(abs, pinned.text)
306
397
  }
307
398
  }
@@ -322,6 +413,7 @@ export function computeUpgrade(cwd: string, recipe: UpgradeRecipe, write: boolea
322
413
  pins,
323
414
  dependencyMoves,
324
415
  importMoves,
416
+ downgrades,
325
417
  notes: recipe.notes ?? [],
326
418
  }
327
419
  }
@@ -336,7 +428,7 @@ function renderPlan(plan: UpgradePlan, write: boolean): string {
336
428
  plan.dependencyMoves.length === 0 &&
337
429
  plan.importMoves.length === 0
338
430
  ) {
339
- lines.push("Already up to date — no changes.")
431
+ lines.push("Already up to date - no changes.")
340
432
  return lines.join("\n")
341
433
  }
342
434
  if (plan.dependencyMoves.length > 0) {
@@ -366,6 +458,19 @@ function renderPlan(plan: UpgradePlan, write: boolean): string {
366
458
  return lines.join("\n").trimEnd()
367
459
  }
368
460
 
461
+ /** Explain a refused rollback: which pins would roll back, why it's blocked, and the escape hatch. */
462
+ function renderDowngradeRefusal(version: string, downgrades: readonly PinChange[]): string {
463
+ return [
464
+ `nifra upgrade → ${version}: refusing to DOWNGRADE ${downgrades.length} pin(s) below what's installed.`,
465
+ "",
466
+ ...downgrades.map((d) => ` ${d.file} ${d.name}: ${d.from} → ${d.to} (would roll back)`),
467
+ "",
468
+ "`nifra upgrade <version>` sets each pin to exactly <version>; an older target on a newer install",
469
+ "rolls dependencies backward and can violate a shared-package peer range. Target a version >= the",
470
+ "installed one, or pass --allow-downgrade if the rollback is intended.",
471
+ ].join("\n")
472
+ }
473
+
369
474
  /** CLI entry. Returns false (→ non-zero exit) on an unknown version, no project, or a failed verify. */
370
475
  export async function runUpgrade(cwd: string, options: UpgradeOptions): Promise<boolean> {
371
476
  if (options.list) {
@@ -393,13 +498,26 @@ export async function runUpgrade(cwd: string, options: UpgradeOptions): Promise<
393
498
  readFileSync(join(cwd, "package.json"), "utf8")
394
499
  } catch {
395
500
  console.error(
396
- `[nifra] no package.json in ${relative(process.cwd(), cwd) || "."} — run from a project root`,
501
+ `[nifra] no package.json in ${relative(process.cwd(), cwd) || "."} - run from a project root`,
397
502
  )
398
503
  return false
399
504
  }
400
505
 
401
506
  const write = options.write === true
402
- const plan = computeUpgrade(cwd, recipe, write)
507
+ const allowDowngrade = options.allowDowngrade === true
508
+
509
+ // Fail-closed on rollback: a dry pass writes nothing, so we can refuse before any package.json is touched.
510
+ const preview = computeUpgrade(cwd, recipe, false, allowDowngrade)
511
+ if (preview.downgrades.length > 0) {
512
+ if (options.json) {
513
+ console.log(JSON.stringify({ ...preview, write: false, refused: "downgrade" }, null, 2))
514
+ } else {
515
+ console.error(renderDowngradeRefusal(recipe.version, preview.downgrades))
516
+ }
517
+ return false
518
+ }
519
+
520
+ const plan = write ? computeUpgrade(cwd, recipe, true, allowDowngrade) : preview
403
521
 
404
522
  if (options.json) {
405
523
  console.log(JSON.stringify({ ...plan, write }, null, 2))
@@ -413,7 +531,7 @@ export async function runUpgrade(cwd: string, options: UpgradeOptions): Promise<
413
531
  const ok = await runCheck(cwd, { json: false })
414
532
  if (!ok) {
415
533
  if (!options.json)
416
- console.error("\n[nifra] upgrade applied but `nifra check` failed — review above.")
534
+ console.error("\n[nifra] upgrade applied but `nifra check` failed - review above.")
417
535
  return false
418
536
  }
419
537
  }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * One project verification, four commands. `nifra check`, `nifra assure`, `nifra levels`, and
3
+ * `nifra capabilities` are not separate audits - they are renderings of the same reflected project: the
4
+ * typed-contract scan, the route-assurance evaluation, and the static capability provenance. Historically
5
+ * each command re-derived the slice it needed, so a caller had to know which command ran which policy (and
6
+ * `levels` paid for the capability walk twice). `collectProjectVerification` runs each underlying policy
7
+ * exactly once and hands back a single value; the commands become thin formatters over subsets of it -
8
+ * `check`/`assure`/`levels` read their view directly, and `capabilities snapshot`/`check` read the same
9
+ * capability report before writing or diffing the lockfile.
10
+ *
11
+ * The typed-contract check is the one heavy pass (source walk + optional `tsc`), and `nifra assure`
12
+ * never needs it, so it is exposed as a lazily-invoked `check()` rather than computed up front.
13
+ */
14
+
15
+ import { existsSync } from "node:fs"
16
+ import { resolve } from "node:path"
17
+ import {
18
+ type AssuranceConfig,
19
+ type AssuranceReport,
20
+ evaluateRouteAssurance,
21
+ } from "@nifrajs/core/assurance"
22
+ import {
23
+ type CapabilityProjectReport,
24
+ collectCapabilityProjectReport,
25
+ } from "./capabilities-tool.ts"
26
+ import { type CheckAssuranceContext, type CheckResult, collectCheckResult } from "./check.ts"
27
+
28
+ export interface CollectProjectVerificationOptions {
29
+ /** Assurance config path, default `nifra.assurance.ts`. Honored by the assurance + levels views. */
30
+ readonly config?: string
31
+ /** Skip the `tsc` pass in the check view (the agent inner-loop mode). */
32
+ readonly lintsOnly?: boolean
33
+ /** Cancels the check view's typecheck. */
34
+ readonly signal?: AbortSignal
35
+ /** Cap the check view's diagnostics (the `levels` L0 and MCP paths set this). */
36
+ readonly maxDiagnostics?: number
37
+ }
38
+
39
+ export interface ProjectVerification {
40
+ readonly cwd: string
41
+ /** Whether `nifra.assurance.ts` (or the requested `config` path) exists. */
42
+ readonly assuranceConfigPresent: boolean
43
+ /** The loaded assurance config, when it loaded cleanly. */
44
+ readonly config?: AssuranceConfig
45
+ /** The error from loading/evaluating the config, if any; the assurance views surface or throw it. */
46
+ readonly configError?: unknown
47
+ /** `evaluateRouteAssurance` over the config's source + policy. Present whenever {@link config} is. */
48
+ readonly routeAssurance?: AssuranceReport
49
+ /** Static capability provenance, when the config declares a capabilities policy. */
50
+ readonly capability?: CapabilityProjectReport
51
+ /**
52
+ * The typed-contract check (scanners + typecheck + the assurance-fed capability/manifest diagnostics).
53
+ * Lazy and memoized: `nifra assure` never calls it, so that command pays nothing for the source walk.
54
+ */
55
+ check(): Promise<CheckResult>
56
+ }
57
+
58
+ /**
59
+ * Run each verification policy once and return the unified value. The config is loaded (and reflected)
60
+ * a single time; the resulting route-assurance + capability evidence is both returned for the assurance
61
+ * views and fed into the check view, so nothing is computed twice.
62
+ */
63
+ export async function collectProjectVerification(
64
+ cwd: string,
65
+ options: CollectProjectVerificationOptions = {},
66
+ ): Promise<ProjectVerification> {
67
+ const { loadAssuranceConfig, DEFAULT_ASSURANCE_CONFIG } = await import("./assure.ts")
68
+ const configPath = resolve(cwd, options.config ?? DEFAULT_ASSURANCE_CONFIG)
69
+ const assuranceConfigPresent = existsSync(configPath)
70
+
71
+ let config: AssuranceConfig | undefined
72
+ let configError: unknown
73
+ let routeAssurance: AssuranceReport | undefined
74
+ let capability: CapabilityProjectReport | undefined
75
+ // One try mirrors `check`'s single catch: any failure to load or reflect the config becomes the
76
+ // `configError` each view then renders in its own idiom (a diagnostic, a throw, a failed rung).
77
+ try {
78
+ config = await loadAssuranceConfig(cwd, options.config)
79
+ routeAssurance = evaluateRouteAssurance(config.source, config.policy, {
80
+ ...(config.capabilities !== undefined
81
+ ? { definitions: config.capabilities.definitions }
82
+ : {}),
83
+ })
84
+ if (config.capabilities !== undefined) {
85
+ capability = await collectCapabilityProjectReport(cwd, config.source, config.capabilities)
86
+ }
87
+ } catch (error) {
88
+ configError = error
89
+ config = undefined
90
+ routeAssurance = undefined
91
+ capability = undefined
92
+ }
93
+
94
+ // The check view's assurance inputs. Fed to `check()` only when no custom config path was requested,
95
+ // so `nifra check` (which always reads the default `nifra.assurance.ts`) and `levels` L0 keep reading
96
+ // exactly the config they read before; a `--config` override stays a route-assurance/levels concern.
97
+ const checkContext: CheckAssuranceContext = {
98
+ present: assuranceConfigPresent,
99
+ ...(config !== undefined ? { config } : {}),
100
+ ...(configError !== undefined ? { error: configError } : {}),
101
+ ...(routeAssurance !== undefined ? { routeAssurance } : {}),
102
+ ...(capability !== undefined ? { capability } : {}),
103
+ }
104
+
105
+ let checkResult: Promise<CheckResult> | undefined
106
+ return Object.freeze({
107
+ cwd,
108
+ assuranceConfigPresent,
109
+ ...(config !== undefined ? { config } : {}),
110
+ ...(configError !== undefined ? { configError } : {}),
111
+ ...(routeAssurance !== undefined ? { routeAssurance } : {}),
112
+ ...(capability !== undefined ? { capability } : {}),
113
+ check(): Promise<CheckResult> {
114
+ checkResult ??= collectCheckResult(cwd, {
115
+ lintsOnly: options.lintsOnly ?? false,
116
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
117
+ ...(options.maxDiagnostics !== undefined ? { maxDiagnostics: options.maxDiagnostics } : {}),
118
+ ...(options.config === undefined ? { assurance: checkContext } : {}),
119
+ })
120
+ return checkResult
121
+ },
122
+ })
123
+ }