@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.
@@ -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 literal, non-placeholder `reason` (from
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). The declaration does
41
- * not propagate upward: it covers escalations inside that function's own
42
- * body, not the function it is nested inside. This detection is purely
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/reason values are conservatively not
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, literalReasonText } from "./_lib/generic-reason";
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
- // declareEscapeHatch({ reason: "..." }) as a direct-body statement of a
376
- // standalone function. No boot validator backs this form (unlike the
377
- // escapeHatch: {...} property, which access-declarations.ts checks at boot),
378
- // so an empty/placeholder reason is rejected here rather than left to it.
379
- function isValidDeclareEscapeHatchCall(stmt: Node): boolean {
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 false;
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 false;
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 false;
390
- const reasonProp = findReasonPropertyAssignment(arg);
391
- if (!reasonProp) return false;
392
- const reasonText = literalReasonText(reasonProp.getInitializer());
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: `${methodName}(...) called with a system identity outside a declared scope — restrict to a systemScope feature, a .job.ts / r.job(...) job, or declare { escapeHatch: { reason: "..." } } on the handler or hook.`,
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 = literalReasonText(call.getArguments()[0]);
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 = literalReasonText(reasonProp.getInitializer());
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 = literalReasonText(reasonProp.getInitializer());
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
- // Only run on direct invocation — otherwise `import { GUARDS }` would kick
90
- // off the whole suite and the list wouldn't be testable.
91
- if (import.meta.main) {
92
- if (process.argv.includes("--explain")) {
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
- process.exit(0);
109
+ return 0;
97
110
  }
98
- if (process.argv.includes("--write-security-baseline")) {
111
+ if (argv.includes("--write-security-baseline")) {
99
112
  writeSecurityBaselines(
100
113
  GUARDS.filter(isSecurityGuard),
101
114
  buildSharedProject(GUARDS.filter(isSecurityGuard)),
102
115
  );
103
- process.exit(0);
116
+ return 0;
104
117
  }
105
- const strictSecurityBaseline = process.argv.includes("--strict-security-baseline");
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
- const failed = reportResults(runGuards(guards, project, { strictSecurityBaseline }));
110
- process.exit(failed > 0 ? 1 : 0);
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
  }
@@ -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
- if (import.meta.main) {
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
- const failed = reportResults(await runRepoChecks(REPO_CHECKS));
30
- process.exit(failed > 0 ? 1 : 0);
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
  }
@@ -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
- // Same as run-guards.ts: only run on direct invocation.
29
- if (import.meta.main) {
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
- const failed = reportResults(runGuards(UI_GUARDS, project));
33
- process.exit(failed > 0 ? 1 : 0);
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
  }