@cosmicdrift/kumiko-guards 0.297.0 → 0.299.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/README.md CHANGED
@@ -14,3 +14,22 @@ The baseline lives at `.kumiko-security-baseline.json` in the consumer
14
14
  repo's root. `--explain` prints the resolved repo and per-guard scan scope
15
15
  without running any guard; `--write-security-baseline` freezes current
16
16
  findings into the baseline file.
17
+
18
+ ## `kumiko-pre-push`
19
+
20
+ A POSIX-sh pre-push hook, wired by copying the package's `hooks/pre-push`
21
+ shim to `.husky/pre-push`. The shim execs the nearest executable
22
+ `node_modules/.bin/kumiko-pre-push` from the repo root upwards (so worktrees
23
+ without their own `node_modules` resolve via the parent install) and fails
24
+ closed with a `bun install` / `PRE_PUSH_SKIP=1` hint when none exists. Inside
25
+ the cosmicdriftgamestudio parent workspace it runs a scoped `bun check` for
26
+ the pushing repo only; in a standalone clone it runs the repo's own
27
+ `package.json` `test` script. Set `PRE_PUSH_SKIP=1` to bypass.
28
+
29
+ Extension point: a tracked, executable `scripts/pre-push-extra.sh` runs
30
+ before the main check in every branch (worktree, parent-scoped, and
31
+ standalone), with `KUMIKO_PUSH_REPO_ROOT` and `KUMIKO_PUSH_PARENT_DIR`
32
+ available inline (never exported, so nothing else inherits them). An
33
+ untracked or non-executable `scripts/pre-push-extra.sh` is never run — this
34
+ is fail-closed so a stray or unreviewed file can't hijack push. A non-zero
35
+ exit from `pre-push-extra.sh` aborts the push before the main check runs.
package/hooks/pre-push ADDED
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env sh
2
+ # PRE_PUSH_SKIP is checked here too so the bypass works even without node_modules.
3
+ if [ "${PRE_PUSH_SKIP:-0}" = "1" ]; then
4
+ echo "[pre-push] PRE_PUSH_SKIP=1 — hook skipped"
5
+ exit 0
6
+ fi
7
+
8
+ REPO_ROOT="$(git rev-parse --show-toplevel)"
9
+ if [ -z "$REPO_ROOT" ]; then
10
+ echo "[pre-push] FATAL: git rev-parse --show-toplevel failed — refusing push" >&2
11
+ exit 1
12
+ fi
13
+
14
+ # Walk up to / so a worktree without its own node_modules resolves via the
15
+ # parent install; `[ -x ]` also skips a broken (uninstalled) symlink.
16
+ BIN=""
17
+ DIR="$REPO_ROOT"
18
+ while true; do
19
+ CANDIDATE="$DIR/node_modules/.bin/kumiko-pre-push"
20
+ if [ -x "$CANDIDATE" ]; then
21
+ BIN="$CANDIDATE"
22
+ break
23
+ fi
24
+ if [ "$DIR" = "/" ]; then
25
+ break
26
+ fi
27
+ DIR="$(dirname "$DIR")"
28
+ done
29
+
30
+ if [ -z "$BIN" ]; then
31
+ echo "[pre-push] kumiko-pre-push not found in any node_modules/.bin above $REPO_ROOT." >&2
32
+ echo "Run \`bun install\`, or set PRE_PUSH_SKIP=1 to bypass this hook." >&2
33
+ exit 1
34
+ fi
35
+
36
+ cd "$REPO_ROOT" && exec "$BIN" "$@"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.297.0",
3
+ "version": "0.299.0",
4
4
  "description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -24,10 +24,11 @@
24
24
  }
25
25
  },
26
26
  "bin": {
27
- "kumiko-guards": "./src/cli.ts"
27
+ "kumiko-guards": "./src/cli.ts",
28
+ "kumiko-pre-push": "./src/pre-push.sh"
28
29
  },
29
30
  "dependencies": {
30
- "@cosmicdrift/kumiko-repo-manifest": "0.297.0",
31
+ "@cosmicdrift/kumiko-repo-manifest": "0.299.0",
31
32
  "ts-morph": "^28.0.0"
32
33
  },
33
34
  "publishConfig": {
@@ -37,6 +38,7 @@
37
38
  "files": [
38
39
  "src",
39
40
  "!src/**/__tests__/**",
41
+ "hooks",
40
42
  "README.md",
41
43
  "LICENSE"
42
44
  ]
@@ -52,7 +52,10 @@ export type AstGuard = {
52
52
  readonly hint?: string;
53
53
  /** Security guards fail on every finding not frozen in the per-repo security baseline — no skip flag. */
54
54
  readonly security?: boolean;
55
- run(files: readonly SourceFile[]): GuardOutcome;
55
+ /** `roots` are the same roots `runGuards` scanned `files` with — a guard classifying paths against repo roots (`relFromRepoRoot`) must use these, not re-derive its own single-repo `resolveRepoRoots()`, or a multi-root caller's roots never reach it. */
56
+ run(files: readonly SourceFile[], roots?: readonly RepoRoot[]): GuardOutcome;
57
+ /** Ratchet guards only: freeze the current findings into that guard's own baseline file. */
58
+ writeBaseline?(files: readonly SourceFile[]): void;
56
59
  };
57
60
 
58
61
  export function isSecurityGuard(guard: Pick<AstGuard, "security">): boolean {
@@ -309,7 +312,7 @@ export function runGuards(
309
312
  const scans = scan(guard, roots);
310
313
  const paths = [...new Set(scans.flatMap((s) => s.files))].sort();
311
314
  const files = paths.map((p) => project.getSourceFile(p) ?? project.addSourceFileAtPath(p));
312
- const outcome = guard.run(files);
315
+ const outcome = guard.run(files, roots);
313
316
  // Security guards get no skip flag: every finding must clear the baseline.
314
317
  const securityResult = isSecurityGuard(guard)
315
318
  ? applySecurityBaseline({
package/src/changes.json CHANGED
@@ -1,4 +1,21 @@
1
1
  [
2
+ {
3
+ "version": "0.299.0",
4
+ "type": "improvement",
5
+ "title": "kumiko-guards ships a kumiko-pre-push bin carrying the shared pre-push hook mechanics",
6
+ "detail": "Consumer repos can replace their vendored .husky/pre-push with a thin shim; a tracked, executable scripts/pre-push-extra.sh is the per-repo extension point."
7
+ },
8
+ {
9
+ "version": "0.299.0",
10
+ "type": "improvement",
11
+ "title": "kumiko-guards ships the canonical `hooks/pre-push` shim that execs the nearest `node_modules/.bin/kumiko-pre-push` and fails closed with a `bun install` / `PRE_PUSH_SKIP=1` hint when it is missing; repos copy it to `.husky/pre-push`."
12
+ },
13
+ {
14
+ "version": "0.299.0",
15
+ "type": "fix",
16
+ "title": "AstGuard.run receives the caller's repo roots (guard-test-timeouts, guard-pii-annotations, guard-section-fields-raw, guard-text-field-stance, guard-i18n-locale-mount, guard-i18n-locale-terminology, check-complexity, guard-direct-fetch, guard-no-direct-fs, guard-write-handler-qns) so multi-repo aggregate runs stop throwing \"cannot classify path\" or dropping sibling-repo findings; --write-baseline now requires --guard=<name>",
17
+ "migration": "No action needed for AstGuard.run's new optional `roots` parameter. `kumiko-guards guards --write-baseline` no longer writes every ratchet guard's baseline in one call; pass `--guard=<name>` to freeze one guard deliberately, or run it once per guard."
18
+ },
2
19
  {
3
20
  "version": "0.292.0",
4
21
  "type": "improvement",
@@ -41,10 +41,12 @@ import {
41
41
  type GuardOutcome,
42
42
  type GuardViolation,
43
43
  isLocalFinding,
44
+ relFromRepoRoot,
44
45
  runStandalone,
45
46
  type ScanSpec,
46
47
  } from "./_lib/guard-kit";
47
48
  import { hasIgnoreTag } from "./_lib/ignore-tag";
49
+ import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
48
50
 
49
51
  const ROOT = process.cwd();
50
52
 
@@ -153,8 +155,11 @@ export function computeComplexity(fn: Node): number {
153
155
  return complexity;
154
156
  }
155
157
 
156
- export function collectHotspots(sf: SourceFile): Hotspot[] {
157
- const file = path.relative(ROOT, sf.getFilePath());
158
+ export function collectHotspots(
159
+ sf: SourceFile,
160
+ roots: readonly RepoRoot[] = resolveRepoRoots(),
161
+ ): Hotspot[] {
162
+ const file = relFromRepoRoot(sf.getFilePath(), roots);
158
163
  const hotspots: Hotspot[] = [];
159
164
  for (const kind of FUNCTION_KINDS) {
160
165
  for (const fn of sf.getDescendantsOfKind(kind)) {
@@ -192,7 +197,10 @@ export function countHotspotsByFile(hotspots: readonly Hotspot[]): Record<string
192
197
  return counts;
193
198
  }
194
199
 
195
- function scan(files: readonly SourceFile[]): {
200
+ function scan(
201
+ files: readonly SourceFile[],
202
+ roots: readonly RepoRoot[] = resolveRepoRoots(),
203
+ ): {
196
204
  hotspots: Hotspot[];
197
205
  scanned: number;
198
206
  } {
@@ -201,7 +209,7 @@ function scan(files: readonly SourceFile[]): {
201
209
  for (const sf of files) {
202
210
  if (EXCLUDE.test(sf.getFilePath())) continue;
203
211
  scanned++;
204
- hotspots.push(...collectHotspots(sf));
212
+ hotspots.push(...collectHotspots(sf, roots));
205
213
  }
206
214
  hotspots.sort((a, b) => b.complexity - a.complexity);
207
215
  return { hotspots, scanned };
@@ -245,14 +253,18 @@ function checkBaseline(scanned: readonly Hotspot[]): GuardViolation[] {
245
253
  countHotspotsByFile(hotspots),
246
254
  `Split the function, or allow it with "// ${BUDGET_TAG} <reason>".`,
247
255
  {
248
- formatDriftRemediation: `Run \`bun guards/check-complexity.ts --write-baseline\` once.`,
256
+ formatDriftRemediation: `Run \`kumiko-guards guards --write-baseline --guard="Complexity Check"\` once.`,
249
257
  resolveLine: (file) => resolveHotspotLine(hotspots, file),
250
258
  },
251
259
  );
252
260
  }
253
261
 
254
- function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
255
- const { hotspots, scanned } = scan(files);
262
+ function analyse(
263
+ files: readonly SourceFile[],
264
+ roots: readonly RepoRoot[],
265
+ compareBaseline: boolean,
266
+ ): GuardOutcome {
267
+ const { hotspots, scanned } = scan(files, roots);
256
268
  report(hotspots, scanned);
257
269
  if (!compareBaseline) {
258
270
  console.log(" Baseline comparison skipped (--no-baseline).");
@@ -267,8 +279,13 @@ export const guard: AstGuard = {
267
279
  // Kein Remediation-Text hier: reportResults haengt hint an JEDEN Fail, auch
268
280
  // an Format-Drift, wo "Funktion aufteilen" in die Irre fuehrt. Der konkrete
269
281
  // Rat steht deshalb in der jeweiligen Violation-Message.
270
- hint: "after a deliberate change: `bun guards/check-complexity.ts --write-baseline`",
271
- run: (files) => analyse(files, true),
282
+ hint: 'after a deliberate change: `kumiko-guards guards --write-baseline --guard="Complexity Check"`',
283
+ run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
284
+ writeBaseline: (files) => {
285
+ const { hotspots, scanned } = scan(files);
286
+ report(hotspots, scanned);
287
+ complexityBaseline.write(countHotspotsByFile(localHotspots(hotspots)));
288
+ },
272
289
  };
273
290
 
274
291
  // Flags werden NUR hier gelesen, nicht in run() — der Shared-Runner
@@ -285,7 +302,7 @@ if (import.meta.main) {
285
302
  }
286
303
  if (args.includes("--no-baseline")) {
287
304
  const project = buildSharedProject([guard]);
288
- analyse(filesForGuard(project, guard), false);
305
+ analyse(filesForGuard(project, guard), resolveRepoRoots(), false);
289
306
  process.exit(0);
290
307
  }
291
308
  runStandalone(guard);
@@ -26,7 +26,7 @@ import {
26
26
  mentionsAsWord,
27
27
  nameForms,
28
28
  } from "./_lib/handler-name-forms";
29
- import { resolveRepoRoots } from "./_lib/roots";
29
+ import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
30
30
 
31
31
  const ROOT = process.cwd();
32
32
  const SCAN: ScanSpec = {
@@ -118,16 +118,15 @@ export const guard: AstGuard = {
118
118
  scan: SCAN,
119
119
  security: true,
120
120
  hint: "Role-restricted write handlers need a test that references the handler and asserts a caller without the role is rejected (AccessDeniedError / access_denied / 403). Existing gaps are frozen in the security baseline; after closing one: `bun guards/run-guards.ts --write-security-baseline`.",
121
- run(files) {
121
+ run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
122
122
  // consumer CI scans only its own repo, so a sibling repo's test must not count as coverage
123
- const violations: GuardViolation[] = findHandlersWithoutAccessDeniedTest(
124
- files,
125
- resolveRepoRoots(),
126
- ).map((h) => ({
127
- file: path.relative(ROOT, h.file),
128
- line: h.line,
129
- message: `role-restricted write handler "${h.name}" has no access-denied test — add a test that references the handler and asserts a caller without the role is rejected (AccessDeniedError / access_denied / 403).`,
130
- }));
123
+ const violations: GuardViolation[] = findHandlersWithoutAccessDeniedTest(files, roots).map(
124
+ (h) => ({
125
+ file: path.relative(ROOT, h.file),
126
+ line: h.line,
127
+ message: `role-restricted write handler "${h.name}" has no access-denied test — add a test that references the handler and asserts a caller without the role is rejected (AccessDeniedError / access_denied / 403).`,
128
+ }),
129
+ );
131
130
  return { violations };
132
131
  },
133
132
  };
@@ -29,7 +29,7 @@ import {
29
29
  runStandalone,
30
30
  type ScanSpec,
31
31
  } from "./_lib/guard-kit";
32
- import { resolveRepoRoots } from "./_lib/roots";
32
+ import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
33
33
 
34
34
  const SCAN: ScanSpec = {
35
35
  scope: "source",
@@ -123,9 +123,8 @@ export const guard: AstGuard = {
123
123
  'Same-origin path literal (`"/api/..."`) is allowed; otherwise put `// guard-allow: same-origin fetch` on the line above. ' +
124
124
  "Local bindings named fetch are flagged (false positive — unblock via that marker or ALLOWLIST in infra/guards/guard-direct-fetch.ts). " +
125
125
  'Known gap: bracket access (globalThis["fetch"](...)).',
126
- run(files) {
126
+ run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
127
127
  const violations: Array<{ file: string; line: number; message: string }> = [];
128
- const roots = resolveRepoRoots();
129
128
 
130
129
  for (const sf of files) {
131
130
  const file = sf.getFilePath();
@@ -18,7 +18,7 @@
18
18
  * bun guards/guard-i18n-locale-mount.ts
19
19
  */
20
20
 
21
- import { dirname, join, relative as pathRelative } from "node:path";
21
+ import { dirname, join } from "node:path";
22
22
  import {
23
23
  type FileSystemHost,
24
24
  type Identifier,
@@ -30,11 +30,12 @@ import {
30
30
  type AstGuard,
31
31
  type GuardViolation,
32
32
  isLocalFinding,
33
+ relFromRepoRoot,
33
34
  runStandalone,
34
35
  type ScanSpec,
35
36
  } from "./_lib/guard-kit";
37
+ import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
36
38
 
37
- const ROOT = process.cwd();
38
39
  const LOCALE_DE_DEP = "@cosmicdrift/kumiko-locale-de";
39
40
 
40
41
  const SCAN: ScanSpec = {
@@ -306,9 +307,9 @@ export const guard: AstGuard = {
306
307
  name: "i18n-Locale-Mount Guard",
307
308
  scan: SCAN,
308
309
  hint: "Mount point (createKumikoApp/createPublicSurface/LocaleProvider) without a German locale feature — add localeDeClient() to clientFeatures, or localeDe() to the server feature list.",
309
- run(files) {
310
+ run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
310
311
  const violations = findViolations(files)
311
- .map((v) => ({ ...v, file: pathRelative(ROOT, v.file) }))
312
+ .map((v) => ({ ...v, file: relFromRepoRoot(v.file, roots) }))
312
313
  .filter(isLocalFinding);
313
314
  return { violations };
314
315
  },
@@ -13,18 +13,17 @@
13
13
  * Ignore: // kumiko-lint-ignore i18n-locale-terminology
14
14
  */
15
15
 
16
- import { relative as pathRelative } from "node:path";
17
16
  import { type Node, type SourceFile, SyntaxKind } from "ts-morph";
18
17
  import {
19
18
  type AstGuard,
20
19
  type GuardViolation,
21
20
  isLocalFinding,
21
+ relFromRepoRoot,
22
22
  runStandalone,
23
23
  type ScanSpec,
24
24
  } from "./_lib/guard-kit";
25
25
  import { hasIgnoreTag } from "./_lib/ignore-tag";
26
-
27
- const ROOT = process.cwd();
26
+ import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
28
27
 
29
28
  const SCAN: ScanSpec = {
30
29
  scope: "source",
@@ -106,9 +105,9 @@ export const guard: AstGuard = {
106
105
  name: "i18n-Locale-Terminology Guard",
107
106
  scan: SCAN,
108
107
  hint: "DE: Mandant statt Tenant/Organisation; ES: Organización statt tenant-Loanword. Rollen wie TenantAdmin sind OK.",
109
- run(files) {
108
+ run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
110
109
  const violations = findViolations(files)
111
- .map((v) => ({ ...v, file: pathRelative(ROOT, v.file) }))
110
+ .map((v) => ({ ...v, file: relFromRepoRoot(v.file, roots) }))
112
111
  .filter(isLocalFinding);
113
112
  return { violations };
114
113
  },
@@ -34,7 +34,7 @@ import {
34
34
  runStandalone,
35
35
  type ScanSpec,
36
36
  } from "./_lib/guard-kit";
37
- import { resolveRepoRoots } from "./_lib/roots";
37
+ import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
38
38
 
39
39
  const SCAN: ScanSpec = {
40
40
  scope: "source",
@@ -204,9 +204,8 @@ export const guard: AstGuard = {
204
204
  scan: SCAN,
205
205
  security: true,
206
206
  hint: "Direct node:fs import outside the allowlist — use FileStorageProvider (packages/framework/src/files/) instead of wiring fs yourself. The path-traversal guard (resolveContainedPath) only exists there. Legitimate new tooling caller? Extend the allowlist in guard-no-direct-fs.ts, with a reason + repo scope.",
207
- run(files) {
207
+ run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
208
208
  const violations: Array<{ file: string; line: number; message: string }> = [];
209
- const roots = resolveRepoRoots();
210
209
 
211
210
  for (const sf of files) {
212
211
  const file = sf.getFilePath();
@@ -37,9 +37,11 @@ import {
37
37
  type GuardOutcome,
38
38
  type GuardViolation,
39
39
  isLocalFinding,
40
+ relFromRepoRoot,
40
41
  runStandalone,
41
42
  type ScanSpec,
42
43
  } from "./_lib/guard-kit";
44
+ import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
43
45
 
44
46
  const ROOT = process.cwd();
45
47
 
@@ -89,8 +91,8 @@ const PII_USER_OWNED_NAME_HINTS = new Set([
89
91
  "notes",
90
92
  ]);
91
93
 
92
- function relFile(sf: SourceFile): string {
93
- return path.relative(ROOT, sf.getFilePath());
94
+ function relFile(sf: SourceFile, roots: readonly RepoRoot[]): string {
95
+ return relFromRepoRoot(sf.getFilePath(), roots);
94
96
  }
95
97
 
96
98
  function fieldFactoryOptions(call: CallExpression): ObjectLiteralExpression | undefined {
@@ -149,7 +151,7 @@ export interface Finding {
149
151
  hint: string;
150
152
  }
151
153
 
152
- function scanFieldFactories(sf: SourceFile): Finding[] {
154
+ function scanFieldFactories(sf: SourceFile, roots: readonly RepoRoot[]): Finding[] {
153
155
  const findings: Finding[] = [];
154
156
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
155
157
  const callee = call.getExpression().getText();
@@ -171,15 +173,19 @@ function scanFieldFactories(sf: SourceFile): Finding[] {
171
173
  if (!hint) continue;
172
174
 
173
175
  const line = call.getStartLineNumber();
174
- findings.push({ file: relFile(sf), line, fieldName, hint });
176
+ const file = relFile(sf, roots);
177
+ findings.push({ file, line, fieldName, hint });
175
178
  console.warn(
176
- ` [pii-annotations WARN] ${relFile(sf)}:${line} field "${fieldName}" looks like PII — mark ${hint}`,
179
+ ` [pii-annotations WARN] ${file}:${line} field "${fieldName}" looks like PII — mark ${hint}`,
177
180
  );
178
181
  }
179
182
  return findings;
180
183
  }
181
184
 
182
- function scan(files: readonly SourceFile[]): {
185
+ function scan(
186
+ files: readonly SourceFile[],
187
+ roots: readonly RepoRoot[] = resolveRepoRoots(),
188
+ ): {
183
189
  findings: Finding[];
184
190
  scanned: number;
185
191
  } {
@@ -188,7 +194,7 @@ function scan(files: readonly SourceFile[]): {
188
194
  for (const sf of files) {
189
195
  if (EXCLUDE.test(sf.getFilePath())) continue;
190
196
  scanned++;
191
- findings.push(...scanFieldFactories(sf));
197
+ findings.push(...scanFieldFactories(sf, roots));
192
198
  }
193
199
  return { findings, scanned };
194
200
  }
@@ -224,14 +230,18 @@ function checkBaseline(findings: readonly Finding[]): GuardViolation[] {
224
230
  baselineCounts(findings),
225
231
  `Annotate the field ({ personal: ... } / { pii: true } / { userOwned: ... } / { tenantOwned: true } / { allowPlaintext: "..." }).`,
226
232
  {
227
- formatDriftRemediation: `Run \`bun guards/guard-pii-annotations.ts --write-baseline\` once.`,
233
+ formatDriftRemediation: `Run \`kumiko-guards guards --write-baseline --guard="PII-Annotations Guard"\` once.`,
228
234
  resolveLine,
229
235
  },
230
236
  );
231
237
  }
232
238
 
233
- function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
234
- const { findings } = scan(files);
239
+ function analyse(
240
+ files: readonly SourceFile[],
241
+ roots: readonly RepoRoot[],
242
+ compareBaseline: boolean,
243
+ ): GuardOutcome {
244
+ const { findings } = scan(files, roots);
235
245
  if (!compareBaseline) {
236
246
  console.log(" Baseline comparison skipped (--no-baseline).");
237
247
  return { violations: [] };
@@ -242,8 +252,9 @@ function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardO
242
252
  export const guard: AstGuard = {
243
253
  name: "PII-Annotations Guard",
244
254
  scan: SCAN,
245
- hint: "after a deliberate annotation: `bun guards/guard-pii-annotations.ts --write-baseline`",
246
- run: (files) => analyse(files, true),
255
+ hint: 'after a deliberate annotation: `kumiko-guards guards --write-baseline --guard="PII-Annotations Guard"`',
256
+ run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
257
+ writeBaseline: (files) => piiBaseline.write(baselineCounts(scan(files).findings)),
247
258
  };
248
259
 
249
260
  // Flags werden NUR hier gelesen, nicht in run() — der Shared-Runner
@@ -260,7 +271,7 @@ if (import.meta.main) {
260
271
  }
261
272
  if (args.includes("--no-baseline")) {
262
273
  const project = buildSharedProject([guard]);
263
- analyse(filesForGuard(project, guard), false);
274
+ analyse(filesForGuard(project, guard), resolveRepoRoots(), false);
264
275
  process.exit(0);
265
276
  }
266
277
  runStandalone(guard);
@@ -113,9 +113,8 @@ export const guard: AstGuard = {
113
113
  name: "Restricted-Symbols Guard",
114
114
  scan: SCAN,
115
115
  hint: 'getUnscopedAggregateStream{MaxVersion,Tenant} is an existence oracle for foreign tenants — only seed-/system-internal code may reference them. New caller needed? Extend the allowlist in guard-restricted-symbols.ts, with a reason. Known gap: `export * from "...event-store"` is not detected (named imports, re-exports, and namespace property access are covered).',
116
- run(files) {
116
+ run(files, roots = resolveRepoRoots()) {
117
117
  const violations: Array<{ file: string; line: number; message: string }> = [];
118
- const roots = resolveRepoRoots();
119
118
 
120
119
  for (const sf of files) {
121
120
  const file = sf.getFilePath();
@@ -44,9 +44,11 @@ import {
44
44
  type GuardOutcome,
45
45
  type GuardViolation,
46
46
  isLocalFinding,
47
+ relFromRepoRoot,
47
48
  runStandalone,
48
49
  type ScanSpec,
49
50
  } from "./_lib/guard-kit";
51
+ import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
50
52
 
51
53
  const ROOT = process.cwd();
52
54
 
@@ -100,8 +102,8 @@ const ARRAY_READS = new Set([
100
102
  "values",
101
103
  ]);
102
104
 
103
- function relFile(sf: SourceFile): string {
104
- return path.relative(ROOT, sf.getFilePath());
105
+ function relFile(sf: SourceFile, roots: readonly RepoRoot[]): string {
106
+ return relFromRepoRoot(sf.getFilePath(), roots);
105
107
  }
106
108
 
107
109
  function hasReasonedIgnoreTag(node: Node): boolean {
@@ -129,7 +131,10 @@ export interface Finding {
129
131
  snippet: string;
130
132
  }
131
133
 
132
- export function scanRawSectionFieldReads(sf: SourceFile): Finding[] {
134
+ export function scanRawSectionFieldReads(
135
+ sf: SourceFile,
136
+ roots: readonly RepoRoot[] = resolveRepoRoots(),
137
+ ): Finding[] {
133
138
  const findings: Finding[] = [];
134
139
  for (const access of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
135
140
  if (access.getName() !== "fields") continue;
@@ -137,19 +142,23 @@ export function scanRawSectionFieldReads(sf: SourceFile): Finding[] {
137
142
  if (!isSequenceRead(access)) continue;
138
143
  if (hasReasonedIgnoreTag(access)) continue;
139
144
  const line = access.getStartLineNumber();
140
- findings.push({ file: relFile(sf), line, snippet: access.getText().slice(0, 80) });
145
+ const file = relFile(sf, roots);
146
+ findings.push({ file, line, snippet: access.getText().slice(0, 80) });
141
147
  console.warn(
142
- ` [section-fields-raw WARN] ${relFile(sf)}:${line} raw read of ${access.getText().slice(0, 80)} — use sectionFieldSpecs(section)`,
148
+ ` [section-fields-raw WARN] ${file}:${line} raw read of ${access.getText().slice(0, 80)} — use sectionFieldSpecs(section)`,
143
149
  );
144
150
  }
145
151
  return findings;
146
152
  }
147
153
 
148
- function scan(files: readonly SourceFile[]): Finding[] {
154
+ function scan(
155
+ files: readonly SourceFile[],
156
+ roots: readonly RepoRoot[] = resolveRepoRoots(),
157
+ ): Finding[] {
149
158
  const findings: Finding[] = [];
150
159
  for (const sf of files) {
151
160
  if (EXCLUDE.test(sf.getFilePath())) continue;
152
- findings.push(...scanRawSectionFieldReads(sf));
161
+ findings.push(...scanRawSectionFieldReads(sf, roots));
153
162
  }
154
163
  return findings;
155
164
  }
@@ -169,8 +178,12 @@ export function baselineCounts(findings: readonly Finding[]): Record<string, num
169
178
  return counts;
170
179
  }
171
180
 
172
- function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
173
- const findings = scan(files);
181
+ function analyse(
182
+ files: readonly SourceFile[],
183
+ roots: readonly RepoRoot[],
184
+ compareBaseline: boolean,
185
+ ): GuardOutcome {
186
+ const findings = scan(files, roots);
174
187
  if (!compareBaseline) {
175
188
  console.log(" Baseline comparison skipped (--no-baseline).");
176
189
  return { violations: [] };
@@ -180,7 +193,8 @@ function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardO
180
193
  baselineCounts(findings),
181
194
  REMEDIATION,
182
195
  {
183
- formatDriftRemediation: "Run `bun guards/guard-section-fields-raw.ts --write-baseline` once.",
196
+ formatDriftRemediation:
197
+ 'Run `kumiko-guards guards --write-baseline --guard="Raw section.fields Guard"` once.',
184
198
  resolveLine: (file) => local.find((f) => f.file === file)?.line ?? 1,
185
199
  },
186
200
  );
@@ -190,8 +204,9 @@ function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardO
190
204
  export const guard: AstGuard = {
191
205
  name: "Raw section.fields Guard",
192
206
  scan: SCAN,
193
- hint: `${REMEDIATION} Known gap: \`section.fields.length\` is not flagged. After a deliberate change: \`bun guards/guard-section-fields-raw.ts --write-baseline\`.`,
194
- run: (files) => analyse(files, true),
207
+ hint: `${REMEDIATION} Known gap: \`section.fields.length\` is not flagged. After a deliberate change: \`kumiko-guards guards --write-baseline --guard="Raw section.fields Guard"\`.`,
208
+ run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
209
+ writeBaseline: (files) => sectionFieldsRawBaseline.write(baselineCounts(scan(files))),
195
210
  };
196
211
 
197
212
  // Flags are read only here, never in run() — the shared runner drives every
@@ -206,7 +221,7 @@ if (import.meta.main) {
206
221
  }
207
222
  if (args.includes("--no-baseline")) {
208
223
  const project = buildSharedProject([guard]);
209
- analyse(filesForGuard(project, guard), false);
224
+ analyse(filesForGuard(project, guard), resolveRepoRoots(), false);
210
225
  process.exit(0);
211
226
  }
212
227
  runStandalone(guard);
@@ -21,10 +21,12 @@
21
21
  * baseline fails, at or under passes. Without the file the guard is
22
22
  * warning-only (bootstrap: `--write-baseline` once).
23
23
  *
24
- * Usage:
24
+ * Usage (framework dev checkout):
25
25
  * bun guards/guard-test-timeouts.ts # compare to baseline
26
26
  * bun guards/guard-test-timeouts.ts --write-baseline # freeze current
27
27
  * bun guards/guard-test-timeouts.ts --no-baseline # skip comparison
28
+ * Consumer (via the published `kumiko-guards` CLI):
29
+ * kumiko-guards guards --write-baseline --guard=test-timeouts
28
30
  */
29
31
  import * as path from "node:path";
30
32
  import { type CallExpression, Node, type SourceFile, SyntaxKind } from "ts-morph";
@@ -36,9 +38,11 @@ import {
36
38
  type GuardOutcome,
37
39
  type GuardViolation,
38
40
  isLocalFinding,
41
+ relFromRepoRoot,
39
42
  runStandalone,
40
43
  type ScanSpec,
41
44
  } from "./_lib/guard-kit";
45
+ import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
42
46
 
43
47
  const ROOT = process.cwd();
44
48
 
@@ -78,8 +82,8 @@ export interface Finding {
78
82
  message: string;
79
83
  }
80
84
 
81
- function relFile(sf: SourceFile): string {
82
- return path.relative(ROOT, sf.getFilePath());
85
+ function relFile(sf: SourceFile, roots: readonly RepoRoot[]): string {
86
+ return relFromRepoRoot(sf.getFilePath(), roots);
83
87
  }
84
88
 
85
89
  function isLoop(node: Node): boolean {
@@ -151,9 +155,12 @@ function withExceptionNote(node: Node, message: string): string | undefined {
151
155
  : message;
152
156
  }
153
157
 
154
- export function scanTimeouts(sf: SourceFile): Finding[] {
158
+ export function scanTimeouts(
159
+ sf: SourceFile,
160
+ roots: readonly RepoRoot[] = resolveRepoRoots(),
161
+ ): Finding[] {
155
162
  if (sf.getFilePath().includes(RECIPES_DIR)) return [];
156
- const file = relFile(sf);
163
+ const file = relFile(sf, roots);
157
164
  const findings: Finding[] = [];
158
165
  const add = (node: Node, message: string): void => {
159
166
  const reported = withExceptionNote(node, message);
@@ -184,7 +191,7 @@ const timeoutsBaseline = baselineRatchet({
184
191
  const REMEDIATION = `Wait for a condition (\`waitFor\`, \`expect.poll\`) instead of raising timeouts or sleeping, or mark the line with \`// ${EXCEPTION_TAG}: #<issue> <technical reason>\`.`;
185
192
 
186
193
  const HINT =
187
- "Diagnose in this order: (1) reproduce a single run (`--repeat-each`, single file), (2) check shared state and missing isolation (one tenant per flow), (3) wait for a condition, not for time (`waitFor`, `expect.poll`), (4) check the seed, (5) only then change the central template via an issue — never per app. See docs/guides/test-failures.md. After a deliberate change: `bun guards/guard-test-timeouts.ts --write-baseline`.";
194
+ "Diagnose in this order: (1) reproduce a single run (`--repeat-each`, single file), (2) check shared state and missing isolation (one tenant per flow), (3) wait for a condition, not for time (`waitFor`, `expect.poll`), (4) check the seed, (5) only then change the central template via an issue — never per app. See https://github.com/CosmicDriftGameStudio/kumiko-framework/blob/main/docs/guides/test-failures.md. After a deliberate change: `kumiko-guards guards --write-baseline --guard=test-timeouts`.";
188
195
 
189
196
  export function baselineCounts(findings: readonly Finding[]): Record<string, number> {
190
197
  const counts: Record<string, number> = {};
@@ -194,10 +201,13 @@ export function baselineCounts(findings: readonly Finding[]): Record<string, num
194
201
  return counts;
195
202
  }
196
203
 
197
- function scan(files: readonly SourceFile[]): Finding[] {
204
+ function scan(
205
+ files: readonly SourceFile[],
206
+ roots: readonly RepoRoot[] = resolveRepoRoots(),
207
+ ): Finding[] {
198
208
  const findings: Finding[] = [];
199
209
  for (const sf of files) {
200
- for (const finding of scanTimeouts(sf)) {
210
+ for (const finding of scanTimeouts(sf, roots)) {
201
211
  findings.push(finding);
202
212
  console.warn(` [test-timeouts WARN] ${finding.file}:${finding.line} ${finding.message}`);
203
213
  }
@@ -205,8 +215,12 @@ function scan(files: readonly SourceFile[]): Finding[] {
205
215
  return findings;
206
216
  }
207
217
 
208
- function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
209
- const findings = scan(files);
218
+ function analyse(
219
+ files: readonly SourceFile[],
220
+ roots: readonly RepoRoot[],
221
+ compareBaseline: boolean,
222
+ ): GuardOutcome {
223
+ const findings = scan(files, roots);
210
224
  if (!compareBaseline) {
211
225
  console.log(" Baseline comparison skipped (--no-baseline).");
212
226
  return { violations: [] };
@@ -216,7 +230,8 @@ function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardO
216
230
  baselineCounts(findings),
217
231
  REMEDIATION,
218
232
  {
219
- formatDriftRemediation: "Run `bun guards/guard-test-timeouts.ts --write-baseline` once.",
233
+ formatDriftRemediation:
234
+ "Run `kumiko-guards guards --write-baseline --guard=test-timeouts` once.",
220
235
  resolveLine: (file) => local.find((f) => f.file === file)?.line ?? 1,
221
236
  },
222
237
  );
@@ -227,7 +242,8 @@ export const guard: AstGuard = {
227
242
  name: "test-timeouts",
228
243
  scan: SCAN,
229
244
  hint: HINT,
230
- run: (files) => analyse(files, true),
245
+ run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
246
+ writeBaseline: (files) => timeoutsBaseline.write(baselineCounts(scan(files))),
231
247
  };
232
248
 
233
249
  // Flags are read only here, never in run() — the shared runner drives every
@@ -237,12 +253,12 @@ if (import.meta.main) {
237
253
  const args = process.argv.slice(2);
238
254
  if (args.includes("--write-baseline")) {
239
255
  const project = buildSharedProject([guard]);
240
- timeoutsBaseline.write(baselineCounts(scan(filesForGuard(project, guard))));
256
+ guard.writeBaseline?.(filesForGuard(project, guard));
241
257
  process.exit(0);
242
258
  }
243
259
  if (args.includes("--no-baseline")) {
244
260
  const project = buildSharedProject([guard]);
245
- analyse(filesForGuard(project, guard), false);
261
+ analyse(filesForGuard(project, guard), resolveRepoRoots(), false);
246
262
  process.exit(0);
247
263
  }
248
264
  runStandalone(guard);
@@ -39,9 +39,11 @@ import {
39
39
  type GuardOutcome,
40
40
  type GuardViolation,
41
41
  isLocalFinding,
42
+ relFromRepoRoot,
42
43
  runStandalone,
43
44
  type ScanSpec,
44
45
  } from "./_lib/guard-kit";
46
+ import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
45
47
 
46
48
  const ROOT = process.cwd();
47
49
 
@@ -61,8 +63,8 @@ const FIELD_FACTORY_CALLEES = new Set(["createTextField", "createLongTextField"]
61
63
  const VALID_PERSONAL_HINT =
62
64
  '{ personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false } ("false" additionally needs { reason: "..." })';
63
65
 
64
- function relFile(sf: SourceFile): string {
65
- return path.relative(ROOT, sf.getFilePath());
66
+ function relFile(sf: SourceFile, roots: readonly RepoRoot[]): string {
67
+ return relFromRepoRoot(sf.getFilePath(), roots);
66
68
  }
67
69
 
68
70
  function fieldFactoryOptions(call: CallExpression): ObjectLiteralExpression | undefined {
@@ -110,7 +112,7 @@ export interface Finding {
110
112
  callee: string;
111
113
  }
112
114
 
113
- function scanFieldFactories(sf: SourceFile): Finding[] {
115
+ function scanFieldFactories(sf: SourceFile, roots: readonly RepoRoot[]): Finding[] {
114
116
  const findings: Finding[] = [];
115
117
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
116
118
  const callee = call.getExpression().getText();
@@ -126,15 +128,19 @@ function scanFieldFactories(sf: SourceFile): Finding[] {
126
128
 
127
129
  const place = enclosingFieldName(call) ?? `${callee}(...)`;
128
130
  const line = call.getStartLineNumber();
129
- findings.push({ file: relFile(sf), line, place, callee });
131
+ const file = relFile(sf, roots);
132
+ findings.push({ file, line, place, callee });
130
133
  console.warn(
131
- ` [text-field-stance WARN] ${relFile(sf)}:${line} ${callee}(...) at "${place}" has no personal stance — mark ${VALID_PERSONAL_HINT}`,
134
+ ` [text-field-stance WARN] ${file}:${line} ${callee}(...) at "${place}" has no personal stance — mark ${VALID_PERSONAL_HINT}`,
132
135
  );
133
136
  }
134
137
  return findings;
135
138
  }
136
139
 
137
- function scan(files: readonly SourceFile[]): {
140
+ function scan(
141
+ files: readonly SourceFile[],
142
+ roots: readonly RepoRoot[] = resolveRepoRoots(),
143
+ ): {
138
144
  findings: Finding[];
139
145
  scanned: number;
140
146
  } {
@@ -143,7 +149,7 @@ function scan(files: readonly SourceFile[]): {
143
149
  for (const sf of files) {
144
150
  if (EXCLUDE.test(sf.getFilePath())) continue;
145
151
  scanned++;
146
- findings.push(...scanFieldFactories(sf));
152
+ findings.push(...scanFieldFactories(sf, roots));
147
153
  }
148
154
  return { findings, scanned };
149
155
  }
@@ -179,14 +185,18 @@ function checkBaseline(findings: readonly Finding[]): GuardViolation[] {
179
185
  baselineCounts(findings),
180
186
  `Annotate the call (${VALID_PERSONAL_HINT}).`,
181
187
  {
182
- formatDriftRemediation: `Run \`bun guards/guard-text-field-stance.ts --write-baseline\` once.`,
188
+ formatDriftRemediation: `Run \`kumiko-guards guards --write-baseline --guard="Text-Field Personal-Stance Guard"\` once.`,
183
189
  resolveLine,
184
190
  },
185
191
  );
186
192
  }
187
193
 
188
- function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
189
- const { findings } = scan(files);
194
+ function analyse(
195
+ files: readonly SourceFile[],
196
+ roots: readonly RepoRoot[],
197
+ compareBaseline: boolean,
198
+ ): GuardOutcome {
199
+ const { findings } = scan(files, roots);
190
200
  if (!compareBaseline) {
191
201
  console.log(" Baseline comparison skipped (--no-baseline).");
192
202
  return { violations: [] };
@@ -197,8 +207,9 @@ function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardO
197
207
  export const guard: AstGuard = {
198
208
  name: "Text-Field Personal-Stance Guard",
199
209
  scan: SCAN,
200
- hint: "after a deliberate annotation: `bun guards/guard-text-field-stance.ts --write-baseline`",
201
- run: (files) => analyse(files, true),
210
+ hint: 'after a deliberate annotation: `kumiko-guards guards --write-baseline --guard="Text-Field Personal-Stance Guard"`',
211
+ run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
212
+ writeBaseline: (files) => textFieldStanceBaseline.write(baselineCounts(scan(files).findings)),
202
213
  };
203
214
 
204
215
  // Flags werden NUR hier gelesen, nicht in run() — der Shared-Runner
@@ -215,7 +226,7 @@ if (import.meta.main) {
215
226
  }
216
227
  if (args.includes("--no-baseline")) {
217
228
  const project = buildSharedProject([guard]);
218
- analyse(filesForGuard(project, guard), false);
229
+ analyse(filesForGuard(project, guard), resolveRepoRoots(), false);
219
230
  process.exit(0);
220
231
  }
221
232
  runStandalone(guard);
@@ -183,8 +183,7 @@ export const guard: AstGuard = {
183
183
  name: "Write-Handler-QN Guard",
184
184
  scan: SCAN,
185
185
  hint: "Typo in the write-handler QN? Check feature name + handler name.",
186
- run(files) {
187
- const roots = resolveRepoRoots();
186
+ run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
188
187
  const knownQnsByRepo = loadKnownQnsByRepo(roots);
189
188
 
190
189
  const violations: Array<{
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env sh
2
+ # Pre-push hook — runs scoped `bun check` before allowing push.
3
+ #
4
+ # Logic:
5
+ # 1. If running inside the cosmicdriftgamestudio Parent-Workspace,
6
+ # delegate to its `bun check` with KUMIKO_CLI_SCOPE set to THIS
7
+ # sub-repo only — so a push from one sub-repo doesn't run
8
+ # Biome/TS/Tests for its siblings as well.
9
+ # 2. Else (standalone clone outside the parent workspace): run the
10
+ # sub-repo's own package.json `test` script.
11
+ #
12
+ # Extension point: a tracked, executable scripts/pre-push-extra.sh runs
13
+ # before the main check in every branch above, with KUMIKO_PUSH_REPO_ROOT
14
+ # and KUMIKO_PUSH_PARENT_DIR available inline (never exported).
15
+ #
16
+ # Override: set PRE_PUSH_SKIP=1 to bypass (use sparingly).
17
+
18
+ if [ "${PRE_PUSH_SKIP:-0}" = "1" ]; then
19
+ echo "[pre-push] PRE_PUSH_SKIP=1 — hook skipped"
20
+ exit 0
21
+ fi
22
+
23
+ # Git exports GIT_DIR & co. to hooks; nothing this hook starts may inherit them,
24
+ # or a test's fixture git calls write into this repo.
25
+ LOCAL_GIT_ENV="$(git rev-parse --local-env-vars)" || { echo "[pre-push] FATAL: git rev-parse --local-env-vars failed" >&2; exit 1; }
26
+ # shellcheck disable=SC2086
27
+ unset $LOCAL_GIT_ENV
28
+
29
+ REPO_ROOT="$(git rev-parse --show-toplevel)"
30
+ if [ -z "$REPO_ROOT" ]; then
31
+ echo "[pre-push] FATAL: git rev-parse --show-toplevel failed — refusing push" >&2
32
+ exit 1
33
+ fi
34
+ # --git-common-dir resolves to the main checkout's .git dir even when
35
+ # REPO_ROOT is a `.wt/<name>` worktree, so REPO_NAME stays the repo's
36
+ # own name instead of the worktree directory name.
37
+ # Empty result (old git without --path-format, or rev-parse failure) would
38
+ # make basename/dirname yield ".", fail-open-scoping the push — refuse instead.
39
+ GIT_COMMON_DIR="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)"
40
+ if [ -z "$GIT_COMMON_DIR" ]; then
41
+ echo "[pre-push] FATAL: git rev-parse --git-common-dir failed — refusing push (would scope as '.')" >&2
42
+ exit 1
43
+ fi
44
+ REPO_NAME="$(basename "$(dirname "$GIT_COMMON_DIR")")"
45
+ if [ -z "$REPO_NAME" ] || [ "$REPO_NAME" = "." ] || [ "$REPO_NAME" = "/" ]; then
46
+ echo "[pre-push] FATAL: could not derive REPO_NAME from git-common-dir='$GIT_COMMON_DIR' — refusing push" >&2
47
+ exit 1
48
+ fi
49
+
50
+ # Walk up from REPO_ROOT looking for the parent workspace's package.json —
51
+ # a `.wt/<name>` worktree sits one level deeper than a regular checkout.
52
+ PARENT_DIR=""
53
+ DIR="$(dirname "$REPO_ROOT")"
54
+ while [ "$DIR" != "/" ]; do
55
+ if [ -f "$DIR/package.json" ] \
56
+ && grep -q '"name": *"cosmicdriftgamestudio"' "$DIR/package.json" 2>/dev/null; then
57
+ PARENT_DIR="$DIR"
58
+ break
59
+ fi
60
+ DIR="$(dirname "$DIR")"
61
+ done
62
+
63
+ if git -C "$REPO_ROOT" ls-files --error-unmatch scripts/pre-push-extra.sh >/dev/null 2>&1 \
64
+ && [ -x "$REPO_ROOT/scripts/pre-push-extra.sh" ]; then
65
+ echo "[pre-push] running scripts/pre-push-extra.sh…"
66
+ (cd "$REPO_ROOT" && KUMIKO_PUSH_REPO_ROOT="$REPO_ROOT" KUMIKO_PUSH_PARENT_DIR="$PARENT_DIR" "$REPO_ROOT/scripts/pre-push-extra.sh" "$@") || exit 1
67
+ fi
68
+
69
+ # infra#559/#569: from a worktree, `--git-common-dir` resolves to the main
70
+ # checkout's .git, so the branch below would scope `bun check` to the
71
+ # canonical sibling path instead of this worktree — checking the wrong
72
+ # code. Run the worktree-local check only when scripts/check-wt.sh is
73
+ # tracked and executable (untracked/symlink stubs must not hijack push).
74
+ if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ] \
75
+ && git -C "$REPO_ROOT" ls-files --error-unmatch scripts/check-wt.sh >/dev/null 2>&1 \
76
+ && [ -x "$REPO_ROOT/scripts/check-wt.sh" ]; then
77
+ echo "[pre-push] worktree detected — worktree check (see scripts/check-wt.sh)…"
78
+ cd "$REPO_ROOT" && exec "$REPO_ROOT/scripts/check-wt.sh"
79
+ fi
80
+
81
+ if [ -n "$PARENT_DIR" ]; then
82
+ echo "[pre-push] bun check (scoped: $REPO_NAME)…"
83
+ # KUMIKO_PUSH_REPO_ROOT forwards this checkout to guards that need it
84
+ # (worktrees with scripts/check-wt.sh take the branch above and skip this path).
85
+ cd "$PARENT_DIR" && KUMIKO_CLI_SCOPE="$REPO_NAME" KUMIKO_PUSH_REPO_ROOT="$REPO_ROOT" bun kumiko-framework/bin/kumiko.ts check
86
+ else
87
+ echo "[pre-push] bun run test (standalone)…"
88
+ # `bun test` globs everything the default bunfig doesn't exclude, including
89
+ # integration tests; the package.json `test` script carries the repo's own
90
+ # exclusions (e.g. integration suites needing infra this hook doesn't have).
91
+ cd "$REPO_ROOT" && bun run test
92
+ fi
package/src/run-guards.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  buildSharedProject,
12
12
  cliFlagsError,
13
13
  explainGuards,
14
+ filesForGuard,
14
15
  isSecurityGuard,
15
16
  printGuardKitBanner,
16
17
  reportResults,
@@ -95,31 +96,61 @@ export const GUARD_FLAGS = [
95
96
  "--explain",
96
97
  "--write-security-baseline",
97
98
  "--strict-security-baseline",
99
+ "--write-baseline",
98
100
  ] as const;
99
101
 
102
+ const GUARD_NAME_PREFIX = "--guard=";
103
+
100
104
  // Shared by the direct `bun run-guards.ts` invocation below and by the
101
105
  // `guards` subcommand in cli.ts — one place for the flag behavior so the
102
106
  // two entry points can never drift.
103
107
  export function runGuardsCli(argv: readonly string[]): number {
104
- const flagsError = cliFlagsError("guards", argv, GUARD_FLAGS);
108
+ const guardNameArg = argv.find((arg) => arg.startsWith(GUARD_NAME_PREFIX));
109
+ const flags = guardNameArg === undefined ? argv : argv.filter((arg) => arg !== guardNameArg);
110
+ const flagsError = cliFlagsError("guards", flags, GUARD_FLAGS);
105
111
  if (flagsError !== undefined) {
106
112
  console.error(flagsError);
107
113
  return 1;
108
114
  }
109
- if (argv.includes("--explain")) {
115
+ if (flags.includes("--explain")) {
110
116
  for (const line of explainGuards(GUARDS, buildSharedProject(GUARDS))) {
111
117
  console.log(line);
112
118
  }
113
119
  return 0;
114
120
  }
115
- if (argv.includes("--write-security-baseline")) {
121
+ if (flags.includes("--write-security-baseline")) {
116
122
  writeSecurityBaselines(
117
123
  GUARDS.filter(isSecurityGuard),
118
124
  buildSharedProject(GUARDS.filter(isSecurityGuard)),
119
125
  );
120
126
  return 0;
121
127
  }
122
- const strictSecurityBaseline = argv.includes("--strict-security-baseline");
128
+ // --write-baseline always needs --guard=<name> — baselines get frozen
129
+ // deliberately, one at a time, never as a drive-by "refreeze everything".
130
+ if (flags.includes("--write-baseline")) {
131
+ if (guardNameArg === undefined) {
132
+ console.error(
133
+ "--write-baseline needs --guard=<name>. Freeze a baseline only deliberately, one guard at a time — fix the cause first (https://github.com/CosmicDriftGameStudio/kumiko-framework/blob/main/docs/guides/test-failures.md).",
134
+ );
135
+ return 1;
136
+ }
137
+ const guardName = guardNameArg.slice(GUARD_NAME_PREFIX.length);
138
+ const target = GUARDS.find((g) => g.name === guardName);
139
+ if (target === undefined) {
140
+ console.error(
141
+ `Unknown guard "${guardName}". Known guard names: ${GUARDS.map((g) => g.name).join(", ")}`,
142
+ );
143
+ return 1;
144
+ }
145
+ if (target.writeBaseline === undefined) {
146
+ console.error(`Guard "${guardName}" has no ratchet baseline to write.`);
147
+ return 1;
148
+ }
149
+ const project = buildSharedProject([target]);
150
+ target.writeBaseline(filesForGuard(project, target));
151
+ return 0;
152
+ }
153
+ const strictSecurityBaseline = flags.includes("--strict-security-baseline");
123
154
  const guards = strictSecurityBaseline ? GUARDS.filter(isSecurityGuard) : GUARDS;
124
155
  const project = buildSharedProject(guards);
125
156
  printGuardKitBanner(guards.length, project);