@cosmicdrift/kumiko-guards 0.395.0 → 0.396.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 +9 -4
- package/package.json +4 -4
- package/src/_lib/test-es-raw-writes.ts +7 -1
- package/src/changes.json +18 -0
- package/src/guard-internal-subpath-imports.ts +10 -0
- package/src/guard-rls-session-settings.ts +4 -0
- package/src/guard-test-es-fixtures.ts +2 -0
- package/src/guard-test-es-row-writes.ts +7 -4
- package/src/pre-push.sh +88 -38
- package/src/run-steps-parallel.ts +105 -0
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
3
|
+
"version": "0.396.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.
|
|
39
|
+
"@cosmicdrift/kumiko-types": "0.396.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.
|
|
55
|
+
"@cosmicdrift/kumiko-framework": "0.396.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.
|
|
63
|
+
"@cosmicdrift/kumiko-framework": "0.396.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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
207
|
+
add_guard_step() {
|
|
178
208
|
GUARD_LABEL="$1"
|
|
179
209
|
GUARD_BIN="$2"
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
+
}
|