@nifrajs/cli 3.3.0 → 3.5.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 (118) hide show
  1. package/dist/check-diagnostics.d.ts +3 -15
  2. package/dist/check-diagnostics.d.ts.map +1 -1
  3. package/dist/check-diagnostics.js +149 -713
  4. package/dist/check-diagnostics.js.map +1 -1
  5. package/dist/check-scan.d.ts +9 -0
  6. package/dist/check-scan.d.ts.map +1 -1
  7. package/dist/check-scan.js +32 -0
  8. package/dist/check-scan.js.map +1 -1
  9. package/dist/check.d.ts +1 -0
  10. package/dist/check.d.ts.map +1 -1
  11. package/dist/check.js +63 -8
  12. package/dist/check.js.map +1 -1
  13. package/dist/cli.d.ts.map +1 -1
  14. package/dist/cli.js +46 -7
  15. package/dist/cli.js.map +1 -1
  16. package/dist/command-catalog.d.ts +4 -1
  17. package/dist/command-catalog.d.ts.map +1 -1
  18. package/dist/command-catalog.js +28 -7
  19. package/dist/command-catalog.js.map +1 -1
  20. package/dist/contracts.d.ts +3 -2
  21. package/dist/contracts.d.ts.map +1 -1
  22. package/dist/contracts.js +4 -3
  23. package/dist/contracts.js.map +1 -1
  24. package/dist/diagnostics.d.ts +86 -0
  25. package/dist/diagnostics.d.ts.map +1 -1
  26. package/dist/diagnostics.js +85 -0
  27. package/dist/diagnostics.js.map +1 -1
  28. package/dist/introspect.d.ts +1 -3
  29. package/dist/introspect.d.ts.map +1 -1
  30. package/dist/introspect.js +1 -47
  31. package/dist/introspect.js.map +1 -1
  32. package/dist/load.d.ts +5 -0
  33. package/dist/load.d.ts.map +1 -1
  34. package/dist/load.js.map +1 -1
  35. package/dist/mcp-context.d.ts.map +1 -1
  36. package/dist/mcp-context.js +4 -5
  37. package/dist/mcp-context.js.map +1 -1
  38. package/dist/mcp-exec.d.ts +1 -0
  39. package/dist/mcp-exec.d.ts.map +1 -1
  40. package/dist/mcp-exec.js +133 -25
  41. package/dist/mcp-exec.js.map +1 -1
  42. package/dist/mcp-http.d.ts.map +1 -1
  43. package/dist/mcp-http.js +4 -1
  44. package/dist/mcp-http.js.map +1 -1
  45. package/dist/mcp-io.d.ts +12 -0
  46. package/dist/mcp-io.d.ts.map +1 -1
  47. package/dist/mcp-io.js +68 -0
  48. package/dist/mcp-io.js.map +1 -1
  49. package/dist/mcp-path.d.ts +10 -0
  50. package/dist/mcp-path.d.ts.map +1 -0
  51. package/dist/mcp-path.js +51 -0
  52. package/dist/mcp-path.js.map +1 -0
  53. package/dist/mcp-reflect.d.ts.map +1 -1
  54. package/dist/mcp-reflect.js +2 -2
  55. package/dist/mcp-reflect.js.map +1 -1
  56. package/dist/mcp-render.d.ts.map +1 -1
  57. package/dist/mcp-render.js +62 -43
  58. package/dist/mcp-render.js.map +1 -1
  59. package/dist/mcp-root.d.ts.map +1 -1
  60. package/dist/mcp-root.js +14 -2
  61. package/dist/mcp-root.js.map +1 -1
  62. package/dist/mcp-run.d.ts.map +1 -1
  63. package/dist/mcp-run.js +82 -51
  64. package/dist/mcp-run.js.map +1 -1
  65. package/dist/mcp.d.ts +2 -2
  66. package/dist/mcp.d.ts.map +1 -1
  67. package/dist/mcp.js +84 -29
  68. package/dist/mcp.js.map +1 -1
  69. package/dist/project-facts.d.ts +23 -4
  70. package/dist/project-facts.d.ts.map +1 -1
  71. package/dist/project-facts.js +12 -2
  72. package/dist/project-facts.js.map +1 -1
  73. package/dist/review.d.ts +21 -0
  74. package/dist/review.d.ts.map +1 -0
  75. package/dist/review.js +819 -0
  76. package/dist/review.js.map +1 -0
  77. package/dist/rules/legacy.d.ts +3 -1
  78. package/dist/rules/legacy.d.ts.map +1 -1
  79. package/dist/rules/legacy.js +796 -34
  80. package/dist/rules/legacy.js.map +1 -1
  81. package/dist/scaffold.d.ts.map +1 -1
  82. package/dist/scaffold.js +23 -4
  83. package/dist/scaffold.js.map +1 -1
  84. package/dist/verification-plan.d.ts.map +1 -1
  85. package/dist/verification-plan.js +4 -0
  86. package/dist/verification-plan.js.map +1 -1
  87. package/dist/work-graph.d.ts +3 -0
  88. package/dist/work-graph.d.ts.map +1 -1
  89. package/dist/work-graph.js +4 -3
  90. package/dist/work-graph.js.map +1 -1
  91. package/docs/llms-full.txt +369 -21
  92. package/docs/types.json +1121 -80
  93. package/package.json +10 -9
  94. package/src/check-diagnostics.ts +181 -796
  95. package/src/check-scan.ts +36 -0
  96. package/src/check.ts +65 -12
  97. package/src/cli.ts +53 -7
  98. package/src/command-catalog.ts +33 -7
  99. package/src/contracts.ts +12 -3
  100. package/src/diagnostics.ts +179 -0
  101. package/src/introspect.ts +3 -55
  102. package/src/load.ts +5 -0
  103. package/src/mcp-context.ts +4 -6
  104. package/src/mcp-exec.ts +142 -30
  105. package/src/mcp-http.ts +4 -1
  106. package/src/mcp-io.ts +73 -0
  107. package/src/mcp-path.ts +50 -0
  108. package/src/mcp-reflect.ts +3 -2
  109. package/src/mcp-render.ts +70 -40
  110. package/src/mcp-root.ts +13 -2
  111. package/src/mcp-run.ts +90 -50
  112. package/src/mcp.ts +87 -27
  113. package/src/project-facts.ts +37 -8
  114. package/src/review.ts +983 -0
  115. package/src/rules/legacy.ts +915 -35
  116. package/src/scaffold.ts +29 -4
  117. package/src/verification-plan.ts +14 -0
  118. package/src/work-graph.ts +13 -3
package/src/review.ts ADDED
@@ -0,0 +1,983 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path"
2
+ import type {
3
+ ReviewCategory,
4
+ ReviewCheckId,
5
+ ReviewCheckResult,
6
+ ReviewEvidenceRef,
7
+ ReviewEvidenceSource,
8
+ ReviewFinding,
9
+ ReviewFixRecipeId,
10
+ ReviewFixResult,
11
+ ReviewReport,
12
+ ReviewReportDraft,
13
+ ReviewScope,
14
+ ReviewSeverity,
15
+ } from "@nifrajs/agent-review"
16
+ import type { CheckResult } from "./check.ts"
17
+ import type { CommandCtx, CommandSpec } from "./command-catalog.ts"
18
+ import type { Diagnostic } from "./diagnostics.ts"
19
+ import { toSarifLog } from "./diagnostics.ts"
20
+ import { RULE_CODES } from "./rules/codes.ts"
21
+ import { LEGACY_RULE_CODES } from "./rules/legacy.ts"
22
+
23
+ export interface ReviewInput {
24
+ readonly strict?: boolean | undefined
25
+ readonly diff?: string | undefined
26
+ readonly sarif?: string | undefined
27
+ readonly fix?: boolean | undefined
28
+ readonly dryRun?: boolean | undefined
29
+ readonly write?: boolean | undefined
30
+ readonly json?: boolean | undefined
31
+ }
32
+
33
+ export class ReviewInputError extends Error {
34
+ readonly exitCode = 2 as const
35
+
36
+ constructor(message: string) {
37
+ super(message)
38
+ this.name = "ReviewInputError"
39
+ }
40
+ }
41
+
42
+ const REVIEW_INPUT_KEYS = new Set(["strict", "diff", "sarif", "fix", "dryRun", "write", "json"])
43
+ const CODE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
44
+ const SAFE_GIT_REF = /^[A-Za-z0-9][A-Za-z0-9._/@+-]{0,255}$/
45
+
46
+ function compareText(left: string, right: string): number {
47
+ return left < right ? -1 : left > right ? 1 : 0
48
+ }
49
+
50
+ const CHECK_IDS: readonly ReviewCheckId[] = [
51
+ "typecheck",
52
+ "typed-client",
53
+ "server-boundary",
54
+ "route-boundary",
55
+ "pipeline",
56
+ "security",
57
+ "route-assurance",
58
+ "capability-provenance",
59
+ "manifest",
60
+ "dependency",
61
+ "contract-witness",
62
+ "coverage",
63
+ "hydration",
64
+ "configuration",
65
+ ]
66
+
67
+ const REQUIRED_CHECKS = new Set<ReviewCheckId>([
68
+ "typecheck",
69
+ "typed-client",
70
+ "server-boundary",
71
+ "route-boundary",
72
+ "pipeline",
73
+ "security",
74
+ "dependency",
75
+ ])
76
+
77
+ const CHECK_CATEGORIES: Readonly<Record<ReviewCheckId, ReviewCategory>> = {
78
+ typecheck: "correctness",
79
+ "typed-client": "correctness",
80
+ "server-boundary": "boundary",
81
+ "route-boundary": "boundary",
82
+ pipeline: "correctness",
83
+ security: "security",
84
+ "route-assurance": "assurance",
85
+ "capability-provenance": "capability",
86
+ manifest: "assurance",
87
+ dependency: "dependency",
88
+ "contract-witness": "contract",
89
+ coverage: "coverage",
90
+ hydration: "hydration",
91
+ configuration: "configuration",
92
+ }
93
+
94
+ const CHECK_SOURCES: Readonly<Record<ReviewCheckId, ReviewEvidenceSource>> = {
95
+ typecheck: "check",
96
+ "typed-client": "check",
97
+ "server-boundary": "check",
98
+ "route-boundary": "check",
99
+ pipeline: "check",
100
+ security: "check",
101
+ "route-assurance": "assurance",
102
+ "capability-provenance": "capability",
103
+ manifest: "manifest",
104
+ dependency: "check",
105
+ "contract-witness": "contract",
106
+ coverage: "coverage",
107
+ hydration: "assurance",
108
+ configuration: "collector",
109
+ }
110
+
111
+ const ALLOWED_FIXES = new Set<ReviewFixRecipeId>(["manifest.sync", "workspace-dist.rebuild"])
112
+
113
+ const CHECK_CODES: Readonly<Record<string, ReviewCheckId>> = {
114
+ "NF-C001": "typecheck",
115
+ "NF-C002": "typed-client",
116
+ "NF-C003": "typed-client",
117
+ "NF-C004": "server-boundary",
118
+ "NF-C005": "typed-client",
119
+ "NF-C006": "security",
120
+ "NF-C007": "typed-client",
121
+ "NF-C008": "dependency",
122
+ "NF-C009": "dependency",
123
+ "NF-C010": "dependency",
124
+ "NF-C011": "pipeline",
125
+ "NF-C012": "manifest",
126
+ "NF-C013": "manifest",
127
+ "NF-C014": "capability-provenance",
128
+ "NF-C015": "route-assurance",
129
+ "NF-C016": "configuration",
130
+ "NF-C017": "configuration",
131
+ "NF-C018": "route-boundary",
132
+ "NF-C019": "route-boundary",
133
+ "NF-C020": "route-boundary",
134
+ "NF-C021": "route-boundary",
135
+ "NF-C022": "route-boundary",
136
+ "NF-C023": "route-boundary",
137
+ "NF-D001": "dependency",
138
+ "NF-A001": "route-assurance",
139
+ "NF-H001": "hydration",
140
+ "NF-H002": "hydration",
141
+ "NF-H003": "hydration",
142
+ "NF-H004": "hydration",
143
+ "NF-K001": "contract-witness",
144
+ }
145
+
146
+ for (const code of ["NF-S001", "NF-S002", "NF-S003", "NF-S004", "NF-S005", "NF-S006", "NF-S007"])
147
+ (CHECK_CODES as Record<string, ReviewCheckId>)[code] = "security"
148
+
149
+ const REVIEW_INPUT_SCHEMA = {
150
+ type: "object",
151
+ properties: {
152
+ strict: { type: "boolean" },
153
+ diff: { type: "string" },
154
+ sarif: { type: "string" },
155
+ fix: { type: "boolean" },
156
+ dryRun: { type: "boolean" },
157
+ write: { type: "boolean" },
158
+ json: { type: "boolean" },
159
+ },
160
+ additionalProperties: false,
161
+ } as const
162
+
163
+ function isRecord(value: unknown): value is Record<string, unknown> {
164
+ return typeof value === "object" && value !== null && !Array.isArray(value)
165
+ }
166
+
167
+ function optionalBoolean(raw: Record<string, unknown>, key: string): boolean | undefined {
168
+ const value = raw[key]
169
+ if (value === undefined) return undefined
170
+ if (typeof value !== "boolean") throw new ReviewInputError(`${key} must be a boolean`)
171
+ return value
172
+ }
173
+
174
+ function optionalString(raw: Record<string, unknown>, key: string): string | undefined {
175
+ const value = raw[key]
176
+ if (value === undefined) return undefined
177
+ if (
178
+ typeof value !== "string" ||
179
+ value.length === 0 ||
180
+ value.includes("\u0000") ||
181
+ value.includes("\\")
182
+ )
183
+ throw new ReviewInputError(`${key} must be a non-empty safe string`)
184
+ return value
185
+ }
186
+
187
+ /** Parse and validate the shared CLI/MCP review input before any collector is invoked. */
188
+ export function parseReviewInput(value: unknown): ReviewInput {
189
+ if (!isRecord(value)) throw new ReviewInputError("review input must be an object")
190
+ for (const key of Object.keys(value))
191
+ if (!REVIEW_INPUT_KEYS.has(key)) throw new ReviewInputError(`unknown review option: ${key}`)
192
+ const strict = optionalBoolean(value, "strict")
193
+ const diff = optionalString(value, "diff")
194
+ const sarif = optionalString(value, "sarif")
195
+ const fix = optionalBoolean(value, "fix")
196
+ const dryRun = optionalBoolean(value, "dryRun")
197
+ const write = optionalBoolean(value, "write")
198
+ const json = optionalBoolean(value, "json")
199
+ if (dryRun === true && fix !== true) throw new ReviewInputError("dryRun requires fix")
200
+ if (write === true && fix !== true) throw new ReviewInputError("write requires fix")
201
+ if (dryRun === true && write === true)
202
+ throw new ReviewInputError("dryRun and write are mutually exclusive")
203
+ return {
204
+ ...(strict === undefined ? {} : { strict }),
205
+ ...(diff === undefined ? {} : { diff }),
206
+ ...(sarif === undefined ? {} : { sarif }),
207
+ ...(fix === undefined ? {} : { fix }),
208
+ ...(dryRun === undefined ? {} : { dryRun }),
209
+ ...(write === undefined ? {} : { write }),
210
+ ...(json === undefined ? {} : { json }),
211
+ }
212
+ }
213
+
214
+ function normalizedCode(value: unknown): string {
215
+ if (typeof value !== "string") return "application-rule"
216
+ const code = LEGACY_RULE_CODES[value] ?? value
217
+ return CODE_PATTERN.test(code) ? code : "application-rule"
218
+ }
219
+
220
+ function normalizedSeverity(value: unknown): ReviewSeverity {
221
+ return value === "error" ? "error" : value === "warn" || value === "warning" ? "warning" : "info"
222
+ }
223
+
224
+ function normalizedLocation(
225
+ cwd: string,
226
+ file: unknown,
227
+ line: unknown,
228
+ ): { readonly path: string; readonly line?: number } | undefined {
229
+ if (
230
+ typeof file !== "string" ||
231
+ file.length === 0 ||
232
+ file.includes("\u0000") ||
233
+ file.includes("\\")
234
+ )
235
+ return undefined
236
+ if (/^[A-Za-z]:[\\/]/.test(file)) return undefined
237
+ const candidate = isAbsolute(file) ? resolve(file) : resolve(cwd, file)
238
+ const path = relative(resolve(cwd), candidate).split(sep).join("/")
239
+ if (path.length === 0 || path === ".." || path.startsWith("../") || path.startsWith("/"))
240
+ return undefined
241
+ if (path.split("/").some((part) => part === "." || part === ".." || part.length === 0))
242
+ return undefined
243
+ return {
244
+ path,
245
+ ...(typeof line === "number" && Number.isSafeInteger(line) && line > 0 ? { line } : {}),
246
+ }
247
+ }
248
+
249
+ function codeCheck(code: string): ReviewCheckId {
250
+ return CHECK_CODES[code] ?? "configuration"
251
+ }
252
+
253
+ function sourceFor(check: ReviewCheckId): ReviewEvidenceSource {
254
+ return CHECK_SOURCES[check]
255
+ }
256
+
257
+ async function sha256Text(value: string): Promise<string> {
258
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))
259
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")
260
+ }
261
+
262
+ async function composeReport(input: ReviewReportDraft): Promise<ReviewReport> {
263
+ // Keep the optional review leaf off ordinary CLI startup. The command catalog can describe the
264
+ // command without importing the contract implementation; only an actual review loads it.
265
+ const { composeReviewReport } = await import("@nifrajs/agent-review")
266
+ return composeReviewReport(input)
267
+ }
268
+
269
+ async function evidenceRef(
270
+ source: ReviewEvidenceSource,
271
+ material: string,
272
+ path?: string,
273
+ ): Promise<ReviewEvidenceRef> {
274
+ const digest = await sha256Text(material)
275
+ const token = `r-${digest.slice(0, 32)}`
276
+ return { source, token, digest, ...(path === undefined ? {} : { path }) }
277
+ }
278
+
279
+ function diagnosticValue(result: CheckResult): readonly Diagnostic[] {
280
+ if (result.structuredDiagnostics !== undefined) return result.structuredDiagnostics
281
+ return result.diagnostics.map((value) => ({
282
+ code: value.code ?? value.rule,
283
+ severity: value.severity === "warning" ? "warn" : value.severity,
284
+ message: "Nifra diagnostic",
285
+ ...(value.file === undefined ? {} : { file: value.file }),
286
+ ...(value.line === undefined ? {} : { line: value.line }),
287
+ ...(value.fix === undefined ? {} : { fix: { recipe: value.fix } }),
288
+ }))
289
+ }
290
+
291
+ interface CandidateFinding {
292
+ readonly diagnostic: Diagnostic
293
+ readonly check: ReviewCheckId
294
+ readonly code: string
295
+ readonly severity: ReviewSeverity
296
+ readonly location?: { readonly path: string; readonly line?: number }
297
+ readonly finding: ReviewFinding
298
+ }
299
+
300
+ async function candidateFinding(
301
+ cwd: string,
302
+ diagnostic: Diagnostic,
303
+ check: ReviewCheckId,
304
+ index: number,
305
+ ): Promise<CandidateFinding> {
306
+ const code = normalizedCode(diagnostic.code)
307
+ const severity = normalizedSeverity(diagnostic.severity)
308
+ const location = normalizedLocation(cwd, diagnostic.file, diagnostic.line)
309
+ const material = `${check}\u0000${code}\u0000${location?.path ?? ""}\u0000${location?.line ?? ""}\u0000${index}`
310
+ const evidence = await evidenceRef(sourceFor(check), material, location?.path)
311
+ const recipe = diagnostic.fix?.recipe
312
+ const fix =
313
+ typeof recipe === "string" && ALLOWED_FIXES.has(recipe as ReviewFixRecipeId)
314
+ ? { recipe: recipe as ReviewFixRecipeId }
315
+ : undefined
316
+ const finding: ReviewFinding = {
317
+ id: `f-${check}-${index}`,
318
+ check,
319
+ code,
320
+ severity,
321
+ category: CHECK_CATEGORIES[check],
322
+ ...(location === undefined ? {} : { location }),
323
+ evidence: [evidence],
324
+ ...(fix === undefined ? {} : { fix }),
325
+ }
326
+ return {
327
+ diagnostic,
328
+ check,
329
+ code,
330
+ severity,
331
+ ...(location === undefined ? {} : { location }),
332
+ finding,
333
+ }
334
+ }
335
+
336
+ function checkConfigured(
337
+ id: ReviewCheckId,
338
+ diagnostics: readonly CandidateFinding[],
339
+ verification: ProjectVerificationLike,
340
+ ): boolean {
341
+ if (REQUIRED_CHECKS.has(id)) return true
342
+ if (diagnostics.length > 0) return true
343
+ switch (id) {
344
+ case "route-assurance":
345
+ return verification.config !== undefined || verification.assuranceConfigPresent
346
+ case "capability-provenance":
347
+ return verification.capability !== undefined
348
+ case "manifest":
349
+ return verification.evidence !== undefined
350
+ case "contract-witness":
351
+ return false
352
+ case "coverage":
353
+ return false
354
+ case "hydration":
355
+ return false
356
+ case "configuration":
357
+ return false
358
+ default:
359
+ return false
360
+ }
361
+ }
362
+
363
+ function checkStatus(findings: readonly CandidateFinding[]): "pass" | "fail" {
364
+ return findings.some((finding) => finding.severity === "error") ? "fail" : "pass"
365
+ }
366
+
367
+ function countsFor(
368
+ findings: readonly CandidateFinding[],
369
+ outOfScope: number,
370
+ ): ReviewCheckResult["counts"] {
371
+ return {
372
+ findings: findings.length,
373
+ errors: findings.filter((finding) => finding.severity === "error").length,
374
+ warnings: findings.filter((finding) => finding.severity === "warning").length,
375
+ info: findings.filter((finding) => finding.severity === "info").length,
376
+ outOfScope,
377
+ }
378
+ }
379
+
380
+ function safeGitRef(value: string): boolean {
381
+ return (
382
+ SAFE_GIT_REF.test(value) &&
383
+ !value.includes("..") &&
384
+ !value.includes("@{") &&
385
+ !value.includes("//") &&
386
+ !value.endsWith(".") &&
387
+ !value.endsWith("/") &&
388
+ !value
389
+ .split("/")
390
+ .some(
391
+ (part) =>
392
+ part.length === 0 ||
393
+ part === "." ||
394
+ part === ".." ||
395
+ part.startsWith(".") ||
396
+ part.endsWith(".lock"),
397
+ )
398
+ )
399
+ }
400
+
401
+ interface GitResult {
402
+ readonly ok: boolean
403
+ readonly stdout: string
404
+ }
405
+
406
+ async function runGit(cwd: string, args: readonly string[]): Promise<GitResult> {
407
+ const process = Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "pipe" })
408
+ const [stdout] = await Promise.all([
409
+ new Response(process.stdout).text(),
410
+ new Response(process.stderr).text(),
411
+ ])
412
+ const exitCode = await process.exited
413
+ return { ok: exitCode === 0, stdout }
414
+ }
415
+
416
+ function nulPaths(value: string): readonly string[] {
417
+ return value
418
+ .split("\u0000")
419
+ .filter((path) => path.length > 0)
420
+ .map((path) => path.replaceAll("\\", "/"))
421
+ }
422
+
423
+ async function diffScope(cwd: string, ref: string): Promise<ReviewScope> {
424
+ if (!safeGitRef(ref)) throw new ReviewInputError("invalid git ref")
425
+ const verified = await runGit(cwd, [
426
+ "rev-parse",
427
+ "--verify",
428
+ "--quiet",
429
+ "--end-of-options",
430
+ `${ref}^{commit}`,
431
+ ])
432
+ if (!verified.ok) throw new ReviewInputError("invalid git ref")
433
+ const commit = verified.stdout.trim()
434
+ if (!/^[0-9a-f]{40,64}$/.test(commit)) throw new ReviewInputError("invalid git ref")
435
+ const changed = await runGit(cwd, [
436
+ "diff",
437
+ "--name-only",
438
+ "-z",
439
+ "--diff-filter=ACDMRTUXB",
440
+ commit,
441
+ "--",
442
+ ])
443
+ const untracked = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--"])
444
+ if (!changed.ok || !untracked.ok) throw new ReviewInputError("invalid git scope")
445
+ const paths = [...new Set([...nulPaths(changed.stdout), ...nulPaths(untracked.stdout)])].sort(
446
+ compareText,
447
+ )
448
+ if (
449
+ paths.some(
450
+ (path) =>
451
+ isAbsolute(path) ||
452
+ path.includes("\u0000") ||
453
+ path.includes("\\") ||
454
+ path.split("/").some((part) => part === "" || part === "." || part === ".."),
455
+ )
456
+ )
457
+ throw new ReviewInputError("invalid git scope")
458
+ return {
459
+ kind: "diff",
460
+ state: "valid",
461
+ gitRef: ref,
462
+ changedPaths: paths,
463
+ pathDigest: await sha256Text(JSON.stringify(paths)),
464
+ outOfScopeCount: 0,
465
+ }
466
+ }
467
+
468
+ async function projectScope(): Promise<ReviewScope> {
469
+ const paths: readonly string[] = []
470
+ return {
471
+ kind: "project",
472
+ state: "valid",
473
+ changedPaths: paths,
474
+ pathDigest: await sha256Text(JSON.stringify(paths)),
475
+ outOfScopeCount: 0,
476
+ }
477
+ }
478
+
479
+ interface ProjectVerificationLike {
480
+ readonly assuranceConfigPresent: boolean
481
+ readonly config?: unknown
482
+ readonly configError?: unknown
483
+ readonly capability?: unknown
484
+ readonly evidence?: unknown
485
+ check(): Promise<CheckResult>
486
+ }
487
+
488
+ type InconclusiveReasonCode =
489
+ | "invalid-git-ref"
490
+ | "invalid-git-scope"
491
+ | "config-invalid"
492
+ | "collector-error"
493
+
494
+ function invalidScope(kind: "project" | "diff", reasonCode: InconclusiveReasonCode): ReviewScope {
495
+ return {
496
+ kind,
497
+ state: "invalid",
498
+ changedPaths: [],
499
+ pathDigest: "0".repeat(64),
500
+ outOfScopeCount: 0,
501
+ reasonCode,
502
+ }
503
+ }
504
+
505
+ async function inconclusiveReport(
506
+ kind: "project" | "diff",
507
+ reasonCode: InconclusiveReasonCode,
508
+ ): Promise<ReviewReport> {
509
+ const scope = invalidScope(kind, reasonCode)
510
+ const evidence = await evidenceRef("collector", `inconclusive\u0000${reasonCode}`)
511
+ const check: ReviewCheckResult = {
512
+ id: "configuration",
513
+ required: true,
514
+ status: "error",
515
+ duration: "none",
516
+ counts: { findings: 0, errors: 0, warnings: 0, info: 0, outOfScope: 0 },
517
+ findingIds: [],
518
+ evidence: [evidence],
519
+ reasonCode,
520
+ }
521
+ return composeReport({
522
+ strict: false,
523
+ scope,
524
+ checks: [check],
525
+ findings: [],
526
+ })
527
+ }
528
+
529
+ /**
530
+ * A collector or composer failure must never turn the review command into an unstructured crash.
531
+ * The normal path uses the public composer; this last-resort report is deliberately tiny and has
532
+ * no collector payload. The digest is still computed through the public canonicalizer whenever it
533
+ * is available, so an agent can consume the result with the same parser as every other report.
534
+ */
535
+ async function safeInconclusiveReport(
536
+ kind: "project" | "diff",
537
+ reasonCode: InconclusiveReasonCode,
538
+ ): Promise<ReviewReport> {
539
+ try {
540
+ return await inconclusiveReport(kind, reasonCode)
541
+ } catch {
542
+ const evidence: ReviewEvidenceRef = {
543
+ source: "collector",
544
+ token: "r-inconclusive",
545
+ digest: "0".repeat(64),
546
+ }
547
+ const report = {
548
+ version: 1 as const,
549
+ strict: false,
550
+ scope: invalidScope(kind, reasonCode),
551
+ checks: [
552
+ {
553
+ id: "configuration" as const,
554
+ required: true,
555
+ status: "error" as const,
556
+ duration: "none" as const,
557
+ counts: { findings: 0, errors: 0, warnings: 0, info: 0, outOfScope: 0 },
558
+ findingIds: [],
559
+ evidence: [evidence],
560
+ reasonCode,
561
+ },
562
+ ],
563
+ findings: [],
564
+ blocking: 0,
565
+ ok: false,
566
+ status: "inconclusive" as const,
567
+ digest: "0".repeat(64),
568
+ } satisfies ReviewReport
569
+ try {
570
+ const { digestReviewReport } = await import("@nifrajs/agent-review")
571
+ return { ...report, digest: await digestReviewReport(report) }
572
+ } catch {
573
+ return report
574
+ }
575
+ }
576
+ }
577
+
578
+ async function composeCollectedReport(
579
+ cwd: string,
580
+ input: ReviewInput,
581
+ scope: ReviewScope,
582
+ verification: ProjectVerificationLike,
583
+ checkResult: CheckResult,
584
+ ): Promise<{ readonly report: ReviewReport; readonly candidates: readonly CandidateFinding[] }> {
585
+ const diagnostics = diagnosticValue(checkResult)
586
+ .map((diagnostic, index) => ({ diagnostic, index }))
587
+ .sort((left, right) => {
588
+ const leftLocation = normalizedLocation(cwd, left.diagnostic.file, left.diagnostic.line)
589
+ const rightLocation = normalizedLocation(cwd, right.diagnostic.file, right.diagnostic.line)
590
+ return (
591
+ compareText(normalizedCode(left.diagnostic.code), normalizedCode(right.diagnostic.code)) ||
592
+ compareText(
593
+ normalizedSeverity(left.diagnostic.severity),
594
+ normalizedSeverity(right.diagnostic.severity),
595
+ ) ||
596
+ compareText(leftLocation?.path ?? "", rightLocation?.path ?? "") ||
597
+ (leftLocation?.line ?? 0) - (rightLocation?.line ?? 0) ||
598
+ compareText(left.diagnostic.fix?.recipe ?? "", right.diagnostic.fix?.recipe ?? "") ||
599
+ left.index - right.index
600
+ )
601
+ })
602
+ const candidatesByCheck = new Map<ReviewCheckId, CandidateFinding[]>()
603
+ for (const id of CHECK_IDS) candidatesByCheck.set(id, [])
604
+ for (let index = 0; index < diagnostics.length; index += 1) {
605
+ const diagnostic = diagnostics[index]!.diagnostic
606
+ const candidate = await candidateFinding(
607
+ cwd,
608
+ diagnostic,
609
+ codeCheck(normalizedCode(diagnostic.code)),
610
+ index,
611
+ )
612
+ candidatesByCheck.get(candidate.check)?.push(candidate)
613
+ }
614
+
615
+ const changedPaths = new Set(scope.changedPaths)
616
+ const retainedByCheck = new Map<ReviewCheckId, CandidateFinding[]>()
617
+ let totalOutOfScope = 0
618
+ for (const id of CHECK_IDS) {
619
+ const source = candidatesByCheck.get(id) ?? []
620
+ const retained: CandidateFinding[] = []
621
+ let outOfScope = 0
622
+ for (const candidate of source) {
623
+ if (
624
+ scope.kind === "diff" &&
625
+ candidate.location !== undefined &&
626
+ !changedPaths.has(candidate.location.path)
627
+ ) {
628
+ outOfScope += 1
629
+ totalOutOfScope += 1
630
+ } else retained.push(candidate)
631
+ }
632
+ retainedByCheck.set(id, retained)
633
+ // Store the per-check count temporarily on the scope map below through a side channel-free map.
634
+ void outOfScope
635
+ }
636
+
637
+ const checks: ReviewCheckResult[] = []
638
+ const findings: ReviewFinding[] = []
639
+ for (const id of CHECK_IDS) {
640
+ const all = candidatesByCheck.get(id) ?? []
641
+ const retained = retainedByCheck.get(id) ?? []
642
+ const configured = checkConfigured(id, all, verification)
643
+ const isTypecheckUnavailable =
644
+ id === "typecheck" &&
645
+ (checkResult.typecheck === "skipped" || checkResult.truncated !== undefined)
646
+ let status: ReviewCheckResult["status"]
647
+ let reasonCode: ReviewCheckResult["reasonCode"]
648
+ if (isTypecheckUnavailable) {
649
+ status = "unavailable"
650
+ reasonCode =
651
+ checkResult.truncated !== undefined
652
+ ? "diagnostics-truncated"
653
+ : checkResult.typecheckNote?.toLowerCase().includes("typescript")
654
+ ? "missing-typescript"
655
+ : "unsupported"
656
+ } else if (!configured) {
657
+ status = "skipped"
658
+ reasonCode = "not-configured"
659
+ } else {
660
+ status = checkStatus(retained)
661
+ }
662
+ const checkEvidence =
663
+ retained.length > 0
664
+ ? retained
665
+ .map((candidate) => candidate.finding.evidence[0]!)
666
+ .filter((entry): entry is ReviewEvidenceRef => entry !== undefined)
667
+ : [await evidenceRef(sourceFor(id), `check\u0000${id}\u0000${status}`)]
668
+ const outOfScope = all.length - retained.length
669
+ for (const candidate of retained) findings.push(candidate.finding)
670
+ checks.push({
671
+ id,
672
+ required: REQUIRED_CHECKS.has(id),
673
+ status,
674
+ duration: status === "unavailable" ? "timeout" : "standard",
675
+ counts: countsFor(retained, outOfScope),
676
+ findingIds: retained.map((candidate) => candidate.finding.id),
677
+ evidence: checkEvidence,
678
+ ...(reasonCode === undefined ? {} : { reasonCode }),
679
+ })
680
+ }
681
+
682
+ if (verification.assuranceConfigPresent && verification.configError !== undefined) {
683
+ const id = "configuration"
684
+ const evidence = await evidenceRef("collector", "assurance-config-invalid")
685
+ const finding: ReviewFinding = {
686
+ id: "f-configuration-config",
687
+ check: id,
688
+ code: "NF-C015",
689
+ severity: "error",
690
+ category: "configuration",
691
+ evidence: [evidence],
692
+ }
693
+ findings.push(finding)
694
+ const index = checks.findIndex((check) => check.id === id)
695
+ const existing = checks[index]
696
+ if (existing !== undefined) {
697
+ checks[index] = {
698
+ ...existing,
699
+ required: true,
700
+ status: "error",
701
+ counts: {
702
+ findings: existing.counts.findings + 1,
703
+ errors: existing.counts.errors + 1,
704
+ warnings: existing.counts.warnings,
705
+ info: existing.counts.info,
706
+ outOfScope: existing.counts.outOfScope,
707
+ },
708
+ findingIds: [...existing.findingIds, finding.id],
709
+ evidence: [evidence],
710
+ reasonCode: "config-invalid",
711
+ }
712
+ }
713
+ }
714
+
715
+ const finalScope: ReviewScope = {
716
+ ...scope,
717
+ outOfScopeCount: totalOutOfScope,
718
+ }
719
+ const fixes =
720
+ input.fix === true
721
+ ? [
722
+ ...new Set(
723
+ findings.flatMap((finding) => (finding.fix === undefined ? [] : [finding.fix.recipe])),
724
+ ),
725
+ ]
726
+ .sort(compareText)
727
+ .map((recipe): ReviewFixResult => ({ recipe, status: "planned" }))
728
+ : undefined
729
+ const draft: ReviewReportDraft = {
730
+ strict: input.strict === true,
731
+ scope: finalScope,
732
+ checks,
733
+ findings,
734
+ ...(fixes === undefined ? {} : { fixes }),
735
+ }
736
+ const report = await composeReport(draft)
737
+ return {
738
+ report,
739
+ candidates: [...retainedByCheck.values()].flat(),
740
+ }
741
+ }
742
+
743
+ function staticDiagnostic(candidate: CandidateFinding): Diagnostic {
744
+ const label = RULE_CODES[candidate.code as keyof typeof RULE_CODES] ?? "Nifra application rule"
745
+ return {
746
+ code: candidate.code,
747
+ severity: candidate.severity === "warning" ? "warn" : candidate.severity,
748
+ message: label,
749
+ ...(candidate.location === undefined
750
+ ? {}
751
+ : {
752
+ file: candidate.location.path,
753
+ ...(candidate.location.line === undefined ? {} : { line: candidate.location.line }),
754
+ }),
755
+ // Fix recipes receive only the internal bounded evidence they need. The public report still
756
+ // exposes opaque references; raw package names/paths never cross the report, SARIF, MCP, or
757
+ // browser boundaries.
758
+ evidence: candidate.diagnostic.evidence?.slice(0, 8) ?? [
759
+ candidate.finding.evidence[0]?.token ?? "review",
760
+ ],
761
+ ...(candidate.finding.fix === undefined
762
+ ? {}
763
+ : { fix: { recipe: candidate.finding.fix.recipe } }),
764
+ }
765
+ }
766
+
767
+ async function safeChangedPaths(cwd: string, paths: readonly string[]): Promise<readonly string[]> {
768
+ const { resolveInsideProject } = await import("./project-path.ts")
769
+ const result: string[] = []
770
+ for (const path of paths) {
771
+ if (typeof path !== "string" || path.length === 0 || isAbsolute(path)) continue
772
+ const resolved = await resolveInsideProject(cwd, path)
773
+ if (resolved === undefined) continue
774
+ const relativePath = relative(resolve(cwd), resolved).split(sep).join("/")
775
+ if (relativePath.length > 0 && relativePath !== ".." && !relativePath.startsWith("../"))
776
+ result.push(relativePath)
777
+ }
778
+ return [...new Set(result)].sort(compareText)
779
+ }
780
+
781
+ async function applyRequestedFixes(
782
+ cwd: string,
783
+ candidates: readonly CandidateFinding[],
784
+ ): Promise<readonly ReviewFixResult[]> {
785
+ const { applyDiagnosticRecipe } = await import("./fix-recipes.ts")
786
+ const results: ReviewFixResult[] = []
787
+ const seen = new Set<ReviewFixRecipeId>()
788
+ for (const candidate of candidates) {
789
+ const recipe = candidate.finding.fix?.recipe
790
+ if (recipe === undefined || seen.has(recipe)) continue
791
+ seen.add(recipe)
792
+ try {
793
+ const changed = await applyDiagnosticRecipe(cwd, staticDiagnostic(candidate))
794
+ const safe = await safeChangedPaths(cwd, changed)
795
+ results.push(
796
+ safe.length === 0
797
+ ? { recipe, status: "no-op", reasonCode: "fix-no-op" }
798
+ : { recipe, status: "changed", changedPaths: safe },
799
+ )
800
+ } catch {
801
+ results.push({ recipe, status: "failed", reasonCode: "fix-failed" })
802
+ }
803
+ }
804
+ return results
805
+ }
806
+
807
+ async function resolveSarifPath(cwd: string, path: string): Promise<string> {
808
+ const { resolveInsideProject } = await import("./project-path.ts")
809
+ const resolved = await resolveInsideProject(cwd, path)
810
+ if (resolved === undefined) throw new ReviewInputError("sarif path must stay inside the project")
811
+ return resolved
812
+ }
813
+
814
+ async function writeSarif(
815
+ cwd: string,
816
+ path: string,
817
+ report: ReviewReport,
818
+ cliVersion?: string,
819
+ ): Promise<void> {
820
+ const resolved = await resolveSarifPath(cwd, path)
821
+ const diagnostics: Diagnostic[] = report.findings.map((finding) => {
822
+ const label = RULE_CODES[finding.code as keyof typeof RULE_CODES] ?? "Nifra application rule"
823
+ return {
824
+ code: finding.code,
825
+ severity: finding.severity === "warning" ? "warn" : finding.severity,
826
+ message: label,
827
+ ...(finding.location === undefined
828
+ ? {}
829
+ : {
830
+ file: finding.location.path,
831
+ ...(finding.location.line === undefined ? {} : { line: finding.location.line }),
832
+ }),
833
+ evidence: [finding.evidence[0]?.token ?? "review"],
834
+ ...(finding.fix === undefined ? {} : { fix: { recipe: finding.fix.recipe } }),
835
+ }
836
+ })
837
+ try {
838
+ await Bun.write(
839
+ resolved,
840
+ `${JSON.stringify(
841
+ toSarifLog(diagnostics, {
842
+ toolName: "nifra",
843
+ ...(cliVersion === undefined ? {} : { toolVersion: cliVersion }),
844
+ }),
845
+ null,
846
+ 2,
847
+ )}\n`,
848
+ )
849
+ } catch {
850
+ throw new ReviewInputError("could not write SARIF output")
851
+ }
852
+ }
853
+
854
+ export async function runReview(input: ReviewInput, ctx: CommandCtx): Promise<ReviewReport> {
855
+ const parsed = parseReviewInput(input)
856
+ // Validate destination confinement before Git or project collectors run. A malformed output
857
+ // target is an input error, not an inconclusive project result, and must not spend collector work.
858
+ if (parsed.sarif !== undefined) await resolveSarifPath(ctx.cwd, parsed.sarif)
859
+ let scope: ReviewScope
860
+ try {
861
+ scope = parsed.diff === undefined ? await projectScope() : await diffScope(ctx.cwd, parsed.diff)
862
+ } catch (error) {
863
+ if (error instanceof ReviewInputError)
864
+ return safeInconclusiveReport(
865
+ parsed.diff === undefined ? "project" : "diff",
866
+ error.message.includes("ref") ? "invalid-git-ref" : "invalid-git-scope",
867
+ )
868
+ return safeInconclusiveReport(parsed.diff === undefined ? "project" : "diff", "collector-error")
869
+ }
870
+
871
+ let verification: ProjectVerificationLike
872
+ try {
873
+ const { collectProjectVerification } = await import("./verification.ts")
874
+ verification = await collectProjectVerification(ctx.cwd, {
875
+ ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
876
+ })
877
+ } catch {
878
+ return safeInconclusiveReport(scope.kind, "collector-error")
879
+ }
880
+ let checkResult: CheckResult
881
+ try {
882
+ checkResult = await verification.check()
883
+ } catch {
884
+ return safeInconclusiveReport(scope.kind, "collector-error")
885
+ }
886
+ let collected: Awaited<ReturnType<typeof composeCollectedReport>>
887
+ try {
888
+ collected = await composeCollectedReport(ctx.cwd, parsed, scope, verification, checkResult)
889
+ } catch {
890
+ return safeInconclusiveReport(scope.kind, "collector-error")
891
+ }
892
+ let report = collected.report
893
+
894
+ if (parsed.fix === true && parsed.write === true && report.status !== "inconclusive") {
895
+ try {
896
+ const fixResults = await applyRequestedFixes(ctx.cwd, collected.candidates)
897
+ const { collectProjectVerification } = await import("./verification.ts")
898
+ const rerunVerification = await collectProjectVerification(ctx.cwd, {
899
+ ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
900
+ })
901
+ const rerunChecks = await rerunVerification.check()
902
+ const rerun = await composeCollectedReport(
903
+ ctx.cwd,
904
+ { ...parsed, fix: false },
905
+ scope,
906
+ rerunVerification,
907
+ rerunChecks,
908
+ )
909
+ report = await composeReport({
910
+ strict: rerun.report.strict,
911
+ scope: rerun.report.scope,
912
+ checks: rerun.report.checks,
913
+ findings: rerun.report.findings,
914
+ fixes: fixResults,
915
+ })
916
+ } catch {
917
+ return safeInconclusiveReport(scope.kind, "collector-error")
918
+ }
919
+ }
920
+
921
+ if (parsed.sarif !== undefined && report.status !== "inconclusive")
922
+ await writeSarif(ctx.cwd, parsed.sarif, report, ctx.cliVersion)
923
+ return report
924
+ }
925
+
926
+ export function renderReviewReport(report: ReviewReport): readonly string[] {
927
+ const lines = [
928
+ report.status === "pass"
929
+ ? `✓ review passed (${report.findings.length} findings)`
930
+ : report.status === "fail"
931
+ ? `✖ review failed (${report.blocking} blocking)`
932
+ : "? review inconclusive (required evidence unavailable)",
933
+ ` digest: ${report.digest}`,
934
+ ]
935
+ for (const finding of report.findings) {
936
+ const location =
937
+ finding.location === undefined
938
+ ? ""
939
+ : ` ${finding.location.path}${finding.location.line === undefined ? "" : `:${finding.location.line}`}`
940
+ lines.push(` ${finding.severity} ${finding.code} [${finding.check}]${location}`)
941
+ }
942
+ for (const check of report.checks) {
943
+ if (check.status === "skipped") lines.push(` skipped ${check.id}`)
944
+ if (check.status === "unavailable" || check.status === "error")
945
+ lines.push(` unavailable ${check.id}`)
946
+ }
947
+ return lines
948
+ }
949
+
950
+ export const reviewSpec: CommandSpec<ReviewInput, ReviewReport> = {
951
+ name: "review",
952
+ summary: "Run the deterministic, evidence-backed Nifra review.",
953
+ input: {
954
+ jsonSchema: REVIEW_INPUT_SCHEMA,
955
+ parse: parseReviewInput,
956
+ },
957
+ output: {
958
+ version: 1,
959
+ jsonSchema: {
960
+ type: "object",
961
+ required: ["version", "scope", "checks", "findings", "blocking", "ok", "status", "digest"],
962
+ },
963
+ parse: (value) => value as ReviewReport,
964
+ },
965
+ transports: ["cli", "mcp"],
966
+ stability: "experimental",
967
+ argv: {
968
+ flags: [
969
+ { name: "strict", field: "strict", type: "boolean" },
970
+ { name: "diff", field: "diff", type: "string" },
971
+ { name: "sarif", field: "sarif", type: "string" },
972
+ { name: "fix", field: "fix", type: "boolean" },
973
+ { name: "dry-run", field: "dryRun", type: "boolean" },
974
+ { name: "write", field: "write", type: "boolean" },
975
+ { name: "json", field: "json", type: "boolean" },
976
+ ],
977
+ },
978
+ run: runReview,
979
+ render: renderReviewReport,
980
+ success: (report) => report.ok,
981
+ exitCode: (report) => (report.status === "inconclusive" ? 2 : report.ok ? 0 : 1),
982
+ json: (report) => report,
983
+ }