@cosmicdrift/kumiko-guards 0.395.0 → 0.397.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
@@ -76,10 +76,15 @@ the pushing repo only; in a standalone clone it runs the repo's own
76
76
  workspace it runs a tracked `scripts/check-wt.sh` if present, otherwise the
77
77
  worktree's own `package.json` scripts `typecheck`, `lint`, `test`, and
78
78
  `test:dom` (whichever are declared), directly in the worktree — the parent's
79
- `bun check` would otherwise check the main checkout instead. There is no
80
- bypass switch. A `check-wt.sh` that wraps this hook (via `exec` or as a child
81
- process) is recognised by process ancestry, so the hook does not call it
82
- again; an exported variable cannot skip it.
79
+ `bun check` would otherwise check the main checkout instead — followed by
80
+ `kumiko-guards guards`, `ui` (only with non-empty `uiRoots` in `kumiko.json`,
81
+ as in `kumiko check`), `checks`, `comment-lang` and `public-exports-changelog`.
82
+ The repo that ships this package runs its checked-out `packages/guards/src/cli.ts`
83
+ for these. There is no bypass switch. A `check-wt.sh` that wraps this hook (via
84
+ `exec` or as a child process) is recognised by process ancestry, so the hook
85
+ does not call it again and skips the pre-flight steps (`pre-push-extra.sh`,
86
+ upgrade-state guard, gitleaks) the outer run already did; an exported variable
87
+ cannot skip either.
83
88
 
84
89
  Extension point: a tracked, executable `scripts/pre-push-extra.sh` runs
85
90
  before the main check in every branch (worktree, parent-scoped, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.395.0",
3
+ "version": "0.397.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": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -36,7 +36,7 @@
36
36
  "kumiko-pre-push": "./src/pre-push.sh"
37
37
  },
38
38
  "dependencies": {
39
- "@cosmicdrift/kumiko-types": "0.395.0",
39
+ "@cosmicdrift/kumiko-types": "0.397.0",
40
40
  "ts-morph": "^28.0.0",
41
41
  "zod": "^4.6.7"
42
42
  },
@@ -52,7 +52,7 @@
52
52
  "LICENSE"
53
53
  ],
54
54
  "peerDependencies": {
55
- "@cosmicdrift/kumiko-framework": "0.395.0"
55
+ "@cosmicdrift/kumiko-framework": "0.397.0"
56
56
  },
57
57
  "peerDependenciesMeta": {
58
58
  "@cosmicdrift/kumiko-framework": {
@@ -60,6 +60,6 @@
60
60
  }
61
61
  },
62
62
  "devDependencies": {
63
- "@cosmicdrift/kumiko-framework": "0.395.0"
63
+ "@cosmicdrift/kumiko-framework": "0.397.0"
64
64
  }
65
65
  }
@@ -21,8 +21,13 @@ const LITERAL_WRITE_RE =
21
21
  /\b(?:DELETE\s+FROM\s+"?(?:read_\w+|kumiko_events)|UPDATE\s+"?read_\w+)\b/i;
22
22
  const TEMPLATE_WRITE_RE = /\b(?:DELETE\s+FROM|UPDATE)\s+"?\$\{\s*(\w+)(?:\.tableName)?\s*\}/gi;
23
23
 
24
+ // Both write patterns start like this; a literal's text is part of the file's, so a
25
+ // file without it has nothing to report.
26
+ const SQL_WRITE_START_RE = /\b(?:DELETE\s+FROM|UPDATE)\s/i;
27
+
24
28
  function entityTableBindings(sourceFile: SourceFile): Set<string> {
25
29
  const names = new Set<string>();
30
+ if (!sourceFile.getFullText().includes("buildEntityTable")) return names;
26
31
  for (const decl of sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
27
32
  const init = decl.getInitializer();
28
33
  if (
@@ -51,8 +56,9 @@ function writesEventSourcedTable(node: Node, entityTables: ReadonlySet<string>):
51
56
  }
52
57
 
53
58
  export function findRawSqlWrites(sourceFile: SourceFile): RawWriteFinding[] {
54
- const entityTables = entityTableBindings(sourceFile);
55
59
  const findings: RawWriteFinding[] = [];
60
+ if (!SQL_WRITE_START_RE.test(sourceFile.getFullText())) return findings;
61
+ const entityTables = entityTableBindings(sourceFile);
56
62
  for (const kind of [
57
63
  SyntaxKind.StringLiteral,
58
64
  SyntaxKind.NoSubstitutionTemplateLiteral,
package/src/changes.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.396.0",
4
+ "type": "improvement",
5
+ "title": "Faster guard suite through text prefilters",
6
+ "detail": "RLS-Session-Settings, Internal-Subpath-Imports, Test-ES-Fixtures and Test-ES-Row-Writes walked every node of every scanned file. Each now first checks the file text for the tokens its rules need (for example kumiko. or set_config, createTenantDb, kumiko_events, DELETE FROM or UPDATE) and skips the walk when none appears; Internal-Subpath-Imports keeps walking any file with a unicode escape, which could spell a name without its plain text. Findings are unchanged: the suites report the same results on kumiko-framework, kumiko-enterprise and publicstatus before and after."
7
+ },
8
+ {
9
+ "version": "0.396.0",
10
+ "type": "improvement",
11
+ "title": "pre-push runs its worktree steps in parallel",
12
+ "detail": "The worktree branch without check-wt.sh now hands typecheck, lint, test, the guard suites, comment-lang and public-exports-changelog to the new run-steps-parallel.ts next to the hook. test:dom stays behind test in the same lane, every step still runs after a failure, and each step's output is printed as one block when it finishes. The failure line keeps its form (worktree check failed: <steps>). The pool follows the free CPU capacity with a floor of four lanes; KUMIKO_PRE_PUSH_POOL overrides it."
13
+ },
14
+ {
15
+ "version": "0.396.0",
16
+ "type": "fix",
17
+ "title": "pre-push runs its pre-flight steps once per push",
18
+ "detail": "When the hook delegated to a tracked scripts/check-wt.sh that wraps the hook again, the inner run repeated pre-push-extra.sh, the upgrade-state guard and the gitleaks scan, and that second scan only covered HEAD because git's stdin was already read. A re-entry recognised by process ancestry now skips these steps; a check-wt.sh run by hand still runs them once. The worktree branch without check-wt.sh adds kumiko-guards ui when kumiko.json declares non-empty uiRoots, and in the repo that ships kumiko-guards it runs the checked-out packages/guards/src/cli.ts instead of a node_modules bin."
19
+ },
2
20
  {
3
21
  "version": "0.394.0",
4
22
  "type": "fix",
@@ -72,6 +72,13 @@ const FORWARDED_TENANT_DB_REASON_ALLOWLIST = [
72
72
 
73
73
  type Finding = { line: number; message: string };
74
74
 
75
+ // Skips a full-tree walk when no name it looks for appears in the file. A unicode
76
+ // escape could spell a name without its plain text, so any `\u` keeps the walk.
77
+ function mayMention(sf: SourceFile, names: readonly string[]): boolean {
78
+ const text = sf.getFullText();
79
+ return text.includes("\\u") || names.some((name) => text.includes(name));
80
+ }
81
+
75
82
  function isTestFile(rel: string): boolean {
76
83
  return rel.split("/").includes("__tests__") || TEST_FILE_RE.test(rel);
77
84
  }
@@ -196,6 +203,7 @@ function reasonSymbolOf(callee: Node, locals: ReadonlyMap<string, string>): stri
196
203
 
197
204
  function findReasonViolations(sf: SourceFile): Finding[] {
198
205
  const findings: Finding[] = [];
206
+ if (!mayMention(sf, Object.keys(REASON_ARG_INDEX))) return findings;
199
207
  const locals = localNamesOf(sf, new Set(Object.keys(REASON_ARG_INDEX)));
200
208
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
201
209
  const callee = call.getExpression();
@@ -219,6 +227,7 @@ function isSystemModeProperty(prop: Node): boolean {
219
227
 
220
228
  function findTenantDbReasonViolations(sf: SourceFile): Finding[] {
221
229
  const findings: Finding[] = [];
230
+ if (!mayMention(sf, ["createTenantDb"])) return findings;
222
231
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
223
232
  const callee = call.getExpression();
224
233
  const calleeName =
@@ -241,6 +250,7 @@ function findTenantDbReasonViolations(sf: SourceFile): Finding[] {
241
250
  }
242
251
 
243
252
  function findFixedScopeUses(sf: SourceFile): Finding[] {
253
+ if (!sf.getFullText().includes(FIXED_SCOPE_SYMBOL)) return [];
244
254
  return sf
245
255
  .getDescendantsOfKind(SyntaxKind.Identifier)
246
256
  .filter((id) => id.getText() === FIXED_SCOPE_SYMBOL)
@@ -140,8 +140,12 @@ function isStringLike(node: Node): boolean {
140
140
  );
141
141
  }
142
142
 
143
+ // Every rule needs "kumiko." or "set_config" in a literal, and a literal's text is part of the file's.
144
+ const MENTIONS_RLS_SETTING_RE = /kumiko\.|set_config/i;
145
+
143
146
  function findSessionSettings(sf: SourceFile, rel: string) {
144
147
  const found: Array<{ line: number; message: string }> = [];
148
+ if (!MENTIONS_RLS_SETTING_RE.test(sf.getFullText())) return found;
145
149
  for (const node of sf.getDescendants()) {
146
150
  if (!isStringLike(node)) continue;
147
151
  // A template's own spans are visited as separate nodes; its full text is checked once here.
@@ -59,6 +59,8 @@ function violationMessage(text: string): string | undefined {
59
59
  function collect(sourceFile: SourceFile, roots: readonly RepoRoot[]): GuardViolation[] {
60
60
  const file = relFromRepoRoot(sourceFile.getFilePath(), roots);
61
61
  if (ALLOWLIST.some((re) => re.test(file))) return [];
62
+ // Both rules need one of these table names in a literal, which is part of the file's text.
63
+ if (!/kumiko_events|read_tenant_memberships/i.test(sourceFile.getFullText())) return [];
62
64
  const violations: GuardViolation[] = [];
63
65
  for (const kind of LITERAL_KINDS) {
64
66
  for (const node of sourceFile.getDescendantsOfKind(kind)) {
@@ -127,10 +127,13 @@ function collect(sourceFile: SourceFile, roots: readonly RepoRoot[]): GuardViola
127
127
  if (ALLOWLIST.some((re) => re.test(file))) return [];
128
128
  const bypass = helperBindings(sourceFile, BYPASS_NAMES);
129
129
  const seedRow = helperBindings(sourceFile, SEED_ROW_NAMES);
130
- const bypassCalls = sourceFile
131
- .getDescendantsOfKind(SyntaxKind.CallExpression)
132
- .filter((call) => isHelperCallee(call.getExpression(), bypass))
133
- .map((call) => ({ line: call.getStartLineNumber(), message: MESSAGE }));
130
+ const importsBypass = bypass.names.size > 0 || bypass.namespaces.size > 0;
131
+ const bypassCalls = importsBypass
132
+ ? sourceFile
133
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
134
+ .filter((call) => isHelperCallee(call.getExpression(), bypass))
135
+ .map((call) => ({ line: call.getStartLineNumber(), message: MESSAGE }))
136
+ : [];
134
137
  return [
135
138
  ...bypassCalls,
136
139
  ...findSeedRowOnEntityTables(sourceFile, (callee) => isHelperCallee(callee, seedRow)),
package/src/pre-push.sh CHANGED
@@ -7,7 +7,8 @@
7
7
  # 2. Else if running inside a worktree under the parent workspace (no
8
8
  # check-wt.sh), run this worktree's own package.json scripts
9
9
  # (typecheck, lint, test, test:dom) plus the consumer-CI guards
10
- # (kumiko-guards guards/checks/comment-lang) directly —
10
+ # (kumiko-guards guards/ui/checks/comment-lang) directly, in parallel
11
+ # lanes via run-steps-parallel.ts —
11
12
  # the parent's `bun check` would otherwise check the main checkout
12
13
  # instead of the worktree.
13
14
  # 3. Else if running inside the cosmicdriftgamestudio Parent-Workspace,
@@ -24,6 +25,10 @@
24
25
  # Extension point: a tracked, executable scripts/pre-push-extra.sh runs
25
26
  # before the main check in every branch above, with KUMIKO_PUSH_REPO_ROOT
26
27
  # and KUMIKO_PUSH_PARENT_DIR available inline (never exported).
28
+ #
29
+ # The pre-flight steps (pre-push-extra.sh, upgrade-state guard, gitleaks) run
30
+ # once per push: a re-entry from the wrapping check-wt.sh skips them, because
31
+ # the outer run already did them before delegating.
27
32
 
28
33
  # Git exports GIT_DIR & co. to hooks; nothing this hook starts may inherit them,
29
34
  # or a test's fixture git calls write into this repo.
@@ -77,7 +82,36 @@ has_package_script() {
77
82
  ' "$REPO_ROOT/package.json" "$1") >/dev/null 2>&1
78
83
  }
79
84
 
80
- if git -C "$REPO_ROOT" ls-files --error-unmatch scripts/pre-push-extra.sh >/dev/null 2>&1 \
85
+ # Same rule as `kumiko check`: the UI guards run only for declared, non-empty uiRoots.
86
+ has_ui_roots() {
87
+ (cd / && bun -e '
88
+ const manifest = await Bun.file(process.argv[1]).json().catch(() => null);
89
+ process.exit(Array.isArray(manifest?.uiRoots) && manifest.uiRoots.length > 0 ? 0 : 1);
90
+ ' "$REPO_ROOT/kumiko.json") >/dev/null 2>&1
91
+ }
92
+
93
+ # Re-entry from the wrapping check-wt.sh is detected by process ancestry: the
94
+ # hook records its own PID in KUMIKO_CHECK_WT_OWNER before delegating, so a
95
+ # freely exported variable can neither skip the tracked check-wt.sh nor the
96
+ # pre-flight steps below.
97
+ is_reentry_from_check_wt() {
98
+ reentry_pid=$$
99
+ case "${KUMIKO_CHECK_WT_OWNER:-}" in '' | *[!0-9]*) return 1 ;; esac
100
+ while [ -n "$reentry_pid" ] && [ "$reentry_pid" -gt 1 ]; do
101
+ [ "$reentry_pid" = "$KUMIKO_CHECK_WT_OWNER" ] && return 0
102
+ reentry_pid=$(ps -o ppid= -p "$reentry_pid" 2>/dev/null | tr -d ' ')
103
+ done
104
+ return 1
105
+ }
106
+
107
+ CHECK_WT_REENTRY=0
108
+ if is_reentry_from_check_wt; then
109
+ CHECK_WT_REENTRY=1
110
+ echo "[pre-push] re-entered from scripts/check-wt.sh — pre-push-extra.sh, upgrade-state guard and secret scan already ran in the outer hook"
111
+ fi
112
+
113
+ if [ "$CHECK_WT_REENTRY" = 0 ] \
114
+ && git -C "$REPO_ROOT" ls-files --error-unmatch scripts/pre-push-extra.sh >/dev/null 2>&1 \
81
115
  && [ -x "$REPO_ROOT/scripts/pre-push-extra.sh" ]; then
82
116
  echo "[pre-push] running scripts/pre-push-extra.sh…"
83
117
  (cd "$REPO_ROOT" && KUMIKO_PUSH_REPO_ROOT="$REPO_ROOT" KUMIKO_PUSH_PARENT_DIR="$PARENT_DIR" "$REPO_ROOT/scripts/pre-push-extra.sh" "$@") || exit 1
@@ -100,7 +134,7 @@ resolve_script_dir() {
100
134
  cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd
101
135
  }
102
136
 
103
- if [ -f "$REPO_ROOT/.kumiko/upgrade-state.json" ]; then
137
+ if [ "$CHECK_WT_REENTRY" = 0 ] && [ -f "$REPO_ROOT/.kumiko/upgrade-state.json" ]; then
104
138
  SCRIPT_DIR="$(resolve_script_dir "$0")"
105
139
  GUARD_UPGRADE_STATE="$SCRIPT_DIR/guard-upgrade-state.ts"
106
140
  if [ ! -f "$GUARD_UPGRADE_STATE" ]; then
@@ -118,7 +152,9 @@ fi
118
152
  # caught in seconds, not after the long checks. A missing binary or a missing
119
153
  # origin/main only warns, so a machine without gitleaks is not locked out.
120
154
  # No --config: gitleaks reads a .gitleaks.toml in the repo root itself.
121
- if ! command -v gitleaks >/dev/null 2>&1; then
155
+ if [ "$CHECK_WT_REENTRY" = 1 ]; then
156
+ : # the outer hook scanned the pushed refs from git's stdin
157
+ elif ! command -v gitleaks >/dev/null 2>&1; then
122
158
  echo "[pre-push] gitleaks not installed — secret scan skipped. Install: brew install gitleaks" >&2
123
159
  elif ! git -C "$REPO_ROOT" rev-parse --verify --quiet origin/main >/dev/null 2>&1; then
124
160
  echo "[pre-push] origin/main not found — secret scan skipped" >&2
@@ -151,20 +187,7 @@ fi
151
187
  # canonical sibling path instead of this worktree — checking the wrong
152
188
  # code. Run the worktree-local check only when scripts/check-wt.sh is
153
189
  # tracked and executable (untracked/symlink stubs must not hijack push).
154
- # Re-entry from the wrapping check-wt.sh is detected by process ancestry: the
155
- # hook records its own PID in KUMIKO_CHECK_WT_OWNER before delegating, so a
156
- # freely exported variable cannot skip the tracked check-wt.sh.
157
- is_reentry_from_check_wt() {
158
- reentry_pid=$$
159
- case "${KUMIKO_CHECK_WT_OWNER:-}" in '' | *[!0-9]*) return 1 ;; esac
160
- while [ -n "$reentry_pid" ] && [ "$reentry_pid" -gt 1 ]; do
161
- [ "$reentry_pid" = "$KUMIKO_CHECK_WT_OWNER" ] && return 0
162
- reentry_pid=$(ps -o ppid= -p "$reentry_pid" 2>/dev/null | tr -d ' ')
163
- done
164
- return 1
165
- }
166
-
167
- if ! is_reentry_from_check_wt \
190
+ if [ "$CHECK_WT_REENTRY" = 0 ] \
168
191
  && [ "$(git rev-parse --path-format=absolute --git-dir)" != "$GIT_COMMON_DIR" ] \
169
192
  && git -C "$REPO_ROOT" ls-files --error-unmatch scripts/check-wt.sh >/dev/null 2>&1 \
170
193
  && [ -x "$REPO_ROOT/scripts/check-wt.sh" ]; then
@@ -172,24 +195,44 @@ if ! is_reentry_from_check_wt \
172
195
  cd "$REPO_ROOT" && KUMIKO_CHECK_WT_OWNER=$$ exec "$REPO_ROOT/scripts/check-wt.sh"
173
196
  fi
174
197
 
198
+ # The repo that ships kumiko-guards runs its checked-out cli.ts, so a push checks
199
+ # the branch's own guards even when node_modules/.bin resolves to another checkout.
200
+ is_kumiko_guards_repo() {
201
+ [ -f "$REPO_ROOT/packages/guards/src/cli.ts" ] \
202
+ && grep -q '"name": *"@cosmicdrift/kumiko-guards"' "$REPO_ROOT/packages/guards/package.json" 2>/dev/null
203
+ }
204
+
175
205
  # Runs a guard bin from the worktree's or the parent's node_modules/.bin
176
206
  # (same guards consumer CI runs); an unresolvable bin is skipped, not fatal.
177
- run_guard_bin() {
207
+ add_guard_step() {
178
208
  GUARD_LABEL="$1"
179
209
  GUARD_BIN="$2"
180
- shift 2
210
+ GUARD_ARGS="$3"
211
+ if [ "$GUARD_BIN" = "kumiko-guards" ] && is_kumiko_guards_repo; then
212
+ add_step --step "$GUARD_LABEL" "bun $(shell_quote "$REPO_ROOT/packages/guards/src/cli.ts") $GUARD_ARGS"
213
+ return 0
214
+ fi
181
215
  for GUARD_CANDIDATE in "$REPO_ROOT/node_modules/.bin/$GUARD_BIN" "$PARENT_DIR/node_modules/.bin/$GUARD_BIN"; do
182
216
  if [ -x "$GUARD_CANDIDATE" ]; then
183
- echo "[pre-push] $GUARD_BIN $*"
184
- if ! "$GUARD_CANDIDATE" "$@"; then
185
- FAILED="$FAILED $GUARD_LABEL"
186
- fi
217
+ add_step --step "$GUARD_LABEL" "$(shell_quote "$GUARD_CANDIDATE") $GUARD_ARGS"
187
218
  return 0
188
219
  fi
189
220
  done
190
221
  echo "[pre-push] $GUARD_BIN: not resolvable in worktree/parent, skipped"
191
222
  }
192
223
 
224
+ shell_quote() {
225
+ printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
226
+ }
227
+
228
+ # POSIX sh has no arrays: the runner's argv is collected as one quoted string.
229
+ STEP_ARGS=""
230
+ add_step() {
231
+ for STEP_ARG in "$@"; do
232
+ STEP_ARGS="$STEP_ARGS $(shell_quote "$STEP_ARG")"
233
+ done
234
+ }
235
+
193
236
  # infra#899: no tracked scripts/check-wt.sh — the parent-scoped `bun check`
194
237
  # branch below would check the main checkout, not this worktree. Run the
195
238
  # worktree's own package.json scripts directly instead (only when nested
@@ -198,32 +241,39 @@ run_guard_bin() {
198
241
  if [ "$(git rev-parse --path-format=absolute --git-dir)" != "$GIT_COMMON_DIR" ] \
199
242
  && [ -n "$PARENT_DIR" ]; then
200
243
  cd "$REPO_ROOT"
201
- echo "[pre-push] worktree without scripts/check-wt.sh — running this worktree's package.json scripts (typecheck, lint, test, test:dom) and guards (guards, checks, comment-lang)…"
244
+ echo "[pre-push] worktree without scripts/check-wt.sh — running this worktree's package.json scripts (typecheck, lint, test, test:dom) and guards (guards, ui, checks, comment-lang, public-exports-changelog)…"
202
245
  if ! has_package_script test; then
203
246
  echo "[pre-push] FATAL: worktree without a package.json \"test\" script — nothing to run, refusing push." >&2
204
247
  echo "Add a \"test\" script to package.json." >&2
205
248
  exit 1
206
249
  fi
207
- FAILED=""
250
+ RUNNER="$(resolve_script_dir "$0")/run-steps-parallel.ts"
251
+ if [ ! -f "$RUNNER" ]; then
252
+ echo "[pre-push] FATAL: run-steps-parallel.ts not found next to the resolved hook — refusing push" >&2
253
+ exit 1
254
+ fi
255
+ # test:dom stays behind test (always declared, see above) in one lane: both are
256
+ # bun test runs over the same repo and have always run one after the other.
208
257
  for SCRIPT_NAME in typecheck lint test test:dom; do
258
+ STEP_FLAG="--step"
259
+ [ "$SCRIPT_NAME" = "test:dom" ] && STEP_FLAG="--then"
209
260
  if has_package_script "$SCRIPT_NAME"; then
210
- echo "[pre-push] bun run $SCRIPT_NAME"
211
- if ! bun run "$SCRIPT_NAME"; then
212
- FAILED="$FAILED $SCRIPT_NAME"
213
- fi
261
+ add_step "$STEP_FLAG" "$SCRIPT_NAME" "bun run $SCRIPT_NAME"
214
262
  else
215
263
  echo "[pre-push] $SCRIPT_NAME: no script, skipped"
216
264
  fi
217
265
  done
218
- run_guard_bin guards kumiko-guards guards
219
- run_guard_bin checks kumiko-guards checks
220
- run_guard_bin comment-lang kumiko-guards comment-lang --touched --base=origin/main
221
- run_guard_bin public-exports-changelog kumiko-guards public-exports-changelog --base=origin/main
222
- if [ -n "$FAILED" ]; then
223
- echo "[pre-push] worktree check failed:$FAILED" >&2
224
- exit 1
266
+ add_guard_step guards kumiko-guards "guards"
267
+ if has_ui_roots; then
268
+ add_guard_step ui kumiko-guards "ui"
269
+ else
270
+ echo "[pre-push] ui: no uiRoots in kumiko.json, skipped"
225
271
  fi
226
- exit 0
272
+ add_guard_step checks kumiko-guards "checks"
273
+ add_guard_step comment-lang kumiko-guards "comment-lang --touched --base=origin/main"
274
+ add_guard_step public-exports-changelog kumiko-guards "public-exports-changelog --base=origin/main"
275
+ eval "set -- $STEP_ARGS"
276
+ exec bun "$RUNNER" --fail-prefix "[pre-push] worktree check failed:" "$@"
227
277
  fi
228
278
 
229
279
  if [ -n "$PARENT_DIR" ]; then
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env bun
2
+ // Runs the pre-push steps in parallel lanes. A lane runs its steps one after another
3
+ // (`--then` appends to the previous lane), every step runs even after a failure,
4
+ // and each step's output is printed as one block when it finishes, so nothing is
5
+ // lost and nothing interleaves.
6
+ //
7
+ // bun run-steps-parallel.ts --fail-prefix "<text>" --step <label> <cmd> [--then <label> <cmd>] ...
8
+ import { availableParallelism, loadavg } from "node:os";
9
+
10
+ export type Step = { readonly label: string; readonly cmd: string };
11
+ export type Lane = readonly Step[];
12
+ export type StepResult = Step & { readonly exitCode: number; readonly output: string };
13
+
14
+ export type ParsedArgs = { readonly failPrefix: string; readonly lanes: readonly Lane[] };
15
+
16
+ export function parseStepArgs(argv: readonly string[]): ParsedArgs {
17
+ let failPrefix = "steps failed:";
18
+ const lanes: Step[][] = [];
19
+ for (let i = 0; i < argv.length; i++) {
20
+ const flag = argv[i];
21
+ if (flag === "--fail-prefix") {
22
+ const value = argv[++i];
23
+ if (value === undefined) throw new Error("--fail-prefix needs a value");
24
+ failPrefix = value;
25
+ continue;
26
+ }
27
+ if (flag !== "--step" && flag !== "--then") throw new Error(`unknown argument "${flag}"`);
28
+ const label = argv[++i];
29
+ const cmd = argv[++i];
30
+ if (label === undefined || cmd === undefined) throw new Error(`${flag} needs <label> <cmd>`);
31
+ const lastLane = lanes.at(-1);
32
+ if (flag === "--then" && lastLane !== undefined) lastLane.push({ label, cmd });
33
+ else lanes.push([{ label, cmd }]);
34
+ }
35
+ return { failPrefix, lanes };
36
+ }
37
+
38
+ // Most steps are single-threaded, so a few lanes still finish far sooner than one
39
+ // even on a busy machine; beyond that floor the pool follows the free capacity
40
+ // (cores minus 1-min load), so parallel pushes don't oversubscribe it further.
41
+ const MIN_POOL = 4;
42
+
43
+ export function resolvePoolSize(
44
+ override: string | undefined,
45
+ cores: number = availableParallelism(),
46
+ load1: number = loadavg()[0] ?? 0,
47
+ ): number {
48
+ const forced = Number(override);
49
+ if (Number.isInteger(forced) && forced > 0) return forced;
50
+ const ceiling = Math.max(1, cores - 1);
51
+ return Math.min(ceiling, Math.max(MIN_POOL, Math.round(cores - load1)));
52
+ }
53
+
54
+ async function runStep(step: Step, log: (text: string) => void): Promise<StepResult> {
55
+ const started = performance.now();
56
+ const proc = Bun.spawn(["sh", "-c", step.cmd], {
57
+ stdin: "ignore",
58
+ stdout: "pipe",
59
+ stderr: "pipe",
60
+ env: process.env,
61
+ });
62
+ const [stdout, stderr, exitCode] = await Promise.all([
63
+ new Response(proc.stdout).text(),
64
+ new Response(proc.stderr).text(),
65
+ proc.exited,
66
+ ]);
67
+ const output = stdout + stderr;
68
+ const seconds = ((performance.now() - started) / 1000).toFixed(1);
69
+ log(
70
+ `[pre-push] ${exitCode === 0 ? "✓" : "✗"} ${step.label} (${seconds}s)\n` +
71
+ (output.length > 0 ? `${output.endsWith("\n") ? output : `${output}\n`}` : ""),
72
+ );
73
+ return { ...step, exitCode, output };
74
+ }
75
+
76
+ export async function runLanes(
77
+ lanes: readonly Lane[],
78
+ poolSize: number,
79
+ log: (text: string) => void,
80
+ ): Promise<StepResult[]> {
81
+ const results: StepResult[][] = lanes.map(() => []);
82
+ let next = 0;
83
+ const worker = async (): Promise<void> => {
84
+ for (let laneIndex = next++; laneIndex < lanes.length; laneIndex = next++) {
85
+ for (const step of lanes[laneIndex] ?? []) {
86
+ results[laneIndex]?.push(await runStep(step, log));
87
+ }
88
+ }
89
+ };
90
+ await Promise.all(Array.from({ length: Math.min(poolSize, lanes.length) }, worker));
91
+ return results.flat();
92
+ }
93
+
94
+ if (import.meta.main) {
95
+ const { failPrefix, lanes } = parseStepArgs(process.argv.slice(2));
96
+ const poolSize = resolvePoolSize(process.env["KUMIKO_PRE_PUSH_POOL"]);
97
+ const labels = lanes.map((lane) => lane.map((step) => step.label).join(" → ")).join(", ");
98
+ console.log(`[pre-push] ${lanes.length} lane(s), pool=${poolSize}: ${labels}`);
99
+ const results = await runLanes(lanes, poolSize, (text) => process.stdout.write(text));
100
+ const failed = results.filter((result) => result.exitCode !== 0).map((result) => result.label);
101
+ if (failed.length > 0) {
102
+ console.error(`${failPrefix} ${failed.join(" ")}`);
103
+ process.exit(1);
104
+ }
105
+ }