@cosmicdrift/kumiko-guards 0.281.0 → 0.283.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/package.json +2 -2
- package/src/_lib/generic-reason.ts +31 -1
- package/src/_lib/guard-kit.ts +58 -0
- package/src/changes.json +23 -0
- package/src/check-runtime-isolation.ts +214 -0
- package/src/cli.ts +55 -31
- package/src/guard-escape-hatch-declared.ts +90 -24
- package/src/guard-upgrade-state.ts +212 -0
- package/src/index.ts +3 -0
- package/src/run-guards.ts +28 -10
- package/src/run-repo-checks.ts +22 -4
- package/src/run-ui-guards.ts +19 -4
- package/src/runtime-isolation-classify.ts +337 -0
|
@@ -34,18 +34,24 @@
|
|
|
34
34
|
* declaration) is additionally recognized when a statement in its own direct
|
|
35
35
|
* body — not a nested function's, not inside an `if` — calls the bare
|
|
36
36
|
* identifier `declareEscapeHatch` with exactly one object-literal argument
|
|
37
|
-
* carrying a
|
|
37
|
+
* carrying a non-placeholder `reason` (from
|
|
38
38
|
* `@cosmicdrift/kumiko-framework/engine`'s `declareEscapeHatch`: a helper
|
|
39
39
|
* that escalates on a `HandlerContext` handed to it by its caller, rather
|
|
40
|
-
* than a `HandlerContext` from its own registration).
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
* than a `HandlerContext` from its own registration). `reason` may be a
|
|
41
|
+
* string/template literal or an Identifier resolving to a module-local
|
|
42
|
+
* `const` with such an initializer (`_lib/generic-reason.ts`'s
|
|
43
|
+
* `resolveReasonText`, shared by R2/R3/R4's four call sites) — so one shared
|
|
44
|
+
* constant covers several declarations without repeating the literal text.
|
|
45
|
+
* An import, a function call, or a template with substitutions stays
|
|
46
|
+
* unresolved and the R2/R3 finding names that explicitly. The declaration
|
|
47
|
+
* does not propagate upward: it covers escalations inside that function's
|
|
48
|
+
* own body, not the function it is nested inside. This detection is purely
|
|
43
49
|
* lexical — the guard matches on the name `declareEscapeHatch`, not on where
|
|
44
50
|
* it was imported from, so a same-named local function clears just as well;
|
|
45
51
|
* consistent with `escapeHatch:` itself, which is likewise never checked for
|
|
46
52
|
* origin. Referenced-by-variable functions, spread options, computed/string
|
|
47
|
-
* keys, and non-literal escapeHatch
|
|
48
|
-
* recognized (miss, don't falsely clear).
|
|
53
|
+
* keys, and a non-literal, non-module-local-const `escapeHatch`/`reason`
|
|
54
|
+
* value are conservatively not recognized (miss, don't falsely clear).
|
|
49
55
|
*
|
|
50
56
|
* Empty reasons, `openToAll.personalData` and PII are the framework boot validator's
|
|
51
57
|
* job (access-declarations.ts), not this guard's — except a `declareEscapeHatch`
|
|
@@ -72,7 +78,7 @@ import {
|
|
|
72
78
|
type SourceFile,
|
|
73
79
|
SyntaxKind,
|
|
74
80
|
} from "ts-morph";
|
|
75
|
-
import { isGenericReason,
|
|
81
|
+
import { isGenericReason, resolveReasonText } from "./_lib/generic-reason";
|
|
76
82
|
import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
77
83
|
|
|
78
84
|
const SCAN: ScanSpec = {
|
|
@@ -208,7 +214,8 @@ function findUnsafeRawFindings(
|
|
|
208
214
|
line: call.getStartLineNumber(),
|
|
209
215
|
rule: "unsafe-raw-outside-system-scope",
|
|
210
216
|
message:
|
|
211
|
-
'unsafeRaw(...) used outside a systemScope feature and outside a handler/hook declaring escapeHatch — declare { escapeHatch: { reason: "..." } } on the handler or hook, declare the feature systemScope, or use ctx.systemDb.acknowledgeCrossTenant(reason) for a scoped read.'
|
|
217
|
+
'unsafeRaw(...) used outside a systemScope feature and outside a handler/hook declaring escapeHatch — declare { escapeHatch: { reason: "..." } } on the handler or hook, declare the feature systemScope, call declareEscapeHatch({ reason: "..." }) as a direct body statement of a named hook, or use ctx.systemDb.acknowledgeCrossTenant(reason) for a scoped read.' +
|
|
218
|
+
unresolvableDeclareEscapeHatchHint(call),
|
|
212
219
|
});
|
|
213
220
|
}
|
|
214
221
|
return out;
|
|
@@ -372,27 +379,84 @@ function findReasonPropertyAssignment(
|
|
|
372
379
|
);
|
|
373
380
|
}
|
|
374
381
|
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if (!stmt.isKind(SyntaxKind.ExpressionStatement)) return false;
|
|
382
|
+
// Syntactic extraction only — shared by the clearance check below and by
|
|
383
|
+
// the "why" hint on R2/R3, which needs the reason node even when it turns
|
|
384
|
+
// out not to resolve.
|
|
385
|
+
function declareEscapeHatchReasonNode(stmt: Node): Node | undefined {
|
|
386
|
+
if (!stmt.isKind(SyntaxKind.ExpressionStatement)) return undefined;
|
|
381
387
|
const expr = stmt.getExpression();
|
|
382
|
-
if (!expr.isKind(SyntaxKind.CallExpression)) return
|
|
388
|
+
if (!expr.isKind(SyntaxKind.CallExpression)) return undefined;
|
|
383
389
|
const callee = expr.getExpression();
|
|
384
390
|
if (!callee.isKind(SyntaxKind.Identifier) || callee.getText() !== "declareEscapeHatch") {
|
|
385
|
-
return
|
|
391
|
+
return undefined;
|
|
386
392
|
}
|
|
387
393
|
const args = expr.getArguments();
|
|
388
394
|
const arg = args[0];
|
|
389
|
-
if (args.length !== 1 || !arg?.isKind(SyntaxKind.ObjectLiteralExpression)) return
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
395
|
+
if (args.length !== 1 || !arg?.isKind(SyntaxKind.ObjectLiteralExpression)) return undefined;
|
|
396
|
+
return findReasonPropertyAssignment(arg)?.getInitializer();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// declareEscapeHatch({ reason: "..." }) as a direct-body statement of a
|
|
400
|
+
// standalone function. No boot validator backs this form (unlike the
|
|
401
|
+
// escapeHatch: {...} property, which access-declarations.ts checks at boot),
|
|
402
|
+
// so an empty/placeholder reason is rejected here rather than left to it.
|
|
403
|
+
function isValidDeclareEscapeHatchCall(stmt: Node): boolean {
|
|
404
|
+
const reasonNode = declareEscapeHatchReasonNode(stmt);
|
|
405
|
+
if (!reasonNode) return false;
|
|
406
|
+
const reasonText = resolveReasonText(reasonNode);
|
|
393
407
|
return reasonText !== undefined && !isGenericReason(reasonText);
|
|
394
408
|
}
|
|
395
409
|
|
|
410
|
+
// The three reason shapes the guard can name a concrete cause for: an
|
|
411
|
+
// import, a function call, or a template with substitutions — none of
|
|
412
|
+
// those are statically judgeable. An ambient/uninitialized identifier
|
|
413
|
+
// (e.g. a `declare const` parameter) stays silently unresolved instead;
|
|
414
|
+
// there is nothing more specific to say about it.
|
|
415
|
+
function isExplicitlyUnresolvableReason(node: Node): boolean {
|
|
416
|
+
if (node.isKind(SyntaxKind.CallExpression)) return true;
|
|
417
|
+
if (node.isKind(SyntaxKind.TemplateExpression)) return true;
|
|
418
|
+
if (!node.isKind(SyntaxKind.Identifier)) return false;
|
|
419
|
+
const decls = node.getSymbol()?.getDeclarations() ?? [];
|
|
420
|
+
return decls.some(
|
|
421
|
+
(decl) =>
|
|
422
|
+
decl.isKind(SyntaxKind.ImportSpecifier) ||
|
|
423
|
+
decl.isKind(SyntaxKind.ImportClause) ||
|
|
424
|
+
decl.isKind(SyntaxKind.NamespaceImport),
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const UNRESOLVABLE_REASON_HINT =
|
|
429
|
+
" A declareEscapeHatch({ reason }) call was found here, but its reason is an import, a function call, or a template with substitutions — none of those can be statically judged, so declareEscapeHatch needs a string literal or a module-local const instead.";
|
|
430
|
+
|
|
431
|
+
// Walks the same ancestor chain as isInsideEscapeHatchDeclaredFunction, but
|
|
432
|
+
// looks for a declareEscapeHatch statement whose reason is one of the three
|
|
433
|
+
// explicitly-unresolvable shapes above, to explain a still-firing R2/R3
|
|
434
|
+
// finding rather than leave the reader to guess why a visible
|
|
435
|
+
// declareEscapeHatch call didn't clear it.
|
|
436
|
+
function unresolvableDeclareEscapeHatchHint(node: Node): string {
|
|
437
|
+
let ancestor: Node | undefined = node.getParent();
|
|
438
|
+
while (ancestor) {
|
|
439
|
+
if (
|
|
440
|
+
ancestor.isKind(SyntaxKind.ArrowFunction) ||
|
|
441
|
+
ancestor.isKind(SyntaxKind.FunctionExpression) ||
|
|
442
|
+
ancestor.isKind(SyntaxKind.MethodDeclaration) ||
|
|
443
|
+
ancestor.isKind(SyntaxKind.FunctionDeclaration)
|
|
444
|
+
) {
|
|
445
|
+
const body = ancestor.getBody();
|
|
446
|
+
if (body?.isKind(SyntaxKind.Block)) {
|
|
447
|
+
for (const stmt of body.getStatements()) {
|
|
448
|
+
const reasonNode = declareEscapeHatchReasonNode(stmt);
|
|
449
|
+
if (reasonNode && isExplicitlyUnresolvableReason(reasonNode)) {
|
|
450
|
+
return UNRESOLVABLE_REASON_HINT;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
ancestor = ancestor.getParent();
|
|
456
|
+
}
|
|
457
|
+
return "";
|
|
458
|
+
}
|
|
459
|
+
|
|
396
460
|
// Only the function's own direct body — not a nested function's, not an
|
|
397
461
|
// `if`'s — so a declareEscapeHatch call does not cover the function it is
|
|
398
462
|
// itself nested inside (miss, don't falsely clear).
|
|
@@ -457,7 +521,9 @@ function findSystemIdentityFindings(
|
|
|
457
521
|
file: path.relative(root, sf.getFilePath()),
|
|
458
522
|
line: call.getStartLineNumber(),
|
|
459
523
|
rule: "system-identity-outside-declared-scope",
|
|
460
|
-
message:
|
|
524
|
+
message:
|
|
525
|
+
`${methodName}(...) called with a system identity outside a declared scope — restrict to a systemScope feature, a .job.ts / r.job(...) job, declare { escapeHatch: { reason: "..." } } on the handler or hook, or call declareEscapeHatch({ reason: "..." }) as a direct body statement of a named hook.` +
|
|
526
|
+
unresolvableDeclareEscapeHatchHint(call),
|
|
461
527
|
});
|
|
462
528
|
}
|
|
463
529
|
return out;
|
|
@@ -525,7 +591,7 @@ function findGenericReasonMethodCalls(sf: SourceFile, root: string): GenericReas
|
|
|
525
591
|
if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
|
|
526
592
|
const methodName = expr.getName();
|
|
527
593
|
if (!GENERIC_REASON_METHODS.has(methodName)) continue;
|
|
528
|
-
const reasonText =
|
|
594
|
+
const reasonText = resolveReasonText(call.getArguments()[0]);
|
|
529
595
|
if (reasonText === undefined || !isGenericReason(reasonText)) continue;
|
|
530
596
|
out.push({
|
|
531
597
|
file: path.relative(root, sf.getFilePath()),
|
|
@@ -554,7 +620,7 @@ function findGenericReasonDeclareEscapeHatchCalls(
|
|
|
554
620
|
if (!arg?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
|
|
555
621
|
const reasonProp = findReasonPropertyAssignment(arg);
|
|
556
622
|
if (!reasonProp) continue;
|
|
557
|
-
const reasonText =
|
|
623
|
+
const reasonText = resolveReasonText(reasonProp.getInitializer());
|
|
558
624
|
if (reasonText === undefined || !isGenericReason(reasonText)) continue;
|
|
559
625
|
out.push({
|
|
560
626
|
file: path.relative(root, sf.getFilePath()),
|
|
@@ -579,7 +645,7 @@ function findGenericReasonObjectProperty(
|
|
|
579
645
|
if (!init?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
|
|
580
646
|
const reasonProp = init.getProperty("reason");
|
|
581
647
|
if (!reasonProp?.isKind(SyntaxKind.PropertyAssignment)) continue;
|
|
582
|
-
const reasonText =
|
|
648
|
+
const reasonText = resolveReasonText(reasonProp.getInitializer());
|
|
583
649
|
if (reasonText === undefined) continue;
|
|
584
650
|
// Empty/whitespace is the boot validator's job — no double-check.
|
|
585
651
|
if (reasonText.trim() === "") continue;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: a consumer repo's `.kumiko/upgrade-state.json` marker must be
|
|
4
|
+
* caught up with the installed Kumiko framework version.
|
|
5
|
+
*
|
|
6
|
+
* The marker is written by `kumiko-upgrade --apply` (local bin from
|
|
7
|
+
* `@cosmicdrift/kumiko-dev-server`) and records the version it was applied
|
|
8
|
+
* at. This guard re-runs `kumiko-upgrade --from <marker version> --json` and
|
|
9
|
+
* fails if any changelog entries are still pending — meaning the marker is
|
|
10
|
+
* stale and the repo hasn't run the upgrade since.
|
|
11
|
+
*
|
|
12
|
+
* Single-repo only, like `guard-upgrade-state.ts` in infra/guards but
|
|
13
|
+
* without that package's multi-repo `resolveRepoRoots()` scan loop — this
|
|
14
|
+
* package's `resolveRepoRoots()` only ever resolves the one repo `roots[0]`
|
|
15
|
+
* sits in. Repos without the marker file are `notApplicable` — this guard
|
|
16
|
+
* only fires once a repo has adopted the upgrade-state workflow at all.
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* bun guard-upgrade-state.ts
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import {
|
|
25
|
+
type GuardViolation,
|
|
26
|
+
type RepoCheck,
|
|
27
|
+
reportResults,
|
|
28
|
+
runRepoChecks,
|
|
29
|
+
} from "./_lib/guard-kit";
|
|
30
|
+
|
|
31
|
+
const MARKER_REL = ".kumiko/upgrade-state.json";
|
|
32
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
33
|
+
|
|
34
|
+
type UpgradeMarker = { readonly version: string };
|
|
35
|
+
|
|
36
|
+
type PendingEntry = {
|
|
37
|
+
readonly version: string;
|
|
38
|
+
readonly type: string;
|
|
39
|
+
readonly title: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type UpgradeJson = {
|
|
43
|
+
readonly currentVersion: string;
|
|
44
|
+
readonly installedVersion?: string | null;
|
|
45
|
+
readonly pending: readonly PendingEntry[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function resolveInstalledVersion(json: UpgradeJson): string {
|
|
49
|
+
return json.installedVersion ?? json.currentVersion;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isPendingEntry(value: unknown): value is PendingEntry {
|
|
53
|
+
if (!value || typeof value !== "object") return false;
|
|
54
|
+
const v = value as Record<string, unknown>;
|
|
55
|
+
return (
|
|
56
|
+
typeof v["version"] === "string" &&
|
|
57
|
+
typeof v["type"] === "string" &&
|
|
58
|
+
typeof v["title"] === "string"
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isUpgradeJson(value: unknown): value is UpgradeJson {
|
|
63
|
+
if (!value || typeof value !== "object") return false;
|
|
64
|
+
const v = value as Record<string, unknown>;
|
|
65
|
+
const installed = v["installedVersion"];
|
|
66
|
+
return (
|
|
67
|
+
typeof v["currentVersion"] === "string" &&
|
|
68
|
+
(installed === undefined || installed === null || typeof installed === "string") &&
|
|
69
|
+
Array.isArray(v["pending"]) &&
|
|
70
|
+
v["pending"].every(isPendingEntry)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function readMarker(root: string): UpgradeMarker | { error: string } {
|
|
75
|
+
const markerPath = join(root, MARKER_REL);
|
|
76
|
+
if (!existsSync(markerPath)) {
|
|
77
|
+
return {
|
|
78
|
+
error: `missing ${MARKER_REL} — run \`bun run kumiko-upgrade --apply\` once and commit the marker file`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
let parsed: unknown;
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(readFileSync(markerPath, "utf-8"));
|
|
84
|
+
} catch {
|
|
85
|
+
return { error: `${MARKER_REL} is not valid JSON — broken marker file` };
|
|
86
|
+
}
|
|
87
|
+
const version =
|
|
88
|
+
parsed && typeof parsed === "object"
|
|
89
|
+
? (parsed as Record<string, unknown>)["version"]
|
|
90
|
+
: undefined;
|
|
91
|
+
if (typeof version !== "string" || !SEMVER_RE.test(version)) {
|
|
92
|
+
return {
|
|
93
|
+
error: `${MARKER_REL} is missing a valid "version" field (expected semver x.y.z[-pre][+build]) — broken marker file`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return { version };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Pending changelog entries turned into guard violations — pure, testable without a subprocess. */
|
|
100
|
+
export function pendingViolations(json: UpgradeJson, markerVersion: string): GuardViolation[] {
|
|
101
|
+
if (json.pending.length === 0) return [];
|
|
102
|
+
const installedVersion = resolveInstalledVersion(json);
|
|
103
|
+
return json.pending.map((entry) => ({
|
|
104
|
+
file: MARKER_REL,
|
|
105
|
+
line: 1,
|
|
106
|
+
message:
|
|
107
|
+
`${MARKER_REL} is at ${markerVersion}, installed is ${installedVersion} — pending: ` +
|
|
108
|
+
`${entry.version} · ${entry.type} · ${entry.title}. Run \`bun run kumiko-upgrade --apply\`.`,
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resolveKumikoUpgradeBin(cwd: string): string | undefined {
|
|
113
|
+
const which = Bun.which("kumiko-upgrade");
|
|
114
|
+
if (which) return which;
|
|
115
|
+
let dir = cwd;
|
|
116
|
+
for (;;) {
|
|
117
|
+
const candidate = join(dir, "node_modules", ".bin", "kumiko-upgrade");
|
|
118
|
+
if (existsSync(candidate)) return candidate;
|
|
119
|
+
const parent = join(dir, "..");
|
|
120
|
+
if (parent === dir) break;
|
|
121
|
+
dir = parent;
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function runKumikoUpgrade(
|
|
127
|
+
version: string,
|
|
128
|
+
cwd: string,
|
|
129
|
+
): Promise<{ ok: true; json: UpgradeJson } | { ok: false; error: string }> {
|
|
130
|
+
const binPath = resolveKumikoUpgradeBin(cwd);
|
|
131
|
+
if (!binPath) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
error:
|
|
135
|
+
"`kumiko-upgrade` not resolvable — add `@cosmicdrift/kumiko-dev-server` as a devDependency or repair the install",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const proc = Bun.spawn({
|
|
139
|
+
cmd: [binPath, "--from", version, "--json"],
|
|
140
|
+
cwd,
|
|
141
|
+
env: { ...process.env, INIT_CWD: cwd },
|
|
142
|
+
stdout: "pipe",
|
|
143
|
+
stderr: "pipe",
|
|
144
|
+
});
|
|
145
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
146
|
+
new Response(proc.stdout).text(),
|
|
147
|
+
new Response(proc.stderr).text(),
|
|
148
|
+
proc.exited,
|
|
149
|
+
]);
|
|
150
|
+
if (exitCode !== 0) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
error: `\`kumiko-upgrade --from ${version} --json\` exited ${exitCode}:\n${stderr || stdout}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
let parsed: unknown;
|
|
157
|
+
try {
|
|
158
|
+
const jsonStart = stdout.indexOf("{");
|
|
159
|
+
parsed = JSON.parse(jsonStart >= 0 ? stdout.slice(jsonStart) : stdout);
|
|
160
|
+
} catch {
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
error: `\`kumiko-upgrade --from ${version} --json\` did not print valid JSON:\n${stdout}${stderr}`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (!isUpgradeJson(parsed)) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
error: `\`kumiko-upgrade --from ${version} --json\` printed JSON without a valid "pending" array:\n${stdout}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return { ok: true, json: parsed };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const check: RepoCheck = {
|
|
176
|
+
name: "Upgrade-State Guard",
|
|
177
|
+
hint: "Marker is written by `kumiko-upgrade --apply` — run it once the pending changelog entries are handled.",
|
|
178
|
+
async run(roots) {
|
|
179
|
+
const root = roots[0];
|
|
180
|
+
if (!root) return { violations: [], matchedFiles: 0, notApplicable: true };
|
|
181
|
+
const rootAbsPath = root.absPath;
|
|
182
|
+
if (!existsSync(join(rootAbsPath, MARKER_REL))) {
|
|
183
|
+
return { violations: [], matchedFiles: 0, notApplicable: true };
|
|
184
|
+
}
|
|
185
|
+
const marker = readMarker(rootAbsPath);
|
|
186
|
+
if ("error" in marker) {
|
|
187
|
+
return {
|
|
188
|
+
violations: [{ file: MARKER_REL, line: 1, message: marker.error }],
|
|
189
|
+
matchedFiles: 1,
|
|
190
|
+
notApplicable: false,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const result = await runKumikoUpgrade(marker.version, rootAbsPath);
|
|
194
|
+
if (!result.ok) {
|
|
195
|
+
return {
|
|
196
|
+
violations: [{ file: MARKER_REL, line: 1, message: result.error }],
|
|
197
|
+
matchedFiles: 1,
|
|
198
|
+
notApplicable: false,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
violations: pendingViolations(result.json, marker.version),
|
|
203
|
+
matchedFiles: 1,
|
|
204
|
+
notApplicable: false,
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
if (import.meta.main) {
|
|
210
|
+
const failed = reportResults(await runRepoChecks([check]));
|
|
211
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
212
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export {
|
|
2
2
|
type AstGuard,
|
|
3
|
+
buildGuardKitInventory,
|
|
3
4
|
buildSharedProject,
|
|
4
5
|
explainGuards,
|
|
5
6
|
filesForGuard,
|
|
7
|
+
type GuardKitInventory,
|
|
6
8
|
type GuardOutcome,
|
|
7
9
|
type GuardViolation,
|
|
8
10
|
isSecurityGuard,
|
|
@@ -14,6 +16,7 @@ export {
|
|
|
14
16
|
runGuards,
|
|
15
17
|
runRepoChecks,
|
|
16
18
|
type ScanSpec,
|
|
19
|
+
type SuiteInventory,
|
|
17
20
|
} from "./_lib/guard-kit";
|
|
18
21
|
export { findLocalRepo, type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
19
22
|
export {
|
package/src/run-guards.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// security guards.
|
|
10
10
|
import {
|
|
11
11
|
buildSharedProject,
|
|
12
|
+
cliFlagsError,
|
|
12
13
|
explainGuards,
|
|
13
14
|
isSecurityGuard,
|
|
14
15
|
printGuardKitBanner,
|
|
@@ -86,26 +87,43 @@ export const GUARDS = [
|
|
|
86
87
|
libTestCoverage,
|
|
87
88
|
];
|
|
88
89
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
export const GUARD_FLAGS = [
|
|
91
|
+
"--explain",
|
|
92
|
+
"--write-security-baseline",
|
|
93
|
+
"--strict-security-baseline",
|
|
94
|
+
] as const;
|
|
95
|
+
|
|
96
|
+
// Shared by the direct `bun run-guards.ts` invocation below and by the
|
|
97
|
+
// `guards` subcommand in cli.ts — one place for the flag behavior so the
|
|
98
|
+
// two entry points can never drift.
|
|
99
|
+
export function runGuardsCli(argv: readonly string[]): number {
|
|
100
|
+
const flagsError = cliFlagsError("guards", argv, GUARD_FLAGS);
|
|
101
|
+
if (flagsError !== undefined) {
|
|
102
|
+
console.error(flagsError);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
if (argv.includes("--explain")) {
|
|
93
106
|
for (const line of explainGuards(GUARDS, buildSharedProject(GUARDS))) {
|
|
94
107
|
console.log(line);
|
|
95
108
|
}
|
|
96
|
-
|
|
109
|
+
return 0;
|
|
97
110
|
}
|
|
98
|
-
if (
|
|
111
|
+
if (argv.includes("--write-security-baseline")) {
|
|
99
112
|
writeSecurityBaselines(
|
|
100
113
|
GUARDS.filter(isSecurityGuard),
|
|
101
114
|
buildSharedProject(GUARDS.filter(isSecurityGuard)),
|
|
102
115
|
);
|
|
103
|
-
|
|
116
|
+
return 0;
|
|
104
117
|
}
|
|
105
|
-
const strictSecurityBaseline =
|
|
118
|
+
const strictSecurityBaseline = argv.includes("--strict-security-baseline");
|
|
106
119
|
const guards = strictSecurityBaseline ? GUARDS.filter(isSecurityGuard) : GUARDS;
|
|
107
120
|
const project = buildSharedProject(guards);
|
|
108
121
|
printGuardKitBanner(guards.length, project);
|
|
109
|
-
|
|
110
|
-
|
|
122
|
+
return reportResults(runGuards(guards, project, { strictSecurityBaseline }));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Only run on direct invocation — otherwise `import { GUARDS }` would kick
|
|
126
|
+
// off the whole suite and the list wouldn't be testable.
|
|
127
|
+
if (import.meta.main) {
|
|
128
|
+
process.exit(runGuardsCli(process.argv.slice(2)));
|
|
111
129
|
}
|
package/src/run-repo-checks.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { printGuardKitBanner, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
2
|
+
import { cliFlagsError, printGuardKitBanner, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
3
3
|
// Standalone-`main()` guards ported as RepoCheck — run in-process, no
|
|
4
4
|
// per-guard subprocess/project.
|
|
5
|
+
import { check as runtimeIsolation } from "./check-runtime-isolation";
|
|
5
6
|
import { check as secretLiterals } from "./check-secret-literals";
|
|
6
7
|
import { check as featureIntegrationTests } from "./guard-feature-integration-tests";
|
|
7
8
|
import { check as noDirectProcessEnv } from "./guard-no-direct-process-env";
|
|
@@ -10,6 +11,7 @@ import { check as rawSql } from "./guard-raw-sql";
|
|
|
10
11
|
import { check as rendererBoundaries } from "./guard-renderer-boundaries";
|
|
11
12
|
import { check as testStackDrift } from "./guard-test-stack-drift";
|
|
12
13
|
import { check as thinWrappers } from "./guard-thin-wrappers";
|
|
14
|
+
import { check as upgradeState } from "./guard-upgrade-state";
|
|
13
15
|
|
|
14
16
|
export const REPO_CHECKS = [
|
|
15
17
|
rawSql,
|
|
@@ -20,12 +22,28 @@ export const REPO_CHECKS = [
|
|
|
20
22
|
secretLiterals,
|
|
21
23
|
featureIntegrationTests,
|
|
22
24
|
testStackDrift,
|
|
25
|
+
runtimeIsolation,
|
|
26
|
+
upgradeState,
|
|
23
27
|
];
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
// No flags today — the array stays so an unknown flag still fails loud
|
|
30
|
+
// instead of silently doing nothing, and so a future flag has one place to land.
|
|
31
|
+
export const REPO_CHECK_FLAGS: readonly string[] = [];
|
|
32
|
+
|
|
33
|
+
// Shared by the direct `bun run-repo-checks.ts` invocation below and by the
|
|
34
|
+
// `checks` subcommand in cli.ts.
|
|
35
|
+
export async function runRepoChecksCli(argv: readonly string[]): Promise<number> {
|
|
36
|
+
const flagsError = cliFlagsError("checks", argv, REPO_CHECK_FLAGS);
|
|
37
|
+
if (flagsError !== undefined) {
|
|
38
|
+
console.error(flagsError);
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
26
41
|
// No shared ts-morph Project here — RepoCheck.run() does its own file
|
|
27
42
|
// walk per check, so the banner omits the "Project: N files" line.
|
|
28
43
|
printGuardKitBanner(REPO_CHECKS.length);
|
|
29
|
-
|
|
30
|
-
|
|
44
|
+
return reportResults(await runRepoChecks(REPO_CHECKS));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (import.meta.main) {
|
|
48
|
+
process.exit(await runRepoChecksCli(process.argv.slice(2)));
|
|
31
49
|
}
|
package/src/run-ui-guards.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Project over the UI enforcement guards.
|
|
4
4
|
import {
|
|
5
5
|
buildSharedProject,
|
|
6
|
+
cliFlagsError,
|
|
6
7
|
printGuardKitBanner,
|
|
7
8
|
reportResults,
|
|
8
9
|
runGuards,
|
|
@@ -25,10 +26,24 @@ export const UI_GUARDS = [
|
|
|
25
26
|
i18nUiStrings,
|
|
26
27
|
];
|
|
27
28
|
|
|
28
|
-
//
|
|
29
|
-
|
|
29
|
+
// No flags today — the array stays so an unknown flag still fails loud
|
|
30
|
+
// instead of silently doing nothing, and so a future flag has one place to land.
|
|
31
|
+
export const UI_GUARD_FLAGS: readonly string[] = [];
|
|
32
|
+
|
|
33
|
+
// Shared by the direct `bun run-ui-guards.ts` invocation below and by the
|
|
34
|
+
// `ui` subcommand in cli.ts.
|
|
35
|
+
export function runUiGuardsCli(argv: readonly string[]): number {
|
|
36
|
+
const flagsError = cliFlagsError("ui", argv, UI_GUARD_FLAGS);
|
|
37
|
+
if (flagsError !== undefined) {
|
|
38
|
+
console.error(flagsError);
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
30
41
|
const project = buildSharedProject(UI_GUARDS);
|
|
31
42
|
printGuardKitBanner(UI_GUARDS.length, project);
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
return reportResults(runGuards(UI_GUARDS, project));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Same as run-guards.ts: only run on direct invocation.
|
|
47
|
+
if (import.meta.main) {
|
|
48
|
+
process.exit(runUiGuardsCli(process.argv.slice(2)));
|
|
34
49
|
}
|