@nifrajs/cli 2.14.1 → 3.1.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/dist/check.d.ts.map +1 -1
- package/dist/check.js +27 -7
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +40 -2
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +6 -0
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +20 -2
- package/dist/command-catalog.js.map +1 -1
- package/dist/doctor.d.ts +42 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +64 -7
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +17 -0
- package/dist/env-file.d.ts.map +1 -0
- package/dist/env-file.js +113 -0
- package/dist/env-file.js.map +1 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +91 -15
- package/dist/fix-recipes.js.map +1 -1
- package/dist/internal/reserved-segment-codemod.d.ts +36 -0
- package/dist/internal/reserved-segment-codemod.d.ts.map +1 -0
- package/dist/internal/reserved-segment-codemod.js +134 -0
- package/dist/internal/reserved-segment-codemod.js.map +1 -0
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +62 -11
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +26 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +74 -24
- package/dist/introspect.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp.d.ts +8 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +68 -6
- package/dist/mcp.js.map +1 -1
- package/dist/recipes/3.0.0.d.ts +10 -0
- package/dist/recipes/3.0.0.d.ts.map +1 -0
- package/dist/recipes/3.0.0.js +23 -0
- package/dist/recipes/3.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +15 -0
- package/dist/release-verification.js.map +1 -1
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +17 -30
- package/dist/rules/routes.js.map +1 -1
- package/dist/workspace-link.d.ts +37 -0
- package/dist/workspace-link.d.ts.map +1 -0
- package/dist/workspace-link.js +72 -0
- package/dist/workspace-link.js.map +1 -0
- package/docs/examples.json +1 -1
- package/docs/llms-full.txt +362 -20
- package/docs/types.json +933 -46
- package/package.json +9 -9
- package/src/check.ts +28 -7
- package/src/cli.ts +41 -2
- package/src/command-catalog.ts +22 -2
- package/src/doctor.ts +99 -8
- package/src/env-file.ts +121 -0
- package/src/fix-recipes.ts +101 -15
- package/src/internal/reserved-segment-codemod.ts +150 -0
- package/src/internal/typescript-import.ts +61 -11
- package/src/introspect.ts +82 -21
- package/src/mcp-http.ts +1 -1
- package/src/mcp.ts +81 -6
- package/src/recipes/3.0.0.ts +24 -0
- package/src/recipes/index.ts +2 -0
- package/src/release-verification.ts +17 -0
- package/src/rules/routes.ts +17 -30
- package/src/workspace-link.ts +80 -0
package/src/fix-recipes.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { existsSync } from "node:fs"
|
|
2
1
|
import { readFile, writeFile } from "node:fs/promises"
|
|
3
2
|
import { join } from "node:path"
|
|
4
3
|
import type { Diagnostic } from "./diagnostics.ts"
|
|
5
4
|
import { resolveInsideProject } from "./project-path.ts"
|
|
5
|
+
import { resolveWorkspaceLinkedPackage } from "./workspace-link.ts"
|
|
6
6
|
|
|
7
7
|
export interface FixRecipe {
|
|
8
8
|
readonly id: string
|
|
@@ -91,29 +91,115 @@ registerFixRecipe({
|
|
|
91
91
|
verify: "nifra doctor --json",
|
|
92
92
|
async apply(root, diagnostic) {
|
|
93
93
|
const packageName = diagnostic.evidence?.[0]
|
|
94
|
-
if (packageName === undefined
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
cwd: packageDir,
|
|
94
|
+
if (packageName === undefined) return []
|
|
95
|
+
// Resolved from the NAME through the project's own node_modules chain, not from any path carried
|
|
96
|
+
// by the diagnostic. A workspace link points outside the project by definition - that escape is
|
|
97
|
+
// what makes the artifact able to go stale - so `resolveInsideProject` would reject every package
|
|
98
|
+
// this diagnostic can ever name. `resolveWorkspaceLinkedPackage` states its own rules instead:
|
|
99
|
+
// reachable through the project's install, not a registry tarball, and declaring its own build.
|
|
100
|
+
const linked = await resolveWorkspaceLinkedPackage(root, packageName)
|
|
101
|
+
// Refusing is a result the caller has to see. Returning "changed nothing" made `nifra fix
|
|
102
|
+
// --code NF-C010` a silent no-op on the only findings that produce it.
|
|
103
|
+
if (!linked.ok) throw new Error(`[nifra] cannot rebuild ${packageName}: ${linked.reason}`)
|
|
104
|
+
const proc = Bun.spawn(["bun", "run", linked.buildScript], {
|
|
105
|
+
cwd: linked.dir,
|
|
107
106
|
stdout: "ignore",
|
|
108
107
|
stderr: "pipe",
|
|
109
108
|
})
|
|
110
109
|
const error = await new Response(proc.stderr).text()
|
|
111
110
|
const exitCode = await proc.exited
|
|
112
|
-
if (exitCode !== 0)
|
|
111
|
+
if (exitCode !== 0) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`[nifra] \`bun run ${linked.buildScript}\` failed in ${linked.dir}: ${error.trim() || `exit code ${exitCode}`}`,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
113
116
|
return [diagnostic.evidence?.[1] ?? packageName]
|
|
114
117
|
},
|
|
115
118
|
})
|
|
116
119
|
|
|
120
|
+
/**
|
|
121
|
+
* How many typecheck passes the reserved-segment codemod will run. A nested collision
|
|
122
|
+
* (`/api/delete/then`) only reveals its second site once the first is rewritten, so one pass is not
|
|
123
|
+
* always enough - but each pass is a full `tsc`, so this converges or stops rather than looping.
|
|
124
|
+
*/
|
|
125
|
+
const RESERVED_SEGMENT_PASSES = 3
|
|
126
|
+
|
|
127
|
+
registerFixRecipe({
|
|
128
|
+
id: "client.reserved-segment",
|
|
129
|
+
description:
|
|
130
|
+
"Rewrite typed-client call sites broken by a reserved-named route segment to the call spelling.",
|
|
131
|
+
verify: "tsc --noEmit",
|
|
132
|
+
async apply(root) {
|
|
133
|
+
const { parseCollisionSites, resolveTscBin, rewriteFile } = await import(
|
|
134
|
+
"./internal/reserved-segment-codemod.ts"
|
|
135
|
+
)
|
|
136
|
+
const tsconfig = join(root, "tsconfig.json")
|
|
137
|
+
if (!(await Bun.file(tsconfig).exists()))
|
|
138
|
+
throw new Error(
|
|
139
|
+
"[nifra] no tsconfig.json here - the reserved-segment codemod reads the compiler's own list of broken call sites, so it needs a typecheckable project",
|
|
140
|
+
)
|
|
141
|
+
const tscBin = resolveTscBin(root)
|
|
142
|
+
if (tscBin === undefined)
|
|
143
|
+
throw new Error(
|
|
144
|
+
"[nifra] no `typescript` install found from this directory upward - run `bun add -d typescript`, then rerun the fix",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
const changed = new Set<string>()
|
|
148
|
+
const skipped: string[] = []
|
|
149
|
+
for (let pass = 0; pass < RESERVED_SEGMENT_PASSES; pass += 1) {
|
|
150
|
+
const proc = Bun.spawn(["bun", tscBin, "--noEmit", "--pretty", "false", "-p", tsconfig], {
|
|
151
|
+
cwd: root,
|
|
152
|
+
stdout: "pipe",
|
|
153
|
+
stderr: "pipe",
|
|
154
|
+
})
|
|
155
|
+
const [stdout, stderr] = await Promise.all([
|
|
156
|
+
new Response(proc.stdout).text(),
|
|
157
|
+
new Response(proc.stderr).text(),
|
|
158
|
+
])
|
|
159
|
+
await proc.exited
|
|
160
|
+
const sites = parseCollisionSites(`${stdout}${stderr}`)
|
|
161
|
+
if (sites.length === 0) break
|
|
162
|
+
|
|
163
|
+
const byFile = new Map<string, (typeof sites)[number][]>()
|
|
164
|
+
for (const site of sites) {
|
|
165
|
+
const existing = byFile.get(site.file)
|
|
166
|
+
if (existing === undefined) byFile.set(site.file, [site])
|
|
167
|
+
else existing.push(site)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let wrote = false
|
|
171
|
+
skipped.length = 0
|
|
172
|
+
for (const [file, fileSites] of byFile) {
|
|
173
|
+
// Resolved through the project boundary: the compiler reports paths from its own rootDir, and
|
|
174
|
+
// a rewrite must never escape the project it was invoked in.
|
|
175
|
+
const path = await resolveInsideProject(root, file)
|
|
176
|
+
if (path === undefined) continue
|
|
177
|
+
const source = await readFile(path, "utf8")
|
|
178
|
+
const result = rewriteFile(source, fileSites)
|
|
179
|
+
for (const site of result.skipped) skipped.push(`${file}:${site.line}:${site.column}`)
|
|
180
|
+
if (result.source === source) continue
|
|
181
|
+
await writeFile(path, result.source, "utf8")
|
|
182
|
+
changed.add(file)
|
|
183
|
+
wrote = true
|
|
184
|
+
}
|
|
185
|
+
// Nothing rewritten this pass means the remaining sites are all shapes the codemod declines to
|
|
186
|
+
// touch. Another `tsc` would report the same list, so stop instead of burning the budget.
|
|
187
|
+
if (!wrote) break
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (skipped.length > 0) {
|
|
191
|
+
const rewritten =
|
|
192
|
+
changed.size === 0
|
|
193
|
+
? "no site was rewritten"
|
|
194
|
+
: `rewrote ${changed.size} file${changed.size === 1 ? "" : "s"}`
|
|
195
|
+
throw new Error(
|
|
196
|
+
`[nifra] ${rewritten}; ${skipped.length} reserved-segment site${skipped.length === 1 ? "" : "s"} need${skipped.length === 1 ? "s" : ""} a manual edit (the collision is not reached by a plain \`.segment\` property access there): ${skipped.join(", ")}`,
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
return [...changed]
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
|
|
117
203
|
export async function applyDiagnosticRecipe(
|
|
118
204
|
root: string,
|
|
119
205
|
diagnostic: Diagnostic,
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codemod for reserved-segment collisions in typed-client call sites.
|
|
3
|
+
*
|
|
4
|
+
* A route segment spelling a reserved proxy key (`get`…`options`, `subscribe`, `ws`, `index`, `then`)
|
|
5
|
+
* cannot be reached by property access - the proxy resolves the reserved behavior first - so the client
|
|
6
|
+
* type resolves that child to `ReservedSegmentCollision<"seg">` and every `api.foo.delete.post()` in the
|
|
7
|
+
* project stops compiling at once. The fix is mechanical and identical at every site: call the parent
|
|
8
|
+
* node with the segment instead, `api.foo("delete").post()`. Doing that by hand across a codebase is
|
|
9
|
+
* pure toil, and it is toil the framework caused.
|
|
10
|
+
*
|
|
11
|
+
* The compiler is the site index. `tsc` already reports every broken access, and its message names the
|
|
12
|
+
* segment (`Property 'post' does not exist on type 'ReservedSegmentCollision<"delete">'`), so the
|
|
13
|
+
* rewrite needs no type graph of its own and cannot invent a site the compiler did not reject. That
|
|
14
|
+
* also disambiguates the one case a textual search could not: `.delete` as a real DELETE verb call is
|
|
15
|
+
* valid and never appears in this list.
|
|
16
|
+
*
|
|
17
|
+
* A site the scan cannot rewrite CONFIDENTLY is left alone and reported, never guessed at. Bracket
|
|
18
|
+
* access, a collision node stored in a variable and used elsewhere, and any shape where the text before
|
|
19
|
+
* the failing property is not `.<segment>` all fall into that bucket.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync } from "node:fs"
|
|
22
|
+
import { dirname, join } from "node:path"
|
|
23
|
+
|
|
24
|
+
/** `path/to/file.ts(12,16): error TS2339: … 'ReservedSegmentCollision<"delete">'.` */
|
|
25
|
+
const TSC_POSITION = /^(.+?)\((\d+),(\d+)\):\s*(?:error|warning)\s+TS\d+:\s*(.+)$/
|
|
26
|
+
const COLLISION_TYPE = /ReservedSegmentCollision<"([^"]+)">/
|
|
27
|
+
|
|
28
|
+
export interface CollisionSite {
|
|
29
|
+
readonly file: string
|
|
30
|
+
/** 1-based, as `tsc` reports them. */
|
|
31
|
+
readonly line: number
|
|
32
|
+
readonly column: number
|
|
33
|
+
/** The colliding path segment, read out of the type the compiler named. */
|
|
34
|
+
readonly segment: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Every reserved-segment collision `tsc` reported, in the order it reported them. */
|
|
38
|
+
export function parseCollisionSites(tscOutput: string): readonly CollisionSite[] {
|
|
39
|
+
const sites: CollisionSite[] = []
|
|
40
|
+
for (const raw of tscOutput.split("\n")) {
|
|
41
|
+
const position = TSC_POSITION.exec(raw.trim())
|
|
42
|
+
if (position === null) continue
|
|
43
|
+
const segment = COLLISION_TYPE.exec(position[4] as string)
|
|
44
|
+
if (segment === null) continue
|
|
45
|
+
sites.push({
|
|
46
|
+
file: position[1] as string,
|
|
47
|
+
line: Number(position[2]),
|
|
48
|
+
column: Number(position[3]),
|
|
49
|
+
segment: segment[1] as string,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
return sites
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Byte offset of a 1-based (line, column) in `source`, or undefined when it is past the end. */
|
|
56
|
+
const offsetOf = (source: string, line: number, column: number): number | undefined => {
|
|
57
|
+
let offset = 0
|
|
58
|
+
for (let current = 1; current < line; current += 1) {
|
|
59
|
+
const next = source.indexOf("\n", offset)
|
|
60
|
+
if (next === -1) return undefined
|
|
61
|
+
offset = next + 1
|
|
62
|
+
}
|
|
63
|
+
const target = offset + column - 1
|
|
64
|
+
return target <= source.length ? target : undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const skipSpaceBack = (source: string, from: number): number => {
|
|
68
|
+
let index = from
|
|
69
|
+
while (index >= 0 && /\s/.test(source[index] as string)) index -= 1
|
|
70
|
+
return index
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The span to replace for one site: the `.segment` immediately preceding the failing property access,
|
|
75
|
+
* which becomes `("segment")`. Undefined when the text does not have that shape - the caller reports
|
|
76
|
+
* those rather than rewriting something it did not recognise.
|
|
77
|
+
*
|
|
78
|
+
* The scan runs backwards over the whole source rather than one line, so a chain broken across lines
|
|
79
|
+
* (`api.foo\n .delete\n .post()`) rewrites the same as a chain on one line.
|
|
80
|
+
*/
|
|
81
|
+
export function collisionSpan(
|
|
82
|
+
source: string,
|
|
83
|
+
site: CollisionSite,
|
|
84
|
+
): { readonly start: number; readonly end: number } | undefined {
|
|
85
|
+
const offset = offsetOf(source, site.line, site.column)
|
|
86
|
+
if (offset === undefined) return undefined
|
|
87
|
+
|
|
88
|
+
// Back over the dot that introduces the FAILING property (`.post` in `…delete.post`).
|
|
89
|
+
let index = skipSpaceBack(source, offset - 1)
|
|
90
|
+
if (source[index] !== ".") return undefined
|
|
91
|
+
|
|
92
|
+
// Back over the colliding segment itself.
|
|
93
|
+
index = skipSpaceBack(source, index - 1)
|
|
94
|
+
const segmentEnd = index
|
|
95
|
+
const segmentStart = segmentEnd - site.segment.length + 1
|
|
96
|
+
if (segmentStart < 0) return undefined
|
|
97
|
+
if (source.slice(segmentStart, segmentEnd + 1) !== site.segment) return undefined
|
|
98
|
+
|
|
99
|
+
// Back over the dot that introduces the segment. Anything else (bracket access, a bare identifier
|
|
100
|
+
// holding the node) is a shape this rewrite does not understand.
|
|
101
|
+
const dot = skipSpaceBack(source, segmentStart - 1)
|
|
102
|
+
if (source[dot] !== ".") return undefined
|
|
103
|
+
|
|
104
|
+
// Take any whitespace before that dot into the span too, so a chain broken across lines closes up
|
|
105
|
+
// (`api.jobs\n .subscribe` becomes `api.jobs("subscribe")`) instead of leaving the call argument
|
|
106
|
+
// dangling on its own line.
|
|
107
|
+
return { start: skipSpaceBack(source, dot - 1) + 1, end: segmentEnd }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface FileRewrite {
|
|
111
|
+
readonly source: string
|
|
112
|
+
/** Sites in this file the scan declined to rewrite, so the caller can report them. */
|
|
113
|
+
readonly skipped: readonly CollisionSite[]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Apply every site belonging to one file. Spans are applied back to front so an earlier rewrite never
|
|
118
|
+
* shifts the offsets of one not yet applied, and a duplicate span (two failing properties resolving
|
|
119
|
+
* through the same access) is applied once.
|
|
120
|
+
*/
|
|
121
|
+
export function rewriteFile(source: string, sites: readonly CollisionSite[]): FileRewrite {
|
|
122
|
+
const skipped: CollisionSite[] = []
|
|
123
|
+
const spans: { start: number; end: number; segment: string }[] = []
|
|
124
|
+
for (const site of sites) {
|
|
125
|
+
const span = collisionSpan(source, site)
|
|
126
|
+
if (span === undefined) {
|
|
127
|
+
skipped.push(site)
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
if (spans.some((other) => other.start === span.start && other.end === span.end)) continue
|
|
131
|
+
spans.push({ ...span, segment: site.segment })
|
|
132
|
+
}
|
|
133
|
+
let out = source
|
|
134
|
+
for (const span of [...spans].sort((a, b) => b.start - a.start)) {
|
|
135
|
+
out = `${out.slice(0, span.start)}("${span.segment}")${out.slice(span.end + 1)}`
|
|
136
|
+
}
|
|
137
|
+
return { source: out, skipped }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The project's own `tsc` binary, found upward from `root`. Never installs one. */
|
|
141
|
+
export function resolveTscBin(root: string): string | undefined {
|
|
142
|
+
let dir = root
|
|
143
|
+
while (true) {
|
|
144
|
+
const candidate = join(dir, "node_modules", "typescript", "bin", "tsc")
|
|
145
|
+
if (existsSync(candidate)) return candidate
|
|
146
|
+
const parent = dirname(dir)
|
|
147
|
+
if (parent === dir) return undefined
|
|
148
|
+
dir = parent
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -12,11 +12,37 @@
|
|
|
12
12
|
* install does not pay for it.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { existsSync } from "node:fs"
|
|
16
|
+
import { dirname, join } from "node:path"
|
|
17
|
+
|
|
15
18
|
/** The slice of the compiler the SQL scanner uses. Structural, so the peer stays optional. */
|
|
16
19
|
export type TypeScriptApi = typeof import("typescript")
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
|
-
*
|
|
22
|
+
* Is this module actually the compiler, rather than something else published under the same name?
|
|
23
|
+
*
|
|
24
|
+
* The `typescript` package name also carries small non-compiler entry points (a version stub, for
|
|
25
|
+
* one), and a resolver that falls back to a global download cache can hand one of those back. They
|
|
26
|
+
* import cleanly and then explode on first use - `undefined is not an object (evaluating
|
|
27
|
+
* 'ts.ScriptKind.TSX')` deep inside a scan - which reads as a Nifra bug rather than as "the compiler
|
|
28
|
+
* we loaded is not a compiler". Checking the two members every caller needs turns that into a
|
|
29
|
+
* resolution miss, which the fallbacks below already know how to handle.
|
|
30
|
+
*/
|
|
31
|
+
function isCompiler(module: unknown): module is TypeScriptApi {
|
|
32
|
+
if (typeof module !== "object" || module === null) return false
|
|
33
|
+
const api = module as Partial<TypeScriptApi>
|
|
34
|
+
return typeof api.createSourceFile === "function" && api.ScriptKind !== undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Unwrap the CJS default interop wrapper, so both `import * as ts` shapes look the same. */
|
|
38
|
+
function compilerOf(module: unknown): TypeScriptApi | undefined {
|
|
39
|
+
if (isCompiler(module)) return module
|
|
40
|
+
const wrapped = (module as { default?: unknown } | undefined)?.default
|
|
41
|
+
return isCompiler(wrapped) ? wrapped : undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Import TypeScript from the CLI's own dependency tree, or `undefined` when it is not installed.
|
|
20
46
|
*
|
|
21
47
|
* Only a RESOLUTION failure is absence. A compiler that resolves and then fails while evaluating is a
|
|
22
48
|
* broken install, not a missing one, and telling that user to install what they already have sends
|
|
@@ -24,7 +50,7 @@ export type TypeScriptApi = typeof import("typescript")
|
|
|
24
50
|
*/
|
|
25
51
|
export async function importTypeScript(): Promise<TypeScriptApi | undefined> {
|
|
26
52
|
try {
|
|
27
|
-
return (await import("typescript"))
|
|
53
|
+
return compilerOf(await import("typescript"))
|
|
28
54
|
} catch (cause) {
|
|
29
55
|
const message = cause instanceof Error ? cause.message : String(cause)
|
|
30
56
|
if (/cannot find (?:package|module)|ERR_MODULE_NOT_FOUND/i.test(message)) return undefined
|
|
@@ -32,6 +58,33 @@ export async function importTypeScript(): Promise<TypeScriptApi | undefined> {
|
|
|
32
58
|
}
|
|
33
59
|
}
|
|
34
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Find the project's compiler the way module resolution would: `node_modules/typescript` in `root`,
|
|
63
|
+
* then each parent directory up to the filesystem root - the same walk the typecheck gate uses to
|
|
64
|
+
* find `tsc`, so both gates agree on which compiler the project has.
|
|
65
|
+
*
|
|
66
|
+
* The walk is deliberate, not a fallback for a missing resolver. `Bun.resolveSync` memoizes a
|
|
67
|
+
* specifier for the life of the process AND auto-installs from the global download cache when the
|
|
68
|
+
* project has none, so a long-lived process (the MCP server) that first resolved `typescript` before
|
|
69
|
+
* `bun install` kept handing back that cache entry afterwards - the typecheck went phantom until the
|
|
70
|
+
* server was restarted. A filesystem probe is re-answered every call, so an install that lands
|
|
71
|
+
* mid-session is picked up on the next scan.
|
|
72
|
+
*/
|
|
73
|
+
function resolveProjectTypeScript(root: string): string | undefined {
|
|
74
|
+
let dir = root
|
|
75
|
+
while (true) {
|
|
76
|
+
const pkg = join(dir, "node_modules", "typescript")
|
|
77
|
+
// `lib/typescript.js` is the compiler entry across every published major; probing it directly
|
|
78
|
+
// (rather than importing the directory) keeps a package whose main entry is a stub out of play.
|
|
79
|
+
const entry = join(pkg, "lib", "typescript.js")
|
|
80
|
+
if (existsSync(entry)) return entry
|
|
81
|
+
if (existsSync(join(pkg, "package.json"))) return pkg
|
|
82
|
+
const parent = dirname(dir)
|
|
83
|
+
if (parent === dir) return undefined
|
|
84
|
+
dir = parent
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
35
88
|
/**
|
|
36
89
|
* Import TypeScript resolved from the PROJECT root first, falling back to the CLI's own dependency
|
|
37
90
|
* tree. A bare `import("typescript")` resolves relative to this file - the CLI's install - so a
|
|
@@ -41,16 +94,13 @@ export async function importTypeScript(): Promise<TypeScriptApi | undefined> {
|
|
|
41
94
|
* the CLI was installed - the same cwd-invariance the typecheck gate's tsc resolution has.
|
|
42
95
|
*/
|
|
43
96
|
export async function importProjectTypeScript(root: string): Promise<TypeScriptApi | undefined> {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// Unresolvable from the project (not installed there, or a non-Bun host without resolveSync):
|
|
49
|
-
// fall back to the CLI's own tree, which keeps the previous behavior as the floor.
|
|
50
|
-
return importTypeScript()
|
|
51
|
-
}
|
|
97
|
+
const resolved = resolveProjectTypeScript(root)
|
|
98
|
+
// Not installed in the project (or installed without a compiler entry): fall back to the CLI's own
|
|
99
|
+
// tree, which keeps the previous behavior as the floor.
|
|
100
|
+
if (resolved === undefined) return importTypeScript()
|
|
52
101
|
try {
|
|
53
|
-
|
|
102
|
+
const compiler = compilerOf(await import(resolved))
|
|
103
|
+
return compiler ?? (await importTypeScript())
|
|
54
104
|
} catch (cause) {
|
|
55
105
|
// Same contract as importTypeScript: a compiler that RESOLVES and then fails evaluating is a
|
|
56
106
|
// broken install, not a missing one - rethrow rather than silently checking with another copy.
|
package/src/introspect.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* *this* project's actual surface, not just generic docs.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { reservedKeyFor } from "@nifrajs/client"
|
|
8
9
|
import {
|
|
9
10
|
type ProjectEvidenceSchema,
|
|
10
11
|
type ProjectEvidenceSchemaPart,
|
|
@@ -153,6 +154,24 @@ function schemaLines(schema: ReflectedRoute["schema"]): string[] {
|
|
|
153
154
|
|
|
154
155
|
const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
155
156
|
|
|
157
|
+
/**
|
|
158
|
+
* The typed-client proxy chain for a path, without the terminal verb call. Shared by `clientCall`
|
|
159
|
+
* and the `nifra routes` collision annotation so both teach exactly one spelling.
|
|
160
|
+
*/
|
|
161
|
+
function clientChain(path: string): string {
|
|
162
|
+
const segs = path.split("/").filter((seg) => seg !== "")
|
|
163
|
+
if (segs.length === 0) return "api.index"
|
|
164
|
+
let chain = "api"
|
|
165
|
+
for (const seg of segs) {
|
|
166
|
+
if (seg.startsWith(":") || seg.startsWith("*")) {
|
|
167
|
+
const name = seg.replace(/^[:*]/, "") || "value"
|
|
168
|
+
chain += `({ ${name} })`
|
|
169
|
+
} else if (reservedKeyFor(seg) !== undefined) chain += `(${JSON.stringify(seg)})`
|
|
170
|
+
else chain += IDENT.test(seg) ? `.${seg}` : `[${JSON.stringify(seg)}]`
|
|
171
|
+
}
|
|
172
|
+
return chain
|
|
173
|
+
}
|
|
174
|
+
|
|
156
175
|
/**
|
|
157
176
|
* The typed-client call form for a route - the exact `client<typeof app>` proxy chain an agent should
|
|
158
177
|
* write, derived from the same convention `@nifrajs/client` implements (so it never has to read the
|
|
@@ -160,20 +179,16 @@ const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
|
160
179
|
* that appends the value (`({ id })`), the root path is `.index`, and the HTTP verb is the terminal call.
|
|
161
180
|
* Body verbs (POST/PUT/PATCH) take the body first then call-options; other verbs take call-options first -
|
|
162
181
|
* so the `{ query }` argument lands in the right slot for each.
|
|
182
|
+
*
|
|
183
|
+
* A segment spelling a reserved proxy key (`delete`, `subscribe`, `then`, ...) is emitted as a call on
|
|
184
|
+
* the parent node instead - and NOT as bracket access, which does not help: the proxy intercepts the
|
|
185
|
+
* key however it is spelled. Emitting `.delete` here would hand an agent a chain that resolves the
|
|
186
|
+
* DELETE verb and silently requests the wrong path.
|
|
163
187
|
*/
|
|
164
188
|
export function clientCall(method: string, path: string, schema: unknown): string {
|
|
165
189
|
const s = schema as { body?: unknown; query?: unknown } | undefined
|
|
166
190
|
const verb = method.toLowerCase()
|
|
167
|
-
const
|
|
168
|
-
let chain = "api"
|
|
169
|
-
if (segs.length === 0) chain += ".index"
|
|
170
|
-
else
|
|
171
|
-
for (const seg of segs) {
|
|
172
|
-
if (seg.startsWith(":") || seg.startsWith("*")) {
|
|
173
|
-
const name = seg.replace(/^[:*]/, "") || "value"
|
|
174
|
-
chain += `({ ${name} })`
|
|
175
|
-
} else chain += IDENT.test(seg) ? `.${seg}` : `[${JSON.stringify(seg)}]`
|
|
176
|
-
}
|
|
191
|
+
const chain = clientChain(path)
|
|
177
192
|
const isBodyVerb = verb === "post" || verb === "put" || verb === "patch"
|
|
178
193
|
let call: string
|
|
179
194
|
if (isBodyVerb) {
|
|
@@ -460,6 +475,27 @@ export function buildRouteTable(input: RouteTableInput): RouteTableEntry[] {
|
|
|
460
475
|
)
|
|
461
476
|
}
|
|
462
477
|
|
|
478
|
+
/**
|
|
479
|
+
* The typed-client spelling for a route path, and the reserved key it collides with when one of its
|
|
480
|
+
* static segments cannot be reached by property access.
|
|
481
|
+
*
|
|
482
|
+
* This is where a dev meets the reserved set EARLIEST: `nifra routes` is what you run while the
|
|
483
|
+
* route is being written, long before a typed client call site exists to break. The reserved names
|
|
484
|
+
* are deliberately unguessable from the outside (`then` and `index` especially - they exist so the
|
|
485
|
+
* proxy stays await-safe and can address `/`), so the route table names the collision and shows the
|
|
486
|
+
* spelling that reaches the route, rather than leaving it to be discovered by a failing build.
|
|
487
|
+
*/
|
|
488
|
+
export function clientSpellingFor(
|
|
489
|
+
path: string,
|
|
490
|
+
): { readonly spelling: string; readonly reserved: string } | undefined {
|
|
491
|
+
for (const segment of path.split("/")) {
|
|
492
|
+
if (segment === "") continue
|
|
493
|
+
const reserved = reservedKeyFor(segment)
|
|
494
|
+
if (reserved !== undefined) return { spelling: clientChain(path), reserved }
|
|
495
|
+
}
|
|
496
|
+
return undefined
|
|
497
|
+
}
|
|
498
|
+
|
|
463
499
|
/** Render the route table as a terse aligned text table (the `nifra routes` default output). Pure. */
|
|
464
500
|
export function renderRouteTable(rows: readonly RouteTableEntry[]): string {
|
|
465
501
|
if (rows.length === 0)
|
|
@@ -468,16 +504,29 @@ export function renderRouteTable(rows: readonly RouteTableEntry[]): string {
|
|
|
468
504
|
methods: r.methods.join(", "),
|
|
469
505
|
kind: r.kind,
|
|
470
506
|
path: r.kind === "api" && r.autoMounted ? `${r.path} (auto-mounted)` : r.path,
|
|
507
|
+
client: clientSpellingFor(r.path),
|
|
471
508
|
}))
|
|
472
509
|
const methodW = Math.max("METHOD".length, ...display.map((d) => d.methods.length))
|
|
473
510
|
const kindW = Math.max("KIND".length, ...display.map((d) => d.kind.length))
|
|
474
511
|
const padEnd = (s: string, w: number): string => s + " ".repeat(Math.max(0, w - s.length))
|
|
475
512
|
const line = (methods: string, kind: string, path: string): string =>
|
|
476
513
|
`${padEnd(methods, methodW)} ${padEnd(kind, kindW)} ${path}`
|
|
477
|
-
|
|
514
|
+
const table = [
|
|
478
515
|
line("METHOD", "KIND", "PATH"),
|
|
479
|
-
...display.
|
|
480
|
-
|
|
516
|
+
...display.flatMap((d) =>
|
|
517
|
+
d.client === undefined
|
|
518
|
+
? [line(d.methods, d.kind, d.path)]
|
|
519
|
+
: [
|
|
520
|
+
line(d.methods, d.kind, d.path),
|
|
521
|
+
line(
|
|
522
|
+
"",
|
|
523
|
+
"",
|
|
524
|
+
` -> '${d.client.reserved}' is a reserved typed-client key; reach this route as \`${d.client.spelling}\``,
|
|
525
|
+
),
|
|
526
|
+
],
|
|
527
|
+
),
|
|
528
|
+
]
|
|
529
|
+
return table.join("\n")
|
|
481
530
|
}
|
|
482
531
|
|
|
483
532
|
/** One JSON row for `nifra routes --json` (the agent-facing, stable shape). */
|
|
@@ -487,21 +536,33 @@ export interface RouteJsonRow {
|
|
|
487
536
|
readonly methods: readonly string[]
|
|
488
537
|
readonly file?: string
|
|
489
538
|
readonly autoMounted?: boolean
|
|
539
|
+
/** The reserved typed-client key a static segment of this path collides with, when one does. */
|
|
540
|
+
readonly reservedKey?: string
|
|
541
|
+
/** The typed-client call spelling that reaches this route, present only alongside `reservedKey`. */
|
|
542
|
+
readonly clientSpelling?: string
|
|
490
543
|
}
|
|
491
544
|
|
|
492
545
|
/** The route table as a JSON document for `--json` (agents). Stable keys; `file`/`autoMounted` present
|
|
493
|
-
* only where meaningful (file on pages, autoMounted on API routes).
|
|
546
|
+
* only where meaningful (file on pages, autoMounted on API routes). A colliding row additionally
|
|
547
|
+
* carries `reservedKey`/`clientSpelling` so an agent writing client code from this table never has to
|
|
548
|
+
* infer the closed set of reserved names - the one thing about it nobody can guess. */
|
|
494
549
|
export function routeTableToJson(rows: readonly RouteTableEntry[]): {
|
|
495
550
|
readonly routes: readonly RouteJsonRow[]
|
|
496
551
|
} {
|
|
497
552
|
return {
|
|
498
|
-
routes: rows.map((r) =>
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
553
|
+
routes: rows.map((r) => {
|
|
554
|
+
const client = clientSpellingFor(r.path)
|
|
555
|
+
return {
|
|
556
|
+
kind: r.kind,
|
|
557
|
+
path: r.path,
|
|
558
|
+
methods: r.methods,
|
|
559
|
+
...(r.file !== undefined ? { file: r.file } : {}),
|
|
560
|
+
...(r.kind === "api" ? { autoMounted: r.autoMounted === true } : {}),
|
|
561
|
+
...(client === undefined
|
|
562
|
+
? {}
|
|
563
|
+
: { reservedKey: client.reserved, clientSpelling: client.spelling }),
|
|
564
|
+
}
|
|
565
|
+
}),
|
|
505
566
|
}
|
|
506
567
|
}
|
|
507
568
|
|
package/src/mcp-http.ts
CHANGED
|
@@ -29,7 +29,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
|
|
|
29
29
|
export type { TypeEntry } from "./types-search.ts"
|
|
30
30
|
|
|
31
31
|
// Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
|
|
32
|
-
const VERSION = "
|
|
32
|
+
const VERSION = "3.1.0"
|
|
33
33
|
const SERVER_INFO = { name: "nifra-docs", version: VERSION }
|
|
34
34
|
// Derive the GET/health tool list from the tools actually served, so the line can never drift from them.
|
|
35
35
|
const docsHealth = (tools: McpTool[]): string =>
|