@nifrajs/cli 2.11.0 → 2.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- 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 +125 -10
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +706 -145
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +23 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +194 -10
- 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/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 +28 -2
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +173 -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 +53 -4
- package/dist/introspect.js.map +1 -1
- package/dist/load.d.ts +6 -0
- package/dist/load.d.ts.map +1 -1
- 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 +358 -25
- package/dist/mcp.js.map +1 -1
- 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 +906 -15
- package/docs/types.json +1682 -130
- 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 +873 -168
- package/src/cli.ts +198 -11
- package/src/contract-proof.ts +194 -0
- package/src/contracts.ts +157 -0
- package/src/diagnostics.ts +32 -0
- package/src/diff-tool.ts +1 -1
- package/src/docs-search.ts +45 -15
- package/src/doctor.ts +259 -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 +75 -6
- package/src/load.ts +6 -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 +438 -32
- 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/examples.ts
CHANGED
|
@@ -23,6 +23,29 @@ export interface ScoredExample extends Example {
|
|
|
23
23
|
readonly score: number
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
interface PreparedExample {
|
|
27
|
+
readonly example: Example
|
|
28
|
+
readonly labelTokens: ReadonlySet<string>
|
|
29
|
+
readonly codeLower: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const preparedExamplesCache = new WeakMap<readonly Example[], readonly PreparedExample[]>()
|
|
33
|
+
const prepareExamples = (examples: readonly Example[]): readonly PreparedExample[] => {
|
|
34
|
+
const cached = preparedExamplesCache.get(examples)
|
|
35
|
+
if (cached !== undefined) return cached
|
|
36
|
+
const prepared = examples.map((example) => ({
|
|
37
|
+
example,
|
|
38
|
+
labelTokens: new Set([
|
|
39
|
+
...tokenize(example.slug),
|
|
40
|
+
...tokenize(example.topic),
|
|
41
|
+
...tokenize(example.name),
|
|
42
|
+
]),
|
|
43
|
+
codeLower: example.code.toLowerCase(),
|
|
44
|
+
}))
|
|
45
|
+
preparedExamplesCache.set(examples, prepared)
|
|
46
|
+
return prepared
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
/** Score examples against the query: a term in the slug/topic/name (the curated, searchable labels)
|
|
27
50
|
* is worth far more than a mention in the code body; body hits cap per term so one long snippet can't
|
|
28
51
|
* outweigh a precisely-tagged one. */
|
|
@@ -34,13 +57,12 @@ export function searchExamples(
|
|
|
34
57
|
const terms = queryTermGroups(query)
|
|
35
58
|
if (terms.length === 0) return []
|
|
36
59
|
const scored: ScoredExample[] = []
|
|
37
|
-
for (const
|
|
38
|
-
const
|
|
39
|
-
const codeLower = ex.code.toLowerCase()
|
|
60
|
+
for (const prepared of prepareExamples(examples)) {
|
|
61
|
+
const ex = prepared.example
|
|
40
62
|
let score = 0
|
|
41
63
|
for (const term of terms) {
|
|
42
|
-
if (tokenSetHas(labelTokens, term)) score += 8
|
|
43
|
-
score += countBodyHits(codeLower, term, 5)
|
|
64
|
+
if (tokenSetHas(prepared.labelTokens, term)) score += 8
|
|
65
|
+
score += countBodyHits(prepared.codeLower, term, 5)
|
|
44
66
|
}
|
|
45
67
|
if (score > 0) scored.push({ ...ex, score })
|
|
46
68
|
}
|
|
@@ -76,14 +98,20 @@ export function renderExamplesResult(
|
|
|
76
98
|
|
|
77
99
|
/** Load the bundled example corpus. Resolves relative to this module so it works from `src/` (repo dev)
|
|
78
100
|
* and `dist/` (published) alike - `docs/examples.json` sits at the package root next to both. */
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
101
|
+
let examplesCorpusPromise: Promise<Example[] | undefined> | undefined
|
|
102
|
+
|
|
103
|
+
/** The published corpus is immutable, so keep one loaded array and its derived search fields. */
|
|
104
|
+
export function loadExamplesCorpus(): Promise<Example[] | undefined> {
|
|
105
|
+
examplesCorpusPromise ??= (async () => {
|
|
106
|
+
const url = new URL("../docs/examples.json", import.meta.url)
|
|
107
|
+
try {
|
|
108
|
+
const text = await Bun.file(url).text()
|
|
109
|
+
if (text.length === 0) return undefined
|
|
110
|
+
const parsed = JSON.parse(text) as Example[]
|
|
111
|
+
return Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined
|
|
112
|
+
} catch {
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
})()
|
|
116
|
+
return examplesCorpusPromise
|
|
89
117
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import type { Diagnostic } from "./diagnostics.ts"
|
|
5
|
+
import { resolveInsideProject } from "./project-path.ts"
|
|
6
|
+
|
|
7
|
+
export interface FixRecipe {
|
|
8
|
+
readonly id: string
|
|
9
|
+
readonly description: string
|
|
10
|
+
apply(root: string, diagnostic: Diagnostic): Promise<readonly string[]>
|
|
11
|
+
readonly verify: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const recipes = new Map<string, FixRecipe>()
|
|
15
|
+
|
|
16
|
+
export function registerFixRecipe(recipe: FixRecipe): void {
|
|
17
|
+
if (recipes.has(recipe.id)) throw new Error(`duplicate fix recipe: ${recipe.id}`)
|
|
18
|
+
recipes.set(recipe.id, Object.freeze(recipe))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getFixRecipe(id: string): FixRecipe | undefined {
|
|
22
|
+
return recipes.get(id)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function listFixRecipes(): readonly FixRecipe[] {
|
|
26
|
+
return Object.freeze([...recipes.values()])
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
registerFixRecipe({
|
|
30
|
+
id: "security.timing-safe-equal",
|
|
31
|
+
description: "Replace a direct secret comparison with a length check and timing-safe comparison.",
|
|
32
|
+
verify: "nifra check --lints-only",
|
|
33
|
+
async apply(root, diagnostic) {
|
|
34
|
+
if (diagnostic.file === undefined || diagnostic.line === undefined) return []
|
|
35
|
+
const path = await resolveInsideProject(root, diagnostic.file)
|
|
36
|
+
if (path === undefined) return []
|
|
37
|
+
const lines = (await readFile(path, "utf8")).split("\n")
|
|
38
|
+
const index = diagnostic.line - 1
|
|
39
|
+
const original = lines[index]
|
|
40
|
+
if (original === undefined || original.includes("@nifra-gate-reviewed")) return []
|
|
41
|
+
const comparison = /\b([A-Za-z_$][\w$]*)\s*(===|!==|==|!=)\s*([A-Za-z_$][\w$]*)/.exec(original)
|
|
42
|
+
if (comparison === null) return []
|
|
43
|
+
const left = comparison[1] as string
|
|
44
|
+
const operator = comparison[2] as string
|
|
45
|
+
const right = comparison[3] as string
|
|
46
|
+
const expression = `nifraTimingSafeEqual(${left}, ${right})`
|
|
47
|
+
const replacement = operator === "!==" || operator === "!=" ? `!${expression}` : expression
|
|
48
|
+
lines[index] = original.replace(comparison[0], replacement)
|
|
49
|
+
if (!lines.some((line) => line.includes("function nifraTimingSafeEqual"))) {
|
|
50
|
+
lines.unshift(
|
|
51
|
+
'import { timingSafeEqual } from "node:crypto"',
|
|
52
|
+
"",
|
|
53
|
+
"function nifraTimingSafeEqual(left: string, right: string): boolean {",
|
|
54
|
+
" const leftBytes = Buffer.from(left)",
|
|
55
|
+
" const rightBytes = Buffer.from(right)",
|
|
56
|
+
" return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes)",
|
|
57
|
+
"}",
|
|
58
|
+
"",
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
await writeFile(path, lines.join("\n"), "utf8")
|
|
62
|
+
return [diagnostic.file]
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
registerFixRecipe({
|
|
67
|
+
id: "manifest.sync",
|
|
68
|
+
description: "Regenerate generated server manifests from the current route tree.",
|
|
69
|
+
verify: "nifra check --lints-only",
|
|
70
|
+
async apply(root) {
|
|
71
|
+
const { syncServerManifests } = await import("./sync-manifest.ts")
|
|
72
|
+
const results = await syncServerManifests(root)
|
|
73
|
+
return results.filter((result) => result.changed).map((result) => result.file)
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
registerFixRecipe({
|
|
78
|
+
id: "contracts.snapshot",
|
|
79
|
+
description: "Refresh the opt-in contract lock after reviewing an intentional route change.",
|
|
80
|
+
verify: "nifra contracts check",
|
|
81
|
+
async apply(root) {
|
|
82
|
+
const { snapshotContracts } = await import("./contracts.ts")
|
|
83
|
+
await snapshotContracts(root)
|
|
84
|
+
return ["contracts.lock.json"]
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
registerFixRecipe({
|
|
89
|
+
id: "workspace-dist.rebuild",
|
|
90
|
+
description: "Rebuild the workspace-linked package whose artifact is older than its source.",
|
|
91
|
+
verify: "nifra doctor --json",
|
|
92
|
+
async apply(root, diagnostic) {
|
|
93
|
+
const packageName = diagnostic.evidence?.[0]
|
|
94
|
+
if (packageName === undefined || !/^@?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)?$/.test(packageName))
|
|
95
|
+
return []
|
|
96
|
+
// The segment regex above admits "." and "..", which would resolve outside node_modules.
|
|
97
|
+
if (packageName.split("/").some((segment) => segment === "." || segment === "..")) return []
|
|
98
|
+
const packageDir = await resolveInsideProject(root, join("node_modules", packageName))
|
|
99
|
+
if (packageDir === undefined) return []
|
|
100
|
+
const packageJson = await resolveInsideProject(
|
|
101
|
+
root,
|
|
102
|
+
join("node_modules", packageName, "package.json"),
|
|
103
|
+
)
|
|
104
|
+
if (packageJson === undefined || !existsSync(packageJson)) return []
|
|
105
|
+
const proc = Bun.spawn(["bun", "run", "build"], {
|
|
106
|
+
cwd: packageDir,
|
|
107
|
+
stdout: "ignore",
|
|
108
|
+
stderr: "pipe",
|
|
109
|
+
})
|
|
110
|
+
const error = await new Response(proc.stderr).text()
|
|
111
|
+
const exitCode = await proc.exited
|
|
112
|
+
if (exitCode !== 0) throw new Error(error.trim() || `build failed for ${packageName}`)
|
|
113
|
+
return [diagnostic.evidence?.[1] ?? packageName]
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
export async function applyDiagnosticRecipe(
|
|
118
|
+
root: string,
|
|
119
|
+
diagnostic: Diagnostic,
|
|
120
|
+
): Promise<readonly string[]> {
|
|
121
|
+
const recipeId = diagnostic.fix?.recipe
|
|
122
|
+
if (recipeId === undefined) return []
|
|
123
|
+
const recipe = getFixRecipe(recipeId)
|
|
124
|
+
if (recipe === undefined) return []
|
|
125
|
+
return recipe.apply(root, diagnostic)
|
|
126
|
+
}
|
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,7 +5,13 @@
|
|
|
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"
|
|
@@ -13,7 +19,59 @@ import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
|
|
|
13
19
|
|
|
14
20
|
/** Read the backend's registered routes, if it's a `server()` with a `.routes()` method. */
|
|
15
21
|
export function backendRoutes(backend: unknown): ReflectedRoute[] {
|
|
16
|
-
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
|
+
})
|
|
17
75
|
}
|
|
18
76
|
|
|
19
77
|
interface JsonSchemaNode {
|
|
@@ -266,15 +324,18 @@ export interface RouteJson {
|
|
|
266
324
|
* Markdown brief. Optionally filtered to routes whose path starts with `pathPrefix`. Reuses the exact
|
|
267
325
|
* same `call` form and TS-shaped contracts the Markdown brief shows.
|
|
268
326
|
*/
|
|
269
|
-
export function
|
|
270
|
-
|
|
327
|
+
export function routesToJsonFromEvidence(
|
|
328
|
+
evidence: ProjectEvidenceSnapshot,
|
|
329
|
+
pathPrefix?: string,
|
|
330
|
+
): RouteJson[] {
|
|
331
|
+
const shape = (v: ProjectEvidenceSchemaPart | undefined): string | undefined => {
|
|
271
332
|
if (!v) return undefined
|
|
272
|
-
const js =
|
|
333
|
+
const js = v.jsonSchema
|
|
273
334
|
return js !== undefined
|
|
274
335
|
? tsTypeOf(js)
|
|
275
336
|
: "(validated; shape not introspectable from this validator)"
|
|
276
337
|
}
|
|
277
|
-
let routes =
|
|
338
|
+
let routes = [...evidence.routes]
|
|
278
339
|
if (pathPrefix !== undefined && pathPrefix !== "") {
|
|
279
340
|
routes = routes.filter((r) => r.path.startsWith(pathPrefix))
|
|
280
341
|
}
|
|
@@ -297,6 +358,14 @@ export function routesToJson(app: LoadedApp, pathPrefix?: string): RouteJson[] {
|
|
|
297
358
|
})
|
|
298
359
|
}
|
|
299
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
|
+
|
|
300
369
|
// ===================================================================================================
|
|
301
370
|
// `nifra routes [--json]` - the focused, uniform view of every route the app serves, with methods.
|
|
302
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.
|
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
|
})
|