@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
package/src/init-agents.ts
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* cwd (no `..` traversal escaping the project root).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import {
|
|
21
|
+
import type { Stats } from "node:fs"
|
|
22
|
+
import { lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
|
|
22
23
|
import { isAbsolute, relative, resolve } from "node:path"
|
|
23
24
|
import {
|
|
24
25
|
AGENTS_MD_PATH,
|
|
@@ -68,6 +69,44 @@ const fileExists = (path: string): Promise<boolean> =>
|
|
|
68
69
|
.then(() => true)
|
|
69
70
|
.catch(() => false)
|
|
70
71
|
|
|
72
|
+
/** `lstat`, with "it isn't there" reported as `undefined` instead of a throw. Every other failure
|
|
73
|
+
* (EACCES, ELOOP, ...) still propagates - only absence is an expected outcome here. */
|
|
74
|
+
async function lstatOrMissing(path: string): Promise<Stats | undefined> {
|
|
75
|
+
try {
|
|
76
|
+
return await lstat(path)
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined
|
|
79
|
+
throw error
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Refuse to write through a symlinked ancestor. `safeJoin` only proves the *textual* path stays
|
|
85
|
+
* under the project root; a symlinked `.cursor` still lands the write wherever it points. The walk
|
|
86
|
+
* stops at the first missing segment - `mkdir` will create the rest, so there is nothing to check.
|
|
87
|
+
*/
|
|
88
|
+
async function assertSafeParents(cwd: string, abs: string): Promise<void> {
|
|
89
|
+
const rel = relative(cwd, resolve(abs, ".."))
|
|
90
|
+
let current = cwd
|
|
91
|
+
for (const part of rel.split(/[\\/]/).filter(Boolean)) {
|
|
92
|
+
current = resolve(current, part)
|
|
93
|
+
const stat = await lstatOrMissing(current)
|
|
94
|
+
if (stat === undefined) break
|
|
95
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
96
|
+
throw new Error(`[nifra] refusing unsafe parent path: ${part}`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Refuse to write anything that is not a plain absent-or-regular file: a symlink here would
|
|
102
|
+
* redirect the write, and a directory or device node would make it fail in a confusing way. */
|
|
103
|
+
async function assertRegularTarget(abs: string, rel: string): Promise<void> {
|
|
104
|
+
const stat = await lstatOrMissing(abs)
|
|
105
|
+
if (stat !== undefined && (stat.isSymbolicLink() || !stat.isFile())) {
|
|
106
|
+
throw new Error(`[nifra] refusing to write non-regular file: ${rel}`)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
71
110
|
/**
|
|
72
111
|
* Write a whole-file generator's output to `path` unless it already exists and `force` is false.
|
|
73
112
|
* Returns the per-file result for the report.
|
|
@@ -79,15 +118,31 @@ async function writeOwned(
|
|
|
79
118
|
force: boolean,
|
|
80
119
|
): Promise<InitAgentsFileResult> {
|
|
81
120
|
const abs = safeJoin(cwd, rel)
|
|
121
|
+
await assertSafeParents(cwd, abs)
|
|
122
|
+
await assertRegularTarget(abs, rel)
|
|
82
123
|
if (!force && (await fileExists(abs))) {
|
|
83
124
|
return { path: rel, action: "skipped", note: "exists - pass --force to overwrite" }
|
|
84
125
|
}
|
|
85
126
|
// `.cursor/mcp.json` needs its parent dir; `recursive` is a no-op for the root-level files.
|
|
86
127
|
await mkdir(resolve(abs, ".."), { recursive: true })
|
|
87
|
-
await
|
|
128
|
+
await atomicWrite(abs, content)
|
|
88
129
|
return { path: rel, action: "wrote" }
|
|
89
130
|
}
|
|
90
131
|
|
|
132
|
+
/** Write via a sibling temp file so a crash mid-write can't leave a half-written config in place.
|
|
133
|
+
* `wx` fails rather than following an existing name, and the temp is cleaned up on any failure so a
|
|
134
|
+
* botched run doesn't litter the user's project. */
|
|
135
|
+
async function atomicWrite(abs: string, content: string): Promise<void> {
|
|
136
|
+
const tmp = `${abs}.nifra-${process.pid}-${Date.now()}`
|
|
137
|
+
await writeFile(tmp, content, { flag: "wx" })
|
|
138
|
+
try {
|
|
139
|
+
await rename(tmp, abs)
|
|
140
|
+
} catch (error) {
|
|
141
|
+
await rm(tmp, { force: true })
|
|
142
|
+
throw error
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
91
146
|
/**
|
|
92
147
|
* AGENTS.md is additive, not owned: if it exists and already has the MCP section, leave it; if it exists
|
|
93
148
|
* without the section, append the section (preserving the user's conventions); if it's absent, write a
|
|
@@ -96,6 +151,8 @@ async function writeOwned(
|
|
|
96
151
|
*/
|
|
97
152
|
async function ensureAgentsMd(cwd: string): Promise<InitAgentsFileResult> {
|
|
98
153
|
const abs = safeJoin(cwd, AGENTS_MD_PATH)
|
|
154
|
+
await assertSafeParents(cwd, abs)
|
|
155
|
+
await assertRegularTarget(abs, AGENTS_MD_PATH)
|
|
99
156
|
const section = agentsMcpSection()
|
|
100
157
|
let existing: string | undefined
|
|
101
158
|
try {
|
|
@@ -104,7 +161,7 @@ async function ensureAgentsMd(cwd: string): Promise<InitAgentsFileResult> {
|
|
|
104
161
|
existing = undefined // no AGENTS.md yet
|
|
105
162
|
}
|
|
106
163
|
if (existing === undefined) {
|
|
107
|
-
await
|
|
164
|
+
await atomicWrite(
|
|
108
165
|
abs,
|
|
109
166
|
`# AGENTS.md\n\nGuidance for AI coding agents working in this repo.\n\n${section}\n`,
|
|
110
167
|
)
|
|
@@ -115,7 +172,7 @@ async function ensureAgentsMd(cwd: string): Promise<InitAgentsFileResult> {
|
|
|
115
172
|
return { path: AGENTS_MD_PATH, action: "present", note: "already has an MCP section" }
|
|
116
173
|
}
|
|
117
174
|
const sep = existing.endsWith("\n") ? "\n" : "\n\n"
|
|
118
|
-
await
|
|
175
|
+
await atomicWrite(abs, `${existing}${sep}${section}\n`)
|
|
119
176
|
return { path: AGENTS_MD_PATH, action: "appended" }
|
|
120
177
|
}
|
|
121
178
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type * as TSApi from "typescript"
|
|
2
|
+
import type { TypeScriptApi } from "./typescript-import.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The deliberately small AST seam used by source lints. It is not a project-wide TypeScript
|
|
6
|
+
* Program: a rule asks for a file only after its lexical fast path found a candidate. That keeps
|
|
7
|
+
* cold checks and projects without a TypeScript install on the existing conservative path.
|
|
8
|
+
*/
|
|
9
|
+
export interface SourceFacts {
|
|
10
|
+
parse(file: string, content: string): TSApi.SourceFile | undefined
|
|
11
|
+
isValueImportAt(
|
|
12
|
+
source: TSApi.SourceFile,
|
|
13
|
+
position: number,
|
|
14
|
+
specifier: string,
|
|
15
|
+
): boolean | undefined
|
|
16
|
+
isRouteRegistrationAt(
|
|
17
|
+
source: TSApi.SourceFile,
|
|
18
|
+
position: number,
|
|
19
|
+
method: string,
|
|
20
|
+
path: string,
|
|
21
|
+
): boolean | undefined
|
|
22
|
+
isResponseSyntaxAt(source: TSApi.SourceFile, position: number): boolean | undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function nodeAt(ts: TypeScriptApi, source: TSApi.SourceFile, position: number): TSApi.Node {
|
|
26
|
+
let best: TSApi.Node = source
|
|
27
|
+
const visit = (node: TSApi.Node): void => {
|
|
28
|
+
if (position < node.pos || position >= node.end) return
|
|
29
|
+
best = node
|
|
30
|
+
ts.forEachChild(node, visit)
|
|
31
|
+
}
|
|
32
|
+
visit(source)
|
|
33
|
+
return best
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ancestorAt(
|
|
37
|
+
ts: TypeScriptApi,
|
|
38
|
+
source: TSApi.SourceFile,
|
|
39
|
+
position: number,
|
|
40
|
+
predicate: (node: TSApi.Node) => boolean,
|
|
41
|
+
): TSApi.Node | undefined {
|
|
42
|
+
let node: TSApi.Node = nodeAt(ts, source, Math.max(0, Math.min(position, source.end - 1)))
|
|
43
|
+
for (;;) {
|
|
44
|
+
if (predicate(node)) return node
|
|
45
|
+
if (node === source) return undefined
|
|
46
|
+
node = node.parent
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function rawResponseExpression(ts: TypeScriptApi, node: TSApi.Expression): boolean {
|
|
51
|
+
if (ts.isNewExpression(node)) {
|
|
52
|
+
return ts.isIdentifier(node.expression) && node.expression.text === "Response"
|
|
53
|
+
}
|
|
54
|
+
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return false
|
|
55
|
+
return (
|
|
56
|
+
ts.isIdentifier(node.expression.expression) &&
|
|
57
|
+
node.expression.expression.text === "Response" &&
|
|
58
|
+
node.expression.name.text === "json"
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
|
|
63
|
+
const cache = new Map<string, { content: string; source: TSApi.SourceFile | undefined }>()
|
|
64
|
+
|
|
65
|
+
const parse = (file: string, content: string): TSApi.SourceFile | undefined => {
|
|
66
|
+
const cached = cache.get(file)
|
|
67
|
+
if (cached?.content === content) return cached.source
|
|
68
|
+
const kind = /\.tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS
|
|
69
|
+
const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind)
|
|
70
|
+
const diagnostics = (
|
|
71
|
+
source as TSApi.SourceFile & { parseDiagnostics?: readonly TSApi.Diagnostic[] }
|
|
72
|
+
).parseDiagnostics
|
|
73
|
+
const parsed = diagnostics !== undefined && diagnostics.length > 0 ? undefined : source
|
|
74
|
+
cache.set(file, { content, source: parsed })
|
|
75
|
+
return parsed
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const isValueImportAt = (
|
|
79
|
+
source: TSApi.SourceFile,
|
|
80
|
+
position: number,
|
|
81
|
+
specifier: string,
|
|
82
|
+
): boolean | undefined => {
|
|
83
|
+
const declaration = ancestorAt(ts, source, position, ts.isImportDeclaration) as
|
|
84
|
+
| TSApi.ImportDeclaration
|
|
85
|
+
| undefined
|
|
86
|
+
if (declaration === undefined || !ts.isStringLiteral(declaration.moduleSpecifier))
|
|
87
|
+
return undefined
|
|
88
|
+
if (declaration.moduleSpecifier.text !== specifier) return undefined
|
|
89
|
+
const clause = declaration.importClause
|
|
90
|
+
if (clause === undefined) return true // side-effect import
|
|
91
|
+
if (clause.isTypeOnly) return false
|
|
92
|
+
if (clause.namedBindings !== undefined && ts.isNamedImports(clause.namedBindings)) {
|
|
93
|
+
return clause.namedBindings.elements.some((element) => !element.isTypeOnly)
|
|
94
|
+
}
|
|
95
|
+
return true // default and namespace bindings are runtime imports
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const isRouteRegistrationAt = (
|
|
99
|
+
source: TSApi.SourceFile,
|
|
100
|
+
position: number,
|
|
101
|
+
method: string,
|
|
102
|
+
path: string,
|
|
103
|
+
): boolean | undefined => {
|
|
104
|
+
const call = ancestorAt(ts, source, position, ts.isCallExpression) as
|
|
105
|
+
| TSApi.CallExpression
|
|
106
|
+
| undefined
|
|
107
|
+
if (call === undefined || !ts.isPropertyAccessExpression(call.expression)) return false
|
|
108
|
+
const name = call.expression.name.text.toUpperCase()
|
|
109
|
+
const first = call.arguments[0]
|
|
110
|
+
return (
|
|
111
|
+
name === method && first !== undefined && ts.isStringLiteralLike(first) && first.text === path
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const isResponseSyntaxAt = (source: TSApi.SourceFile, position: number): boolean | undefined => {
|
|
116
|
+
const returnStatement = ancestorAt(ts, source, position, ts.isReturnStatement) as
|
|
117
|
+
| TSApi.ReturnStatement
|
|
118
|
+
| undefined
|
|
119
|
+
if (returnStatement !== undefined) {
|
|
120
|
+
return (
|
|
121
|
+
returnStatement.expression !== undefined &&
|
|
122
|
+
rawResponseExpression(ts, returnStatement.expression)
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
const arrow = ancestorAt(ts, source, position, ts.isArrowFunction) as
|
|
126
|
+
| TSApi.ArrowFunction
|
|
127
|
+
| undefined
|
|
128
|
+
if (arrow !== undefined && !ts.isBlock(arrow.body)) return rawResponseExpression(ts, arrow.body)
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { parse, isValueImportAt, isRouteRegistrationAt, isResponseSyntaxAt }
|
|
133
|
+
}
|
|
@@ -31,3 +31,32 @@ export async function importTypeScript(): Promise<TypeScriptApi | undefined> {
|
|
|
31
31
|
throw cause
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Import TypeScript resolved from the PROJECT root first, falling back to the CLI's own dependency
|
|
37
|
+
* tree. A bare `import("typescript")` resolves relative to this file - the CLI's install - so a
|
|
38
|
+
* globally-installed or bunx-run CLI could miss the project's compiler (or load a different major)
|
|
39
|
+
* depending on where the CLI happened to live, while the project's own `typescript` sat unused in
|
|
40
|
+
* its node_modules. Resolving from `root` makes the verdicts a function of the project, not of how
|
|
41
|
+
* the CLI was installed - the same cwd-invariance the typecheck gate's tsc resolution has.
|
|
42
|
+
*/
|
|
43
|
+
export async function importProjectTypeScript(root: string): Promise<TypeScriptApi | undefined> {
|
|
44
|
+
let resolved: string
|
|
45
|
+
try {
|
|
46
|
+
resolved = Bun.resolveSync("typescript", root)
|
|
47
|
+
} catch {
|
|
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
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
return (await import(resolved)) as TypeScriptApi
|
|
54
|
+
} catch (cause) {
|
|
55
|
+
// Same contract as importTypeScript: a compiler that RESOLVES and then fails evaluating is a
|
|
56
|
+
// broken install, not a missing one - rethrow rather than silently checking with another copy.
|
|
57
|
+
const message = cause instanceof Error ? cause.message : String(cause)
|
|
58
|
+
if (/cannot find (?:package|module)|ERR_MODULE_NOT_FOUND/i.test(message))
|
|
59
|
+
return importTypeScript()
|
|
60
|
+
throw cause
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/introspect.ts
CHANGED
|
@@ -5,14 +5,73 @@
|
|
|
5
5
|
* *this* project's actual surface, not just generic docs.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
type ProjectEvidenceSchema,
|
|
10
|
+
type ProjectEvidenceSchemaPart,
|
|
11
|
+
type ProjectEvidenceSnapshot,
|
|
12
|
+
snapshotProjectEvidence,
|
|
13
|
+
} from "@nifrajs/core/evidence"
|
|
14
|
+
import type { ReflectedRoute, SchemaReflection } from "@nifrajs/core/reflection"
|
|
9
15
|
import type { Manifest } from "@nifrajs/web"
|
|
10
16
|
import { discoverRoutes } from "@nifrajs/web/fs"
|
|
11
17
|
import type { LoadedApp } from "./load.ts"
|
|
18
|
+
import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
|
|
12
19
|
|
|
13
20
|
/** Read the backend's registered routes, if it's a `server()` with a `.routes()` method. */
|
|
14
21
|
export function backendRoutes(backend: unknown): ReflectedRoute[] {
|
|
15
|
-
return
|
|
22
|
+
return reflectedRoutesFromEvidence(snapshotProjectEvidence(backend))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function schemaPartToReflection(
|
|
26
|
+
value: ProjectEvidenceSchemaPart | undefined,
|
|
27
|
+
): SchemaReflection | undefined {
|
|
28
|
+
return value === undefined
|
|
29
|
+
? undefined
|
|
30
|
+
: { standard: undefined, jsonSchema: value.jsonSchema, fields: value.fields }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function schemaFromEvidence(schema: ProjectEvidenceSchema | undefined): ReflectedRoute["schema"] {
|
|
34
|
+
if (schema === undefined) return undefined
|
|
35
|
+
const errors = Object.fromEntries(
|
|
36
|
+
Object.entries(schema.errors ?? {}).map(([status, value]) => [
|
|
37
|
+
status,
|
|
38
|
+
schemaPartToReflection(value) as SchemaReflection,
|
|
39
|
+
]),
|
|
40
|
+
)
|
|
41
|
+
const headers = schemaPartToReflection(schema.headers)
|
|
42
|
+
const body = schemaPartToReflection(schema.body)
|
|
43
|
+
const query = schemaPartToReflection(schema.query)
|
|
44
|
+
const params = schemaPartToReflection(schema.params)
|
|
45
|
+
const response = schemaPartToReflection(schema.response)
|
|
46
|
+
const sse = schemaPartToReflection(schema.sse)
|
|
47
|
+
return {
|
|
48
|
+
...(schema.bodyLimit !== undefined ? { bodyLimit: schema.bodyLimit } : {}),
|
|
49
|
+
...(schema.bodyLimitReason !== undefined ? { bodyLimitReason: schema.bodyLimitReason } : {}),
|
|
50
|
+
...(headers !== undefined ? { headers } : {}),
|
|
51
|
+
...(body !== undefined ? { body } : {}),
|
|
52
|
+
...(query !== undefined ? { query } : {}),
|
|
53
|
+
...(params !== undefined ? { params } : {}),
|
|
54
|
+
...(response !== undefined ? { response } : {}),
|
|
55
|
+
...(Object.keys(errors).length > 0 ? { errors } : {}),
|
|
56
|
+
...(sse !== undefined ? { sse } : {}),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Adapt the canonical token-only snapshot to the legacy reflection view used by Markdown helpers. */
|
|
61
|
+
export function reflectedRoutesFromEvidence(evidence: ProjectEvidenceSnapshot): ReflectedRoute[] {
|
|
62
|
+
return evidence.routes.map((route) => {
|
|
63
|
+
const schema = schemaFromEvidence(route.schema)
|
|
64
|
+
return {
|
|
65
|
+
method: route.method,
|
|
66
|
+
path: route.path,
|
|
67
|
+
...(schema !== undefined ? { schema } : {}),
|
|
68
|
+
...(route.assurance !== undefined ? { assurance: route.assurance } : {}),
|
|
69
|
+
...(route.capabilities !== undefined ? { capabilities: route.capabilities } : {}),
|
|
70
|
+
...(route.family === true ? { family: true } : {}),
|
|
71
|
+
...(route.classification !== undefined ? { classification: route.classification } : {}),
|
|
72
|
+
...(route.tool !== undefined ? { tool: route.tool } : {}),
|
|
73
|
+
}
|
|
74
|
+
})
|
|
16
75
|
}
|
|
17
76
|
|
|
18
77
|
interface JsonSchemaNode {
|
|
@@ -232,6 +291,11 @@ export function describeProject(app: LoadedApp, filter?: ContextFilter): string
|
|
|
232
291
|
// Point agents at the verified surface, not recalled APIs - nifra is young, so training data is the
|
|
233
292
|
// wrong source. The MCP tools (nifra_docs / nifra_example) are checked against the installed version.
|
|
234
293
|
"> **Build against THIS surface + the verified tools - do not write nifra APIs from memory** (the\n> framework is young; recalled APIs drift). For framework code, call `nifra_example` (snippets\n> typechecked against the installed version) or `nifra_docs`; verify edits with `nifra_run` + `nifra_check`.",
|
|
294
|
+
// The authoritative answer, not the static reading `nifra_check`/`nifra_doctor` give: this runs on
|
|
295
|
+
// the LOADED config, so it is the same decision `nifra dev` and `nifra build` make. Stated in the
|
|
296
|
+
// index because it governs where a plugin goes and which toolchain compiles a component - an edit
|
|
297
|
+
// put in the other pipeline's slot is accepted and then silently never runs.
|
|
298
|
+
`**Bundler**: ${describePipeline(chooseBuildPipeline(app.resolvedPlugins))}. \`vitePlugins\` feed Vite; \`clientPlugins\`/\`serverPlugins\` feed Bun.build. Both phases (dev and build) use the same one.`,
|
|
235
299
|
)
|
|
236
300
|
}
|
|
237
301
|
if (filter?.kind !== "pages") {
|
|
@@ -260,15 +324,18 @@ export interface RouteJson {
|
|
|
260
324
|
* Markdown brief. Optionally filtered to routes whose path starts with `pathPrefix`. Reuses the exact
|
|
261
325
|
* same `call` form and TS-shaped contracts the Markdown brief shows.
|
|
262
326
|
*/
|
|
263
|
-
export function
|
|
264
|
-
|
|
327
|
+
export function routesToJsonFromEvidence(
|
|
328
|
+
evidence: ProjectEvidenceSnapshot,
|
|
329
|
+
pathPrefix?: string,
|
|
330
|
+
): RouteJson[] {
|
|
331
|
+
const shape = (v: ProjectEvidenceSchemaPart | undefined): string | undefined => {
|
|
265
332
|
if (!v) return undefined
|
|
266
|
-
const js =
|
|
333
|
+
const js = v.jsonSchema
|
|
267
334
|
return js !== undefined
|
|
268
335
|
? tsTypeOf(js)
|
|
269
336
|
: "(validated; shape not introspectable from this validator)"
|
|
270
337
|
}
|
|
271
|
-
let routes =
|
|
338
|
+
let routes = [...evidence.routes]
|
|
272
339
|
if (pathPrefix !== undefined && pathPrefix !== "") {
|
|
273
340
|
routes = routes.filter((r) => r.path.startsWith(pathPrefix))
|
|
274
341
|
}
|
|
@@ -291,6 +358,14 @@ export function routesToJson(app: LoadedApp, pathPrefix?: string): RouteJson[] {
|
|
|
291
358
|
})
|
|
292
359
|
}
|
|
293
360
|
|
|
361
|
+
export function routesToJson(
|
|
362
|
+
app: LoadedApp,
|
|
363
|
+
pathPrefix?: string,
|
|
364
|
+
evidence?: ProjectEvidenceSnapshot,
|
|
365
|
+
): RouteJson[] {
|
|
366
|
+
return routesToJsonFromEvidence(evidence ?? snapshotProjectEvidence(app.backend), pathPrefix)
|
|
367
|
+
}
|
|
368
|
+
|
|
294
369
|
// ===================================================================================================
|
|
295
370
|
// `nifra routes [--json]` - the focused, uniform view of every route the app serves, with methods.
|
|
296
371
|
//
|
package/src/load.ts
CHANGED
|
@@ -38,6 +38,12 @@ export interface NifraFramework {
|
|
|
38
38
|
readonly adapter: unknown
|
|
39
39
|
/** The client runtime module providing `mountRouter`, e.g. `"@nifrajs/web-react/client"`. */
|
|
40
40
|
readonly clientModule: string
|
|
41
|
+
/** App-level middleware applied to the web app before its page routes are declared -
|
|
42
|
+
* e.g. `(app) => app.use(securityHeaders())`. Runs ahead of the `api`/`mounts` hooks.
|
|
43
|
+
*
|
|
44
|
+
* MUST be exported from `framework.ts`, not `nifra.config.ts`: `nifra build` emits a
|
|
45
|
+
* server entry that imports it, and that entry is edge-bundled. See the module header. */
|
|
46
|
+
readonly use?: (app: never) => void
|
|
41
47
|
/** Vite plugins for `nifra dev`'s HMR, e.g. `[react()]` / `() => import("@vitejs/plugin-vue")…`. */
|
|
42
48
|
readonly vitePlugins?: PluginsField
|
|
43
49
|
/** Bun build plugins that compile routes for the CLIENT bundle (`nifra build`), e.g.
|
|
@@ -93,6 +99,10 @@ export interface ResolvedPlugins {
|
|
|
93
99
|
|
|
94
100
|
export interface LoadedApp {
|
|
95
101
|
readonly cwd: string
|
|
102
|
+
/** Absolute path of the config this app was loaded from - `nifra.config.ts`, or `framework.ts` as the
|
|
103
|
+
* fallback. `nifra dev`'s Bun pipeline re-imports it from a GENERATED module so the app's own
|
|
104
|
+
* `clientPlugins` reach Bun's dev-server bundler, which takes plugins only as module paths. */
|
|
105
|
+
readonly configPath: string
|
|
96
106
|
readonly routesDir: string
|
|
97
107
|
/** Build output dir (also where `nifra start` reads `manifest.json` + serves `/assets/*`). */
|
|
98
108
|
readonly outDir: string
|
|
@@ -222,6 +232,7 @@ export async function loadApp(
|
|
|
222
232
|
|
|
223
233
|
return {
|
|
224
234
|
cwd,
|
|
235
|
+
configPath,
|
|
225
236
|
routesDir: resolve(cwd, "routes"),
|
|
226
237
|
outDir: resolve(cwd, outDirName),
|
|
227
238
|
framework: { ...fw, clientModule } as NifraFramework,
|
package/src/manifest-tool.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import { existsSync } from "node:fs"
|
|
4
4
|
import { resolve } from "node:path"
|
|
5
|
-
import { evaluateRouteAssurance } from "@nifrajs/core/assurance"
|
|
6
5
|
import {
|
|
7
6
|
buildNifraManifest,
|
|
8
7
|
diffNifraManifests,
|
|
@@ -12,8 +11,7 @@ import {
|
|
|
12
11
|
serializeNifraManifestSignature,
|
|
13
12
|
signNifraManifest,
|
|
14
13
|
} from "@nifrajs/core/manifest"
|
|
15
|
-
import {
|
|
16
|
-
import { collectCapabilityProjectReport } from "./capabilities-tool.ts"
|
|
14
|
+
import { collectProjectVerification } from "./verification.ts"
|
|
17
15
|
|
|
18
16
|
export const DEFAULT_MANIFEST_FILE = "nifra.manifest.json"
|
|
19
17
|
|
|
@@ -45,20 +43,28 @@ export async function runManifestEmit(
|
|
|
45
43
|
cwd: string,
|
|
46
44
|
options: { readonly config?: string; readonly out?: string; readonly sign?: string } = {},
|
|
47
45
|
): Promise<boolean> {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
...(config.capabilities !== undefined ? { definitions: config.capabilities.definitions } : {}),
|
|
46
|
+
const verification = await collectProjectVerification(cwd, {
|
|
47
|
+
...(options.config !== undefined ? { config: options.config } : {}),
|
|
51
48
|
})
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
49
|
+
if (verification.config === undefined) {
|
|
50
|
+
throw (
|
|
51
|
+
verification.configError ??
|
|
52
|
+
new Error("[nifra] assurance config could not be loaded for manifest emission")
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
const config = verification.config
|
|
56
|
+
const assurance = verification.routeAssurance
|
|
57
|
+
const capabilityProject = verification.capability
|
|
58
|
+
if (assurance === undefined) {
|
|
59
|
+
throw new Error("[nifra] assurance evaluation was unavailable for manifest emission")
|
|
60
|
+
}
|
|
56
61
|
if (!assurance.ok || (capabilityProject !== undefined && !capabilityProject.report.ok)) {
|
|
57
62
|
console.error("[nifra] refusing to emit a manifest from failing assurance")
|
|
58
63
|
return false
|
|
59
64
|
}
|
|
60
65
|
const manifest = await buildNifraManifest({
|
|
61
66
|
source: config.source,
|
|
67
|
+
...(verification.evidence !== undefined ? { evidence: verification.evidence } : {}),
|
|
62
68
|
assurance,
|
|
63
69
|
...(capabilityProject !== undefined ? { capabilities: capabilityProject.report } : {}),
|
|
64
70
|
})
|
package/src/mcp-docs-tools.ts
CHANGED
|
@@ -35,6 +35,7 @@ export function docsTools(
|
|
|
35
35
|
properties: {
|
|
36
36
|
query: {
|
|
37
37
|
type: "string",
|
|
38
|
+
maxLength: 256,
|
|
38
39
|
description: "Keywords to match against doc sections (omit for the index).",
|
|
39
40
|
},
|
|
40
41
|
limit: { type: "number", description: "Max sections to return (default 3, max 5)." },
|
|
@@ -64,6 +65,7 @@ export function docsTools(
|
|
|
64
65
|
properties: {
|
|
65
66
|
query: {
|
|
66
67
|
type: "string",
|
|
68
|
+
maxLength: 256,
|
|
67
69
|
description: "What you want an example of (omit for the index).",
|
|
68
70
|
},
|
|
69
71
|
limit: { type: "number", description: "Max examples to return (default 3, max 5)." },
|
|
@@ -94,6 +96,7 @@ export function docsTools(
|
|
|
94
96
|
},
|
|
95
97
|
query: {
|
|
96
98
|
type: "string",
|
|
99
|
+
maxLength: 256,
|
|
97
100
|
description:
|
|
98
101
|
"Keyword search over names + signatures (when you don't know the exact name).",
|
|
99
102
|
},
|
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 = "2.
|
|
32
|
+
const VERSION = "2.12.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 =>
|