@nifrajs/cli 2.11.0 → 2.12.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 (177) hide show
  1. package/README.md +6 -0
  2. package/dist/assure-hydration.d.ts +14 -0
  3. package/dist/assure-hydration.d.ts.map +1 -0
  4. package/dist/assure-hydration.js +483 -0
  5. package/dist/assure-hydration.js.map +1 -0
  6. package/dist/assure.d.ts +35 -0
  7. package/dist/assure.d.ts.map +1 -1
  8. package/dist/assure.js +222 -1
  9. package/dist/assure.js.map +1 -1
  10. package/dist/capabilities-tool.d.ts +13 -0
  11. package/dist/capabilities-tool.d.ts.map +1 -1
  12. package/dist/capabilities-tool.js +64 -1
  13. package/dist/capabilities-tool.js.map +1 -1
  14. package/dist/check.d.ts +125 -10
  15. package/dist/check.d.ts.map +1 -1
  16. package/dist/check.js +706 -145
  17. package/dist/check.js.map +1 -1
  18. package/dist/cli.d.ts +23 -0
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +194 -10
  21. package/dist/cli.js.map +1 -1
  22. package/dist/contract-proof.d.ts +49 -0
  23. package/dist/contract-proof.d.ts.map +1 -0
  24. package/dist/contract-proof.js +128 -0
  25. package/dist/contract-proof.js.map +1 -0
  26. package/dist/contracts.d.ts +22 -0
  27. package/dist/contracts.d.ts.map +1 -0
  28. package/dist/contracts.js +134 -0
  29. package/dist/contracts.js.map +1 -0
  30. package/dist/diagnostics.d.ts +23 -0
  31. package/dist/diagnostics.d.ts.map +1 -0
  32. package/dist/diagnostics.js +9 -0
  33. package/dist/diagnostics.js.map +1 -0
  34. package/dist/diff-tool.d.ts +2 -0
  35. package/dist/diff-tool.d.ts.map +1 -1
  36. package/dist/diff-tool.js +1 -1
  37. package/dist/diff-tool.js.map +1 -1
  38. package/dist/docs-search.d.ts +8 -3
  39. package/dist/docs-search.d.ts.map +1 -1
  40. package/dist/docs-search.js +36 -15
  41. package/dist/docs-search.js.map +1 -1
  42. package/dist/doctor.d.ts +28 -2
  43. package/dist/doctor.d.ts.map +1 -1
  44. package/dist/doctor.js +173 -7
  45. package/dist/doctor.js.map +1 -1
  46. package/dist/examples.d.ts +1 -2
  47. package/dist/examples.d.ts.map +1 -1
  48. package/dist/examples.js +37 -16
  49. package/dist/examples.js.map +1 -1
  50. package/dist/fix-recipes.d.ts +12 -0
  51. package/dist/fix-recipes.d.ts.map +1 -0
  52. package/dist/fix-recipes.js +106 -0
  53. package/dist/fix-recipes.js.map +1 -0
  54. package/dist/init-agents.d.ts.map +1 -1
  55. package/dist/init-agents.js +60 -4
  56. package/dist/init-agents.js.map +1 -1
  57. package/dist/internal/source-facts.d.ts +15 -0
  58. package/dist/internal/source-facts.d.ts.map +1 -0
  59. package/dist/internal/source-facts.js +82 -0
  60. package/dist/internal/source-facts.js.map +1 -0
  61. package/dist/internal/typescript-import.d.ts +9 -0
  62. package/dist/internal/typescript-import.d.ts.map +1 -1
  63. package/dist/internal/typescript-import.js +38 -0
  64. package/dist/internal/typescript-import.js.map +1 -1
  65. package/dist/introspect.d.ts +6 -2
  66. package/dist/introspect.d.ts.map +1 -1
  67. package/dist/introspect.js +53 -4
  68. package/dist/introspect.js.map +1 -1
  69. package/dist/load.d.ts +6 -0
  70. package/dist/load.d.ts.map +1 -1
  71. package/dist/load.js.map +1 -1
  72. package/dist/manifest-tool.d.ts.map +1 -1
  73. package/dist/manifest-tool.js +14 -9
  74. package/dist/manifest-tool.js.map +1 -1
  75. package/dist/mcp-docs-tools.d.ts.map +1 -1
  76. package/dist/mcp-docs-tools.js +3 -0
  77. package/dist/mcp-docs-tools.js.map +1 -1
  78. package/dist/mcp-http.js +1 -1
  79. package/dist/mcp-root.d.ts +83 -0
  80. package/dist/mcp-root.d.ts.map +1 -0
  81. package/dist/mcp-root.js +221 -0
  82. package/dist/mcp-root.js.map +1 -0
  83. package/dist/mcp-run.d.ts.map +1 -1
  84. package/dist/mcp-run.js +18 -4
  85. package/dist/mcp-run.js.map +1 -1
  86. package/dist/mcp.d.ts +27 -4
  87. package/dist/mcp.d.ts.map +1 -1
  88. package/dist/mcp.js +358 -25
  89. package/dist/mcp.js.map +1 -1
  90. package/dist/project-path.d.ts +3 -0
  91. package/dist/project-path.d.ts.map +1 -0
  92. package/dist/project-path.js +36 -0
  93. package/dist/project-path.js.map +1 -0
  94. package/dist/replay.d.ts +12 -0
  95. package/dist/replay.d.ts.map +1 -0
  96. package/dist/replay.js +36 -0
  97. package/dist/replay.js.map +1 -0
  98. package/dist/rules/codes.d.ts +42 -0
  99. package/dist/rules/codes.d.ts.map +1 -0
  100. package/dist/rules/codes.js +52 -0
  101. package/dist/rules/codes.js.map +1 -0
  102. package/dist/rules/comment-markers.d.ts +10 -0
  103. package/dist/rules/comment-markers.d.ts.map +1 -0
  104. package/dist/rules/comment-markers.js +24 -0
  105. package/dist/rules/comment-markers.js.map +1 -0
  106. package/dist/rules/index.d.ts +31 -0
  107. package/dist/rules/index.d.ts.map +1 -0
  108. package/dist/rules/index.js +133 -0
  109. package/dist/rules/index.js.map +1 -0
  110. package/dist/rules/legacy.d.ts +7 -0
  111. package/dist/rules/legacy.d.ts.map +1 -0
  112. package/dist/rules/legacy.js +85 -0
  113. package/dist/rules/legacy.js.map +1 -0
  114. package/dist/rules/routes.d.ts +12 -0
  115. package/dist/rules/routes.d.ts.map +1 -0
  116. package/dist/rules/routes.js +142 -0
  117. package/dist/rules/routes.js.map +1 -0
  118. package/dist/rules/security.d.ts +10 -0
  119. package/dist/rules/security.d.ts.map +1 -0
  120. package/dist/rules/security.js +471 -0
  121. package/dist/rules/security.js.map +1 -0
  122. package/dist/search-terms.d.ts +2 -0
  123. package/dist/search-terms.d.ts.map +1 -1
  124. package/dist/search-terms.js +5 -1
  125. package/dist/search-terms.js.map +1 -1
  126. package/dist/types-search.d.ts +1 -2
  127. package/dist/types-search.d.ts.map +1 -1
  128. package/dist/types-search.js +33 -16
  129. package/dist/types-search.js.map +1 -1
  130. package/dist/verification.d.ts +9 -0
  131. package/dist/verification.d.ts.map +1 -1
  132. package/dist/verification.js +17 -1
  133. package/dist/verification.js.map +1 -1
  134. package/dist/work-graph.d.ts +123 -0
  135. package/dist/work-graph.d.ts.map +1 -0
  136. package/dist/work-graph.js +465 -0
  137. package/dist/work-graph.js.map +1 -0
  138. package/docs/examples.json +32 -2
  139. package/docs/llms-full.txt +906 -15
  140. package/docs/types.json +1682 -130
  141. package/package.json +34 -9
  142. package/src/assure-hydration.ts +606 -0
  143. package/src/assure.ts +316 -2
  144. package/src/capabilities-tool.ts +80 -1
  145. package/src/check.ts +873 -168
  146. package/src/cli.ts +198 -11
  147. package/src/contract-proof.ts +194 -0
  148. package/src/contracts.ts +157 -0
  149. package/src/diagnostics.ts +32 -0
  150. package/src/diff-tool.ts +1 -1
  151. package/src/docs-search.ts +45 -15
  152. package/src/doctor.ts +259 -8
  153. package/src/examples.ts +43 -15
  154. package/src/fix-recipes.ts +126 -0
  155. package/src/init-agents.ts +61 -4
  156. package/src/internal/source-facts.ts +133 -0
  157. package/src/internal/typescript-import.ts +29 -0
  158. package/src/introspect.ts +75 -6
  159. package/src/load.ts +6 -0
  160. package/src/manifest-tool.ts +16 -10
  161. package/src/mcp-docs-tools.ts +3 -0
  162. package/src/mcp-http.ts +1 -1
  163. package/src/mcp-root.ts +271 -0
  164. package/src/mcp-run.ts +19 -4
  165. package/src/mcp.ts +438 -32
  166. package/src/project-path.ts +36 -0
  167. package/src/replay.ts +44 -0
  168. package/src/rules/codes.ts +57 -0
  169. package/src/rules/comment-markers.ts +26 -0
  170. package/src/rules/index.ts +175 -0
  171. package/src/rules/legacy.ts +93 -0
  172. package/src/rules/routes.ts +157 -0
  173. package/src/rules/security.ts +505 -0
  174. package/src/search-terms.ts +9 -4
  175. package/src/types-search.ts +39 -15
  176. package/src/verification.ts +18 -1
  177. package/src/work-graph.ts +613 -0
package/src/assure.ts CHANGED
@@ -1,9 +1,59 @@
1
1
  /** `nifra assure` - evaluate a project's reflected routes against `nifra.assurance.ts`. */
2
2
 
3
3
  import { existsSync } from "node:fs"
4
- import { resolve } from "node:path"
4
+ import { join, resolve } from "node:path"
5
5
  import { pathToFileURL } from "node:url"
6
6
  import type { AssuranceConfig, AssuranceReport } from "@nifrajs/core/assurance"
7
+ import type { Diagnostic } from "./diagnostics.ts"
8
+
9
+ export interface AssureSink {
10
+ record(bundle: AssureBundle): Promise<void>
11
+ }
12
+
13
+ export interface GateResult {
14
+ readonly gate:
15
+ | "check"
16
+ | "doctor"
17
+ | "render"
18
+ | "hydration"
19
+ | "contracts"
20
+ | "size"
21
+ | "tests"
22
+ | string
23
+ readonly status: "pass" | "fail" | "skip"
24
+ readonly skipReason?: string
25
+ readonly durationMs: number
26
+ readonly diagnostics: readonly Diagnostic[]
27
+ readonly replay?: string
28
+ }
29
+
30
+ export interface AssureBundle {
31
+ readonly version: 1
32
+ readonly createdAt: string
33
+ readonly commit: string
34
+ readonly gates: readonly GateResult[]
35
+ readonly verdict: "green" | "red"
36
+ }
37
+
38
+ export interface MemoryAssureSink extends AssureSink {
39
+ readonly bundles: readonly AssureBundle[]
40
+ clear(): void
41
+ }
42
+
43
+ export function createMemoryAssureSink(): MemoryAssureSink {
44
+ const bundles: AssureBundle[] = []
45
+ return {
46
+ record: async (bundle) => {
47
+ bundles.push(bundle)
48
+ },
49
+ get bundles() {
50
+ return Object.freeze([...bundles])
51
+ },
52
+ clear: () => {
53
+ bundles.length = 0
54
+ },
55
+ }
56
+ }
7
57
 
8
58
  export const DEFAULT_ASSURANCE_CONFIG = "nifra.assurance.ts"
9
59
 
@@ -77,11 +127,275 @@ export async function collectAssuranceReport(
77
127
 
78
128
  export async function runAssurance(
79
129
  cwd: string,
80
- options: { readonly json?: boolean; readonly config?: string } = {},
130
+ options: {
131
+ readonly json?: boolean
132
+ readonly config?: string
133
+ readonly bundle?: boolean
134
+ readonly strict?: boolean
135
+ readonly out?: string
136
+ readonly hydration?: boolean
137
+ readonly interact?: boolean
138
+ } = {},
81
139
  ): Promise<boolean> {
140
+ if (options.bundle === true) {
141
+ const bundle = await collectAssureBundle(cwd, options)
142
+ const serialized = JSON.stringify(bundle, null, 2)
143
+ if (options.out !== undefined) await Bun.write(resolve(cwd, options.out), `${serialized}\n`)
144
+ else console.log(serialized)
145
+ return bundle.verdict === "green"
146
+ }
82
147
  const report = await collectAssuranceReport(cwd, options.config)
83
148
  console.log(
84
149
  options.json === true ? JSON.stringify(report, null, 2) : formatAssuranceReport(report),
85
150
  )
86
151
  return report.ok
87
152
  }
153
+
154
+ function elapsed(start: number): number {
155
+ return Math.max(0, Math.round(performance.now() - start))
156
+ }
157
+
158
+ async function gitCommit(cwd: string): Promise<string> {
159
+ try {
160
+ const proc = Bun.spawn(["git", "rev-parse", "HEAD"], { cwd, stdout: "pipe", stderr: "ignore" })
161
+ const text = (await new Response(proc.stdout).text()).trim()
162
+ await proc.exited
163
+ return text || "unknown"
164
+ } catch {
165
+ return "unknown"
166
+ }
167
+ }
168
+
169
+ function diagnosticFromMessage(
170
+ code: string,
171
+ message: string,
172
+ severity: "error" | "warn" | "info" = "error",
173
+ ): Diagnostic {
174
+ return Object.freeze({ code, severity, message })
175
+ }
176
+
177
+ function statusFor(diagnostics: readonly Diagnostic[], strict: boolean): "pass" | "fail" {
178
+ return diagnostics.some(
179
+ (item) => item.severity === "error" || (strict && item.severity === "warn"),
180
+ )
181
+ ? "fail"
182
+ : "pass"
183
+ }
184
+
185
+ export async function collectAssureBundle(
186
+ cwd: string,
187
+ options: {
188
+ readonly config?: string
189
+ readonly strict?: boolean
190
+ readonly hydration?: boolean
191
+ readonly interact?: boolean
192
+ } = {},
193
+ ): Promise<AssureBundle> {
194
+ const strict = options.strict === true
195
+ const gates: GateResult[] = []
196
+ let assuranceConfig: AssuranceConfig | undefined
197
+ let assuranceConfigError: unknown
198
+ try {
199
+ assuranceConfig = await loadAssuranceConfig(cwd, options.config)
200
+ } catch (error) {
201
+ assuranceConfigError = error
202
+ }
203
+ const addGate = async (
204
+ gate: GateResult["gate"],
205
+ run: () => Promise<{ diagnostics: Diagnostic[]; skipReason?: string; replay?: string }>,
206
+ ): Promise<void> => {
207
+ const start = performance.now()
208
+ try {
209
+ const result = await run()
210
+ gates.push({
211
+ gate,
212
+ status: result.skipReason === undefined ? statusFor(result.diagnostics, strict) : "skip",
213
+ ...(result.skipReason === undefined ? {} : { skipReason: result.skipReason }),
214
+ durationMs: elapsed(start),
215
+ diagnostics: Object.freeze(result.diagnostics),
216
+ ...(result.replay === undefined ? {} : { replay: result.replay }),
217
+ })
218
+ } catch (error) {
219
+ gates.push({
220
+ gate,
221
+ status: "fail",
222
+ durationMs: elapsed(start),
223
+ diagnostics: [
224
+ diagnosticFromMessage(
225
+ gate === "contracts" ? "NF-K001" : gate === "hydration" ? "NF-H001" : "NF-A001",
226
+ error instanceof Error ? error.message : String(error),
227
+ ),
228
+ ],
229
+ })
230
+ }
231
+ }
232
+
233
+ await addGate("check", async () => {
234
+ const { collectProjectVerification } = await import("./verification.ts")
235
+ const verification = await collectProjectVerification(cwd, {
236
+ ...(options.config !== undefined ? { config: options.config } : {}),
237
+ preloaded:
238
+ assuranceConfig !== undefined
239
+ ? { config: assuranceConfig }
240
+ : { error: assuranceConfigError },
241
+ })
242
+ const result = await verification.check()
243
+ const diagnostics = (result.structuredDiagnostics ?? []).map((item) => item)
244
+ return { diagnostics }
245
+ })
246
+
247
+ await addGate("doctor", async () => {
248
+ const { collectDoctorResult } = await import("./doctor.ts")
249
+ const result = await collectDoctorResult(cwd)
250
+ const diagnostics: Diagnostic[] = []
251
+ for (const finding of result.findings) {
252
+ diagnostics.push(
253
+ diagnosticFromMessage(
254
+ "NF-D001",
255
+ `imports ${finding.package} which is not declared`,
256
+ "error",
257
+ ),
258
+ )
259
+ }
260
+ for (const finding of result.duplicateInstalls) {
261
+ diagnostics.push(
262
+ diagnosticFromMessage("NF-D001", `${finding.package} resolves to duplicate installs`),
263
+ )
264
+ }
265
+ return { diagnostics }
266
+ })
267
+
268
+ await addGate("contracts", async () => {
269
+ const { checkContractsLock } = await import("./contracts.ts")
270
+ const path = resolve(cwd, "contracts.lock.json")
271
+ if (!existsSync(path)) return { diagnostics: [], skipReason: "no contract lock configured" }
272
+ const result = await checkContractsLock(cwd)
273
+ return {
274
+ diagnostics: result.diagnostics.map((item) =>
275
+ diagnosticFromMessage("NF-K001", item.message, "error"),
276
+ ),
277
+ }
278
+ })
279
+
280
+ await addGate("render", async () => {
281
+ if (!existsSync(join(cwd, "nifra.config.ts")) && !existsSync(join(cwd, "framework.ts")))
282
+ return { diagnostics: [], skipReason: "no client render target configured" }
283
+ const { renderPages } = await import("./mcp-render.ts")
284
+ const result = await renderPages(cwd, [{ path: "/" }])
285
+ if ("error" in result)
286
+ return {
287
+ diagnostics: [diagnosticFromMessage("NF-A001", "SSR render gate failed", "error")],
288
+ }
289
+ const pages = Array.isArray(result.results) ? result.results : []
290
+ const failure = pages.find(
291
+ (page): page is { status?: unknown } =>
292
+ typeof page === "object" &&
293
+ page !== null &&
294
+ Number((page as { status?: unknown }).status) >= 500,
295
+ )
296
+ return failure === undefined
297
+ ? { diagnostics: [] }
298
+ : { diagnostics: [diagnosticFromMessage("NF-A001", "SSR render returned a server error")] }
299
+ })
300
+ await addGate("size", async () => {
301
+ const budget = assuranceConfig?.size
302
+ if (
303
+ budget === undefined ||
304
+ (budget.maxBytes === undefined && budget.maxGzipBytes === undefined)
305
+ )
306
+ return { diagnostics: [], skipReason: "no size budget configured" }
307
+ const outDir = resolve(cwd, budget.outDir ?? "dist")
308
+ if (!existsSync(outDir))
309
+ return {
310
+ diagnostics: [diagnosticFromMessage("NF-A001", "size gate output directory is missing")],
311
+ }
312
+ const files: string[] = []
313
+ for await (const file of new Bun.Glob("**/*").scan({ cwd: outDir, onlyFiles: true }))
314
+ files.push(join(outDir, file))
315
+ let bytes = 0
316
+ let gzipBytes = 0
317
+ for (const file of files) {
318
+ const data = await Bun.file(file).bytes()
319
+ bytes += data.byteLength
320
+ gzipBytes += Bun.gzipSync(data).byteLength
321
+ }
322
+ const overRaw = budget.maxBytes !== undefined && bytes > budget.maxBytes
323
+ const overGzip = budget.maxGzipBytes !== undefined && gzipBytes > budget.maxGzipBytes
324
+ return {
325
+ diagnostics:
326
+ overRaw || overGzip
327
+ ? [
328
+ diagnosticFromMessage(
329
+ "NF-A001",
330
+ `output exceeds configured size budget (${bytes} bytes, ${gzipBytes} gzip bytes)`,
331
+ ),
332
+ ]
333
+ : [],
334
+ }
335
+ })
336
+ if (options.hydration === true) {
337
+ await addGate("hydration", async () => {
338
+ const { runHydrationAssurance } = await import("./assure-hydration.ts")
339
+ const result = await runHydrationAssurance(cwd, { interact: options.interact === true })
340
+ return {
341
+ diagnostics: [...result.diagnostics],
342
+ ...(result.skipReason === undefined ? {} : { skipReason: result.skipReason }),
343
+ ...(result.replays?.[0] === undefined ? {} : { replay: result.replays[0] }),
344
+ }
345
+ })
346
+ } else {
347
+ await addGate("hydration", async () => ({
348
+ diagnostics: [],
349
+ skipReason: "pass --hydration to run the hydration gate",
350
+ }))
351
+ }
352
+ const workloads = assuranceConfig?.idempotency ?? []
353
+ const testGateStart = performance.now()
354
+ if (workloads.length === 0) {
355
+ gates.push({
356
+ gate: "tests",
357
+ status: "skip",
358
+ skipReason: "no idempotency workloads configured",
359
+ durationMs: elapsed(testGateStart),
360
+ diagnostics: [],
361
+ })
362
+ } else {
363
+ const { proveIdempotency } = await import("@nifrajs/testing")
364
+ const diagnostics: Diagnostic[] = []
365
+ for (const workload of workloads) {
366
+ const proof = await proveIdempotency({ run: workload.run })
367
+ for (const divergence of proof.divergences) {
368
+ diagnostics.push(
369
+ diagnosticFromMessage(
370
+ "NF-A001",
371
+ `${workload.name} diverged at step ${divergence.step} field ${divergence.field}`,
372
+ "error",
373
+ ),
374
+ )
375
+ }
376
+ }
377
+ gates.push({
378
+ gate: "tests",
379
+ status: statusFor(diagnostics, strict),
380
+ durationMs: elapsed(testGateStart),
381
+ diagnostics,
382
+ })
383
+ }
384
+ const bundle: AssureBundle = Object.freeze({
385
+ version: 1,
386
+ createdAt: new Date().toISOString(),
387
+ commit: await gitCommit(cwd),
388
+ gates: Object.freeze(gates),
389
+ verdict: gates.some((gate) => gate.status === "fail") ? "red" : "green",
390
+ })
391
+ const sink = assuranceConfig?.assureSink
392
+ if (
393
+ typeof sink === "object" &&
394
+ sink !== null &&
395
+ "record" in sink &&
396
+ typeof sink.record === "function"
397
+ ) {
398
+ await sink.record(bundle)
399
+ }
400
+ return bundle
401
+ }
@@ -48,6 +48,30 @@ function specifierMatches(pattern: string, specifier: string): boolean {
48
48
  return pattern.endsWith("/*") ? specifier.startsWith(pattern.slice(0, -1)) : specifier === pattern
49
49
  }
50
50
 
51
+ const SPECIFIER_EXTENSION = /\.(?:[cm]?[jt]sx?)$/
52
+
53
+ /** Reduce a specifier to the name a human would recognise: `./services/mail.ts` and `../mail/index.ts`
54
+ * both stem to `mail`, which is what makes a written-vs-actual mismatch suggestible. */
55
+ function specifierStem(specifier: string): string {
56
+ const segments = specifier
57
+ .replace(SPECIFIER_EXTENSION, "")
58
+ .split("/")
59
+ .filter((segment) => segment !== "" && segment !== "." && segment !== "..")
60
+ const last = segments.at(-1)
61
+ if (last === undefined) return specifier
62
+ return last === "index" ? (segments.at(-2) ?? last) : last
63
+ }
64
+
65
+ const suggestionSuffix = (suggestions: readonly string[]): string =>
66
+ suggestions.length === 0 ? "" : ` - did you mean ${suggestions.map((s) => `"${s}"`).join(", ")}?`
67
+
68
+ function nearestSpecifiers(pattern: string, candidates: Iterable<string>): string[] {
69
+ const stem = specifierStem(pattern.endsWith("/*") ? pattern.slice(0, -2) : pattern)
70
+ const near = new Set<string>()
71
+ for (const candidate of candidates) if (specifierStem(candidate) === stem) near.add(candidate)
72
+ return [...near].sort().slice(0, 3)
73
+ }
74
+
51
75
  const routeKey = (method: string, path: string): string => `${method.toUpperCase()}\n${path}`
52
76
 
53
77
  export interface CapabilityImportViolation {
@@ -63,6 +87,20 @@ export interface CapabilityProjectReport {
63
87
  readonly report: CapabilityAssuranceReport
64
88
  readonly violations: readonly CapabilityImportViolation[]
65
89
  readonly truncations: readonly CapabilityProvenanceTruncation[]
90
+ readonly unmatchedSeams: readonly CapabilityUnmatchedSeam[]
91
+ }
92
+
93
+ /**
94
+ * A provenance rule that names something the project does not contain. Seam specifiers are matched
95
+ * as written, so `"./services/mail.ts"` against `import "./services/mail"` silently governs nothing -
96
+ * the effect stays invisible to assurance and every route using it still passes.
97
+ */
98
+ export interface CapabilityUnmatchedSeam {
99
+ readonly kind: "import" | "route-module"
100
+ /** The specifier (import rule) or project-relative module path (routeModules entry) as written. */
101
+ readonly value: string
102
+ /** Closest specifiers/modules actually present, when one is recognisable. */
103
+ readonly suggestions: readonly string[]
66
104
  }
67
105
 
68
106
  export interface CapabilityProvenanceTruncation {
@@ -264,9 +302,40 @@ export async function collectCapabilityProjectReport(
264
302
  violations.push(...walked.violations)
265
303
  truncations.push(...walked.truncations)
266
304
  }
305
+
306
+ // A seam rule that matches nothing is a policy typo, not a passing project: the effect it was
307
+ // written to govern keeps executing, unwatched, and every route touching it still reports clean.
308
+ // Checked against every scanned source (not just route-reachable ones) so a seam used by a job or
309
+ // a script is not reported as missing.
310
+ const scannedSpecifiers = new Set<string>()
311
+ for (const content of sources.values())
312
+ for (const specifier of scanEffectImports(content)) scannedSpecifiers.add(specifier)
313
+ const unmatchedSeams: CapabilityUnmatchedSeam[] = []
314
+ for (const rule of policy.provenance.imports) {
315
+ if (rule.optional === true) continue
316
+ if ([...scannedSpecifiers].some((specifier) => specifierMatches(rule.specifier, specifier)))
317
+ continue
318
+ unmatchedSeams.push({
319
+ kind: "import",
320
+ value: rule.specifier,
321
+ suggestions: Object.freeze(nearestSpecifiers(rule.specifier, scannedSpecifiers)),
322
+ })
323
+ }
324
+ for (const association of policy.provenance.routeModules ?? []) {
325
+ for (const module of association.modules) {
326
+ const normalized = module.replaceAll("\\", "/")
327
+ if (sources.has(normalized) || existsSync(join(cwd, normalized))) continue
328
+ unmatchedSeams.push({
329
+ kind: "route-module",
330
+ value: module,
331
+ suggestions: Object.freeze(nearestSpecifiers(module, sources.keys())),
332
+ })
333
+ }
334
+ }
335
+
267
336
  const evaluated = evaluateCapabilityAssurance(source, policy, { routes: evidenceRoutes })
268
337
  const report: CapabilityAssuranceReport =
269
- violations.length === 0 && truncations.length === 0
338
+ violations.length === 0 && truncations.length === 0 && unmatchedSeams.length === 0
270
339
  ? evaluated
271
340
  : Object.freeze({
272
341
  ...evaluated,
@@ -285,12 +354,22 @@ export async function collectCapabilityProjectReport(
285
354
  path: truncation.path,
286
355
  message: `${truncation.method} ${truncation.path} capability provenance hit the ${truncation.reason === "depth-limit" ? "import-depth" : "module-count"} safety limit via ${truncation.chain.join(" → ")}; assurance cannot prove the remaining graph`,
287
356
  })),
357
+ ...unmatchedSeams.map((seam) => ({
358
+ code: "unmatched-provenance-seam" as const,
359
+ method: "*",
360
+ path: "*",
361
+ message:
362
+ seam.kind === "import"
363
+ ? `capabilities.provenance.imports "${seam.value}" matched no import in this project, so the effect it declares is ungoverned - specifiers are compared exactly (or as a trailing /* prefix), not resolved${suggestionSuffix(seam.suggestions)}`
364
+ : `capabilities.provenance.routeModules "${seam.value}" is not a file in this project, so the routes it associates stay uncovered${suggestionSuffix(seam.suggestions)}`,
365
+ })),
288
366
  ]),
289
367
  })
290
368
  return Object.freeze({
291
369
  report,
292
370
  violations: Object.freeze(violations),
293
371
  truncations: Object.freeze(truncations),
372
+ unmatchedSeams: Object.freeze(unmatchedSeams),
294
373
  })
295
374
  }
296
375