@nifrajs/cli 2.13.0 → 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/capabilities-tool.d.ts +54 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +146 -18
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +11 -11
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +260 -49
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +130 -414
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +250 -0
- package/dist/command-catalog.d.ts.map +1 -0
- package/dist/command-catalog.js +1032 -0
- package/dist/command-catalog.js.map +1 -0
- package/dist/contracts.d.ts +9 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +22 -6
- package/dist/contracts.js.map +1 -1
- package/dist/doctor.d.ts +55 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +82 -9
- 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/load.d.ts.map +1 -1
- package/dist/load.js +22 -5
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts +15 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +23 -9
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp.d.ts +16 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +127 -355
- package/dist/mcp.js.map +1 -1
- package/dist/project-facts.d.ts +40 -0
- package/dist/project-facts.d.ts.map +1 -0
- package/dist/project-facts.js +36 -0
- package/dist/project-facts.js.map +1 -0
- 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/comment-markers.d.ts +8 -0
- package/dist/rules/comment-markers.d.ts.map +1 -1
- package/dist/rules/comment-markers.js +36 -0
- package/dist/rules/comment-markers.js.map +1 -1
- package/dist/rules/index.d.ts +2 -3
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +29 -44
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/security.js +14 -14
- package/dist/rules/security.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 +956 -56
- package/package.json +9 -9
- package/src/capabilities-tool.ts +229 -21
- package/src/check.ts +312 -65
- package/src/cli.ts +140 -405
- package/src/command-catalog.ts +1435 -0
- package/src/contracts.ts +28 -7
- package/src/doctor.ts +134 -12
- 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/load.ts +25 -6
- package/src/manifest-tool.ts +43 -12
- package/src/mcp-http.ts +1 -1
- package/src/mcp.ts +163 -420
- package/src/project-facts.ts +86 -0
- 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/comment-markers.ts +36 -0
- package/src/rules/index.ts +3 -4
- package/src/rules/routes.ts +30 -44
- package/src/rules/security.ts +14 -14
- package/src/workspace-link.ts +80 -0
package/src/contracts.ts
CHANGED
|
@@ -41,8 +41,7 @@ async function sha256(value: string): Promise<string> {
|
|
|
41
41
|
return [...digest].map((item) => item.toString(16).padStart(2, "0")).join("")
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
function schemaValue(
|
|
45
|
-
const schema = route.schema
|
|
44
|
+
function schemaValue(schema: ReflectedRoute["schema"], side: "request" | "response"): unknown {
|
|
46
45
|
if (schema === undefined) return {}
|
|
47
46
|
if (side === "request") {
|
|
48
47
|
return {
|
|
@@ -64,11 +63,28 @@ function schemaValue(route: ReflectedRoute, side: "request" | "response"): unkno
|
|
|
64
63
|
}
|
|
65
64
|
|
|
66
65
|
export async function digestRoute(route: ReflectedRoute): Promise<ContractDigest> {
|
|
67
|
-
const request = JSON.stringify(canonical(schemaValue(route, "request")))
|
|
68
|
-
const response = JSON.stringify(canonical(schemaValue(route, "response")))
|
|
66
|
+
const request = JSON.stringify(canonical(schemaValue(route.schema, "request")))
|
|
67
|
+
const response = JSON.stringify(canonical(schemaValue(route.schema, "response")))
|
|
69
68
|
return { request: await sha256(request), response: await sha256(response) }
|
|
70
69
|
}
|
|
71
70
|
|
|
71
|
+
/** The digest a route with NO declared schema produces on both sides - `sha256("{}")`. A lock whose
|
|
72
|
+
* every route hashes to this is guarding nothing: drift detection can only ever compare empty schema to
|
|
73
|
+
* empty schema. Computed through the same canonical/stringify path as {@link digestRoute} so it tracks
|
|
74
|
+
* that path rather than a hard-coded literal. */
|
|
75
|
+
export async function emptySchemaDigest(): Promise<string> {
|
|
76
|
+
return sha256(JSON.stringify(canonical(schemaValue(undefined, "request"))))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether every route in `lock` hashes to the empty-schema digest (so no route declares a schema). An
|
|
80
|
+
* empty `routes` map is NOT vacuous in this sense - there is no route whose contract went unguarded. */
|
|
81
|
+
export async function isVacuousLock(lock: ContractsLock): Promise<boolean> {
|
|
82
|
+
const digests = Object.values(lock.routes)
|
|
83
|
+
if (digests.length === 0) return false
|
|
84
|
+
const empty = await emptySchemaDigest()
|
|
85
|
+
return digests.every((d) => d.request === empty && d.response === empty)
|
|
86
|
+
}
|
|
87
|
+
|
|
72
88
|
export async function buildContractsLock(source: unknown): Promise<ContractsLock> {
|
|
73
89
|
const routes = Object.fromEntries(
|
|
74
90
|
await Promise.all(
|
|
@@ -131,11 +147,16 @@ export function parseContractsLock(value: unknown): ContractsLock {
|
|
|
131
147
|
export async function checkContractsLock(
|
|
132
148
|
cwd: string,
|
|
133
149
|
file = DEFAULT_CONTRACTS_LOCK,
|
|
134
|
-
): Promise<{
|
|
150
|
+
): Promise<{
|
|
151
|
+
present: boolean
|
|
152
|
+
vacuous: boolean
|
|
153
|
+
diagnostics: Array<{ route?: string; message: string }>
|
|
154
|
+
}> {
|
|
135
155
|
const path = resolve(cwd, file)
|
|
136
|
-
if (!existsSync(path)) return { present: false, diagnostics: [] }
|
|
156
|
+
if (!existsSync(path)) return { present: false, vacuous: false, diagnostics: [] }
|
|
137
157
|
const parsed = parseContractsLock(JSON.parse(await Bun.file(path).text()))
|
|
138
158
|
const current = await buildContractsLock(await loadBackend(cwd))
|
|
159
|
+
const vacuous = await isVacuousLock(current)
|
|
139
160
|
const diagnostics: Array<{ route?: string; message: string }> = []
|
|
140
161
|
const keys = new Set([...Object.keys(parsed.routes), ...Object.keys(current.routes)])
|
|
141
162
|
for (const key of [...keys].sort()) {
|
|
@@ -153,5 +174,5 @@ export async function checkContractsLock(
|
|
|
153
174
|
})
|
|
154
175
|
}
|
|
155
176
|
}
|
|
156
|
-
return { present: true, diagnostics }
|
|
177
|
+
return { present: true, vacuous, diagnostics }
|
|
157
178
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -25,11 +25,14 @@ import {
|
|
|
25
25
|
displayPath,
|
|
26
26
|
type IdentityParityCopy,
|
|
27
27
|
type IdentityParityFinding,
|
|
28
|
+
identityParityBasis,
|
|
28
29
|
resolvedInstalledCopy,
|
|
29
30
|
} from "@nifrajs/web/internal/parity"
|
|
30
31
|
import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
|
|
32
|
+
import { detectToolingDrift, type ToolingDrift } from "./mcp-root.ts"
|
|
31
33
|
import { collectPipelineReport, type PipelineReport } from "./pipeline-report.ts"
|
|
32
34
|
import { type ResolvedTarget, resolveTarget } from "./port.ts"
|
|
35
|
+
import { buildScriptName } from "./workspace-link.ts"
|
|
33
36
|
|
|
34
37
|
// Runtime-provided modules that are never an npm dependency: Node core (bare + `node:` form) and Bun's
|
|
35
38
|
// own `bun` module. `node:`/`bun:`-prefixed specifiers are filtered in packageOf by prefix.
|
|
@@ -140,10 +143,37 @@ export interface DoctorResult {
|
|
|
140
143
|
readonly findings: readonly DoctorFinding[]
|
|
141
144
|
/** Packages that resolve to more than one physical install across this workspace. */
|
|
142
145
|
readonly duplicateInstalls: readonly DuplicateInstallFinding[]
|
|
146
|
+
/**
|
|
147
|
+
* Duplicate installs the app declared single-copy (`"nifra": { "singleCopy": [...] }`), which nifra
|
|
148
|
+
* collapses to one copy at resolve time. Reported so the topology stays visible, never folded into
|
|
149
|
+
* `ok`: the copies exist on disk but cannot both load. Absent on an older result shape.
|
|
150
|
+
*/
|
|
151
|
+
readonly deduplicatedInstalls?: readonly DuplicateInstallFinding[]
|
|
152
|
+
/**
|
|
153
|
+
* Which tree the identity scan actually looked at, in the same words the build/dev preflight uses.
|
|
154
|
+
* Both run the same seam now; printing the basis is what lets a reader confirm that instead of
|
|
155
|
+
* having to trust it. Absent on an older result shape.
|
|
156
|
+
*/
|
|
157
|
+
readonly identityBasis?: string
|
|
158
|
+
/**
|
|
159
|
+
* The identity scan stopped at the workspace-enumeration cap, so "no duplicates" means "none in the
|
|
160
|
+
* part that was scanned". Never folded into `ok` - a huge workspace is not a defect - but it is
|
|
161
|
+
* always printed, because an unqualified clean bill from a partial scan is the failure mode this
|
|
162
|
+
* whole report exists to avoid.
|
|
163
|
+
*/
|
|
164
|
+
readonly identityScanTruncated?: boolean
|
|
143
165
|
/** Workspace-linked deps whose `default`-condition artifact lags their `bun`-condition source.
|
|
144
166
|
* Advisory (never folded into `ok`): while actively editing a linked package its dist is always
|
|
145
167
|
* momentarily behind - the finding matters when a dev server starts against it. */
|
|
146
168
|
readonly staleDists: readonly StaleDistFinding[]
|
|
169
|
+
/**
|
|
170
|
+
* The running CLI is a different feature version than the `@nifrajs/cli` (or `@nifrajs/core`) the
|
|
171
|
+
* project installs. Advisory (never folded into `ok`): a stale global/bunx binary answering about a
|
|
172
|
+
* project it does not match is an environment problem, not a defect in the project - but every
|
|
173
|
+
* answer it gives (types, checks, docs) reads as authoritative. Present only when `cliVersion` was
|
|
174
|
+
* supplied and the feature versions disagree. Computed by {@link detectToolingDrift}.
|
|
175
|
+
*/
|
|
176
|
+
readonly toolingDrift?: ToolingDrift
|
|
147
177
|
/** Static production-readiness evidence for the selected deploy target. */
|
|
148
178
|
readonly readiness?: DoctorReadiness
|
|
149
179
|
/**
|
|
@@ -351,18 +381,52 @@ export async function collectDuplicateInstalls(
|
|
|
351
381
|
cwd: string,
|
|
352
382
|
rootPackage: Record<string, unknown>,
|
|
353
383
|
): Promise<DuplicateInstallFinding[]> {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
384
|
+
return (await collectAllDuplicateInstalls(cwd, rootPackage)).duplicates
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const asDuplicateInstall = (finding: IdentityParityFinding): DuplicateInstallFinding => ({
|
|
388
|
+
...finding,
|
|
389
|
+
copies: finding.copies.map((copy): DuplicateInstallCopy => ({ ...copy })),
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Both halves of the identity scan: what still breaks, and what a declaration already collapses -
|
|
394
|
+
* plus the basis they were decided on.
|
|
395
|
+
*
|
|
396
|
+
* The basis travels with the findings because doctor and the build/dev preflight answer the same
|
|
397
|
+
* question, and a reader who gets two answers needs to see which tree each one looked at. They now
|
|
398
|
+
* scan identically (one shared seam, one workspace resolution), so the line is corroboration rather
|
|
399
|
+
* than an excuse - and it is the only way a TRUNCATED scan can be told apart from a clean one.
|
|
400
|
+
*/
|
|
401
|
+
export async function collectAllDuplicateInstalls(
|
|
402
|
+
cwd: string,
|
|
403
|
+
rootPackage: Record<string, unknown>,
|
|
404
|
+
): Promise<{
|
|
405
|
+
readonly duplicates: DuplicateInstallFinding[]
|
|
406
|
+
readonly deduplicated: DuplicateInstallFinding[]
|
|
407
|
+
readonly basis: string
|
|
408
|
+
readonly truncated: boolean
|
|
409
|
+
}> {
|
|
410
|
+
const result = await collectIdentityParity(cwd, rootPackage)
|
|
411
|
+
return {
|
|
412
|
+
duplicates: result.findings.map(asDuplicateInstall),
|
|
413
|
+
deduplicated: result.deduplicated.map(asDuplicateInstall),
|
|
414
|
+
basis: identityParityBasis(result),
|
|
415
|
+
truncated: result.truncated,
|
|
416
|
+
}
|
|
361
417
|
}
|
|
362
418
|
|
|
363
419
|
/** A workspace-linked dependency whose build artifact is older than its source. */
|
|
364
420
|
export interface StaleDistFinding {
|
|
365
421
|
readonly package: string
|
|
422
|
+
/** Where the linked package actually lives, relative to the doctor root when possible. A workspace
|
|
423
|
+
* link routinely points into a SIBLING repo, so "rebuild it" is not actionable without the path -
|
|
424
|
+
* this is the directory the rebuild runs in. */
|
|
425
|
+
readonly packageDir: string
|
|
426
|
+
/** The package's own build script name (`scripts.build` etc.), or `undefined` when it declares none -
|
|
427
|
+
* then the rebuild cannot be automated and the report says so instead of suggesting a command that
|
|
428
|
+
* would fail. */
|
|
429
|
+
readonly buildScript: string | undefined
|
|
366
430
|
/** The `default`-condition artifact (what Vite's SSR runner, vitest, and node consumers load),
|
|
367
431
|
* relative to the doctor root when possible. `missing: true` means it was never built at all. */
|
|
368
432
|
readonly distFile: string
|
|
@@ -473,6 +537,8 @@ export async function collectStaleWorkspaceDists(
|
|
|
473
537
|
newest = candidate
|
|
474
538
|
}
|
|
475
539
|
if (newest === undefined) continue
|
|
540
|
+
const packageDir = displayPath(cwd, copy.path)
|
|
541
|
+
const buildScript = buildScriptName(meta)
|
|
476
542
|
// Report the package once, anchored on its stalest (or missing) artifact.
|
|
477
543
|
let worst: StaleDistFinding | undefined
|
|
478
544
|
for (const pair of pairs) {
|
|
@@ -482,6 +548,8 @@ export async function collectStaleWorkspaceDists(
|
|
|
482
548
|
distStat === undefined
|
|
483
549
|
? {
|
|
484
550
|
package: name,
|
|
551
|
+
packageDir,
|
|
552
|
+
buildScript,
|
|
485
553
|
distFile: displayPath(cwd, distPath),
|
|
486
554
|
missing: true,
|
|
487
555
|
sourceFile: displayPath(cwd, newest.file),
|
|
@@ -490,6 +558,8 @@ export async function collectStaleWorkspaceDists(
|
|
|
490
558
|
: distStat.mtimeMs < newest.mtimeMs
|
|
491
559
|
? {
|
|
492
560
|
package: name,
|
|
561
|
+
packageDir,
|
|
562
|
+
buildScript,
|
|
493
563
|
distFile: displayPath(cwd, distPath),
|
|
494
564
|
missing: false,
|
|
495
565
|
sourceFile: displayPath(cwd, newest.file),
|
|
@@ -672,7 +742,7 @@ async function inferDependencyFix(
|
|
|
672
742
|
/** Run doctor against the project at `cwd`: diff source imports vs declared deps. */
|
|
673
743
|
export async function collectDoctorResult(
|
|
674
744
|
cwd: string,
|
|
675
|
-
opts: { readonly target?: string; readonly strict?: boolean } = {},
|
|
745
|
+
opts: { readonly target?: string; readonly strict?: boolean; readonly cliVersion?: string } = {},
|
|
676
746
|
): Promise<DoctorResult> {
|
|
677
747
|
const pkg = await readJson(join(cwd, "package.json"))
|
|
678
748
|
if (pkg === undefined)
|
|
@@ -695,12 +765,18 @@ export async function collectDoctorResult(
|
|
|
695
765
|
{ includeTests: true },
|
|
696
766
|
)
|
|
697
767
|
findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)
|
|
698
|
-
const
|
|
768
|
+
const identity = await collectAllDuplicateInstalls(cwd, pkg)
|
|
769
|
+
const duplicateInstalls = identity.duplicates
|
|
699
770
|
const staleDists = await collectStaleWorkspaceDists(cwd, pkg)
|
|
700
771
|
const pipeline = await collectPipelineReport(cwd)
|
|
701
772
|
const pipelineErrors = pipeline.findings.filter((f) => f.severity === "error")
|
|
702
773
|
const readiness = await collectDoctorReadiness(cwd, opts)
|
|
703
|
-
//
|
|
774
|
+
// A stale binary answering about a project it does not match is an environment condition, so like
|
|
775
|
+
// `staleDists` it is reported but never folded into `ok`. Skipped entirely when the caller did not
|
|
776
|
+
// pass its own version (e.g. the MCP server, which already annotates every result with the drift).
|
|
777
|
+
const toolingDrift =
|
|
778
|
+
opts.cliVersion !== undefined ? await detectToolingDrift(cwd, opts.cliVersion) : undefined
|
|
779
|
+
// `staleDists` and `toolingDrift` are advisory (see DoctorResult): they never fail `ok`.
|
|
704
780
|
return {
|
|
705
781
|
ok:
|
|
706
782
|
findings.length === 0 &&
|
|
@@ -710,8 +786,12 @@ export async function collectDoctorResult(
|
|
|
710
786
|
ran: true,
|
|
711
787
|
findings,
|
|
712
788
|
duplicateInstalls,
|
|
789
|
+
deduplicatedInstalls: identity.deduplicated,
|
|
790
|
+
identityBasis: identity.basis,
|
|
791
|
+
...(identity.truncated ? { identityScanTruncated: true } : {}),
|
|
713
792
|
staleDists,
|
|
714
793
|
readiness,
|
|
794
|
+
...(toolingDrift !== undefined ? { toolingDrift } : {}),
|
|
715
795
|
...(pipeline.ran ? { pipeline } : {}),
|
|
716
796
|
}
|
|
717
797
|
}
|
|
@@ -719,7 +799,7 @@ export async function collectDoctorResult(
|
|
|
719
799
|
/** Safely add undeclared imports to package.json when the version can be inferred without network I/O. */
|
|
720
800
|
export async function applyDoctorAutoFix(
|
|
721
801
|
cwd: string,
|
|
722
|
-
opts: { readonly target?: string; readonly strict?: boolean } = {},
|
|
802
|
+
opts: { readonly target?: string; readonly strict?: boolean; readonly cliVersion?: string } = {},
|
|
723
803
|
): Promise<DoctorResult> {
|
|
724
804
|
const before = await collectDoctorResult(cwd, opts)
|
|
725
805
|
if (!before.ran || before.findings.length === 0) return before
|
|
@@ -798,6 +878,8 @@ export async function runDoctor(
|
|
|
798
878
|
readonly autoFix?: boolean
|
|
799
879
|
readonly strict?: boolean
|
|
800
880
|
readonly target?: string
|
|
881
|
+
/** The running CLI's own version, so doctor can flag a binary that mismatches the project. */
|
|
882
|
+
readonly cliVersion?: string
|
|
801
883
|
} = {},
|
|
802
884
|
): Promise<boolean> {
|
|
803
885
|
const result = opts.autoFix
|
|
@@ -872,10 +954,31 @@ export async function runDoctor(
|
|
|
872
954
|
}
|
|
873
955
|
console.log("")
|
|
874
956
|
}
|
|
957
|
+
// Advisory, printed regardless of ok: the binary answering is a different feature version than the
|
|
958
|
+
// one the project builds with, so its types/checks/docs describe a surface the code does not have.
|
|
959
|
+
if (result.toolingDrift !== undefined) {
|
|
960
|
+
const drift = result.toolingDrift
|
|
961
|
+
console.log(
|
|
962
|
+
`⚠ this CLI is nifra ${drift.cli}, but the project installs ${drift.package} ${drift.project} - ` +
|
|
963
|
+
"its types, checks, and docs may describe a different version than your code builds with.",
|
|
964
|
+
)
|
|
965
|
+
console.log(
|
|
966
|
+
" fix: run the project's own CLI (`bunx --bun nifra doctor` from the project directory, " +
|
|
967
|
+
"or ./node_modules/.bin/nifra)\n",
|
|
968
|
+
)
|
|
969
|
+
}
|
|
875
970
|
if (result.ok) {
|
|
876
971
|
console.log(
|
|
877
972
|
"✓ every imported package is declared and identity-sensitive installs are deduplicated",
|
|
878
973
|
)
|
|
974
|
+
if (result.identityBasis !== undefined) console.log(` identity scan: ${result.identityBasis}`)
|
|
975
|
+
// A partial scan that prints a bare ✓ is a lie by omission - the packages past the cap were never
|
|
976
|
+
// looked at, and the reader has no way to know that from the tick alone.
|
|
977
|
+
if (result.identityScanTruncated === true) {
|
|
978
|
+
console.log(
|
|
979
|
+
" ! the identity scan was truncated, so this is not a clean bill for the whole workspace",
|
|
980
|
+
)
|
|
981
|
+
}
|
|
879
982
|
return true
|
|
880
983
|
}
|
|
881
984
|
// Group by package so the fix ("add X to dependencies") is stated once with its import sites.
|
|
@@ -896,15 +999,34 @@ export async function runDoctor(
|
|
|
896
999
|
console.log(
|
|
897
1000
|
`${byPkg.size > 0 ? "\n" : ""}✗ identity-sensitive packages resolve to multiple physical copies:\n`,
|
|
898
1001
|
)
|
|
1002
|
+
if (result.identityBasis !== undefined) console.log(` scan: ${result.identityBasis}\n`)
|
|
899
1003
|
for (const finding of result.duplicateInstalls) {
|
|
900
|
-
console.log(` ${finding.package}`)
|
|
1004
|
+
console.log(` ${finding.package} [${finding.cause}]`)
|
|
1005
|
+
console.log(` versions: ${finding.versions.join(", ")}`)
|
|
901
1006
|
for (const copy of finding.copies) {
|
|
902
1007
|
console.log(` ${copy.version} at ${copy.path} ← ${copy.importers.join(", ")}`)
|
|
903
1008
|
}
|
|
904
1009
|
console.log(` ${finding.explanation}`)
|
|
1010
|
+
// The shape of the split decides which fix can work, so it is printed above the fix itself.
|
|
1011
|
+
if (finding.topology !== undefined) console.log(` topology: ${finding.topology}`)
|
|
1012
|
+
// Why a copy this directory never imports still counts. Without it, a workspace-wide answer
|
|
1013
|
+
// read from inside one app looks like the tool checking the wrong project.
|
|
1014
|
+
if (finding.scope !== undefined) console.log(` scope: ${finding.scope}`)
|
|
905
1015
|
console.log(` fix: ${finding.remediation}`)
|
|
906
1016
|
}
|
|
907
1017
|
}
|
|
1018
|
+
// Not a failure and not silence: the copies are real, and whoever reads this report needs to know
|
|
1019
|
+
// the graph is held together by a declaration rather than by the install.
|
|
1020
|
+
if (result.deduplicatedInstalls !== undefined && result.deduplicatedInstalls.length > 0) {
|
|
1021
|
+
console.log("\n• identity-sensitive packages deduplicated by declaration:\n")
|
|
1022
|
+
for (const finding of result.deduplicatedInstalls) {
|
|
1023
|
+
console.log(` ${finding.package} @ ${finding.versions.join(", ")}`)
|
|
1024
|
+
for (const copy of finding.copies) {
|
|
1025
|
+
console.log(` ${copy.path} ← ${copy.importers.join(", ")}`)
|
|
1026
|
+
}
|
|
1027
|
+
console.log(` ${finding.remediation}`)
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
908
1030
|
if (result.readiness?.strict === true && result.readiness.ok === false) {
|
|
909
1031
|
console.log("\n✗ production readiness is incomplete under --strict")
|
|
910
1032
|
}
|
package/src/env-file.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--env-file <path>` for every `nifra` command.
|
|
3
|
+
*
|
|
4
|
+
* Most nifra commands (`check`, `assure`, `levels`, `routes`, `capabilities`, `manifest`, `contracts`,
|
|
5
|
+
* `openapi`, `types`) reflect a project by IMPORTING it, and a production-grade app validates its
|
|
6
|
+
* environment at module scope. Without the app's environment those commands do not report a gate
|
|
7
|
+
* failure - the app's own validator kills the process before nifra reaches its first check, and the
|
|
8
|
+
* only output is the app's `FATAL: invalid environment` with nothing tying it to the command that was
|
|
9
|
+
* run. This flag supplies that environment the same way `bun --env-file` / `node --env-file` do, so a
|
|
10
|
+
* project whose env lives in an uncommitted `.env.local` stays checkable.
|
|
11
|
+
*
|
|
12
|
+
* Two deliberate rules:
|
|
13
|
+
* - A variable already present in `process.env` WINS. The shell (and CI's secret store) is the more
|
|
14
|
+
* explicit source; a checked-in defaults file must never shadow it.
|
|
15
|
+
* - A missing file is an error, never a silent no-op. `--env-file .env.prod` that quietly did nothing
|
|
16
|
+
* would make the command look like it verified an environment it never loaded.
|
|
17
|
+
*/
|
|
18
|
+
import { readFile } from "node:fs/promises"
|
|
19
|
+
import { resolve } from "node:path"
|
|
20
|
+
|
|
21
|
+
const ENV_FILE_FLAG = "--env-file"
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A `KEY=VALUE` line. `export ` is tolerated so a file that is also `source`-able works. The key is
|
|
25
|
+
* POSIX-shaped on purpose: it keeps a stray `- foo` bullet or a YAML `key: value` line from being read
|
|
26
|
+
* as an assignment when the wrong file is passed.
|
|
27
|
+
*/
|
|
28
|
+
const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/
|
|
29
|
+
|
|
30
|
+
/** Strip one layer of matching quotes; a double-quoted value also expands `\n`, `\r`, `\t` and `\"`. */
|
|
31
|
+
function unquote(raw: string): string {
|
|
32
|
+
const value = raw.trim()
|
|
33
|
+
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
|
34
|
+
return value
|
|
35
|
+
.slice(1, -1)
|
|
36
|
+
.replace(/\\([nrt"\\])/g, (_, char: string) =>
|
|
37
|
+
char === "n" ? "\n" : char === "r" ? "\r" : char === "t" ? "\t" : char,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1)
|
|
41
|
+
// Unquoted: an inline ` #` starts a comment, matching every dotenv reader. A `#` with no leading
|
|
42
|
+
// space is part of the value (URLs and generated secrets contain one).
|
|
43
|
+
const comment = value.search(/\s#/)
|
|
44
|
+
return (comment === -1 ? value : value.slice(0, comment)).trim()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Parse dotenv text into a plain record. Later assignments to the same key win, as in a shell. */
|
|
48
|
+
export function parseEnvFile(text: string): Record<string, string> {
|
|
49
|
+
const values: Record<string, string> = {}
|
|
50
|
+
for (const line of text.split(/\r?\n/)) {
|
|
51
|
+
if (line.trim() === "" || line.trimStart().startsWith("#")) continue
|
|
52
|
+
const match = ASSIGNMENT.exec(line)
|
|
53
|
+
if (match === null) continue
|
|
54
|
+
values[match[1] as string] = unquote(match[2] as string)
|
|
55
|
+
}
|
|
56
|
+
return values
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Pull every `--env-file <path>` / `--env-file=<path>` out of `argv`, leaving the rest for the command
|
|
61
|
+
* parser (which rejects options it does not know). Repeatable; later files override earlier ones, the
|
|
62
|
+
* same precedence `node --env-file` uses.
|
|
63
|
+
*/
|
|
64
|
+
export function takeEnvFileFlags(argv: readonly string[]): {
|
|
65
|
+
readonly argv: readonly string[]
|
|
66
|
+
readonly files: readonly string[]
|
|
67
|
+
} {
|
|
68
|
+
const rest: string[] = []
|
|
69
|
+
const files: string[] = []
|
|
70
|
+
for (let index = 0; index < argv.length; index++) {
|
|
71
|
+
const token = argv[index] as string
|
|
72
|
+
if (token === ENV_FILE_FLAG) {
|
|
73
|
+
const value = argv[index + 1]
|
|
74
|
+
if (value === undefined || value.startsWith("-")) {
|
|
75
|
+
throw new Error(`[nifra] ${ENV_FILE_FLAG} needs a path, e.g. ${ENV_FILE_FLAG} .env.local`)
|
|
76
|
+
}
|
|
77
|
+
files.push(value)
|
|
78
|
+
index++
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
if (token.startsWith(`${ENV_FILE_FLAG}=`)) {
|
|
82
|
+
const value = token.slice(ENV_FILE_FLAG.length + 1)
|
|
83
|
+
if (value === "") {
|
|
84
|
+
throw new Error(`[nifra] ${ENV_FILE_FLAG} needs a path, e.g. ${ENV_FILE_FLAG}=.env.local`)
|
|
85
|
+
}
|
|
86
|
+
files.push(value)
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
rest.push(token)
|
|
90
|
+
}
|
|
91
|
+
return { argv: rest, files }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read each file in order and merge it into `process.env`, never overwriting a variable the process
|
|
96
|
+
* already has. Returns the resolved paths that were applied so a command can say what it loaded.
|
|
97
|
+
*/
|
|
98
|
+
export async function applyEnvFiles(
|
|
99
|
+
cwd: string,
|
|
100
|
+
files: readonly string[],
|
|
101
|
+
): Promise<readonly string[]> {
|
|
102
|
+
const applied: string[] = []
|
|
103
|
+
const merged: Record<string, string> = {}
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
const path = resolve(cwd, file)
|
|
106
|
+
let text: string
|
|
107
|
+
try {
|
|
108
|
+
text = await readFile(path, "utf8")
|
|
109
|
+
} catch {
|
|
110
|
+
throw new Error(`[nifra] --env-file not found or unreadable: ${path}`)
|
|
111
|
+
}
|
|
112
|
+
// Files merge left to right, so a later `--env-file` overrides an earlier one; the merged result
|
|
113
|
+
// is applied afterwards so that precedence never depends on which file happened to define a key.
|
|
114
|
+
Object.assign(merged, parseEnvFile(text))
|
|
115
|
+
applied.push(path)
|
|
116
|
+
}
|
|
117
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
118
|
+
if (process.env[key] === undefined) process.env[key] = value
|
|
119
|
+
}
|
|
120
|
+
return applied
|
|
121
|
+
}
|
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,
|