@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.
Files changed (202) hide show
  1. package/README.md +7 -2
  2. package/dist/assure-hydration.d.ts.map +1 -1
  3. package/dist/assure-hydration.js +4 -2
  4. package/dist/assure-hydration.js.map +1 -1
  5. package/dist/assure.d.ts +12 -2
  6. package/dist/assure.d.ts.map +1 -1
  7. package/dist/assure.js +59 -8
  8. package/dist/assure.js.map +1 -1
  9. package/dist/capabilities-tool.d.ts.map +1 -1
  10. package/dist/capabilities-tool.js +24 -7
  11. package/dist/capabilities-tool.js.map +1 -1
  12. package/dist/check-diagnostics.d.ts +2 -0
  13. package/dist/check-diagnostics.d.ts.map +1 -1
  14. package/dist/check-diagnostics.js.map +1 -1
  15. package/dist/check-scan.d.ts +8 -2
  16. package/dist/check-scan.d.ts.map +1 -1
  17. package/dist/check-scan.js +43 -10
  18. package/dist/check-scan.js.map +1 -1
  19. package/dist/check.d.ts.map +1 -1
  20. package/dist/check.js +39 -33
  21. package/dist/check.js.map +1 -1
  22. package/dist/cli.d.ts +2 -0
  23. package/dist/cli.d.ts.map +1 -1
  24. package/dist/cli.js +106 -48
  25. package/dist/cli.js.map +1 -1
  26. package/dist/client-module.d.ts +5 -3
  27. package/dist/client-module.d.ts.map +1 -1
  28. package/dist/client-module.js +34 -10
  29. package/dist/client-module.js.map +1 -1
  30. package/dist/command-catalog.d.ts +9 -1
  31. package/dist/command-catalog.d.ts.map +1 -1
  32. package/dist/command-catalog.js +67 -22
  33. package/dist/command-catalog.js.map +1 -1
  34. package/dist/dev-feed-tool.d.ts +18 -1
  35. package/dist/dev-feed-tool.d.ts.map +1 -1
  36. package/dist/dev-feed-tool.js +93 -0
  37. package/dist/dev-feed-tool.js.map +1 -1
  38. package/dist/fix-recipes.d.ts +2 -0
  39. package/dist/fix-recipes.d.ts.map +1 -1
  40. package/dist/fix-recipes.js +93 -12
  41. package/dist/fix-recipes.js.map +1 -1
  42. package/dist/init-agents.d.ts +16 -0
  43. package/dist/init-agents.d.ts.map +1 -1
  44. package/dist/init-agents.js +3 -2
  45. package/dist/init-agents.js.map +1 -1
  46. package/dist/internal/config-paths.d.ts +16 -0
  47. package/dist/internal/config-paths.d.ts.map +1 -0
  48. package/dist/internal/config-paths.js +124 -0
  49. package/dist/internal/config-paths.js.map +1 -0
  50. package/dist/internal/installed-package.d.ts +4 -0
  51. package/dist/internal/installed-package.d.ts.map +1 -0
  52. package/dist/internal/installed-package.js +17 -0
  53. package/dist/internal/installed-package.js.map +1 -0
  54. package/dist/internal/project-specifiers.d.ts +14 -1
  55. package/dist/internal/project-specifiers.d.ts.map +1 -1
  56. package/dist/internal/project-specifiers.js +54 -15
  57. package/dist/internal/project-specifiers.js.map +1 -1
  58. package/dist/internal/source-facts.d.ts +6 -1
  59. package/dist/internal/source-facts.d.ts.map +1 -1
  60. package/dist/internal/source-facts.js +11 -5
  61. package/dist/internal/source-facts.js.map +1 -1
  62. package/dist/internal/typescript-import.d.ts.map +1 -1
  63. package/dist/internal/typescript-import.js +5 -3
  64. package/dist/internal/typescript-import.js.map +1 -1
  65. package/dist/introspect.d.ts +9 -0
  66. package/dist/introspect.d.ts.map +1 -1
  67. package/dist/introspect.js +32 -1
  68. package/dist/introspect.js.map +1 -1
  69. package/dist/levels-tool.d.ts.map +1 -1
  70. package/dist/levels-tool.js +5 -8
  71. package/dist/levels-tool.js.map +1 -1
  72. package/dist/load.d.ts +2 -2
  73. package/dist/load.d.ts.map +1 -1
  74. package/dist/load.js +23 -3
  75. package/dist/load.js.map +1 -1
  76. package/dist/mcp-context.d.ts.map +1 -1
  77. package/dist/mcp-context.js +6 -21
  78. package/dist/mcp-context.js.map +1 -1
  79. package/dist/mcp-delegate.d.ts +2 -2
  80. package/dist/mcp-delegate.d.ts.map +1 -1
  81. package/dist/mcp-delegate.js +19 -7
  82. package/dist/mcp-delegate.js.map +1 -1
  83. package/dist/mcp-exec.d.ts +1 -0
  84. package/dist/mcp-exec.d.ts.map +1 -1
  85. package/dist/mcp-exec.js +8 -3
  86. package/dist/mcp-exec.js.map +1 -1
  87. package/dist/mcp-http.js +1 -1
  88. package/dist/mcp-io.d.ts +13 -0
  89. package/dist/mcp-io.d.ts.map +1 -1
  90. package/dist/mcp-io.js +23 -0
  91. package/dist/mcp-io.js.map +1 -1
  92. package/dist/mcp-path.d.ts +2 -1
  93. package/dist/mcp-path.d.ts.map +1 -1
  94. package/dist/mcp-path.js +9 -24
  95. package/dist/mcp-path.js.map +1 -1
  96. package/dist/mcp.d.ts.map +1 -1
  97. package/dist/mcp.js +40 -17
  98. package/dist/mcp.js.map +1 -1
  99. package/dist/migrate-layout.d.ts +40 -1
  100. package/dist/migrate-layout.d.ts.map +1 -1
  101. package/dist/migrate-layout.js +1316 -228
  102. package/dist/migrate-layout.js.map +1 -1
  103. package/dist/output-schemas.d.ts +34 -0
  104. package/dist/output-schemas.d.ts.map +1 -1
  105. package/dist/output-schemas.js +132 -21
  106. package/dist/output-schemas.js.map +1 -1
  107. package/dist/project-path.d.ts +6 -0
  108. package/dist/project-path.d.ts.map +1 -1
  109. package/dist/project-path.js +48 -14
  110. package/dist/project-path.js.map +1 -1
  111. package/dist/release-verification.d.ts +5 -0
  112. package/dist/release-verification.d.ts.map +1 -1
  113. package/dist/release-verification.js +14 -0
  114. package/dist/release-verification.js.map +1 -1
  115. package/dist/review.d.ts.map +1 -1
  116. package/dist/review.js +5 -0
  117. package/dist/review.js.map +1 -1
  118. package/dist/rules/codes.d.ts +3 -1
  119. package/dist/rules/codes.d.ts.map +1 -1
  120. package/dist/rules/codes.js +3 -1
  121. package/dist/rules/codes.js.map +1 -1
  122. package/dist/rules/data-guard.d.ts +6 -0
  123. package/dist/rules/data-guard.d.ts.map +1 -1
  124. package/dist/rules/data-guard.js +37 -0
  125. package/dist/rules/data-guard.js.map +1 -1
  126. package/dist/rules/legacy.d.ts.map +1 -1
  127. package/dist/rules/legacy.js +20 -1
  128. package/dist/rules/legacy.js.map +1 -1
  129. package/dist/rules/pages.d.ts +29 -3
  130. package/dist/rules/pages.d.ts.map +1 -1
  131. package/dist/rules/pages.js +236 -22
  132. package/dist/rules/pages.js.map +1 -1
  133. package/dist/rules/security.d.ts +3 -2
  134. package/dist/rules/security.d.ts.map +1 -1
  135. package/dist/rules/security.js +42 -11
  136. package/dist/rules/security.js.map +1 -1
  137. package/dist/rules/zones.d.ts.map +1 -1
  138. package/dist/rules/zones.js +31 -9
  139. package/dist/rules/zones.js.map +1 -1
  140. package/dist/stylex-migrate.d.ts.map +1 -1
  141. package/dist/stylex-migrate.js +127 -17
  142. package/dist/stylex-migrate.js.map +1 -1
  143. package/dist/test-tool.d.ts.map +1 -1
  144. package/dist/test-tool.js +3 -9
  145. package/dist/test-tool.js.map +1 -1
  146. package/dist/upgrade.d.ts +20 -5
  147. package/dist/upgrade.d.ts.map +1 -1
  148. package/dist/upgrade.js +86 -9
  149. package/dist/upgrade.js.map +1 -1
  150. package/dist/verification-plan.d.ts.map +1 -1
  151. package/dist/verification-plan.js +5 -2
  152. package/dist/verification-plan.js.map +1 -1
  153. package/dist/version-drift.d.ts.map +1 -1
  154. package/dist/version-drift.js +55 -3
  155. package/dist/version-drift.js.map +1 -1
  156. package/docs/examples.json +2 -2
  157. package/docs/llms-full.txt +481 -61
  158. package/docs/types.json +147 -70
  159. package/package.json +11 -11
  160. package/src/assure-hydration.ts +4 -1
  161. package/src/assure.ts +88 -10
  162. package/src/capabilities-tool.ts +33 -7
  163. package/src/check-diagnostics.ts +2 -0
  164. package/src/check-scan.ts +56 -9
  165. package/src/check.ts +55 -39
  166. package/src/cli.ts +104 -35
  167. package/src/client-module.ts +45 -9
  168. package/src/command-catalog.ts +77 -21
  169. package/src/dev-feed-tool.ts +112 -1
  170. package/src/fix-recipes.ts +101 -10
  171. package/src/init-agents.ts +4 -3
  172. package/src/internal/config-paths.ts +154 -0
  173. package/src/internal/installed-package.ts +15 -0
  174. package/src/internal/project-specifiers.ts +69 -16
  175. package/src/internal/source-facts.ts +23 -5
  176. package/src/internal/typescript-import.ts +5 -3
  177. package/src/introspect.ts +39 -1
  178. package/src/levels-tool.ts +7 -9
  179. package/src/load.ts +29 -3
  180. package/src/mcp-context.ts +5 -18
  181. package/src/mcp-delegate.ts +22 -10
  182. package/src/mcp-exec.ts +9 -2
  183. package/src/mcp-http.ts +1 -1
  184. package/src/mcp-io.ts +33 -0
  185. package/src/mcp-path.ts +8 -22
  186. package/src/mcp.ts +48 -19
  187. package/src/migrate-layout.ts +1496 -237
  188. package/src/output-schemas.ts +179 -23
  189. package/src/project-path.ts +44 -13
  190. package/src/release-verification.ts +20 -0
  191. package/src/review.ts +5 -0
  192. package/src/rules/codes.ts +3 -1
  193. package/src/rules/data-guard.ts +40 -0
  194. package/src/rules/legacy.ts +22 -1
  195. package/src/rules/pages.ts +266 -24
  196. package/src/rules/security.ts +49 -11
  197. package/src/rules/zones.ts +30 -7
  198. package/src/stylex-migrate.ts +117 -17
  199. package/src/test-tool.ts +3 -9
  200. package/src/upgrade.ts +96 -8
  201. package/src/verification-plan.ts +9 -2
  202. package/src/version-drift.ts +53 -2
@@ -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. Delete this line to edit it by hand.`,
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(root: string, routes: readonly ProbeRoute[]): Promise<ProbeResult> {
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 routeFiles) contents.set(file, readFileSync(file, "utf8"))
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: [...routeFiles, probePath],
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.createCompilerHost(options, true))
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 Plan {
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
- /** What each listed route backend half needs, and the file contents that would apply it. */
882
- async function plan(
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
- ): Promise<Plan> {
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 probe: ProbeRoute[] = [...perFile].map(([file, { targets }]) => ({
895
- file,
896
- fns: targets.map((target) => target.fn),
897
- schemas: targets.filter((t) => t.declaration !== undefined).map((t) => t.schema),
898
- }))
899
- const { shapes, warnings } = await readShapes(root, probe)
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
- return entries.push(item) - 1
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
- const at = entry("missing")
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 plan(dir, routeBackends(dir), (file) => {
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: warnings.length === 0 && !final.some((entry) => entry.status === "stale"),
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 plan(root, marked, read)
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"),
@@ -1,6 +1,45 @@
1
- import { realpath } from "node:fs/promises"
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
- const realRoot = await realpath(resolvedRoot)
21
- let realCandidate: string
22
- try {
23
- realCandidate = await realpath(resolved)
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",
@@ -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 mounted at the implicit /api",
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",
@@ -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
  },
@@ -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
- if (contract.vacuous) {
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"]!,