@nifrajs/cli 2.13.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/capabilities-tool.d.ts +54 -1
  2. package/dist/capabilities-tool.d.ts.map +1 -1
  3. package/dist/capabilities-tool.js +146 -18
  4. package/dist/capabilities-tool.js.map +1 -1
  5. package/dist/check.d.ts +11 -11
  6. package/dist/check.d.ts.map +1 -1
  7. package/dist/check.js +260 -49
  8. package/dist/check.js.map +1 -1
  9. package/dist/cli.d.ts +11 -0
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +130 -414
  12. package/dist/cli.js.map +1 -1
  13. package/dist/command-catalog.d.ts +250 -0
  14. package/dist/command-catalog.d.ts.map +1 -0
  15. package/dist/command-catalog.js +1032 -0
  16. package/dist/command-catalog.js.map +1 -0
  17. package/dist/contracts.d.ts +9 -0
  18. package/dist/contracts.d.ts.map +1 -1
  19. package/dist/contracts.js +22 -6
  20. package/dist/contracts.js.map +1 -1
  21. package/dist/doctor.d.ts +55 -0
  22. package/dist/doctor.d.ts.map +1 -1
  23. package/dist/doctor.js +82 -9
  24. package/dist/doctor.js.map +1 -1
  25. package/dist/env-file.d.ts +17 -0
  26. package/dist/env-file.d.ts.map +1 -0
  27. package/dist/env-file.js +113 -0
  28. package/dist/env-file.js.map +1 -0
  29. package/dist/fix-recipes.d.ts.map +1 -1
  30. package/dist/fix-recipes.js +91 -15
  31. package/dist/fix-recipes.js.map +1 -1
  32. package/dist/internal/reserved-segment-codemod.d.ts +36 -0
  33. package/dist/internal/reserved-segment-codemod.d.ts.map +1 -0
  34. package/dist/internal/reserved-segment-codemod.js +134 -0
  35. package/dist/internal/reserved-segment-codemod.js.map +1 -0
  36. package/dist/internal/typescript-import.d.ts +1 -1
  37. package/dist/internal/typescript-import.d.ts.map +1 -1
  38. package/dist/internal/typescript-import.js +62 -11
  39. package/dist/internal/typescript-import.js.map +1 -1
  40. package/dist/introspect.d.ts +26 -1
  41. package/dist/introspect.d.ts.map +1 -1
  42. package/dist/introspect.js +74 -24
  43. package/dist/introspect.js.map +1 -1
  44. package/dist/load.d.ts.map +1 -1
  45. package/dist/load.js +22 -5
  46. package/dist/load.js.map +1 -1
  47. package/dist/manifest-tool.d.ts +15 -1
  48. package/dist/manifest-tool.d.ts.map +1 -1
  49. package/dist/manifest-tool.js +23 -9
  50. package/dist/manifest-tool.js.map +1 -1
  51. package/dist/mcp-http.js +1 -1
  52. package/dist/mcp-http.js.map +1 -1
  53. package/dist/mcp.d.ts +16 -0
  54. package/dist/mcp.d.ts.map +1 -1
  55. package/dist/mcp.js +127 -355
  56. package/dist/mcp.js.map +1 -1
  57. package/dist/project-facts.d.ts +40 -0
  58. package/dist/project-facts.d.ts.map +1 -0
  59. package/dist/project-facts.js +36 -0
  60. package/dist/project-facts.js.map +1 -0
  61. package/dist/recipes/3.0.0.d.ts +10 -0
  62. package/dist/recipes/3.0.0.d.ts.map +1 -0
  63. package/dist/recipes/3.0.0.js +23 -0
  64. package/dist/recipes/3.0.0.js.map +1 -0
  65. package/dist/recipes/index.d.ts.map +1 -1
  66. package/dist/recipes/index.js +2 -0
  67. package/dist/recipes/index.js.map +1 -1
  68. package/dist/release-verification.d.ts.map +1 -1
  69. package/dist/release-verification.js +15 -0
  70. package/dist/release-verification.js.map +1 -1
  71. package/dist/rules/comment-markers.d.ts +8 -0
  72. package/dist/rules/comment-markers.d.ts.map +1 -1
  73. package/dist/rules/comment-markers.js +36 -0
  74. package/dist/rules/comment-markers.js.map +1 -1
  75. package/dist/rules/index.d.ts +2 -3
  76. package/dist/rules/index.d.ts.map +1 -1
  77. package/dist/rules/index.js.map +1 -1
  78. package/dist/rules/routes.d.ts.map +1 -1
  79. package/dist/rules/routes.js +29 -44
  80. package/dist/rules/routes.js.map +1 -1
  81. package/dist/rules/security.js +14 -14
  82. package/dist/rules/security.js.map +1 -1
  83. package/dist/workspace-link.d.ts +37 -0
  84. package/dist/workspace-link.d.ts.map +1 -0
  85. package/dist/workspace-link.js +72 -0
  86. package/dist/workspace-link.js.map +1 -0
  87. package/docs/examples.json +1 -1
  88. package/docs/llms-full.txt +362 -20
  89. package/docs/types.json +956 -56
  90. package/package.json +9 -9
  91. package/src/capabilities-tool.ts +229 -21
  92. package/src/check.ts +312 -65
  93. package/src/cli.ts +140 -405
  94. package/src/command-catalog.ts +1435 -0
  95. package/src/contracts.ts +28 -7
  96. package/src/doctor.ts +134 -12
  97. package/src/env-file.ts +121 -0
  98. package/src/fix-recipes.ts +101 -15
  99. package/src/internal/reserved-segment-codemod.ts +150 -0
  100. package/src/internal/typescript-import.ts +61 -11
  101. package/src/introspect.ts +82 -21
  102. package/src/load.ts +25 -6
  103. package/src/manifest-tool.ts +43 -12
  104. package/src/mcp-http.ts +1 -1
  105. package/src/mcp.ts +163 -420
  106. package/src/project-facts.ts +86 -0
  107. package/src/recipes/3.0.0.ts +24 -0
  108. package/src/recipes/index.ts +2 -0
  109. package/src/release-verification.ts +17 -0
  110. package/src/rules/comment-markers.ts +36 -0
  111. package/src/rules/index.ts +3 -4
  112. package/src/rules/routes.ts +30 -44
  113. package/src/rules/security.ts +14 -14
  114. package/src/workspace-link.ts +80 -0
@@ -0,0 +1,86 @@
1
+ import type { CapabilityProjectReport } from "./capabilities-tool.ts"
2
+ import type {
3
+ CheckAssuranceContext,
4
+ CheckConfig,
5
+ CheckDiagnostic,
6
+ ManifestDriftFinding,
7
+ SourceFinding,
8
+ StaticRouteFinding,
9
+ TransitiveServerImportFinding,
10
+ } from "./check.ts"
11
+ import type { DoctorResult } from "./doctor.ts"
12
+ import type { PipelineReport } from "./pipeline-report.ts"
13
+ import type { RulePack, SourceIndex } from "./rules/index.ts"
14
+
15
+ export interface ProjectSourceFindings {
16
+ readonly fetches: readonly SourceFinding[]
17
+ readonly untypedClients: readonly SourceFinding[]
18
+ readonly removedImports: readonly SourceFinding[]
19
+ readonly responseRoutes: readonly SourceFinding[]
20
+ readonly interpolatedSql: readonly SourceFinding[]
21
+ }
22
+
23
+ export interface ProjectPackageFacts {
24
+ readonly doctor: DoctorResult
25
+ readonly manifestDrift: readonly ManifestDriftFinding[]
26
+ }
27
+
28
+ export interface ProjectPolicyFacts {
29
+ readonly assurance?: CheckAssuranceContext
30
+ readonly capability?: CapabilityProjectReport
31
+ readonly checkConfig: CheckConfig
32
+ readonly rulePacks: readonly RulePack[]
33
+ }
34
+
35
+ export interface ProjectFacts {
36
+ readonly source: SourceIndex
37
+ readonly routes: readonly StaticRouteFinding[]
38
+ readonly importGraph: readonly TransitiveServerImportFinding[]
39
+ readonly packages: ProjectPackageFacts
40
+ readonly pipeline?: PipelineReport
41
+ readonly policies: ProjectPolicyFacts
42
+ readonly sourceFindings: ProjectSourceFindings
43
+ readonly legacyDiagnostics: readonly CheckDiagnostic[]
44
+ }
45
+
46
+ export type ProjectFactsSeed = Omit<ProjectFacts, "legacyDiagnostics">
47
+
48
+ function freezeFindings<T extends object>(findings: readonly T[]): readonly T[] {
49
+ return Object.freeze(findings.map((finding) => Object.freeze({ ...finding })))
50
+ }
51
+
52
+ /**
53
+ * Publish the one immutable rule snapshot. The loader owns all mutable scan buffers; rules only see
54
+ * these copied, frozen collections. Policy/config objects remain caller-owned because assurance config
55
+ * can contain executable adapters and must not be frozen as a side effect of checking.
56
+ */
57
+ export function freezeProjectFacts(
58
+ seed: ProjectFactsSeed,
59
+ legacyDiagnostics: readonly CheckDiagnostic[],
60
+ ): ProjectFacts {
61
+ const sourceFindings: ProjectSourceFindings = Object.freeze({
62
+ fetches: freezeFindings(seed.sourceFindings.fetches),
63
+ untypedClients: freezeFindings(seed.sourceFindings.untypedClients),
64
+ removedImports: freezeFindings(seed.sourceFindings.removedImports),
65
+ responseRoutes: freezeFindings(seed.sourceFindings.responseRoutes),
66
+ interpolatedSql: freezeFindings(seed.sourceFindings.interpolatedSql),
67
+ })
68
+ const policies = Object.freeze({
69
+ ...seed.policies,
70
+ rulePacks: Object.freeze([...seed.policies.rulePacks]),
71
+ })
72
+ return Object.freeze({
73
+ ...seed,
74
+ source: Object.freeze(seed.source),
75
+ routes: freezeFindings(seed.routes),
76
+ importGraph: freezeFindings(seed.importGraph),
77
+ packages: Object.freeze({
78
+ doctor: seed.packages.doctor,
79
+ manifestDrift: freezeFindings(seed.packages.manifestDrift),
80
+ }),
81
+ ...(seed.pipeline === undefined ? {} : { pipeline: Object.freeze(seed.pipeline) }),
82
+ policies,
83
+ sourceFindings,
84
+ legacyDiagnostics: freezeFindings(legacyDiagnostics),
85
+ })
86
+ }
@@ -0,0 +1,24 @@
1
+ import type { UpgradeRecipe } from "./index.ts"
2
+
3
+ /**
4
+ * Upgrade recipe for the Nifra 3.0 fixed-group cutover.
5
+ *
6
+ * No package is removed and no import specifier moves this release, so the runner only pins the
7
+ * fixed version group. The two breaking changes are structural - a redirect return value and the
8
+ * reserved typed-client segment types - and stay as explicit notes, one of which points at a codemod.
9
+ */
10
+ export const recipe: UpgradeRecipe = {
11
+ version: "3.0.0",
12
+ pins: [
13
+ { match: "@nifrajs/", to: "3.0.0" },
14
+ { match: "create-nifra", to: "3.0.0" },
15
+ { match: "nifra", to: "3.0.0" },
16
+ ],
17
+ importMoves: [],
18
+ notes: [
19
+ "Read the 2.x → 3.0 migration guide: https://nifra.dev/docs/migrate-3.",
20
+ "`redirect(...)` now returns plain render data, not a `Response`. Read it with `.plain` (`{ status, headers, body }`) or `toResponse()`, add headers with the second argument, and assert on `.plain` in tests. `return redirect()` / `throw redirect()` are unchanged, and cookies still apply.",
21
+ "Reserved typed-client segment keys are a frozen contract: a property or bracket access on a reserved-named route segment no longer type-checks. Run `nifra fix --code NF-C018` to rewrite the affected call sites; `nifra routes` annotates a colliding route with the spelling that reaches it.",
22
+ "On Node, `@nifrajs/proxy` defaults to the undici transport. Pass an explicit `transport` to `createProxy` to override it.",
23
+ ],
24
+ }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { recipe as v1_8_0 } from "./1.8.0.ts"
8
8
  import { recipe as v2_0_0 } from "./2.0.0.ts"
9
+ import { recipe as v3_0_0 } from "./3.0.0.ts"
9
10
 
10
11
  /** A pin rule: every dependency whose name starts with `match` is set to version `to`. */
11
12
  export interface PinRule {
@@ -40,6 +41,7 @@ export interface UpgradeRecipe {
40
41
  const RECIPES: Record<string, UpgradeRecipe> = {
41
42
  [v1_8_0.version]: v1_8_0,
42
43
  [v2_0_0.version]: v2_0_0,
44
+ [v3_0_0.version]: v3_0_0,
43
45
  }
44
46
 
45
47
  export function getRecipe(version: string): UpgradeRecipe | undefined {
@@ -103,6 +103,14 @@ const DEFAULT_GATES: readonly ReleaseGateSpec[] = [
103
103
  remediation:
104
104
  "Run `bun run check:size` and either reduce the bundle or update the reviewed budget.",
105
105
  },
106
+ // Appended, not inserted: RELEASE_GATES below composes this list BY INDEX
107
+ // (`slice(0, 3)`, `[3]`), so a gate added in the middle would silently re-point those.
108
+ {
109
+ id: "changesets",
110
+ commands: [["run", "check:changesets"]],
111
+ remediation:
112
+ "Run `bun run changeset` and name every package whose source changed, so the release documents it.",
113
+ },
106
114
  ]
107
115
 
108
116
  const RELEASE_GATES: readonly ReleaseGateSpec[] = [
@@ -183,6 +191,15 @@ const RELEASE_GATES: readonly ReleaseGateSpec[] = [
183
191
  remediation:
184
192
  "Run `bun run check:pipeline-parity` and fix the development and production manifest drift.",
185
193
  },
194
+ // Last in release mode on purpose: this is the gate on what the release SAYS, and it is the only
195
+ // one whose failure is invisible afterwards - a shipped version cannot grow the changelog line it
196
+ // never had.
197
+ {
198
+ id: "changesets",
199
+ commands: [["run", "check:changesets"]],
200
+ remediation:
201
+ "Run `bun run changeset` and name every package whose source changed, so the release documents it.",
202
+ },
186
203
  ]
187
204
 
188
205
  const runBunCommand = async (spec: ReleaseCommandSpec): Promise<ReleaseCommandResult> => {
@@ -24,3 +24,39 @@ export function commentBlockHasMarker(
24
24
  }
25
25
  return false
26
26
  }
27
+
28
+ /**
29
+ * The justification a reason-carrying marker (`<marker>: <reason>`) supplies, or `undefined` when the
30
+ * marker is absent or written with no reason after its colon. Searches the same trailing-comment /
31
+ * comment-block-above region as {@link commentBlockHasMarker}, so a hatch that demands a written reason
32
+ * can require this to be non-empty before it silences anything - an empty `<marker>:` never suppresses,
33
+ * which is how "reason mandatory" is enforced without a second diagnostic.
34
+ */
35
+ export function commentBlockMarkerReason(
36
+ lines: readonly string[],
37
+ line: number,
38
+ marker: string,
39
+ ): string | undefined {
40
+ const reasonOn = (text: string): string | undefined => {
41
+ const at = text.indexOf(marker)
42
+ if (at < 0) return undefined
43
+ const rest = text.slice(at + marker.length)
44
+ const colon = rest.indexOf(":")
45
+ if (colon < 0) return undefined
46
+ // A block comment's `*/` closer is not part of the reason.
47
+ const reason = rest
48
+ .slice(colon + 1)
49
+ .replace(/\*\/\s*$/, "")
50
+ .trim()
51
+ return reason.length > 0 ? reason : undefined
52
+ }
53
+ const onLine = reasonOn(lines[line - 1] ?? "")
54
+ if (onLine !== undefined) return onLine
55
+ for (let i = line - 2; i >= 0; i--) {
56
+ const text = lines[i] ?? ""
57
+ if (!COMMENT_LINE.test(text)) return undefined
58
+ const reason = reasonOn(text)
59
+ if (reason !== undefined) return reason
60
+ }
61
+ return undefined
62
+ }
@@ -1,6 +1,9 @@
1
1
  import type { Diagnostic } from "../diagnostics.ts"
2
+ import type { ProjectFacts } from "../project-facts.ts"
2
3
  import { isBuiltInCode } from "./codes.ts"
3
4
 
5
+ export type { ProjectFacts } from "../project-facts.ts"
6
+
4
7
  export interface SourceFile {
5
8
  readonly file: string
6
9
  readonly content: string
@@ -11,10 +14,6 @@ export interface SourceIndex {
11
14
  read(file: string): string | undefined
12
15
  }
13
16
 
14
- export interface ProjectFacts {
15
- readonly [key: string]: unknown
16
- }
17
-
18
17
  export interface RuleContext {
19
18
  readonly root: string
20
19
  readonly sources: SourceIndex
@@ -1,33 +1,25 @@
1
+ import { RESERVED_KEY_READOUT, reservedKeyFor } from "@nifrajs/client"
1
2
  import { type Diagnostic, diagnostic } from "../diagnostics.ts"
2
3
  import { commentBlockHasMarker } from "./comment-markers.ts"
3
4
  import type { CheckRule, RuleContext } from "./index.ts"
4
5
 
5
6
  /**
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).
7
+ * Route-table lints over the statically collected route registrations (`ProjectFacts.routes` is built
8
+ * once before the registry runs - no rule re-walks source).
8
9
  *
9
10
  * 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.
11
+ * path resolution (`resolveSegment` + the thenable guard in @nifrajs/client). A route whose path
12
+ * contains a static segment spelling one of these cannot be reached by DOT ACCESS -
13
+ * `api.delete.post` resolves the DELETE verb, not the path. The typed spelling is a call on the
14
+ * parent node (`api.api("delete").post()`, treaty.ts `SegmentCall`), and the client type rejects
15
+ * the dot access with the same guidance - so the collision is a warning that teaches the call
16
+ * spelling, not a blocking error: the route IS reachable, just not by the spelling its name
17
+ * suggests.
18
+ *
19
+ * The set itself is NOT copied here. It comes from `@nifrajs/client`'s `reserved.ts`, which is the
20
+ * one place it is written down and the one place the freeze policy lives - a lint that carried its
21
+ * own copy is a lint that can disagree with the compiler it is explaining.
17
22
  */
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
23
 
32
24
  /** Opt-out pragma for a route deliberately served only to NON-typed-client consumers. */
33
25
  const RESERVED_SEGMENT_PRAGMA = "nifra-expect reserved-segment"
@@ -41,20 +33,19 @@ interface StaticRouteFact {
41
33
 
42
34
  /** Parse-don't-cast over the project fact: a malformed entry is dropped, never trusted. */
43
35
  function routeFacts(ctx: RuleContext): StaticRouteFact[] {
44
- const raw = ctx.project.staticRoutes
45
- if (!Array.isArray(raw)) return []
36
+ const raw: readonly unknown[] = ctx.project.routes
46
37
  const out: StaticRouteFact[] = []
47
38
  for (const item of raw) {
48
- if (typeof item !== "object" || item === null) continue
49
- const record = item as Record<string, unknown>
39
+ if (typeof item !== "object" || item === null || Array.isArray(item)) continue
40
+ const record = Object.fromEntries(Object.entries(item))
50
41
  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
- }
42
+ typeof record.file !== "string" ||
43
+ typeof record.line !== "number" ||
44
+ typeof record.method !== "string" ||
45
+ typeof record.path !== "string"
46
+ )
47
+ continue
48
+ out.push({ file: record.file, line: record.line, method: record.method, path: record.path })
58
49
  }
59
50
  return out
60
51
  }
@@ -70,14 +61,6 @@ function routeEscapeHint(path: string, colliding: string): string {
70
61
  return `${["api", ...before].join(".")}("${colliding}")`
71
62
  }
72
63
 
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
64
  export const reservedSegmentRule: CheckRule = {
82
65
  code: "NF-C018",
83
66
  title: "Route segment collides with a reserved client proxy key",
@@ -87,11 +70,11 @@ export const reservedSegmentRule: CheckRule = {
87
70
  for (const route of routeFacts(ctx)) {
88
71
  for (const segment of route.path.split("/")) {
89
72
  if (segment === "") continue
90
- const collision = reservedCollision(segment)
73
+ const collision = reservedKeyFor(segment)
91
74
  if (collision === undefined) continue
92
75
  let lines = linesByFile.get(route.file)
93
76
  if (lines === undefined) {
94
- lines = (ctx.sources.read(route.file) ?? "").split("\n")
77
+ lines = (ctx.project.source.read(route.file) ?? "").split("\n")
95
78
  linesByFile.set(route.file, lines)
96
79
  }
97
80
  if (commentBlockHasMarker(lines, route.line, RESERVED_SEGMENT_PRAGMA)) continue
@@ -101,8 +84,11 @@ export const reservedSegmentRule: CheckRule = {
101
84
  severity: "warn",
102
85
  file: route.file,
103
86
  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`,
87
+ message: `${route.method} ${route.path} - segment '${segment}' collides with the reserved client proxy key '${collision}' (reserved: ${RESERVED_KEY_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. \`nifra fix --code NF-C018\` rewrites the broken call sites for you`,
105
88
  evidence: [`${route.method} ${route.path}`, `segment: ${segment}`],
89
+ // The route is fine; what needs editing is every typed-client call site the collision
90
+ // broke, which the recipe finds from the compiler rather than from this one finding.
91
+ fix: { recipe: "client.reserved-segment", command: "nifra fix --code NF-C018" },
106
92
  verify: "nifra check --lints-only",
107
93
  }),
108
94
  )
@@ -108,8 +108,8 @@ export const secretComparisonRule: CheckRule = {
108
108
  const ts = await importProjectTypeScript(ctx.root)
109
109
  if (ts === undefined) return didNotRun("NF-S002", "Non-constant-time secret comparison scan")
110
110
  const findings: Diagnostic[] = []
111
- for (const file of ctx.sources.files) {
112
- const parsed = parsedFile(ts, ctx.sources, file)
111
+ for (const file of ctx.project.source.files) {
112
+ const parsed = parsedFile(ts, ctx.project.source, file)
113
113
  if (parsed === undefined) continue
114
114
  const { tree, lines } = parsed
115
115
  const visit = (node: TSApi.Node): void => {
@@ -169,8 +169,8 @@ export const piiLogRule: CheckRule = {
169
169
  const ts = await importProjectTypeScript(ctx.root)
170
170
  if (ts === undefined) return didNotRun("NF-S003", "Sensitive-value-in-log scan")
171
171
  const findings: Diagnostic[] = []
172
- for (const file of ctx.sources.files) {
173
- const parsed = parsedFile(ts, ctx.sources, file)
172
+ for (const file of ctx.project.source.files) {
173
+ const parsed = parsedFile(ts, ctx.project.source, file)
174
174
  if (parsed === undefined) continue
175
175
  const { tree, lines } = parsed
176
176
  const visit = (node: TSApi.Node): void => {
@@ -217,8 +217,8 @@ export const failOpenGateRule: CheckRule = {
217
217
  const ts = await importProjectTypeScript(ctx.root)
218
218
  if (ts === undefined) return didNotRun("NF-S001", "Fail-open gate scan")
219
219
  const findings: Diagnostic[] = []
220
- for (const file of ctx.sources.files) {
221
- const parsed = parsedFile(ts, ctx.sources, file)
220
+ for (const file of ctx.project.source.files) {
221
+ const parsed = parsedFile(ts, ctx.project.source, file)
222
222
  if (parsed === undefined) continue
223
223
  const { tree, lines } = parsed
224
224
  const visit = (node: TSApi.Node): void => {
@@ -284,8 +284,8 @@ export const corsOriginPredicateRule: CheckRule = {
284
284
  const ts = await importProjectTypeScript(ctx.root)
285
285
  if (ts === undefined) return didNotRun("NF-S004", "CORS origin predicate scan")
286
286
  const findings: Diagnostic[] = []
287
- for (const file of ctx.sources.files) {
288
- const parsed = parsedFile(ts, ctx.sources, file)
287
+ for (const file of ctx.project.source.files) {
288
+ const parsed = parsedFile(ts, ctx.project.source, file)
289
289
  if (parsed === undefined) continue
290
290
  const { tree, lines } = parsed
291
291
  const visit = (node: TSApi.Node): void => {
@@ -345,8 +345,8 @@ export const externalRedirectRule: CheckRule = {
345
345
  const ts = await importProjectTypeScript(ctx.root)
346
346
  if (ts === undefined) return didNotRun("NF-S005", "External redirect opt-out scan")
347
347
  const findings: Diagnostic[] = []
348
- for (const file of ctx.sources.files) {
349
- const parsed = parsedFile(ts, ctx.sources, file)
348
+ for (const file of ctx.project.source.files) {
349
+ const parsed = parsedFile(ts, ctx.project.source, file)
350
350
  if (parsed === undefined) continue
351
351
  const { tree, lines } = parsed
352
352
  const visit = (node: TSApi.Node): void => {
@@ -402,8 +402,8 @@ export const assuranceEscapeHatchRule: CheckRule = {
402
402
  const ts = await importProjectTypeScript(ctx.root)
403
403
  if (ts === undefined) return didNotRun("NF-S006", "Security escape hatch scan")
404
404
  const findings: Diagnostic[] = []
405
- for (const file of ctx.sources.files) {
406
- const parsed = parsedFile(ts, ctx.sources, file)
405
+ for (const file of ctx.project.source.files) {
406
+ const parsed = parsedFile(ts, ctx.project.source, file)
407
407
  if (parsed === undefined) continue
408
408
  const { tree, lines } = parsed
409
409
  const visit = (node: TSApi.Node): void => {
@@ -445,8 +445,8 @@ export const unprefixedSecureCookieRule: CheckRule = {
445
445
  const ts = await importProjectTypeScript(ctx.root)
446
446
  if (ts === undefined) return didNotRun("NF-S007", "Secure cookie prefix scan")
447
447
  const findings: Diagnostic[] = []
448
- for (const file of ctx.sources.files) {
449
- const parsed = parsedFile(ts, ctx.sources, file)
448
+ for (const file of ctx.project.source.files) {
449
+ const parsed = parsedFile(ts, ctx.project.source, file)
450
450
  if (parsed === undefined) continue
451
451
  const { tree } = parsed
452
452
  const visit = (node: TSApi.Node): void => {
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Resolving a WORKSPACE-LINKED dependency back to the directory it really lives in.
3
+ *
4
+ * A workspace link is the one install whose realpath deliberately leaves the project: `link:` and
5
+ * `workspaces` entries point at a source checkout, routinely in a sibling repository. That is exactly
6
+ * what makes such a package able to go stale (its `dist/` is nobody's build step) and exactly what a
7
+ * project-contained path check rejects - so the rebuild has to resolve the escape on purpose, under
8
+ * its own rules, rather than reuse `resolveInsideProject`.
9
+ *
10
+ * The rules that keep it safe:
11
+ * - the path is never taken from a diagnostic; it is re-derived from the package NAME by walking the
12
+ * project's own `node_modules` chain, so only a symlink the package manager already created can be
13
+ * reached, and only a package the project actually declares;
14
+ * - a copy that resolves INSIDE `node_modules` is a registry tarball - immutable, never stale, and
15
+ * never rebuilt;
16
+ * - the package must declare the build script itself. nifra runs the project's own script, never a
17
+ * command of its own composition, and never through a shell.
18
+ */
19
+
20
+ import { sep } from "node:path"
21
+ import { resolvedInstalledCopy } from "@nifrajs/web/internal/parity"
22
+
23
+ /** The one script name a rebuild will run. Guessing among `prepare`/`compile`/`build:*` would risk
24
+ * running something that is not the build at all; a package that names it differently is reported as
25
+ * un-automatable instead. */
26
+ const BUILD_SCRIPT = "build"
27
+
28
+ /** A package name npm would accept, with no path segment that could climb out of `node_modules`. */
29
+ const PACKAGE_NAME = /^@?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)?$/
30
+
31
+ export function isPackageName(value: string): boolean {
32
+ return PACKAGE_NAME.test(value) && !value.split("/").some((part) => part === "." || part === "..")
33
+ }
34
+
35
+ /** The package's own build script name, or `undefined` when it declares none. */
36
+ export function buildScriptName(meta: Record<string, unknown> | undefined): string | undefined {
37
+ const scripts = meta?.scripts
38
+ if (typeof scripts !== "object" || scripts === null) return undefined
39
+ const script = (scripts as Record<string, unknown>)[BUILD_SCRIPT]
40
+ return typeof script === "string" && script.trim().length > 0 ? BUILD_SCRIPT : undefined
41
+ }
42
+
43
+ export type WorkspaceLinkResolution =
44
+ | { readonly ok: true; readonly dir: string; readonly buildScript: string }
45
+ | { readonly ok: false; readonly reason: string }
46
+
47
+ /**
48
+ * Resolve `name` to the workspace-linked checkout the project loads it from, refusing with a stated
49
+ * reason rather than resolving to nothing. The upward walk is unbounded on purpose: it has to reach the
50
+ * same hoisted copy the staleness scan found, which in a monorepo sits above the package being checked.
51
+ * It only ever inspects `node_modules/<name>` directories, so it cannot wander into an arbitrary path.
52
+ */
53
+ export async function resolveWorkspaceLinkedPackage(
54
+ cwd: string,
55
+ name: string,
56
+ ): Promise<WorkspaceLinkResolution> {
57
+ if (!isPackageName(name)) return { ok: false, reason: `not a package name: ${name}` }
58
+ // Boundary "" and the `node_modules` refusal below mirror `collectStaleWorkspaceDists` exactly, so
59
+ // the rebuild always lands on the same copy the staleness scan flagged.
60
+ const copy = await resolvedInstalledCopy(cwd, "", name)
61
+ if (copy === undefined) return { ok: false, reason: `${name} is not installed under ${cwd}` }
62
+ if (copy.path.includes(`${sep}node_modules${sep}`)) {
63
+ return {
64
+ ok: false,
65
+ reason: `${name} resolves to a registry install (${copy.path}), which cannot be stale or rebuilt`,
66
+ }
67
+ }
68
+ const meta = (await Bun.file(`${copy.path}${sep}package.json`)
69
+ .json()
70
+ .catch(() => undefined)) as Record<string, unknown> | undefined
71
+ if (meta === undefined) return { ok: false, reason: `${copy.path} has no readable package.json` }
72
+ const buildScript = buildScriptName(meta)
73
+ if (buildScript === undefined) {
74
+ return {
75
+ ok: false,
76
+ reason: `${name} declares no "${BUILD_SCRIPT}" script in ${copy.path}/package.json, so its artifact cannot be rebuilt automatically - build it the way that package expects`,
77
+ }
78
+ }
79
+ return { ok: true, dir: copy.path, buildScript }
80
+ }