@nifrajs/cli 2.13.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capabilities-tool.d.ts +54 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +146 -18
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +11 -11
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +260 -49
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +130 -414
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +250 -0
- package/dist/command-catalog.d.ts.map +1 -0
- package/dist/command-catalog.js +1032 -0
- package/dist/command-catalog.js.map +1 -0
- package/dist/contracts.d.ts +9 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +22 -6
- package/dist/contracts.js.map +1 -1
- package/dist/doctor.d.ts +55 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +82 -9
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +17 -0
- package/dist/env-file.d.ts.map +1 -0
- package/dist/env-file.js +113 -0
- package/dist/env-file.js.map +1 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +91 -15
- package/dist/fix-recipes.js.map +1 -1
- package/dist/internal/reserved-segment-codemod.d.ts +36 -0
- package/dist/internal/reserved-segment-codemod.d.ts.map +1 -0
- package/dist/internal/reserved-segment-codemod.js +134 -0
- package/dist/internal/reserved-segment-codemod.js.map +1 -0
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +62 -11
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +26 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +74 -24
- package/dist/introspect.js.map +1 -1
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +22 -5
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts +15 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +23 -9
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp.d.ts +16 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +127 -355
- package/dist/mcp.js.map +1 -1
- package/dist/project-facts.d.ts +40 -0
- package/dist/project-facts.d.ts.map +1 -0
- package/dist/project-facts.js +36 -0
- package/dist/project-facts.js.map +1 -0
- package/dist/recipes/3.0.0.d.ts +10 -0
- package/dist/recipes/3.0.0.d.ts.map +1 -0
- package/dist/recipes/3.0.0.js +23 -0
- package/dist/recipes/3.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +15 -0
- package/dist/release-verification.js.map +1 -1
- package/dist/rules/comment-markers.d.ts +8 -0
- package/dist/rules/comment-markers.d.ts.map +1 -1
- package/dist/rules/comment-markers.js +36 -0
- package/dist/rules/comment-markers.js.map +1 -1
- package/dist/rules/index.d.ts +2 -3
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +29 -44
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/security.js +14 -14
- package/dist/rules/security.js.map +1 -1
- package/dist/workspace-link.d.ts +37 -0
- package/dist/workspace-link.d.ts.map +1 -0
- package/dist/workspace-link.js +72 -0
- package/dist/workspace-link.js.map +1 -0
- package/docs/examples.json +1 -1
- package/docs/llms-full.txt +362 -20
- package/docs/types.json +956 -56
- package/package.json +9 -9
- package/src/capabilities-tool.ts +229 -21
- package/src/check.ts +312 -65
- package/src/cli.ts +140 -405
- package/src/command-catalog.ts +1435 -0
- package/src/contracts.ts +28 -7
- package/src/doctor.ts +134 -12
- package/src/env-file.ts +121 -0
- package/src/fix-recipes.ts +101 -15
- package/src/internal/reserved-segment-codemod.ts +150 -0
- package/src/internal/typescript-import.ts +61 -11
- package/src/introspect.ts +82 -21
- package/src/load.ts +25 -6
- package/src/manifest-tool.ts +43 -12
- package/src/mcp-http.ts +1 -1
- package/src/mcp.ts +163 -420
- package/src/project-facts.ts +86 -0
- package/src/recipes/3.0.0.ts +24 -0
- package/src/recipes/index.ts +2 -0
- package/src/release-verification.ts +17 -0
- package/src/rules/comment-markers.ts +36 -0
- package/src/rules/index.ts +3 -4
- package/src/rules/routes.ts +30 -44
- package/src/rules/security.ts +14 -14
- package/src/workspace-link.ts +80 -0
|
@@ -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
|
+
}
|
package/src/recipes/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/rules/index.ts
CHANGED
|
@@ -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
|
package/src/rules/routes.ts
CHANGED
|
@@ -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 (`
|
|
7
|
-
*
|
|
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)
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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.
|
|
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
|
|
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
|
|
52
|
-
typeof record.line
|
|
53
|
-
typeof record.method
|
|
54
|
-
typeof record.path
|
|
55
|
-
)
|
|
56
|
-
|
|
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 =
|
|
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.
|
|
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: ${
|
|
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
|
)
|
package/src/rules/security.ts
CHANGED
|
@@ -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.
|
|
112
|
-
const parsed = parsedFile(ts, ctx.
|
|
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.
|
|
173
|
-
const parsed = parsedFile(ts, ctx.
|
|
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.
|
|
221
|
-
const parsed = parsedFile(ts, ctx.
|
|
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.
|
|
288
|
-
const parsed = parsedFile(ts, ctx.
|
|
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.
|
|
349
|
-
const parsed = parsedFile(ts, ctx.
|
|
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.
|
|
406
|
-
const parsed = parsedFile(ts, ctx.
|
|
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.
|
|
449
|
-
const parsed = parsedFile(ts, ctx.
|
|
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
|
+
}
|