@nifrajs/cli 2.10.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +7 -1
  2. package/dist/assure-hydration.d.ts +14 -0
  3. package/dist/assure-hydration.d.ts.map +1 -0
  4. package/dist/assure-hydration.js +483 -0
  5. package/dist/assure-hydration.js.map +1 -0
  6. package/dist/assure.d.ts +35 -0
  7. package/dist/assure.d.ts.map +1 -1
  8. package/dist/assure.js +222 -1
  9. package/dist/assure.js.map +1 -1
  10. package/dist/capabilities-tool.d.ts +13 -0
  11. package/dist/capabilities-tool.d.ts.map +1 -1
  12. package/dist/capabilities-tool.js +64 -1
  13. package/dist/capabilities-tool.js.map +1 -1
  14. package/dist/check.d.ts +133 -10
  15. package/dist/check.d.ts.map +1 -1
  16. package/dist/check.js +741 -148
  17. package/dist/check.js.map +1 -1
  18. package/dist/cli.d.ts +21 -14
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +288 -73
  21. package/dist/cli.js.map +1 -1
  22. package/dist/contract-proof.d.ts +49 -0
  23. package/dist/contract-proof.d.ts.map +1 -0
  24. package/dist/contract-proof.js +128 -0
  25. package/dist/contract-proof.js.map +1 -0
  26. package/dist/contracts.d.ts +22 -0
  27. package/dist/contracts.d.ts.map +1 -0
  28. package/dist/contracts.js +134 -0
  29. package/dist/contracts.js.map +1 -0
  30. package/dist/dev-bun-config.d.ts +17 -6
  31. package/dist/dev-bun-config.d.ts.map +1 -1
  32. package/dist/dev-bun-config.js +51 -14
  33. package/dist/dev-bun-config.js.map +1 -1
  34. package/dist/diagnostics.d.ts +23 -0
  35. package/dist/diagnostics.d.ts.map +1 -0
  36. package/dist/diagnostics.js +9 -0
  37. package/dist/diagnostics.js.map +1 -0
  38. package/dist/diff-tool.d.ts +2 -0
  39. package/dist/diff-tool.d.ts.map +1 -1
  40. package/dist/diff-tool.js +1 -1
  41. package/dist/diff-tool.js.map +1 -1
  42. package/dist/docs-search.d.ts +8 -3
  43. package/dist/docs-search.d.ts.map +1 -1
  44. package/dist/docs-search.js +36 -15
  45. package/dist/docs-search.js.map +1 -1
  46. package/dist/doctor.d.ts +38 -2
  47. package/dist/doctor.d.ts.map +1 -1
  48. package/dist/doctor.js +189 -7
  49. package/dist/doctor.js.map +1 -1
  50. package/dist/examples.d.ts +1 -2
  51. package/dist/examples.d.ts.map +1 -1
  52. package/dist/examples.js +37 -16
  53. package/dist/examples.js.map +1 -1
  54. package/dist/fix-recipes.d.ts +12 -0
  55. package/dist/fix-recipes.d.ts.map +1 -0
  56. package/dist/fix-recipes.js +106 -0
  57. package/dist/fix-recipes.js.map +1 -0
  58. package/dist/init-agents.d.ts.map +1 -1
  59. package/dist/init-agents.js +60 -4
  60. package/dist/init-agents.js.map +1 -1
  61. package/dist/internal/source-facts.d.ts +15 -0
  62. package/dist/internal/source-facts.d.ts.map +1 -0
  63. package/dist/internal/source-facts.js +82 -0
  64. package/dist/internal/source-facts.js.map +1 -0
  65. package/dist/internal/typescript-import.d.ts +9 -0
  66. package/dist/internal/typescript-import.d.ts.map +1 -1
  67. package/dist/internal/typescript-import.js +38 -0
  68. package/dist/internal/typescript-import.js.map +1 -1
  69. package/dist/introspect.d.ts +6 -2
  70. package/dist/introspect.d.ts.map +1 -1
  71. package/dist/introspect.js +60 -5
  72. package/dist/introspect.js.map +1 -1
  73. package/dist/load.d.ts +10 -0
  74. package/dist/load.d.ts.map +1 -1
  75. package/dist/load.js +1 -0
  76. package/dist/load.js.map +1 -1
  77. package/dist/manifest-tool.d.ts.map +1 -1
  78. package/dist/manifest-tool.js +14 -9
  79. package/dist/manifest-tool.js.map +1 -1
  80. package/dist/mcp-docs-tools.d.ts.map +1 -1
  81. package/dist/mcp-docs-tools.js +3 -0
  82. package/dist/mcp-docs-tools.js.map +1 -1
  83. package/dist/mcp-http.js +1 -1
  84. package/dist/mcp-root.d.ts +83 -0
  85. package/dist/mcp-root.d.ts.map +1 -0
  86. package/dist/mcp-root.js +221 -0
  87. package/dist/mcp-root.js.map +1 -0
  88. package/dist/mcp-run.d.ts.map +1 -1
  89. package/dist/mcp-run.js +18 -4
  90. package/dist/mcp-run.js.map +1 -1
  91. package/dist/mcp.d.ts +27 -4
  92. package/dist/mcp.d.ts.map +1 -1
  93. package/dist/mcp.js +360 -27
  94. package/dist/mcp.js.map +1 -1
  95. package/dist/pipeline-guard.d.ts +25 -10
  96. package/dist/pipeline-guard.d.ts.map +1 -1
  97. package/dist/pipeline-guard.js +35 -15
  98. package/dist/pipeline-guard.js.map +1 -1
  99. package/dist/pipeline-report.d.ts +34 -0
  100. package/dist/pipeline-report.d.ts.map +1 -0
  101. package/dist/pipeline-report.js +282 -0
  102. package/dist/pipeline-report.js.map +1 -0
  103. package/dist/project-path.d.ts +3 -0
  104. package/dist/project-path.d.ts.map +1 -0
  105. package/dist/project-path.js +36 -0
  106. package/dist/project-path.js.map +1 -0
  107. package/dist/replay.d.ts +12 -0
  108. package/dist/replay.d.ts.map +1 -0
  109. package/dist/replay.js +36 -0
  110. package/dist/replay.js.map +1 -0
  111. package/dist/rules/codes.d.ts +42 -0
  112. package/dist/rules/codes.d.ts.map +1 -0
  113. package/dist/rules/codes.js +52 -0
  114. package/dist/rules/codes.js.map +1 -0
  115. package/dist/rules/comment-markers.d.ts +10 -0
  116. package/dist/rules/comment-markers.d.ts.map +1 -0
  117. package/dist/rules/comment-markers.js +24 -0
  118. package/dist/rules/comment-markers.js.map +1 -0
  119. package/dist/rules/index.d.ts +31 -0
  120. package/dist/rules/index.d.ts.map +1 -0
  121. package/dist/rules/index.js +133 -0
  122. package/dist/rules/index.js.map +1 -0
  123. package/dist/rules/legacy.d.ts +7 -0
  124. package/dist/rules/legacy.d.ts.map +1 -0
  125. package/dist/rules/legacy.js +85 -0
  126. package/dist/rules/legacy.js.map +1 -0
  127. package/dist/rules/routes.d.ts +12 -0
  128. package/dist/rules/routes.d.ts.map +1 -0
  129. package/dist/rules/routes.js +142 -0
  130. package/dist/rules/routes.js.map +1 -0
  131. package/dist/rules/security.d.ts +10 -0
  132. package/dist/rules/security.d.ts.map +1 -0
  133. package/dist/rules/security.js +471 -0
  134. package/dist/rules/security.js.map +1 -0
  135. package/dist/search-terms.d.ts +2 -0
  136. package/dist/search-terms.d.ts.map +1 -1
  137. package/dist/search-terms.js +5 -1
  138. package/dist/search-terms.js.map +1 -1
  139. package/dist/types-search.d.ts +1 -2
  140. package/dist/types-search.d.ts.map +1 -1
  141. package/dist/types-search.js +33 -16
  142. package/dist/types-search.js.map +1 -1
  143. package/dist/verification.d.ts +9 -0
  144. package/dist/verification.d.ts.map +1 -1
  145. package/dist/verification.js +17 -1
  146. package/dist/verification.js.map +1 -1
  147. package/dist/work-graph.d.ts +123 -0
  148. package/dist/work-graph.d.ts.map +1 -0
  149. package/dist/work-graph.js +465 -0
  150. package/dist/work-graph.js.map +1 -0
  151. package/docs/examples.json +32 -2
  152. package/docs/llms-full.txt +1105 -27
  153. package/docs/types.json +1772 -137
  154. package/package.json +34 -9
  155. package/src/assure-hydration.ts +606 -0
  156. package/src/assure.ts +316 -2
  157. package/src/capabilities-tool.ts +80 -1
  158. package/src/check.ts +923 -169
  159. package/src/cli.ts +297 -76
  160. package/src/contract-proof.ts +194 -0
  161. package/src/contracts.ts +157 -0
  162. package/src/dev-bun-config.ts +57 -14
  163. package/src/diagnostics.ts +32 -0
  164. package/src/diff-tool.ts +1 -1
  165. package/src/docs-search.ts +45 -15
  166. package/src/doctor.ts +286 -8
  167. package/src/examples.ts +43 -15
  168. package/src/fix-recipes.ts +126 -0
  169. package/src/init-agents.ts +61 -4
  170. package/src/internal/source-facts.ts +133 -0
  171. package/src/internal/typescript-import.ts +29 -0
  172. package/src/introspect.ts +81 -6
  173. package/src/load.ts +11 -0
  174. package/src/manifest-tool.ts +16 -10
  175. package/src/mcp-docs-tools.ts +3 -0
  176. package/src/mcp-http.ts +1 -1
  177. package/src/mcp-root.ts +271 -0
  178. package/src/mcp-run.ts +19 -4
  179. package/src/mcp.ts +440 -34
  180. package/src/pipeline-guard.ts +44 -14
  181. package/src/pipeline-report.ts +341 -0
  182. package/src/project-path.ts +36 -0
  183. package/src/replay.ts +44 -0
  184. package/src/rules/codes.ts +57 -0
  185. package/src/rules/comment-markers.ts +26 -0
  186. package/src/rules/index.ts +175 -0
  187. package/src/rules/legacy.ts +93 -0
  188. package/src/rules/routes.ts +157 -0
  189. package/src/rules/security.ts +505 -0
  190. package/src/search-terms.ts +9 -4
  191. package/src/types-search.ts +39 -15
  192. package/src/verification.ts +18 -1
  193. package/src/work-graph.ts +613 -0
@@ -0,0 +1,175 @@
1
+ import type { Diagnostic } from "../diagnostics.ts"
2
+ import { isBuiltInCode } from "./codes.ts"
3
+
4
+ export interface SourceFile {
5
+ readonly file: string
6
+ readonly content: string
7
+ }
8
+
9
+ export interface SourceIndex {
10
+ readonly files: readonly string[]
11
+ read(file: string): string | undefined
12
+ }
13
+
14
+ export interface ProjectFacts {
15
+ readonly [key: string]: unknown
16
+ }
17
+
18
+ export interface RuleContext {
19
+ readonly root: string
20
+ readonly sources: SourceIndex
21
+ readonly project: ProjectFacts
22
+ }
23
+
24
+ export interface CheckRule {
25
+ readonly code: string
26
+ readonly title: string
27
+ scan(ctx: RuleContext): Promise<readonly Diagnostic[]>
28
+ }
29
+
30
+ export interface RulePack {
31
+ readonly name: string
32
+ readonly rules: readonly CheckRule[]
33
+ }
34
+
35
+ export function validateRulePacks(packs: readonly RulePack[] | undefined): readonly RulePack[] {
36
+ if (packs === undefined) return []
37
+ const names = new Set<string>()
38
+ const codes = new Set<string>()
39
+ const normalized: RulePack[] = []
40
+ for (const pack of packs) {
41
+ if (typeof pack?.name !== "string" || pack.name.trim() === "")
42
+ throw new Error("rule pack name must be a non-empty string")
43
+ if (names.has(pack.name)) throw new Error(`duplicate rule pack: ${pack.name}`)
44
+ names.add(pack.name)
45
+ if (!Array.isArray(pack.rules)) throw new Error(`rule pack ${pack.name} must contain rules`)
46
+ const rules: CheckRule[] = []
47
+ for (const rule of pack.rules) {
48
+ if (
49
+ typeof rule?.code !== "string" ||
50
+ typeof rule.title !== "string" ||
51
+ typeof rule.scan !== "function"
52
+ ) {
53
+ throw new Error(`rule pack ${pack.name} contains an invalid rule`)
54
+ }
55
+ if (rule.code.startsWith("NF-"))
56
+ throw new Error(`application rule pack code ${rule.code} uses the reserved NF- prefix`)
57
+ if (codes.has(rule.code)) throw new Error(`duplicate application rule code: ${rule.code}`)
58
+ codes.add(rule.code)
59
+ rules.push(rule)
60
+ }
61
+ normalized.push(Object.freeze({ name: pack.name, rules: Object.freeze(rules) }))
62
+ }
63
+ return Object.freeze(normalized)
64
+ }
65
+
66
+ export function parseRulePacks(value: unknown): readonly RulePack[] {
67
+ if (value === undefined) return []
68
+ if (!Array.isArray(value)) throw new TypeError("rulePacks must be an array")
69
+ const packs: RulePack[] = []
70
+ for (const rawPack of value) {
71
+ if (typeof rawPack !== "object" || rawPack === null || Array.isArray(rawPack))
72
+ throw new TypeError("rule pack must be an object")
73
+ const packRecord = Object.fromEntries(Object.entries(rawPack))
74
+ const name = packRecord.name
75
+ const rawRules = packRecord.rules
76
+ if (typeof name !== "string" || !Array.isArray(rawRules))
77
+ throw new TypeError("rule pack requires a name and rules array")
78
+ const rules: CheckRule[] = []
79
+ for (const rawRule of rawRules) {
80
+ if (typeof rawRule !== "object" || rawRule === null || Array.isArray(rawRule))
81
+ throw new TypeError(`rule pack ${name} contains an invalid rule`)
82
+ const ruleRecord = Object.fromEntries(Object.entries(rawRule))
83
+ if (
84
+ typeof ruleRecord.code !== "string" ||
85
+ typeof ruleRecord.title !== "string" ||
86
+ typeof ruleRecord.scan !== "function"
87
+ ) {
88
+ throw new TypeError(`rule pack ${name} contains an invalid rule`)
89
+ }
90
+ const scan = ruleRecord.scan
91
+ rules.push({
92
+ code: ruleRecord.code,
93
+ title: ruleRecord.title,
94
+ scan: async (ctx) => {
95
+ const result: unknown = await Reflect.apply(scan, undefined, [ctx])
96
+ if (!Array.isArray(result))
97
+ throw new TypeError(`rule ${ruleRecord.code} did not return diagnostics`)
98
+ const findings: Diagnostic[] = []
99
+ for (const item of result) {
100
+ if (typeof item !== "object" || item === null || Array.isArray(item))
101
+ throw new TypeError(`rule ${ruleRecord.code} returned an invalid diagnostic`)
102
+ const finding = Object.fromEntries(Object.entries(item))
103
+ if (
104
+ typeof finding.code !== "string" ||
105
+ (finding.severity !== "error" &&
106
+ finding.severity !== "warn" &&
107
+ finding.severity !== "info") ||
108
+ typeof finding.message !== "string"
109
+ ) {
110
+ throw new TypeError(`rule ${ruleRecord.code} returned an invalid diagnostic`)
111
+ }
112
+ const code = finding.code
113
+ const severity = finding.severity
114
+ const message = finding.message
115
+ const rawFix =
116
+ typeof finding.fix === "object" && finding.fix !== null && !Array.isArray(finding.fix)
117
+ ? Object.fromEntries(Object.entries(finding.fix))
118
+ : undefined
119
+ findings.push({
120
+ code,
121
+ severity,
122
+ message,
123
+ ...(typeof finding.file === "string" ? { file: finding.file } : {}),
124
+ ...(typeof finding.line === "number" ? { line: finding.line } : {}),
125
+ ...(Array.isArray(finding.evidence)
126
+ ? {
127
+ evidence: finding.evidence.filter(
128
+ (item): item is string => typeof item === "string",
129
+ ),
130
+ }
131
+ : {}),
132
+ ...(typeof rawFix?.recipe === "string"
133
+ ? {
134
+ fix: {
135
+ recipe: rawFix.recipe,
136
+ ...(typeof rawFix.command === "string" ? { command: rawFix.command } : {}),
137
+ },
138
+ }
139
+ : {}),
140
+ ...(typeof finding.verify === "string" ? { verify: finding.verify } : {}),
141
+ })
142
+ }
143
+ return findings
144
+ },
145
+ })
146
+ }
147
+ packs.push({ name, rules })
148
+ }
149
+ return validateRulePacks(packs)
150
+ }
151
+
152
+ export async function runRuleRegistry(
153
+ ctx: RuleContext,
154
+ builtIns: readonly CheckRule[],
155
+ packs: readonly RulePack[] = [],
156
+ ): Promise<Diagnostic[]> {
157
+ const rules = [...builtIns, ...validateRulePacks(packs).flatMap((pack) => pack.rules)]
158
+ const out: Diagnostic[] = []
159
+ for (const rule of rules) {
160
+ if (isBuiltInCode(rule.code) === false && rule.code.startsWith("NF-")) {
161
+ throw new Error(`application rule pack code ${rule.code} uses the reserved NF- prefix`)
162
+ }
163
+ const findings = await rule.scan(ctx)
164
+ out.push(...findings)
165
+ }
166
+ return out
167
+ }
168
+
169
+ export function sourceIndex(files: readonly SourceFile[]): SourceIndex {
170
+ const map = new Map(files.map((file) => [file.file, file.content]))
171
+ return Object.freeze({
172
+ files: Object.freeze(files.map((file) => file.file)),
173
+ read: (file: string) => map.get(file),
174
+ })
175
+ }
@@ -0,0 +1,93 @@
1
+ import type { CheckDiagnostic } from "../check.ts"
2
+ import type { Diagnostic, Severity } from "../diagnostics.ts"
3
+ import type { CheckRule, RuleContext } from "./index.ts"
4
+
5
+ /** Legacy rule name → stable NF- code. Exported so `nifra.check.json` rule overrides can be keyed
6
+ * by either name and still hit both diagnostic views. */
7
+ export const LEGACY_RULE_CODES: Readonly<Record<string, string>> = Object.freeze({
8
+ typecheck: "NF-C001",
9
+ "typed-client": "NF-C002",
10
+ "untyped-client": "NF-C003",
11
+ "server-only-import": "NF-C004",
12
+ "removed-import": "NF-C005",
13
+ "interpolated-sql": "NF-C006",
14
+ "response-route": "NF-C007",
15
+ "undeclared-dependency": "NF-C008",
16
+ "duplicate-install": "NF-C009",
17
+ "stale-workspace-dist": "NF-C010",
18
+ pipeline: "NF-C011",
19
+ "server-manifest-drift": "NF-C012",
20
+ "manifest-drift": "NF-C013",
21
+ "capability-assurance": "NF-C014",
22
+ "capability-config": "NF-C015",
23
+ "check-config": "NF-C016",
24
+ "contract-drift": "NF-K001",
25
+ })
26
+
27
+ const TITLES: Readonly<Record<string, string>> = Object.freeze({
28
+ typecheck: "TypeScript contract check",
29
+ "typed-client": "Typed client check",
30
+ "untyped-client": "Untyped client check",
31
+ "server-only-import": "Server-only import check",
32
+ "removed-import": "Removed import check",
33
+ "interpolated-sql": "Interpolated SQL check",
34
+ "response-route": "Raw response route advisory",
35
+ "undeclared-dependency": "Undeclared dependency check",
36
+ "duplicate-install": "Duplicate install check",
37
+ "stale-workspace-dist": "Stale workspace build check",
38
+ pipeline: "Build pipeline check",
39
+ "server-manifest-drift": "Server manifest drift check",
40
+ "manifest-drift": "Trust manifest drift check",
41
+ "capability-assurance": "Capability assurance check",
42
+ "capability-config": "Assurance configuration check",
43
+ "check-config": "Check configuration check",
44
+ "contract-drift": "Contract snapshot drift",
45
+ })
46
+
47
+ function recipeFor(finding: CheckDiagnostic): Diagnostic["fix"] {
48
+ if (finding.rule === "stale-workspace-dist")
49
+ return { recipe: "workspace-dist.rebuild", command: "nifra fix --code NF-C010" }
50
+ if (finding.rule === "server-manifest-drift")
51
+ return { recipe: "manifest.sync", command: "nifra sync-manifest" }
52
+ if (finding.rule === "contract-drift")
53
+ return { recipe: "contracts.snapshot", command: "nifra contracts snapshot" }
54
+ return undefined
55
+ }
56
+
57
+ function toDiagnostic(finding: CheckDiagnostic, code: string): Diagnostic {
58
+ const severity: Severity = finding.severity === "warning" ? "warn" : finding.severity
59
+ const evidence = finding.evidence ?? finding.chain
60
+ const fix = recipeFor(finding)
61
+ return Object.freeze({
62
+ code,
63
+ severity,
64
+ message: finding.message,
65
+ ...(finding.file === undefined ? {} : { file: finding.file }),
66
+ ...(finding.line === undefined ? {} : { line: finding.line }),
67
+ ...(evidence === undefined ? {} : { evidence }),
68
+ ...(fix === undefined ? {} : { fix }),
69
+ ...(finding.verify === undefined ? {} : { verify: finding.verify }),
70
+ })
71
+ }
72
+
73
+ function findingsFor(ctx: RuleContext, rule: string): Diagnostic[] {
74
+ const findings = ctx.project.legacyDiagnostics
75
+ if (!Array.isArray(findings)) return []
76
+ const code = LEGACY_RULE_CODES[rule]
77
+ if (code === undefined) return []
78
+ return findings
79
+ .filter((finding): finding is CheckDiagnostic => finding?.rule === rule)
80
+ .map((finding) => toDiagnostic(finding, code))
81
+ }
82
+
83
+ function makeRule(rule: string): CheckRule {
84
+ const code = LEGACY_RULE_CODES[rule]
85
+ const title = TITLES[rule]
86
+ if (code === undefined || title === undefined) throw new Error(`unknown legacy rule ${rule}`)
87
+ return Object.freeze({ code, title, scan: async (ctx: RuleContext) => findingsFor(ctx, rule) })
88
+ }
89
+
90
+ /** Stable registry adapters for the existing source scanners. */
91
+ export const legacyRules: readonly CheckRule[] = Object.freeze(
92
+ Object.keys(LEGACY_RULE_CODES).map((name) => makeRule(name)),
93
+ )
@@ -0,0 +1,157 @@
1
+ import { type Diagnostic, diagnostic } from "../diagnostics.ts"
2
+ import { commentBlockHasMarker } from "./comment-markers.ts"
3
+ import type { CheckRule, RuleContext } from "./index.ts"
4
+
5
+ /**
6
+ * Route-table lints over the statically collected route registrations (`scanStaticRouteText` in
7
+ * ../check.ts fills `ctx.project.staticRoutes` during the source walk - no second scan here).
8
+ *
9
+ * NF-C018 exists because the typed client's proxy intercepts a fixed set of property names before
10
+ * path resolution (`resolveSegment` + the thenable guard in @nifrajs/client): the seven HTTP verbs
11
+ * (case-insensitively), `subscribe`, `ws`, `index`, and `then`. A route whose path contains a
12
+ * static segment spelling one of these cannot be reached by DOT ACCESS - `api.delete.post` resolves
13
+ * the DELETE verb, not the path. The typed spelling is a call on the parent node
14
+ * (`api.api("delete").post()`, treaty.ts `CollisionEscape`), and the client type rejects the dot
15
+ * access with the same guidance - so the collision is a warning that teaches the escape spelling,
16
+ * not a blocking error: the route IS reachable, just not by the spelling its name suggests.
17
+ */
18
+ const RESERVED_VERB_SEGMENTS: ReadonlySet<string> = new Set([
19
+ "get",
20
+ "post",
21
+ "put",
22
+ "patch",
23
+ "delete",
24
+ "head",
25
+ "options",
26
+ ])
27
+ // Intercepted by exact match only (`key === "subscribe"` etc.), unlike the lowercased verbs.
28
+ const RESERVED_EXACT_SEGMENTS: ReadonlySet<string> = new Set(["subscribe", "ws", "index", "then"])
29
+ const RESERVED_READOUT =
30
+ "get/post/put/patch/delete/head/options (any casing), subscribe, ws, index, then"
31
+
32
+ /** Opt-out pragma for a route deliberately served only to NON-typed-client consumers. */
33
+ const RESERVED_SEGMENT_PRAGMA = "nifra-expect reserved-segment"
34
+
35
+ interface StaticRouteFact {
36
+ readonly file: string
37
+ readonly line: number
38
+ readonly method: string
39
+ readonly path: string
40
+ }
41
+
42
+ /** Parse-don't-cast over the project fact: a malformed entry is dropped, never trusted. */
43
+ function routeFacts(ctx: RuleContext): StaticRouteFact[] {
44
+ const raw = ctx.project.staticRoutes
45
+ if (!Array.isArray(raw)) return []
46
+ const out: StaticRouteFact[] = []
47
+ for (const item of raw) {
48
+ if (typeof item !== "object" || item === null) continue
49
+ const record = item as Record<string, unknown>
50
+ if (
51
+ typeof record.file === "string" &&
52
+ typeof record.line === "number" &&
53
+ typeof record.method === "string" &&
54
+ typeof record.path === "string"
55
+ ) {
56
+ out.push({ file: record.file, line: record.line, method: record.method, path: record.path })
57
+ }
58
+ }
59
+ return out
60
+ }
61
+
62
+ /**
63
+ * The typed escape spelling for a colliding segment, e.g. `/api/delete` + `delete` →
64
+ * `api("delete").post()` shown as the chain up to the collision. Best-effort readability: earlier
65
+ * segments render as dot access, the colliding one as the parent-node call.
66
+ */
67
+ function routeEscapeHint(path: string, colliding: string): string {
68
+ const segments = path.split("/").filter((segment) => segment !== "")
69
+ const before = segments.slice(0, Math.max(segments.indexOf(colliding), 0))
70
+ return `${["api", ...before].join(".")}("${colliding}")`
71
+ }
72
+
73
+ /** The reserved key a static path segment collides with, or undefined. Params/wildcards never collide. */
74
+ function reservedCollision(segment: string): string | undefined {
75
+ if (segment.startsWith(":") || segment.startsWith("*")) return undefined
76
+ if (RESERVED_EXACT_SEGMENTS.has(segment)) return segment
77
+ const lower = segment.toLowerCase()
78
+ return RESERVED_VERB_SEGMENTS.has(lower) ? lower : undefined
79
+ }
80
+
81
+ export const reservedSegmentRule: CheckRule = {
82
+ code: "NF-C018",
83
+ title: "Route segment collides with a reserved client proxy key",
84
+ async scan(ctx) {
85
+ const findings: Diagnostic[] = []
86
+ const linesByFile = new Map<string, readonly string[]>()
87
+ for (const route of routeFacts(ctx)) {
88
+ for (const segment of route.path.split("/")) {
89
+ if (segment === "") continue
90
+ const collision = reservedCollision(segment)
91
+ if (collision === undefined) continue
92
+ let lines = linesByFile.get(route.file)
93
+ if (lines === undefined) {
94
+ lines = (ctx.sources.read(route.file) ?? "").split("\n")
95
+ linesByFile.set(route.file, lines)
96
+ }
97
+ if (commentBlockHasMarker(lines, route.line, RESERVED_SEGMENT_PRAGMA)) continue
98
+ findings.push(
99
+ diagnostic({
100
+ code: "NF-C018",
101
+ severity: "warn",
102
+ file: route.file,
103
+ line: route.line,
104
+ message: `${route.method} ${route.path} - segment '${segment}' collides with the reserved client proxy key '${collision}' (reserved: ${RESERVED_READOUT}); dot access cannot reach it - call the parent node with the segment instead (\`${routeEscapeHint(route.path, segment)}\`), rename the segment, or mark an intentionally non-typed-client route with \`// ${RESERVED_SEGMENT_PRAGMA}\` above the registration`,
105
+ evidence: [`${route.method} ${route.path}`, `segment: ${segment}`],
106
+ verify: "nifra check --lints-only",
107
+ }),
108
+ )
109
+ break // one finding per route, even if several segments collide
110
+ }
111
+ }
112
+ return findings
113
+ },
114
+ }
115
+
116
+ /**
117
+ * NF-C019: the same method+path registered twice in one file - the later registration is dead or
118
+ * shadowing, and which one serves is a router implementation detail nobody should depend on.
119
+ * Scoped to a single file on purpose: two different apps in one repo (a monorepo with several
120
+ * backends) legitimately both register `GET /health`, and this scanner cannot tell app instances
121
+ * apart across files - so cross-file duplicates are NOT flagged rather than guessed at.
122
+ */
123
+ export const duplicateRouteRule: CheckRule = {
124
+ code: "NF-C019",
125
+ title: "Duplicate route registration",
126
+ async scan(ctx) {
127
+ const findings: Diagnostic[] = []
128
+ const byFile = new Map<string, Map<string, StaticRouteFact>>()
129
+ for (const route of routeFacts(ctx)) {
130
+ let seen = byFile.get(route.file)
131
+ if (seen === undefined) {
132
+ seen = new Map()
133
+ byFile.set(route.file, seen)
134
+ }
135
+ const key = `${route.method} ${route.path}`
136
+ const first = seen.get(key)
137
+ if (first === undefined) {
138
+ seen.set(key, route)
139
+ continue
140
+ }
141
+ findings.push(
142
+ diagnostic({
143
+ code: "NF-C019",
144
+ severity: "error",
145
+ file: route.file,
146
+ line: route.line,
147
+ message: `${key} is registered twice in this file (first at line ${first.line}) - remove or rename one; which registration serves is undefined`,
148
+ evidence: [key, `first registration: line ${first.line}`],
149
+ verify: "nifra check --lints-only",
150
+ }),
151
+ )
152
+ }
153
+ return findings
154
+ },
155
+ }
156
+
157
+ export const routeRules = Object.freeze([reservedSegmentRule, duplicateRouteRule])