@nifrajs/cli 5.0.0 → 5.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/README.md +7 -2
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +4 -2
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts +12 -2
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +59 -8
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +24 -7
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +2 -0
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +8 -2
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +43 -10
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +39 -33
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +106 -48
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +5 -3
- package/dist/client-module.d.ts.map +1 -1
- package/dist/client-module.js +34 -10
- package/dist/client-module.js.map +1 -1
- package/dist/command-catalog.d.ts +9 -1
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +67 -22
- package/dist/command-catalog.js.map +1 -1
- package/dist/dev-feed-tool.d.ts +18 -1
- package/dist/dev-feed-tool.d.ts.map +1 -1
- package/dist/dev-feed-tool.js +93 -0
- package/dist/dev-feed-tool.js.map +1 -1
- package/dist/fix-recipes.d.ts +2 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +93 -12
- package/dist/fix-recipes.js.map +1 -1
- package/dist/init-agents.d.ts +16 -0
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +3 -2
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/config-paths.d.ts +16 -0
- package/dist/internal/config-paths.d.ts.map +1 -0
- package/dist/internal/config-paths.js +124 -0
- package/dist/internal/config-paths.js.map +1 -0
- package/dist/internal/installed-package.d.ts +4 -0
- package/dist/internal/installed-package.d.ts.map +1 -0
- package/dist/internal/installed-package.js +17 -0
- package/dist/internal/installed-package.js.map +1 -0
- package/dist/internal/project-specifiers.d.ts +14 -1
- package/dist/internal/project-specifiers.d.ts.map +1 -1
- package/dist/internal/project-specifiers.js +54 -15
- package/dist/internal/project-specifiers.js.map +1 -1
- package/dist/internal/source-facts.d.ts +6 -1
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +11 -5
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +5 -3
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +9 -0
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +32 -1
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +5 -8
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +2 -2
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +23 -3
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +6 -21
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-delegate.d.ts +2 -2
- package/dist/mcp-delegate.d.ts.map +1 -1
- package/dist/mcp-delegate.js +19 -7
- package/dist/mcp-delegate.js.map +1 -1
- package/dist/mcp-exec.d.ts +1 -0
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +8 -3
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +13 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +23 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-path.d.ts +2 -1
- package/dist/mcp-path.d.ts.map +1 -1
- package/dist/mcp-path.js +9 -24
- package/dist/mcp-path.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +40 -17
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +40 -1
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +1316 -228
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +34 -0
- package/dist/output-schemas.d.ts.map +1 -1
- package/dist/output-schemas.js +132 -21
- package/dist/output-schemas.js.map +1 -1
- package/dist/project-path.d.ts +6 -0
- package/dist/project-path.d.ts.map +1 -1
- package/dist/project-path.js +48 -14
- package/dist/project-path.js.map +1 -1
- package/dist/release-verification.d.ts +5 -0
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +14 -0
- package/dist/release-verification.js.map +1 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +5 -0
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +3 -1
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +3 -1
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/data-guard.d.ts +6 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +37 -0
- package/dist/rules/data-guard.js.map +1 -1
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +20 -1
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +29 -3
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +236 -22
- package/dist/rules/pages.js.map +1 -1
- package/dist/rules/security.d.ts +3 -2
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +42 -11
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/zones.d.ts.map +1 -1
- package/dist/rules/zones.js +31 -9
- package/dist/rules/zones.js.map +1 -1
- package/dist/stylex-migrate.d.ts.map +1 -1
- package/dist/stylex-migrate.js +127 -17
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +3 -9
- package/dist/test-tool.js.map +1 -1
- package/dist/upgrade.d.ts +20 -5
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +86 -9
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +5 -2
- package/dist/verification-plan.js.map +1 -1
- package/dist/version-drift.d.ts.map +1 -1
- package/dist/version-drift.js +55 -3
- package/dist/version-drift.js.map +1 -1
- package/docs/examples.json +2 -2
- package/docs/llms-full.txt +481 -61
- package/docs/types.json +147 -70
- package/package.json +11 -11
- package/src/assure-hydration.ts +4 -1
- package/src/assure.ts +88 -10
- package/src/capabilities-tool.ts +33 -7
- package/src/check-diagnostics.ts +2 -0
- package/src/check-scan.ts +56 -9
- package/src/check.ts +55 -39
- package/src/cli.ts +104 -35
- package/src/client-module.ts +45 -9
- package/src/command-catalog.ts +77 -21
- package/src/dev-feed-tool.ts +112 -1
- package/src/fix-recipes.ts +101 -10
- package/src/init-agents.ts +4 -3
- package/src/internal/config-paths.ts +154 -0
- package/src/internal/installed-package.ts +15 -0
- package/src/internal/project-specifiers.ts +69 -16
- package/src/internal/source-facts.ts +23 -5
- package/src/internal/typescript-import.ts +5 -3
- package/src/introspect.ts +39 -1
- package/src/levels-tool.ts +7 -9
- package/src/load.ts +29 -3
- package/src/mcp-context.ts +5 -18
- package/src/mcp-delegate.ts +22 -10
- package/src/mcp-exec.ts +9 -2
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +33 -0
- package/src/mcp-path.ts +8 -22
- package/src/mcp.ts +48 -19
- package/src/migrate-layout.ts +1496 -237
- package/src/output-schemas.ts +179 -23
- package/src/project-path.ts +44 -13
- package/src/release-verification.ts +20 -0
- package/src/review.ts +5 -0
- package/src/rules/codes.ts +3 -1
- package/src/rules/data-guard.ts +40 -0
- package/src/rules/legacy.ts +22 -1
- package/src/rules/pages.ts +266 -24
- package/src/rules/security.ts +49 -11
- package/src/rules/zones.ts +30 -7
- package/src/stylex-migrate.ts +117 -17
- package/src/test-tool.ts +3 -9
- package/src/upgrade.ts +96 -8
- package/src/verification-plan.ts +9 -2
- package/src/version-drift.ts +53 -2
package/src/output-schemas.ts
CHANGED
|
@@ -267,6 +267,9 @@ async function shapeOf<T>(
|
|
|
267
267
|
for (const property of properties) {
|
|
268
268
|
if (property.name.startsWith("__@") || (await reader.callable(property.type))) continue
|
|
269
269
|
const shape = await shapeOf(reader, property.type, seen, depth + 1)
|
|
270
|
+
// `key?: undefined` is how TypeScript lines up a union of object literals: a key the other
|
|
271
|
+
// members have and this one never sends.
|
|
272
|
+
if (shape.kind === "undefined" || shape.kind === "never") continue
|
|
270
273
|
const defined = withoutUndefined(shape)
|
|
271
274
|
fields.push({
|
|
272
275
|
name: property.name,
|
|
@@ -341,6 +344,58 @@ export function schemaSource(shape: Shape, indent = ""): string {
|
|
|
341
344
|
}
|
|
342
345
|
}
|
|
343
346
|
|
|
347
|
+
/** The paths a draft of `shape` leaves out: credential-like names, and values that are not JSON. */
|
|
348
|
+
export function omittedPaths(shape: Shape, path = ""): string[] {
|
|
349
|
+
switch (shape.kind) {
|
|
350
|
+
case "array":
|
|
351
|
+
return omittedPaths(shape.items, `${path}[]`)
|
|
352
|
+
case "tuple":
|
|
353
|
+
return omittedPaths(unionOf(shape.items), `${path}[]`)
|
|
354
|
+
case "record":
|
|
355
|
+
return omittedPaths(shape.values, `${path}{}`)
|
|
356
|
+
case "deferred":
|
|
357
|
+
return omittedPaths(shape.value, path)
|
|
358
|
+
case "union":
|
|
359
|
+
return [...new Set(shape.members.flatMap((member) => omittedPaths(member, path)))]
|
|
360
|
+
case "object":
|
|
361
|
+
return shape.fields.flatMap((field) => {
|
|
362
|
+
const at = path === "" ? field.name : `${path}.${field.name}`
|
|
363
|
+
return isSensitiveFieldName(field.name) || field.shape.kind === "opaque"
|
|
364
|
+
? [at]
|
|
365
|
+
: omittedPaths(field.shape, at)
|
|
366
|
+
})
|
|
367
|
+
default:
|
|
368
|
+
return []
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The paths a draft of `shape` admits any value at: `any` or `unknown` there, or a type too deep or
|
|
373
|
+
* recursive to read. The output guard drops nothing below them. */
|
|
374
|
+
export function openPaths(shape: Shape, path = ""): string[] {
|
|
375
|
+
switch (shape.kind) {
|
|
376
|
+
case "json":
|
|
377
|
+
return shape.reason === undefined ? [] : [path === "" ? "(the whole value)" : path]
|
|
378
|
+
case "array":
|
|
379
|
+
return openPaths(shape.items, `${path}[]`)
|
|
380
|
+
case "tuple":
|
|
381
|
+
return openPaths(unionOf(shape.items), `${path}[]`)
|
|
382
|
+
case "record":
|
|
383
|
+
return openPaths(shape.values, `${path}{}`)
|
|
384
|
+
case "deferred":
|
|
385
|
+
return openPaths(shape.value, path)
|
|
386
|
+
case "union":
|
|
387
|
+
return [...new Set(shape.members.flatMap((member) => openPaths(member, path)))]
|
|
388
|
+
case "object":
|
|
389
|
+
return shape.fields.flatMap((field) =>
|
|
390
|
+
isSensitiveFieldName(field.name) || field.shape.kind === "opaque"
|
|
391
|
+
? []
|
|
392
|
+
: openPaths(field.shape, path === "" ? field.name : `${path}.${field.name}`),
|
|
393
|
+
)
|
|
394
|
+
default:
|
|
395
|
+
return []
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
344
399
|
/**
|
|
345
400
|
* The paths `returned` carries that `declared` does not, as `a.b`, `items[].c`: what the output guard
|
|
346
401
|
* drops before the page renders. A declared `t.json()` or record admits everything below it.
|
|
@@ -430,7 +485,7 @@ function schemaTokens(text: string): string {
|
|
|
430
485
|
|
|
431
486
|
function declarationText(schema: OutputExport, fn: RouteFn, body: string): string {
|
|
432
487
|
return [
|
|
433
|
-
`// ${GENERATED_MARK} ${hashOf(body)} - from the ${fn}'s return type; \`nifra schemas --write\` keeps it current
|
|
488
|
+
`// ${GENERATED_MARK} ${hashOf(body)} - drafted from the ${fn}'s return type; review that the browser may see every field it declares. \`nifra schemas --write\` keeps it current; delete this line to edit it by hand.`,
|
|
434
489
|
`export const ${schema} = ${body}`,
|
|
435
490
|
].join("\n")
|
|
436
491
|
}
|
|
@@ -618,7 +673,11 @@ function reader7(semantic: TypeScriptSemanticSession, checker: Checker7): TypeRe
|
|
|
618
673
|
}
|
|
619
674
|
|
|
620
675
|
/** Read every listed route function's return type and schema's output type through one program. */
|
|
621
|
-
async function readShapes(
|
|
676
|
+
async function readShapes(
|
|
677
|
+
root: string,
|
|
678
|
+
routes: readonly ProbeRoute[],
|
|
679
|
+
sources: ReadonlyMap<string, string>,
|
|
680
|
+
): Promise<ProbeResult> {
|
|
622
681
|
const warnings: string[] = []
|
|
623
682
|
const shapes = new Map<string, Shape>()
|
|
624
683
|
if (routes.length === 0) return { shapes, warnings }
|
|
@@ -658,10 +717,14 @@ async function readShapes(root: string, routes: readonly ProbeRoute[]): Promise<
|
|
|
658
717
|
),
|
|
659
718
|
"utf8",
|
|
660
719
|
)
|
|
720
|
+
// The routes and every planned file the caller supplies are read from `sources`, not the disk:
|
|
721
|
+
// a migration plans against the layout it has not written yet.
|
|
661
722
|
const contents = new Map<string, string>([[probePath, probe]])
|
|
662
|
-
for (const file of
|
|
723
|
+
for (const [file, text] of sources) contents.set(join(root, file), text)
|
|
724
|
+
for (const file of routeFiles)
|
|
725
|
+
if (!contents.has(file)) contents.set(file, readFileSync(file, "utf8"))
|
|
663
726
|
const loaded = await loadProjectTypeScript(root, undefined, {
|
|
664
|
-
files: [...
|
|
727
|
+
files: [...contents.keys()],
|
|
665
728
|
read: (file) => contents.get(file),
|
|
666
729
|
projectConfig: probeConfigPath,
|
|
667
730
|
})
|
|
@@ -704,7 +767,7 @@ async function readShapes(root: string, routes: readonly ProbeRoute[]): Promise<
|
|
|
704
767
|
skipLibCheck: true,
|
|
705
768
|
allowImportingTsExtensions: true,
|
|
706
769
|
}
|
|
707
|
-
const program = ts.createProgram([probePath], options, ts
|
|
770
|
+
const program = ts.createProgram([probePath], options, overlaidHost(ts, options, contents))
|
|
708
771
|
const source = program.getSourceFile(probePath)
|
|
709
772
|
if (source === undefined) {
|
|
710
773
|
warnings.push("could not create the type probe")
|
|
@@ -731,6 +794,43 @@ async function readShapes(root: string, routes: readonly ProbeRoute[]): Promise<
|
|
|
731
794
|
}
|
|
732
795
|
}
|
|
733
796
|
|
|
797
|
+
/** A compiler host that reads `contents` (absolute path -> text) before the disk. */
|
|
798
|
+
function overlaidHost(
|
|
799
|
+
ts: TypeScriptApi,
|
|
800
|
+
options: TSApi.CompilerOptions,
|
|
801
|
+
contents: ReadonlyMap<string, string>,
|
|
802
|
+
): TSApi.CompilerHost {
|
|
803
|
+
const host = ts.createCompilerHost(options, true)
|
|
804
|
+
const key = (file: string): string => posix(ts.sys.resolvePath(file))
|
|
805
|
+
const overlay = new Map([...contents].map(([file, text]) => [key(file), text]))
|
|
806
|
+
const folders = new Set<string>()
|
|
807
|
+
for (const file of overlay.keys())
|
|
808
|
+
for (let at = file.lastIndexOf("/"); at > 0; at = file.lastIndexOf("/", at - 1))
|
|
809
|
+
folders.add(file.slice(0, at))
|
|
810
|
+
const { fileExists, readFile, getSourceFile, directoryExists } = host
|
|
811
|
+
return Object.assign(host, {
|
|
812
|
+
fileExists: (file: string): boolean => overlay.has(key(file)) || fileExists.call(host, file),
|
|
813
|
+
readFile: (file: string): string | undefined =>
|
|
814
|
+
overlay.get(key(file)) ?? readFile.call(host, file),
|
|
815
|
+
directoryExists: (folder: string): boolean =>
|
|
816
|
+
folders.has(key(folder)) ||
|
|
817
|
+
(directoryExists === undefined
|
|
818
|
+
? ts.sys.directoryExists(folder)
|
|
819
|
+
: directoryExists.call(host, folder)),
|
|
820
|
+
getSourceFile: (
|
|
821
|
+
file: string,
|
|
822
|
+
language: TSApi.ScriptTarget | TSApi.CreateSourceFileOptions,
|
|
823
|
+
onError?: (message: string) => void,
|
|
824
|
+
fresh?: boolean,
|
|
825
|
+
): TSApi.SourceFile | undefined => {
|
|
826
|
+
const text = overlay.get(key(file))
|
|
827
|
+
return text === undefined
|
|
828
|
+
? getSourceFile.call(host, file, language, onError, fresh)
|
|
829
|
+
: ts.createSourceFile(file, text, language)
|
|
830
|
+
},
|
|
831
|
+
})
|
|
832
|
+
}
|
|
833
|
+
|
|
734
834
|
// ---- planning edits ----------------------------------------------------------------------------
|
|
735
835
|
|
|
736
836
|
export type OutputSchemaStatus =
|
|
@@ -755,6 +855,11 @@ export interface OutputSchemaEntry {
|
|
|
755
855
|
/** A generated schema whose text was changed under its marker: stale though its route is not. */
|
|
756
856
|
readonly edited?: boolean
|
|
757
857
|
readonly reason?: string
|
|
858
|
+
/** Returned paths a draft leaves out: credential-like names, and values that are not JSON. */
|
|
859
|
+
readonly omitted?: readonly string[]
|
|
860
|
+
/** Returned paths a generated schema admits any value at, because the type there is `any` or
|
|
861
|
+
* `unknown`: the output guard drops nothing below them until the type says what is sent. */
|
|
862
|
+
readonly open?: readonly string[]
|
|
758
863
|
}
|
|
759
864
|
|
|
760
865
|
interface SchemaDeclaration {
|
|
@@ -872,18 +977,37 @@ function withSchemaImport(content: string): string | undefined {
|
|
|
872
977
|
: `${content.slice(0, end)}\n${line}${content.slice(end)}`
|
|
873
978
|
}
|
|
874
979
|
|
|
875
|
-
interface
|
|
980
|
+
export interface OutputSchemaPlan {
|
|
876
981
|
readonly entries: OutputSchemaEntry[]
|
|
982
|
+
/** App-relative file -> its content with the planned schemas written. */
|
|
877
983
|
readonly edits: Map<string, string>
|
|
878
984
|
readonly warnings: readonly string[]
|
|
879
985
|
}
|
|
880
986
|
|
|
881
|
-
|
|
882
|
-
|
|
987
|
+
export interface OutputSchemaPlanOptions {
|
|
988
|
+
/**
|
|
989
|
+
* App-relative file -> text the types are read from instead of the disk: a layout planned but not
|
|
990
|
+
* written yet. The routes' own text comes from `read`.
|
|
991
|
+
*/
|
|
992
|
+
readonly overlay?: ReadonlyMap<string, string>
|
|
993
|
+
/**
|
|
994
|
+
* Only these declarations, and only where none exists: a draft for a missing schema, never a
|
|
995
|
+
* refresh of a generated one or a change to a hand-written one.
|
|
996
|
+
*/
|
|
997
|
+
readonly draft?: (file: string, schema: OutputExport) => boolean
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* What each listed route backend half needs, and the file contents that would apply it. `nifra
|
|
1002
|
+
* schemas`, `nifra migrate layout` and the NF-C030 fix share it, so one return type drafts one schema.
|
|
1003
|
+
*/
|
|
1004
|
+
export async function planOutputSchemas(
|
|
883
1005
|
root: string,
|
|
884
1006
|
files: readonly string[],
|
|
885
1007
|
read: (file: string) => string | undefined,
|
|
886
|
-
|
|
1008
|
+
options: OutputSchemaPlanOptions = {},
|
|
1009
|
+
): Promise<OutputSchemaPlan> {
|
|
1010
|
+
const { draft } = options
|
|
887
1011
|
const perFile = new Map<string, { content: string; targets: Target[] }>()
|
|
888
1012
|
for (const file of files) {
|
|
889
1013
|
const content = read(file)
|
|
@@ -891,18 +1015,31 @@ async function plan(
|
|
|
891
1015
|
const targets = targetsOf(content)
|
|
892
1016
|
if (targets.length > 0) perFile.set(file, { content, targets })
|
|
893
1017
|
}
|
|
894
|
-
const
|
|
895
|
-
file,
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1018
|
+
const chosen = (file: string, target: Target): boolean =>
|
|
1019
|
+
draft === undefined || (draft(file, target.schema) && target.declaration === undefined)
|
|
1020
|
+
const probe: ProbeRoute[] = [...perFile].flatMap(([file, { targets }]) => {
|
|
1021
|
+
const read = targets.filter((target) => chosen(file, target))
|
|
1022
|
+
return read.length === 0
|
|
1023
|
+
? []
|
|
1024
|
+
: [
|
|
1025
|
+
{
|
|
1026
|
+
file,
|
|
1027
|
+
fns: read.map((target) => target.fn),
|
|
1028
|
+
schemas: read.filter((t) => t.declaration !== undefined).map((t) => t.schema),
|
|
1029
|
+
},
|
|
1030
|
+
]
|
|
1031
|
+
})
|
|
1032
|
+
const sources = new Map(options.overlay)
|
|
1033
|
+
for (const [file, { content }] of perFile) sources.set(file, content)
|
|
1034
|
+
const { shapes, warnings } = await readShapes(root, probe, sources)
|
|
900
1035
|
const entries: OutputSchemaEntry[] = []
|
|
901
1036
|
const edits = new Map<string, string>()
|
|
902
1037
|
if (warnings.length > 0) return { entries, edits, warnings }
|
|
903
1038
|
for (const [file, { content, targets }] of perFile) {
|
|
1039
|
+
const mask = codePositionMask(content)
|
|
904
1040
|
const replacements: Array<{ start: number; end: number; text: string; entry: number }> = []
|
|
905
1041
|
for (const target of targets) {
|
|
1042
|
+
if (!chosen(file, target)) continue
|
|
906
1043
|
const raw = shapes.get(`${file}#${target.fn}`)
|
|
907
1044
|
const declared = shapes.get(`${file}#${target.schema}`)
|
|
908
1045
|
const { declaration } = target
|
|
@@ -912,13 +1049,16 @@ async function plan(
|
|
|
912
1049
|
dropped: readonly string[] = [],
|
|
913
1050
|
reason?: string,
|
|
914
1051
|
edited = false,
|
|
1052
|
+
omitted: readonly string[] = [],
|
|
1053
|
+
open: readonly string[] = [],
|
|
915
1054
|
): number => {
|
|
916
1055
|
const schema = target.schema
|
|
917
1056
|
const generated = declaration?.hash !== undefined
|
|
918
1057
|
const base: OutputSchemaEntry = { file, line, schema, status, generated, dropped }
|
|
919
1058
|
const item: OutputSchemaEntry =
|
|
920
1059
|
reason !== undefined ? { ...base, reason } : edited ? { ...base, edited } : base
|
|
921
|
-
|
|
1060
|
+
const withOmitted = omitted.length === 0 ? item : { ...item, omitted }
|
|
1061
|
+
return entries.push(open.length === 0 ? withOmitted : { ...withOmitted, open }) - 1
|
|
922
1062
|
}
|
|
923
1063
|
if (target.annotated) {
|
|
924
1064
|
// A `Route.Loader` annotation returns the schema's own input type: comparing the two would
|
|
@@ -956,8 +1096,19 @@ async function plan(
|
|
|
956
1096
|
continue
|
|
957
1097
|
}
|
|
958
1098
|
const body = schemaSource(returned)
|
|
1099
|
+
const omitted = omittedPaths(returned)
|
|
1100
|
+
const open = openPaths(returned)
|
|
959
1101
|
if (declaration === undefined) {
|
|
960
|
-
|
|
1102
|
+
// The name bound any other way (declared, imported) would clash with the one written here.
|
|
1103
|
+
if (new RegExp(`(?<![\\w$.])${target.schema}(?![\\w$])`).test(mask)) {
|
|
1104
|
+
entry(
|
|
1105
|
+
"missing",
|
|
1106
|
+
[],
|
|
1107
|
+
`\`${target.schema}\` already names something in this file, so no draft was written`,
|
|
1108
|
+
)
|
|
1109
|
+
continue
|
|
1110
|
+
}
|
|
1111
|
+
const at = entry("missing", [], undefined, false, omitted, open)
|
|
961
1112
|
const written = declarationText(target.schema, target.fn, body)
|
|
962
1113
|
const text = target.insertAt === content.length ? `\n${written}\n` : `${written}\n\n`
|
|
963
1114
|
replacements.push({ start: target.insertAt, end: target.insertAt, text, entry: at })
|
|
@@ -965,11 +1116,11 @@ async function plan(
|
|
|
965
1116
|
declaration.hash === hashOf(body) &&
|
|
966
1117
|
schemaTokens(declaration.value) === schemaTokens(body)
|
|
967
1118
|
) {
|
|
968
|
-
entry("current")
|
|
1119
|
+
entry("current", [], undefined, false, [], open)
|
|
969
1120
|
} else {
|
|
970
1121
|
// The marker still matches what would be written, so the text under it was edited.
|
|
971
1122
|
const edited = declaration.hash === hashOf(body)
|
|
972
|
-
const at = entry("stale", dropped, undefined, edited)
|
|
1123
|
+
const at = entry("stale", dropped, undefined, edited, omitted, open)
|
|
973
1124
|
const text = declarationText(target.schema, target.fn, body)
|
|
974
1125
|
replacements.push({ start: declaration.start, end: declaration.end, text, entry: at })
|
|
975
1126
|
}
|
|
@@ -1024,10 +1175,10 @@ export interface OutputSchemasReport {
|
|
|
1024
1175
|
*/
|
|
1025
1176
|
export async function outputSchemas(
|
|
1026
1177
|
dir: string,
|
|
1027
|
-
options: { readonly write?: boolean } = {},
|
|
1178
|
+
options: { readonly write?: boolean; readonly check?: boolean } = {},
|
|
1028
1179
|
): Promise<OutputSchemasReport> {
|
|
1029
1180
|
const write = options.write === true
|
|
1030
|
-
const { entries, edits, warnings } = await
|
|
1181
|
+
const { entries, edits, warnings } = await planOutputSchemas(dir, routeBackends(dir), (file) => {
|
|
1031
1182
|
try {
|
|
1032
1183
|
return readFileSync(join(dir, file), "utf8")
|
|
1033
1184
|
} catch {
|
|
@@ -1051,8 +1202,13 @@ export async function outputSchemas(
|
|
|
1051
1202
|
: entry,
|
|
1052
1203
|
)
|
|
1053
1204
|
}
|
|
1205
|
+
// `check` also fails what a review still owes: a schema not written yet, and one that admits anything.
|
|
1206
|
+
const owed = (entry: OutputSchemaEntry): boolean =>
|
|
1207
|
+
entry.status === "missing" || (entry.open?.length ?? 0) > 0
|
|
1054
1208
|
return {
|
|
1055
|
-
ok:
|
|
1209
|
+
ok:
|
|
1210
|
+
warnings.length === 0 &&
|
|
1211
|
+
!final.some((entry) => entry.status === "stale" || (options.check === true && owed(entry))),
|
|
1056
1212
|
write,
|
|
1057
1213
|
entries: final,
|
|
1058
1214
|
written,
|
|
@@ -1075,7 +1231,7 @@ export async function staleGeneratedSchemas(
|
|
|
1075
1231
|
}> {
|
|
1076
1232
|
const marked = files.filter((file) => read(file)?.includes(GENERATED_MARK) === true)
|
|
1077
1233
|
if (marked.length === 0) return { stale: [], unchecked: [], warnings: [] }
|
|
1078
|
-
const { entries, warnings } = await
|
|
1234
|
+
const { entries, warnings } = await planOutputSchemas(root, marked, read)
|
|
1079
1235
|
return {
|
|
1080
1236
|
stale: entries.filter((entry) => entry.status === "stale"),
|
|
1081
1237
|
unchecked: entries.filter((entry) => entry.generated && entry.status === "unreadable"),
|
package/src/project-path.ts
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { lstatSync, readlinkSync, realpathSync } from "node:fs"
|
|
2
2
|
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"
|
|
3
3
|
|
|
4
|
+
const MAX_SYMLINKS = 40
|
|
5
|
+
|
|
6
|
+
function errorCode(error: unknown): unknown {
|
|
7
|
+
return typeof error === "object" && error !== null ? Reflect.get(error, "code") : undefined
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Where a write to `path` lands, canonicalized: the real path of its nearest existing ancestor plus
|
|
12
|
+
* the components still missing below it. A dangling symlink is followed to the path it names, since
|
|
13
|
+
* writing through it creates that path. `undefined` when the filesystem cannot say.
|
|
14
|
+
*/
|
|
15
|
+
export function writeTargetOf(path: string): string | undefined {
|
|
16
|
+
let probe = resolve(path)
|
|
17
|
+
const missing: string[] = []
|
|
18
|
+
let links = 0
|
|
19
|
+
for (;;) {
|
|
20
|
+
try {
|
|
21
|
+
return resolve(realpathSync(probe), ...[...missing].reverse())
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (errorCode(error) !== "ENOENT") return undefined
|
|
24
|
+
}
|
|
25
|
+
let link: string | undefined
|
|
26
|
+
try {
|
|
27
|
+
if (lstatSync(probe).isSymbolicLink()) link = readlinkSync(probe)
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (errorCode(error) !== "ENOENT") return undefined
|
|
30
|
+
}
|
|
31
|
+
if (link !== undefined) {
|
|
32
|
+
if (++links > MAX_SYMLINKS) return undefined
|
|
33
|
+
probe = resolve(dirname(probe), link)
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
const parent = dirname(probe)
|
|
37
|
+
if (parent === probe) return undefined
|
|
38
|
+
missing.push(basename(probe))
|
|
39
|
+
probe = parent
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
4
43
|
function escapesRoot(root: string, candidate: string): boolean {
|
|
5
44
|
const rel = relative(root, candidate)
|
|
6
45
|
return rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)
|
|
@@ -17,18 +56,10 @@ export async function resolveInsideProject(
|
|
|
17
56
|
const resolved = resolve(resolvedRoot, candidate)
|
|
18
57
|
if (escapesRoot(resolvedRoot, resolved)) return undefined
|
|
19
58
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
} catch (error) {
|
|
25
|
-
if (!(error && typeof error === "object" && (error as { code?: string }).code === "ENOENT")) {
|
|
26
|
-
return undefined
|
|
27
|
-
}
|
|
28
|
-
const realParent = await realpath(dirname(resolved))
|
|
29
|
-
realCandidate = resolve(realParent, basename(resolved))
|
|
30
|
-
}
|
|
31
|
-
if (escapesRoot(realRoot, realCandidate)) return undefined
|
|
59
|
+
// The same canonicalizer on both sides: fs/promises expands Windows 8.3 names, realpathSync does not.
|
|
60
|
+
const realRoot = realpathSync(resolvedRoot)
|
|
61
|
+
const realCandidate = writeTargetOf(resolved)
|
|
62
|
+
if (realCandidate === undefined || escapesRoot(realRoot, realCandidate)) return undefined
|
|
32
63
|
return resolved
|
|
33
64
|
} catch {
|
|
34
65
|
return undefined
|
|
@@ -59,6 +59,11 @@ export interface ReleaseVerificationOptions {
|
|
|
59
59
|
* runner swings too far run to run to gate a release on.
|
|
60
60
|
*/
|
|
61
61
|
readonly sharedRunner?: boolean
|
|
62
|
+
/**
|
|
63
|
+
* Fail a gate whose script package.json does not declare, instead of reporting it `undeclared`.
|
|
64
|
+
* The repository's own gate runs every gate in its plan, so a renamed script cannot drop one.
|
|
65
|
+
*/
|
|
66
|
+
readonly requireScripts?: boolean
|
|
62
67
|
readonly runCommand?: (spec: ReleaseCommandSpec) => Promise<ReleaseCommandResult>
|
|
63
68
|
}
|
|
64
69
|
|
|
@@ -213,6 +218,14 @@ const undeclaredGate = (gate: VerificationGateSpec, script: string): ReleaseGate
|
|
|
213
218
|
remediation: `Declare a \`${script}\` script to run this gate.`,
|
|
214
219
|
})
|
|
215
220
|
|
|
221
|
+
const missingScriptGate = (gate: VerificationGateSpec, script: string): ReleaseGateResult => ({
|
|
222
|
+
id: gate.id,
|
|
223
|
+
status: "fail",
|
|
224
|
+
commands: gate.commands.map(commandLabel),
|
|
225
|
+
message: `\`${script}\` is not a script in package.json`,
|
|
226
|
+
remediation: `Declare the \`${script}\` script this gate runs, or rename the gate's command to the script that replaced it.`,
|
|
227
|
+
})
|
|
228
|
+
|
|
216
229
|
/** The first `bun run <script>` of a gate that the root package.json does not declare. */
|
|
217
230
|
const undeclaredScript = (
|
|
218
231
|
gate: VerificationGateSpec,
|
|
@@ -248,6 +261,12 @@ export async function collectReleaseVerification(
|
|
|
248
261
|
}
|
|
249
262
|
// The plan names the nifra repository's own scripts; a project without one has nothing to run.
|
|
250
263
|
const missing = undeclaredScript(gate, scripts)
|
|
264
|
+
if (missing !== undefined && options.requireScripts === true) {
|
|
265
|
+
const result = missingScriptGate(gate, missing)
|
|
266
|
+
gates.push(result)
|
|
267
|
+
failedId = result.id
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
251
270
|
if (missing !== undefined) {
|
|
252
271
|
gates.push(undeclaredGate(gate, missing))
|
|
253
272
|
continue
|
|
@@ -315,6 +334,7 @@ if (import.meta.main) {
|
|
|
315
334
|
const ok = await runReleaseVerification(process.cwd(), {
|
|
316
335
|
mode,
|
|
317
336
|
sharedRunner: process.argv.includes("--shared-runner"),
|
|
337
|
+
requireScripts: true,
|
|
318
338
|
json: process.argv.includes("--json"),
|
|
319
339
|
})
|
|
320
340
|
if (!ok) process.exitCode = 1
|
package/src/review.ts
CHANGED
|
@@ -152,6 +152,8 @@ export const CHECK_CODES: Readonly<Record<string, ReviewCheckId>> = {
|
|
|
152
152
|
"NF-C038": "configuration",
|
|
153
153
|
"NF-C039": "contract-witness",
|
|
154
154
|
"NF-C040": "route-boundary",
|
|
155
|
+
"NF-C041": "route-boundary",
|
|
156
|
+
"NF-C042": "route-boundary",
|
|
155
157
|
"NF-D001": "dependency",
|
|
156
158
|
"NF-D002": "dependency",
|
|
157
159
|
"NF-A001": "route-assurance",
|
|
@@ -456,8 +458,11 @@ async function diffScope(cwd: string, ref: string): Promise<ReviewScope> {
|
|
|
456
458
|
if (!verified.ok) throw new ReviewInputError("invalid git ref")
|
|
457
459
|
const commit = verified.stdout.trim()
|
|
458
460
|
if (!/^[0-9a-f]{40,64}$/.test(commit)) throw new ReviewInputError("invalid git ref")
|
|
461
|
+
// `--relative` keeps tracked paths app-relative, like `ls-files` and every finding, when the app is a
|
|
462
|
+
// subdirectory of the repository; without it no finding in a changed tracked file is in scope.
|
|
459
463
|
const changed = await runGit(cwd, [
|
|
460
464
|
"diff",
|
|
465
|
+
"--relative",
|
|
461
466
|
"--name-only",
|
|
462
467
|
"-z",
|
|
463
468
|
"--diff-filter=ACDMRTUXB",
|
package/src/rules/codes.ts
CHANGED
|
@@ -31,12 +31,14 @@ export const RULE_CODES = Object.freeze({
|
|
|
31
31
|
"NF-C032": "Credential in browser code",
|
|
32
32
|
"NF-C033": "Generated output schema out of date",
|
|
33
33
|
"NF-C034": "Root status page wraps the root layout itself",
|
|
34
|
-
"NF-C035": "Backend
|
|
34
|
+
"NF-C035": "Backend not served over HTTP",
|
|
35
35
|
"NF-C036": "Route path the static scan cannot read",
|
|
36
36
|
"NF-C037": "API-only declaration over a web app",
|
|
37
37
|
"NF-C038": "Retired root layout file",
|
|
38
38
|
"NF-C039": "Implemented contract operation the backend does not serve",
|
|
39
39
|
"NF-C040": "Layout meta reads data its layout never loads",
|
|
40
|
+
"NF-C041": "Server-only export in a route frontend file",
|
|
41
|
+
"NF-C042": "Layout loader refuses access without a gate",
|
|
40
42
|
"NF-C020": "Island enhancer cleanup check",
|
|
41
43
|
"NF-C021": "nano binding cleanup check",
|
|
42
44
|
"NF-C022": "nano bindList key check",
|
package/src/rules/data-guard.ts
CHANGED
|
@@ -264,6 +264,32 @@ export function missingOutputSchemas(content: string): MissingOutputSchema[] {
|
|
|
264
264
|
return missing
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
const GENERATED_LINE = /^[ \t]*\/\/ nifra:generated [0-9a-f]{8}\b/
|
|
268
|
+
const OPEN_DRAFT = /\bt\.json\(\)\s*\/\*\s*(any|unknown) in the source type\s*\*\//g
|
|
269
|
+
|
|
270
|
+
/** Where a generated output schema (the export under a `nifra:generated` line) admits any value: the
|
|
271
|
+
* note `nifra schemas` writes beside a `t.json()` drafted from `any` or `unknown`. */
|
|
272
|
+
export function openDraftLines(content: string): Array<{ line: number; type: string }> {
|
|
273
|
+
const found: Array<{ line: number; type: string }> = []
|
|
274
|
+
let marked = false
|
|
275
|
+
let generated = false
|
|
276
|
+
for (const [index, text] of content.split("\n").entries()) {
|
|
277
|
+
if (GENERATED_LINE.test(text)) {
|
|
278
|
+
marked = true
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
if (/^export\s/.test(text)) {
|
|
282
|
+
generated = marked
|
|
283
|
+
marked = false
|
|
284
|
+
}
|
|
285
|
+
if (!generated) continue
|
|
286
|
+
for (const match of text.matchAll(OPEN_DRAFT)) {
|
|
287
|
+
found.push({ line: index + 1, type: match[1] ?? "any" })
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return found
|
|
291
|
+
}
|
|
292
|
+
|
|
267
293
|
export const missingOutputRule: CheckRule = {
|
|
268
294
|
code: "NF-C030",
|
|
269
295
|
title: "Route data without an output schema",
|
|
@@ -289,10 +315,24 @@ export const missingOutputRule: CheckRule = {
|
|
|
289
315
|
`missing: ${schema}`,
|
|
290
316
|
...(proven ? ["returns: a data literal"] : []),
|
|
291
317
|
],
|
|
318
|
+
fix: { recipe: "schemas.draft", command: "nifra fix --code NF-C030" },
|
|
292
319
|
verify: "nifra check",
|
|
293
320
|
}),
|
|
294
321
|
)
|
|
295
322
|
}
|
|
323
|
+
for (const { line, type } of openDraftLines(content)) {
|
|
324
|
+
findings.push(
|
|
325
|
+
diagnostic({
|
|
326
|
+
code: "NF-C030",
|
|
327
|
+
severity: "warn",
|
|
328
|
+
file,
|
|
329
|
+
line,
|
|
330
|
+
message: `${file}'s generated output schema admits any value here, because the type it was drafted from is ${type} at this point: the output guard drops nothing below it. Give that value a type, then run \`nifra schemas --write\`.`,
|
|
331
|
+
evidence: [`draft: t.json() from ${type}`],
|
|
332
|
+
verify: "nifra schemas --check",
|
|
333
|
+
}),
|
|
334
|
+
)
|
|
335
|
+
}
|
|
296
336
|
}
|
|
297
337
|
return findings.sort(byPlace)
|
|
298
338
|
},
|
package/src/rules/legacy.ts
CHANGED
|
@@ -345,6 +345,25 @@ const typecheckRule: CheckRule = {
|
|
|
345
345
|
}),
|
|
346
346
|
)
|
|
347
347
|
}
|
|
348
|
+
if (tc.sourcesWithoutTsconfig !== undefined) {
|
|
349
|
+
findings.push(
|
|
350
|
+
legacyDiagnostic("typecheck", {
|
|
351
|
+
severity: "error",
|
|
352
|
+
file: "tsconfig.json",
|
|
353
|
+
message: `${tc.sourcesWithoutTsconfig} TypeScript file(s) but no tsconfig.json in this directory - the typecheck gate did NOT run`,
|
|
354
|
+
fix: "add a tsconfig.json that includes the project's sources",
|
|
355
|
+
suggestion: {
|
|
356
|
+
kind: "manual",
|
|
357
|
+
title: "Add a tsconfig.json so the contract gate can run",
|
|
358
|
+
steps: [
|
|
359
|
+
"Create tsconfig.json in the directory `nifra check` runs from, with `include` covering the project's TypeScript sources.",
|
|
360
|
+
"In a monorepo, run `nifra check` from each app that has its own tsconfig.json.",
|
|
361
|
+
"Run `nifra_check` again: the typecheck now runs `tsc --noEmit -p tsconfig.json`.",
|
|
362
|
+
],
|
|
363
|
+
},
|
|
364
|
+
}),
|
|
365
|
+
)
|
|
366
|
+
}
|
|
348
367
|
if (tc.ran && !tc.ok) {
|
|
349
368
|
const lines = (tc.output ?? "").split("\n")
|
|
350
369
|
let matched = false
|
|
@@ -1083,7 +1102,9 @@ const contractRule: CheckRule = {
|
|
|
1083
1102
|
}),
|
|
1084
1103
|
]
|
|
1085
1104
|
}
|
|
1086
|
-
|
|
1105
|
+
// `vacuous` describes the routes as they are now, so it only stands in for drift when the lock has
|
|
1106
|
+
// none: a lock whose schemas were all deleted is drift, not an empty contract.
|
|
1107
|
+
if (contract.vacuous && contract.error === undefined && contract.diagnostics.length === 0) {
|
|
1087
1108
|
return [
|
|
1088
1109
|
diagnostic({
|
|
1089
1110
|
code: LEGACY_RULE_CODES["contract-drift"]!,
|