@nifrajs/cli 2.10.0 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/dist/assure-hydration.d.ts +14 -0
- package/dist/assure-hydration.d.ts.map +1 -0
- package/dist/assure-hydration.js +483 -0
- package/dist/assure-hydration.js.map +1 -0
- package/dist/assure.d.ts +35 -0
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +222 -1
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +13 -0
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +64 -1
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +133 -10
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +741 -148
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +21 -14
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +288 -73
- package/dist/cli.js.map +1 -1
- package/dist/contract-proof.d.ts +49 -0
- package/dist/contract-proof.d.ts.map +1 -0
- package/dist/contract-proof.js +128 -0
- package/dist/contract-proof.js.map +1 -0
- package/dist/contracts.d.ts +22 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +134 -0
- package/dist/contracts.js.map +1 -0
- package/dist/dev-bun-config.d.ts +17 -6
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +51 -14
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/diagnostics.d.ts +23 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +9 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/diff-tool.d.ts +2 -0
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +1 -1
- package/dist/diff-tool.js.map +1 -1
- package/dist/docs-search.d.ts +8 -3
- package/dist/docs-search.d.ts.map +1 -1
- package/dist/docs-search.js +36 -15
- package/dist/docs-search.js.map +1 -1
- package/dist/doctor.d.ts +38 -2
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +189 -7
- package/dist/doctor.js.map +1 -1
- package/dist/examples.d.ts +1 -2
- package/dist/examples.d.ts.map +1 -1
- package/dist/examples.js +37 -16
- package/dist/examples.js.map +1 -1
- package/dist/fix-recipes.d.ts +12 -0
- package/dist/fix-recipes.d.ts.map +1 -0
- package/dist/fix-recipes.js +106 -0
- package/dist/fix-recipes.js.map +1 -0
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +60 -4
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/source-facts.d.ts +15 -0
- package/dist/internal/source-facts.d.ts.map +1 -0
- package/dist/internal/source-facts.js +82 -0
- package/dist/internal/source-facts.js.map +1 -0
- package/dist/internal/typescript-import.d.ts +9 -0
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +38 -0
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -2
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +60 -5
- package/dist/introspect.js.map +1 -1
- package/dist/load.d.ts +10 -0
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +1 -0
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +14 -9
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +3 -0
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-root.d.ts +83 -0
- package/dist/mcp-root.d.ts.map +1 -0
- package/dist/mcp-root.js +221 -0
- package/dist/mcp-root.js.map +1 -0
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +18 -4
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp.d.ts +27 -4
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +360 -27
- package/dist/mcp.js.map +1 -1
- package/dist/pipeline-guard.d.ts +25 -10
- package/dist/pipeline-guard.d.ts.map +1 -1
- package/dist/pipeline-guard.js +35 -15
- package/dist/pipeline-guard.js.map +1 -1
- package/dist/pipeline-report.d.ts +34 -0
- package/dist/pipeline-report.d.ts.map +1 -0
- package/dist/pipeline-report.js +282 -0
- package/dist/pipeline-report.js.map +1 -0
- package/dist/project-path.d.ts +3 -0
- package/dist/project-path.d.ts.map +1 -0
- package/dist/project-path.js +36 -0
- package/dist/project-path.js.map +1 -0
- package/dist/replay.d.ts +12 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +36 -0
- package/dist/replay.js.map +1 -0
- package/dist/rules/codes.d.ts +42 -0
- package/dist/rules/codes.d.ts.map +1 -0
- package/dist/rules/codes.js +52 -0
- package/dist/rules/codes.js.map +1 -0
- package/dist/rules/comment-markers.d.ts +10 -0
- package/dist/rules/comment-markers.d.ts.map +1 -0
- package/dist/rules/comment-markers.js +24 -0
- package/dist/rules/comment-markers.js.map +1 -0
- package/dist/rules/index.d.ts +31 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +133 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/legacy.d.ts +7 -0
- package/dist/rules/legacy.d.ts.map +1 -0
- package/dist/rules/legacy.js +85 -0
- package/dist/rules/legacy.js.map +1 -0
- package/dist/rules/routes.d.ts +12 -0
- package/dist/rules/routes.d.ts.map +1 -0
- package/dist/rules/routes.js +142 -0
- package/dist/rules/routes.js.map +1 -0
- package/dist/rules/security.d.ts +10 -0
- package/dist/rules/security.d.ts.map +1 -0
- package/dist/rules/security.js +471 -0
- package/dist/rules/security.js.map +1 -0
- package/dist/search-terms.d.ts +2 -0
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +5 -1
- package/dist/search-terms.js.map +1 -1
- package/dist/types-search.d.ts +1 -2
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +33 -16
- package/dist/types-search.js.map +1 -1
- package/dist/verification.d.ts +9 -0
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +17 -1
- package/dist/verification.js.map +1 -1
- package/dist/work-graph.d.ts +123 -0
- package/dist/work-graph.d.ts.map +1 -0
- package/dist/work-graph.js +465 -0
- package/dist/work-graph.js.map +1 -0
- package/docs/examples.json +32 -2
- package/docs/llms-full.txt +1105 -27
- package/docs/types.json +1772 -137
- package/package.json +34 -9
- package/src/assure-hydration.ts +606 -0
- package/src/assure.ts +316 -2
- package/src/capabilities-tool.ts +80 -1
- package/src/check.ts +923 -169
- package/src/cli.ts +297 -76
- package/src/contract-proof.ts +194 -0
- package/src/contracts.ts +157 -0
- package/src/dev-bun-config.ts +57 -14
- package/src/diagnostics.ts +32 -0
- package/src/diff-tool.ts +1 -1
- package/src/docs-search.ts +45 -15
- package/src/doctor.ts +286 -8
- package/src/examples.ts +43 -15
- package/src/fix-recipes.ts +126 -0
- package/src/init-agents.ts +61 -4
- package/src/internal/source-facts.ts +133 -0
- package/src/internal/typescript-import.ts +29 -0
- package/src/introspect.ts +81 -6
- package/src/load.ts +11 -0
- package/src/manifest-tool.ts +16 -10
- package/src/mcp-docs-tools.ts +3 -0
- package/src/mcp-http.ts +1 -1
- package/src/mcp-root.ts +271 -0
- package/src/mcp-run.ts +19 -4
- package/src/mcp.ts +440 -34
- package/src/pipeline-guard.ts +44 -14
- package/src/pipeline-report.ts +341 -0
- package/src/project-path.ts +36 -0
- package/src/replay.ts +44 -0
- package/src/rules/codes.ts +57 -0
- package/src/rules/comment-markers.ts +26 -0
- package/src/rules/index.ts +175 -0
- package/src/rules/legacy.ts +93 -0
- package/src/rules/routes.ts +157 -0
- package/src/rules/security.ts +505 -0
- package/src/search-terms.ts +9 -4
- package/src/types-search.ts +39 -15
- package/src/verification.ts +18 -1
- package/src/work-graph.ts +613 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs"
|
|
2
|
+
import { resolve, sep } from "node:path"
|
|
3
|
+
import type { AssuranceFinding, AssuranceReport, AssuredRoute } from "@nifrajs/core/assurance"
|
|
4
|
+
import type {
|
|
5
|
+
AssuredCapabilityRoute,
|
|
6
|
+
CapabilityAssuranceReport,
|
|
7
|
+
CapabilityFinding,
|
|
8
|
+
} from "@nifrajs/core/capabilities"
|
|
9
|
+
import { diffRouteSnapshots, type RouteChange, type RoutesDiff } from "@nifrajs/core/diff"
|
|
10
|
+
import type { CheckResult } from "./check.ts"
|
|
11
|
+
import { DEFAULT_SNAPSHOT_FILE, parseSnapshotFile, snapshotBackend } from "./diff-tool.ts"
|
|
12
|
+
import { collectProjectVerification } from "./verification.ts"
|
|
13
|
+
|
|
14
|
+
const routeKey = (method: string, path: string): string => `${method.toUpperCase()}\n${path}`
|
|
15
|
+
|
|
16
|
+
export interface ContractProofRoute {
|
|
17
|
+
readonly method: string
|
|
18
|
+
readonly path: string
|
|
19
|
+
readonly changes: readonly RouteChange[]
|
|
20
|
+
readonly assurance?: {
|
|
21
|
+
readonly route?: AssuredRoute
|
|
22
|
+
readonly findings: readonly AssuranceFinding[]
|
|
23
|
+
}
|
|
24
|
+
readonly capability?: {
|
|
25
|
+
readonly route?: AssuredCapabilityRoute
|
|
26
|
+
readonly findings: readonly CapabilityFinding[]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ContractProofVerification {
|
|
31
|
+
readonly assuranceConfigPresent: boolean
|
|
32
|
+
readonly configError?: string
|
|
33
|
+
readonly assurance?: {
|
|
34
|
+
readonly ok: boolean
|
|
35
|
+
readonly routeCount: number
|
|
36
|
+
readonly findings: readonly AssuranceFinding[]
|
|
37
|
+
}
|
|
38
|
+
readonly capability?: {
|
|
39
|
+
readonly ok: boolean
|
|
40
|
+
readonly routeCount: number
|
|
41
|
+
readonly findings: readonly CapabilityFinding[]
|
|
42
|
+
}
|
|
43
|
+
readonly check?: CheckResult
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ContractProofReport {
|
|
47
|
+
readonly baseline: string
|
|
48
|
+
readonly hasBreaking: boolean
|
|
49
|
+
readonly changes: readonly RouteChange[]
|
|
50
|
+
readonly routes: readonly ContractProofRoute[]
|
|
51
|
+
readonly verification: ContractProofVerification
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ContractProofOptions {
|
|
55
|
+
/** Baseline path relative to the project directory. Defaults to api-snapshot.json. */
|
|
56
|
+
readonly baselinePath?: string
|
|
57
|
+
/** Run the lazy typed-contract check only when explicitly requested. */
|
|
58
|
+
readonly check?: boolean
|
|
59
|
+
readonly signal?: AbortSignal
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function safeBaselinePath(cwd: string, baselinePath: string): string {
|
|
63
|
+
const root = resolve(cwd)
|
|
64
|
+
const target = resolve(root, baselinePath)
|
|
65
|
+
if (target !== root && !target.startsWith(`${root}${sep}`)) {
|
|
66
|
+
throw new Error("contract proof baseline must stay inside the project directory")
|
|
67
|
+
}
|
|
68
|
+
return target
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function errorMessage(error: unknown): string {
|
|
72
|
+
return error instanceof Error ? error.message : String(error)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function changedRoutes(
|
|
76
|
+
diff: RoutesDiff,
|
|
77
|
+
assurance: AssuranceReport | undefined,
|
|
78
|
+
capability: CapabilityAssuranceReport | undefined,
|
|
79
|
+
): readonly ContractProofRoute[] {
|
|
80
|
+
const changes = new Map<string, RouteChange[]>()
|
|
81
|
+
for (const change of diff.changes) {
|
|
82
|
+
const key = routeKey(change.method, change.path)
|
|
83
|
+
const current = changes.get(key)
|
|
84
|
+
if (current === undefined) changes.set(key, [change])
|
|
85
|
+
else current.push(change)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const assured = new Map(
|
|
89
|
+
(assurance?.routes ?? []).map((route) => [routeKey(route.method, route.path), route]),
|
|
90
|
+
)
|
|
91
|
+
const assuranceFindings = new Map<string, AssuranceFinding[]>()
|
|
92
|
+
for (const finding of assurance?.findings ?? []) {
|
|
93
|
+
const key = routeKey(finding.method, finding.path)
|
|
94
|
+
const current = assuranceFindings.get(key)
|
|
95
|
+
if (current === undefined) assuranceFindings.set(key, [finding])
|
|
96
|
+
else current.push(finding)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const capable = new Map(
|
|
100
|
+
(capability?.routes ?? []).map((route) => [routeKey(route.method, route.path), route]),
|
|
101
|
+
)
|
|
102
|
+
const capabilityFindings = new Map<string, CapabilityFinding[]>()
|
|
103
|
+
for (const finding of capability?.findings ?? []) {
|
|
104
|
+
const key = routeKey(finding.method, finding.path)
|
|
105
|
+
const current = capabilityFindings.get(key)
|
|
106
|
+
if (current === undefined) capabilityFindings.set(key, [finding])
|
|
107
|
+
else current.push(finding)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return Object.freeze(
|
|
111
|
+
[...changes].map(([key, routeChanges]) => {
|
|
112
|
+
const first = routeChanges[0]
|
|
113
|
+
if (first === undefined) throw new Error("contract proof: missing route change")
|
|
114
|
+
const assuredRoute = assured.get(key)
|
|
115
|
+
const capableRoute = capable.get(key)
|
|
116
|
+
return {
|
|
117
|
+
method: first.method,
|
|
118
|
+
path: first.path,
|
|
119
|
+
changes: Object.freeze([...routeChanges]),
|
|
120
|
+
...(assurance === undefined
|
|
121
|
+
? {}
|
|
122
|
+
: {
|
|
123
|
+
assurance: {
|
|
124
|
+
...(assuredRoute === undefined ? {} : { route: assuredRoute }),
|
|
125
|
+
findings: Object.freeze([...(assuranceFindings.get(key) ?? [])]),
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
...(capability === undefined
|
|
129
|
+
? {}
|
|
130
|
+
: {
|
|
131
|
+
capability: {
|
|
132
|
+
...(capableRoute === undefined ? {} : { route: capableRoute }),
|
|
133
|
+
findings: Object.freeze([...(capabilityFindings.get(key) ?? [])]),
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
}
|
|
137
|
+
}),
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Compose route diff, route assurance, capability evidence, and an optional check result. */
|
|
142
|
+
export async function collectContractProof(
|
|
143
|
+
cwd: string,
|
|
144
|
+
options: ContractProofOptions = {},
|
|
145
|
+
): Promise<ContractProofReport> {
|
|
146
|
+
const baselinePath = options.baselinePath ?? DEFAULT_SNAPSHOT_FILE
|
|
147
|
+
const resolvedBaseline = safeBaselinePath(cwd, baselinePath)
|
|
148
|
+
if (!existsSync(resolvedBaseline)) {
|
|
149
|
+
throw new Error(`contract proof baseline not found: ${resolvedBaseline}`)
|
|
150
|
+
}
|
|
151
|
+
const realRoot = realpathSync(resolve(cwd))
|
|
152
|
+
const realBaseline = realpathSync(resolvedBaseline)
|
|
153
|
+
if (realBaseline !== realRoot && !realBaseline.startsWith(`${realRoot}${sep}`)) {
|
|
154
|
+
throw new Error("contract proof baseline must stay inside the project directory")
|
|
155
|
+
}
|
|
156
|
+
const baseline = parseSnapshotFile(await Bun.file(resolvedBaseline).text(), baselinePath)
|
|
157
|
+
const current = await snapshotBackend(cwd)
|
|
158
|
+
const diff = diffRouteSnapshots(baseline.routes, current)
|
|
159
|
+
const verification = await collectProjectVerification(cwd, {
|
|
160
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
161
|
+
})
|
|
162
|
+
const proof: ContractProofVerification = {
|
|
163
|
+
assuranceConfigPresent: verification.assuranceConfigPresent,
|
|
164
|
+
...(verification.configError === undefined
|
|
165
|
+
? {}
|
|
166
|
+
: { configError: errorMessage(verification.configError) }),
|
|
167
|
+
...(verification.routeAssurance === undefined
|
|
168
|
+
? {}
|
|
169
|
+
: {
|
|
170
|
+
assurance: {
|
|
171
|
+
ok: verification.routeAssurance.ok,
|
|
172
|
+
routeCount: verification.routeAssurance.routes.length,
|
|
173
|
+
findings: verification.routeAssurance.findings,
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
176
|
+
...(verification.capability === undefined
|
|
177
|
+
? {}
|
|
178
|
+
: {
|
|
179
|
+
capability: {
|
|
180
|
+
ok: verification.capability.report.ok,
|
|
181
|
+
routeCount: verification.capability.report.routes.length,
|
|
182
|
+
findings: verification.capability.report.findings,
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
...(options.check === true ? { check: await verification.check() } : {}),
|
|
186
|
+
}
|
|
187
|
+
return Object.freeze({
|
|
188
|
+
baseline: baselinePath,
|
|
189
|
+
hasBreaking: diff.hasBreaking,
|
|
190
|
+
changes: diff.changes,
|
|
191
|
+
routes: changedRoutes(diff, verification.routeAssurance, verification.capability?.report),
|
|
192
|
+
verification: proof,
|
|
193
|
+
})
|
|
194
|
+
}
|
package/src/contracts.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { resolve } from "node:path"
|
|
3
|
+
import { type ReflectedRoute, reflectRoutes } from "@nifrajs/core/reflection"
|
|
4
|
+
|
|
5
|
+
export interface ContractDigest {
|
|
6
|
+
readonly request: string
|
|
7
|
+
readonly response: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ContractsLock {
|
|
11
|
+
readonly version: 1
|
|
12
|
+
readonly routes: Readonly<Record<string, ContractDigest>>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_CONTRACTS_LOCK = "contracts.lock.json"
|
|
16
|
+
const DIGEST = /^[a-f0-9]{64}$/
|
|
17
|
+
|
|
18
|
+
function recordOf(value: unknown): Record<string, unknown> | undefined {
|
|
19
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
20
|
+
? Object.fromEntries(Object.entries(value))
|
|
21
|
+
: undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function canonical(value: unknown, key?: string): unknown {
|
|
25
|
+
if (["description", "title", "default", "example", "examples"].includes(key ?? ""))
|
|
26
|
+
return undefined
|
|
27
|
+
if (Array.isArray(value)) return value.map((item) => canonical(item))
|
|
28
|
+
const record = recordOf(value)
|
|
29
|
+
if (record === undefined) return value
|
|
30
|
+
const out: Record<string, unknown> = {}
|
|
31
|
+
for (const name of Object.keys(record).sort()) {
|
|
32
|
+
const item = canonical(record[name], name)
|
|
33
|
+
if (item !== undefined) out[name] = item
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function sha256(value: string): Promise<string> {
|
|
39
|
+
const bytes = new TextEncoder().encode(value)
|
|
40
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))
|
|
41
|
+
return [...digest].map((item) => item.toString(16).padStart(2, "0")).join("")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function schemaValue(route: ReflectedRoute, side: "request" | "response"): unknown {
|
|
45
|
+
const schema = route.schema
|
|
46
|
+
if (schema === undefined) return {}
|
|
47
|
+
if (side === "request") {
|
|
48
|
+
return {
|
|
49
|
+
body: schema.body?.jsonSchema ?? schema.body?.fields ?? null,
|
|
50
|
+
query: schema.query?.jsonSchema ?? schema.query?.fields ?? null,
|
|
51
|
+
params: schema.params?.jsonSchema ?? schema.params?.fields ?? null,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
response: schema.response?.jsonSchema ?? schema.response?.fields ?? null,
|
|
56
|
+
sse: schema.sse?.jsonSchema ?? schema.sse?.fields ?? null,
|
|
57
|
+
errors: Object.fromEntries(
|
|
58
|
+
Object.entries(schema.errors ?? {}).map(([status, value]) => [
|
|
59
|
+
status,
|
|
60
|
+
value.jsonSchema ?? value.fields ?? null,
|
|
61
|
+
]),
|
|
62
|
+
),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function digestRoute(route: ReflectedRoute): Promise<ContractDigest> {
|
|
67
|
+
const request = JSON.stringify(canonical(schemaValue(route, "request")))
|
|
68
|
+
const response = JSON.stringify(canonical(schemaValue(route, "response")))
|
|
69
|
+
return { request: await sha256(request), response: await sha256(response) }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function buildContractsLock(source: unknown): Promise<ContractsLock> {
|
|
73
|
+
const routes = Object.fromEntries(
|
|
74
|
+
await Promise.all(
|
|
75
|
+
reflectRoutes(source).map(
|
|
76
|
+
async (route) => [`${route.method} ${route.path}`, await digestRoute(route)] as const,
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
return {
|
|
81
|
+
version: 1,
|
|
82
|
+
routes: Object.fromEntries(Object.entries(routes).sort(([a], [b]) => a.localeCompare(b))),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function loadBackend(cwd: string): Promise<unknown> {
|
|
87
|
+
const backendPath = resolve(cwd, "backend.ts")
|
|
88
|
+
if (!existsSync(backendPath)) throw new Error(`[nifra] no backend.ts in ${cwd}`)
|
|
89
|
+
const loadedValue: unknown = await import(backendPath)
|
|
90
|
+
const loaded = recordOf(loadedValue)
|
|
91
|
+
if (loaded?.backend === undefined)
|
|
92
|
+
throw new Error(`[nifra] ${backendPath} does not export backend`)
|
|
93
|
+
return loaded.backend
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function snapshotContracts(
|
|
97
|
+
cwd: string,
|
|
98
|
+
out = DEFAULT_CONTRACTS_LOCK,
|
|
99
|
+
): Promise<ContractsLock> {
|
|
100
|
+
const lock = await buildContractsLock(await loadBackend(cwd))
|
|
101
|
+
await Bun.write(resolve(cwd, out), `${JSON.stringify(lock, null, 2)}\n`)
|
|
102
|
+
return lock
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function parseContractsLock(value: unknown): ContractsLock {
|
|
106
|
+
const record = recordOf(value)
|
|
107
|
+
if (record === undefined) throw new TypeError("contracts lock must be an object")
|
|
108
|
+
const routesValue = record.routes
|
|
109
|
+
const routesRecord = recordOf(routesValue)
|
|
110
|
+
if (record.version !== 1 || routesRecord === undefined)
|
|
111
|
+
throw new TypeError("contracts lock must have version 1 and routes")
|
|
112
|
+
const routes: Record<string, ContractDigest> = {}
|
|
113
|
+
for (const [key, raw] of Object.entries(routesRecord)) {
|
|
114
|
+
const item = recordOf(raw)
|
|
115
|
+
const request = item?.request
|
|
116
|
+
const response = item?.response
|
|
117
|
+
if (
|
|
118
|
+
request === undefined ||
|
|
119
|
+
typeof request !== "string" ||
|
|
120
|
+
response === undefined ||
|
|
121
|
+
typeof response !== "string" ||
|
|
122
|
+
!DIGEST.test(request) ||
|
|
123
|
+
!DIGEST.test(response)
|
|
124
|
+
)
|
|
125
|
+
throw new TypeError(`invalid contract ${key}`)
|
|
126
|
+
routes[key] = { request, response }
|
|
127
|
+
}
|
|
128
|
+
return { version: 1, routes }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function checkContractsLock(
|
|
132
|
+
cwd: string,
|
|
133
|
+
file = DEFAULT_CONTRACTS_LOCK,
|
|
134
|
+
): Promise<{ present: boolean; diagnostics: Array<{ route?: string; message: string }> }> {
|
|
135
|
+
const path = resolve(cwd, file)
|
|
136
|
+
if (!existsSync(path)) return { present: false, diagnostics: [] }
|
|
137
|
+
const parsed = parseContractsLock(JSON.parse(await Bun.file(path).text()))
|
|
138
|
+
const current = await buildContractsLock(await loadBackend(cwd))
|
|
139
|
+
const diagnostics: Array<{ route?: string; message: string }> = []
|
|
140
|
+
const keys = new Set([...Object.keys(parsed.routes), ...Object.keys(current.routes)])
|
|
141
|
+
for (const key of [...keys].sort()) {
|
|
142
|
+
const before = parsed.routes[key]
|
|
143
|
+
const after = current.routes[key]
|
|
144
|
+
if (
|
|
145
|
+
before === undefined ||
|
|
146
|
+
after === undefined ||
|
|
147
|
+
before.request !== after.request ||
|
|
148
|
+
before.response !== after.response
|
|
149
|
+
) {
|
|
150
|
+
diagnostics.push({
|
|
151
|
+
route: key,
|
|
152
|
+
message: `contract changed for ${key}; if intentional run \`nifra contracts snapshot\` and include the lock update in the same change.`,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { present: true, diagnostics }
|
|
157
|
+
}
|
package/src/dev-bun-config.ts
CHANGED
|
@@ -7,10 +7,15 @@
|
|
|
7
7
|
* would ship to the browser WHOLE (body, DB handles, secrets) instead of as the RPC stub the
|
|
8
8
|
* production client build emits, and a `*.server` module would ship instead of being emptied.
|
|
9
9
|
*
|
|
10
|
+
* The same channel is the only way an app's OWN `clientPlugins` (SFC compilers and the like) can reach
|
|
11
|
+
* that bundler: the CLI holds them as plugin OBJECTS, and bunfig accepts only module paths - so the
|
|
12
|
+
* generated module re-imports the app's config and composes them in.
|
|
13
|
+
*
|
|
10
14
|
* So the CLI generates, under `.nifra/dev-bun/`:
|
|
11
15
|
* - `boundary-plugin.ts` - default-exports one plugin composing the SAME `serverFnStubPlugin` +
|
|
12
|
-
* `serverOnlyEmptyPlugin` the production build uses (imported from
|
|
13
|
-
* from the app - identical
|
|
16
|
+
* `serverOnlyEmptyPlugin` + CSS Modules transform the production build uses (imported from
|
|
17
|
+
* `@nifrajs/web`, resolved from the app - identical transforms, not re-implementations), plus the
|
|
18
|
+
* app's own `clientPlugins`;
|
|
14
19
|
* - `bunfig.toml` - the app's ENTIRE bunfig carried over verbatim (jsx, loaders, defines, install
|
|
15
20
|
* settings - dropping any of them would give dev a different Bun than the app configured), with
|
|
16
21
|
* `[serve.static] plugins` merged to put the boundary plugin first and path-bearing entries
|
|
@@ -34,7 +39,7 @@
|
|
|
34
39
|
*/
|
|
35
40
|
import { timingSafeEqual } from "node:crypto"
|
|
36
41
|
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
|
37
|
-
import { resolve } from "node:path"
|
|
42
|
+
import { relative, resolve } from "node:path"
|
|
38
43
|
|
|
39
44
|
/** TOML string literal: basic (double-quoted) form with the two escapes it needs. */
|
|
40
45
|
function tomlString(value: string): string {
|
|
@@ -132,22 +137,56 @@ export function renderDevBunfig(
|
|
|
132
137
|
)
|
|
133
138
|
}
|
|
134
139
|
|
|
135
|
-
/**
|
|
136
|
-
*
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return
|
|
140
|
-
|
|
140
|
+
/** A TS/JS import specifier for `target` as seen from `fromDir`, always `./`-prefixed and posix-slashed
|
|
141
|
+
* so it reads as a FILE path to the module loader rather than a package name. */
|
|
142
|
+
function relativeSpecifier(fromDir: string, target: string): string {
|
|
143
|
+
const rel = relative(fromDir, target).replaceAll("\\", "/")
|
|
144
|
+
return rel.startsWith(".") ? rel : `./${rel}`
|
|
145
|
+
}
|
|
141
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The generated plugin module: compose the production boundary plugins, the production CSS Modules
|
|
149
|
+
* transform, and the APP's own `clientPlugins` into one default export.
|
|
150
|
+
*
|
|
151
|
+
* The `@nifrajs/*` specifiers are bare so they resolve from the APP's install (the module lives under
|
|
152
|
+
* the app's `.nifra/`), keeping dev transforms byte-identical to production's.
|
|
153
|
+
*
|
|
154
|
+
* `configPath` is the app's `nifra.config.ts` (or `framework.ts`). Importing it here is the only way an
|
|
155
|
+
* app's `clientPlugins` can reach Bun's dev-server bundler at all: that bundler takes plugins as module
|
|
156
|
+
* PATHS via bunfig, never as the plugin objects the CLI already holds in memory. Without this an app
|
|
157
|
+
* whose transforms live in `clientPlugins` had no working Bun dev loop - its client would be bundled
|
|
158
|
+
* with the transform silently missing. `setup` is async because `clientPlugins` may be a thunk.
|
|
159
|
+
*/
|
|
160
|
+
export function renderBoundaryPluginModule(configPath?: string, pluginDir?: string): string {
|
|
161
|
+
const appImport =
|
|
162
|
+
configPath !== undefined && pluginDir !== undefined
|
|
163
|
+
? `import * as appConfig from ${tomlString(relativeSpecifier(pluginDir, configPath))}\n`
|
|
164
|
+
: ""
|
|
165
|
+
const appSetup =
|
|
166
|
+
appImport === ""
|
|
167
|
+
? ""
|
|
168
|
+
: ` // The app's OWN client transforms (SFC compilers etc.). \`clientPlugins\` may be a thunk.
|
|
169
|
+
const field = appConfig.clientPlugins
|
|
170
|
+
const appPlugins = (typeof field === "function" ? await field() : field) ?? []
|
|
171
|
+
for (const p of appPlugins) await p.setup(build)
|
|
172
|
+
`
|
|
173
|
+
return `// Generated by \`nifra dev\` (Bun pipeline) - do not edit. Regenerated on every dev start.
|
|
174
|
+
import { serverFnStubPlugin, serverOnlyEmptyPlugin } from "@nifrajs/web/build"
|
|
175
|
+
import { cssModulesBunPlugin } from "@nifrajs/web/plugins/css-modules"
|
|
176
|
+
${appImport}
|
|
142
177
|
const fn = serverFnStubPlugin()
|
|
143
178
|
const serverOnly = serverOnlyEmptyPlugin()
|
|
179
|
+
const cssModules = cssModulesBunPlugin("dom")
|
|
144
180
|
|
|
145
181
|
export default {
|
|
146
182
|
name: "nifra-dev-boundary",
|
|
147
|
-
setup(build) {
|
|
183
|
+
async setup(build) {
|
|
184
|
+
// Boundary first: an app plugin must never get to transform a module that the boundary is about to
|
|
185
|
+
// empty or stub, or the transform decides what ships instead of the boundary.
|
|
148
186
|
serverOnly.setup(build)
|
|
149
187
|
fn.setup(build)
|
|
150
|
-
|
|
188
|
+
cssModules.setup(build)
|
|
189
|
+
${appSetup} },
|
|
151
190
|
}
|
|
152
191
|
`
|
|
153
192
|
}
|
|
@@ -161,12 +200,16 @@ export interface BunDevConfig {
|
|
|
161
200
|
const TOKEN_FILE = "launch-token"
|
|
162
201
|
|
|
163
202
|
/** Write the generated config for an app; returns the bunfig path to re-exec with and the fresh
|
|
164
|
-
* launch token to pass in the child's environment.
|
|
165
|
-
|
|
203
|
+
* launch token to pass in the child's environment. `configPath` (the app's `nifra.config.ts` /
|
|
204
|
+
* `framework.ts`) is what lets the app's own `clientPlugins` reach Bun's dev-server bundler. */
|
|
205
|
+
export async function writeBunDevConfig(
|
|
206
|
+
appRoot: string,
|
|
207
|
+
configPath?: string,
|
|
208
|
+
): Promise<BunDevConfig> {
|
|
166
209
|
const dir = resolve(appRoot, ".nifra", "dev-bun")
|
|
167
210
|
mkdirSync(dir, { recursive: true })
|
|
168
211
|
const pluginPath = resolve(dir, "boundary-plugin.ts")
|
|
169
|
-
writeFileSync(pluginPath, renderBoundaryPluginModule())
|
|
212
|
+
writeFileSync(pluginPath, renderBoundaryPluginModule(configPath, dir))
|
|
170
213
|
const userToml = await Bun.file(resolve(appRoot, "bunfig.toml"))
|
|
171
214
|
.text()
|
|
172
215
|
.catch(() => undefined)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Stable, machine-actionable diagnostics shared by CLI gates and MCP renderers. */
|
|
2
|
+
|
|
3
|
+
export type Severity = "error" | "warn" | "info"
|
|
4
|
+
|
|
5
|
+
export interface DiagnosticFix {
|
|
6
|
+
readonly recipe: string
|
|
7
|
+
readonly command?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Diagnostic {
|
|
11
|
+
/** Stable forever. Never renumber or reuse. */
|
|
12
|
+
readonly code: string
|
|
13
|
+
readonly severity: Severity
|
|
14
|
+
readonly message: string
|
|
15
|
+
readonly file?: string
|
|
16
|
+
readonly line?: number
|
|
17
|
+
/** Short evidence strings that help an agent act without exposing payloads. */
|
|
18
|
+
readonly evidence?: readonly string[]
|
|
19
|
+
readonly fix?: DiagnosticFix
|
|
20
|
+
/** Command that proves the fix worked. */
|
|
21
|
+
readonly verify?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const diagnostic = (value: Diagnostic): Diagnostic => Object.freeze(value)
|
|
25
|
+
|
|
26
|
+
export function severityFails(severity: Severity, strict = false): boolean {
|
|
27
|
+
return severity === "error" || (strict && severity === "warn")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeSeverity(value: "error" | "warning" | "warn" | "info"): Severity {
|
|
31
|
+
return value === "warning" ? "warn" : value
|
|
32
|
+
}
|
package/src/diff-tool.ts
CHANGED
|
@@ -82,7 +82,7 @@ export function formatDiff(diff: RoutesDiff): string {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/** Import `backend.ts` from `cwd` and snapshot its routes. */
|
|
85
|
-
async function snapshotBackend(cwd: string): Promise<readonly RouteSnapshot[]> {
|
|
85
|
+
export async function snapshotBackend(cwd: string): Promise<readonly RouteSnapshot[]> {
|
|
86
86
|
const backendPath = resolve(cwd, "backend.ts")
|
|
87
87
|
if (!existsSync(backendPath)) {
|
|
88
88
|
throw new Error(
|
package/src/docs-search.ts
CHANGED
|
@@ -17,6 +17,13 @@ export interface DocSection {
|
|
|
17
17
|
readonly body: string
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export interface PreparedDocSection extends DocSection {
|
|
21
|
+
readonly headingTokens: ReadonlySet<string>
|
|
22
|
+
readonly bodyLower: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sectionCache = new Map<string, readonly PreparedDocSection[]>()
|
|
26
|
+
|
|
20
27
|
/** Split the generated doc into its `##` sections (the `# title` intro becomes "Overview"). */
|
|
21
28
|
export function splitSections(markdown: string): DocSection[] {
|
|
22
29
|
const out: DocSection[] = []
|
|
@@ -40,6 +47,23 @@ export function splitSections(markdown: string): DocSection[] {
|
|
|
40
47
|
return out
|
|
41
48
|
}
|
|
42
49
|
|
|
50
|
+
function prepareSection(section: DocSection): PreparedDocSection {
|
|
51
|
+
return {
|
|
52
|
+
...section,
|
|
53
|
+
headingTokens: new Set(tokenize(section.heading)),
|
|
54
|
+
bodyLower: section.body.toLowerCase(),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Return the immutable-build section parse and derived search fields from the process cache. */
|
|
59
|
+
export function getCachedDocSections(markdown: string): readonly PreparedDocSection[] {
|
|
60
|
+
const cached = sectionCache.get(markdown)
|
|
61
|
+
if (cached !== undefined) return cached
|
|
62
|
+
const prepared = splitSections(markdown).map(prepareSection)
|
|
63
|
+
sectionCache.set(markdown, prepared)
|
|
64
|
+
return prepared
|
|
65
|
+
}
|
|
66
|
+
|
|
43
67
|
export interface ScoredSection extends DocSection {
|
|
44
68
|
readonly score: number
|
|
45
69
|
}
|
|
@@ -48,7 +72,7 @@ export interface ScoredSection extends DocSection {
|
|
|
48
72
|
* (headings are curated names for exactly these lookups); body occurrences cap per term so one
|
|
49
73
|
* spammy section can't drown a precisely-titled one. */
|
|
50
74
|
export function searchSections(
|
|
51
|
-
sections: readonly DocSection[],
|
|
75
|
+
sections: readonly (DocSection | PreparedDocSection)[],
|
|
52
76
|
query: string,
|
|
53
77
|
limit: number,
|
|
54
78
|
): ScoredSection[] {
|
|
@@ -56,14 +80,14 @@ export function searchSections(
|
|
|
56
80
|
if (terms.length === 0) return []
|
|
57
81
|
const scored: ScoredSection[] = []
|
|
58
82
|
for (const section of sections) {
|
|
59
|
-
const
|
|
60
|
-
|
|
83
|
+
const prepared =
|
|
84
|
+
"headingTokens" in section && "bodyLower" in section ? section : prepareSection(section)
|
|
61
85
|
let score = 0
|
|
62
86
|
for (const term of terms) {
|
|
63
|
-
if (tokenSetHas(headingTokens, term)) score += 8
|
|
64
|
-
score += countBodyHits(bodyLower, term, 5)
|
|
87
|
+
if (tokenSetHas(prepared.headingTokens, term)) score += 8
|
|
88
|
+
score += countBodyHits(prepared.bodyLower, term, 5)
|
|
65
89
|
}
|
|
66
|
-
if (score > 0) scored.push({
|
|
90
|
+
if (score > 0) scored.push({ heading: section.heading, body: section.body, score })
|
|
67
91
|
}
|
|
68
92
|
return scored.sort((a, b) => b.score - a.score).slice(0, limit)
|
|
69
93
|
}
|
|
@@ -78,7 +102,7 @@ export function renderDocsResult(
|
|
|
78
102
|
query: string | undefined,
|
|
79
103
|
limit: number,
|
|
80
104
|
): string {
|
|
81
|
-
const sections =
|
|
105
|
+
const sections = getCachedDocSections(markdown)
|
|
82
106
|
if (query === undefined || query.trim() === "") {
|
|
83
107
|
const index = sections.map((s) => `- ${s.heading}`).join("\n")
|
|
84
108
|
return `# nifra docs - section index (pass \`query\` to fetch matching sections)\n\n${index}`
|
|
@@ -100,12 +124,18 @@ export function renderDocsResult(
|
|
|
100
124
|
|
|
101
125
|
/** Load the bundled corpus. Resolves relative to this module so it works from `src/` (repo dev)
|
|
102
126
|
* and `dist/` (published) alike - `docs/llms-full.txt` sits at the package root next to both. */
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
127
|
+
let docsCorpusPromise: Promise<string | undefined> | undefined
|
|
128
|
+
|
|
129
|
+
/** The published corpus is immutable, so one read and one parsed-section cache are sufficient. */
|
|
130
|
+
export function loadDocsCorpus(): Promise<string | undefined> {
|
|
131
|
+
docsCorpusPromise ??= (async () => {
|
|
132
|
+
const url = new URL("../docs/llms-full.txt", import.meta.url)
|
|
133
|
+
try {
|
|
134
|
+
const text = await Bun.file(url).text()
|
|
135
|
+
return text.length > 0 ? text : undefined
|
|
136
|
+
} catch {
|
|
137
|
+
return undefined
|
|
138
|
+
}
|
|
139
|
+
})()
|
|
140
|
+
return docsCorpusPromise
|
|
111
141
|
}
|