@cosmicdrift/kumiko-guards 0.386.0 → 0.388.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.
Files changed (54) hide show
  1. package/LICENSE +21 -57
  2. package/package.json +4 -4
  3. package/src/_lib/agent-docs.ts +12 -7
  4. package/src/_lib/comment-trivia.ts +148 -0
  5. package/src/_lib/guard-kit.ts +2 -2
  6. package/src/_lib/guards-doc.ts +4 -1
  7. package/src/_lib/roots.ts +9 -5
  8. package/src/_lib/run-slots.ts +39 -10
  9. package/src/_lib/scan-scope.ts +1 -1
  10. package/src/_lib/security-audit.ts +15 -8
  11. package/src/_lib/sql-inventory.ts +2 -2
  12. package/src/changes.json +45 -0
  13. package/src/check-as-casts.ts +6 -7
  14. package/src/check-complexity.ts +86 -29
  15. package/src/check-licenses.ts +23 -6
  16. package/src/check-real-provider-isolation.ts +1 -1
  17. package/src/check-runtime-isolation.ts +2 -2
  18. package/src/check-security.ts +2 -1
  19. package/src/check-single-runtime-instance.ts +1 -1
  20. package/src/dependency-license-exceptions.json +0 -24
  21. package/src/guard-admin-api.ts +5 -6
  22. package/src/guard-app-feature-structure.ts +9 -9
  23. package/src/guard-comment-issue-ref-only.ts +223 -0
  24. package/src/guard-comment-lang.ts +10 -141
  25. package/src/guard-direct-entity-writes.ts +43 -31
  26. package/src/guard-direct-fetch.ts +3 -3
  27. package/src/guard-feature-integration-tests.ts +1 -1
  28. package/src/guard-file-size.ts +2 -2
  29. package/src/guard-i18n-locale-mount.ts +4 -4
  30. package/src/guard-i18n-locale-terminology.ts +3 -3
  31. package/src/guard-i18n-ui-strings.ts +5 -5
  32. package/src/guard-import-extensions.ts +34 -9
  33. package/src/guard-no-custom-primitives.ts +2 -4
  34. package/src/guard-no-direct-fs.ts +1 -1
  35. package/src/guard-no-direct-process-env.ts +2 -2
  36. package/src/guard-no-inline-styles.ts +1 -1
  37. package/src/guard-no-raw-hooks.ts +0 -2
  38. package/src/guard-primitives-discipline.ts +1 -1
  39. package/src/guard-public-exports-changelog.ts +8 -8
  40. package/src/guard-raw-classname.ts +1 -1
  41. package/src/guard-raw-interactive-elements.ts +1 -1
  42. package/src/guard-renderer-boundaries.ts +61 -9
  43. package/src/guard-restricted-symbols.ts +2 -3
  44. package/src/guard-tailwind-scan-surface.ts +28 -29
  45. package/src/guard-tenant-escalation.ts +3 -3
  46. package/src/guard-test-template-drift.ts +1 -1
  47. package/src/guard-test-timeouts.ts +12 -4
  48. package/src/guard-thin-wrappers.ts +35 -0
  49. package/src/guard-write-handler-qns.ts +1 -1
  50. package/src/pre-push.sh +22 -6
  51. package/src/repo-manifest/app-ports/static-app-config.ts +15 -11
  52. package/src/run-guards.ts +11 -4
  53. package/src/run-ui-guards.ts +1 -1
  54. package/src/runtime-isolation-classify.ts +3 -2
@@ -9,7 +9,7 @@
9
9
  * <button>/<input>/<select>/<textarea>/<dialog>/<form> are deliberately NOT
10
10
  * in this guard: <button>/<input>/<select>/<textarea>/<dialog> are already
11
11
  * enforced by guard-primitives-discipline.ts (its own CI step, not this
12
- * bundle), and <form> is a documented exception (PR #488, agent-layer.tsx:71
12
+ * bundle), and <form> is a documented exception (agent-layer.tsx:71
13
13
  * uses a native <form onSubmit> on purpose) — adding either back here would
14
14
  * duplicate or contradict an existing rule.
15
15
  *
@@ -11,6 +11,9 @@
11
11
  * emits SVG strings and the live dispatcher is a fetch client, so the symbol and JSX-tag rules would be noise there. They stay free
12
12
  * of react-dom, jsdom and any import of `src/web`.
13
13
  *
14
+ * `headless` additionally may not import react, the renderer root or anything outside its own
15
+ * folder (server/mail bundles import it); the shared layer may not import `live`.
16
+ *
14
17
  * Deliberately simple: a regex scan over the source text. False positives
15
18
  * (e.g. "window" in a string literal) are possible but rare — the guard is a
16
19
  * maintenance alarm, not a semantic proof.
@@ -41,6 +44,24 @@ const FORBIDDEN_IMPORTS = [
41
44
  /from ["']@cosmicdrift\/kumiko-dispatcher-live["']/,
42
45
  ];
43
46
 
47
+ // `headless` is consumed server-side (prod bundle, mail HTML): it must stay free of React and of
48
+ // the shared layer, so it may only import itself and third-party packages other than react.
49
+ const HEADLESS_FORBIDDEN_IMPORTS = [
50
+ /from ["']react(?:\/.*)?["']/,
51
+ /from ["']@cosmicdrift\/kumiko-renderer(?:\/(?!headless(?:["'/]))[^"']*)?["']/,
52
+ ];
53
+
54
+ // The fetch/document.cookie dispatcher client belongs to consumers of `live`, not the shared layer.
55
+ const SHARED_FORBIDDEN_IMPORTS = [
56
+ /from ["'](?:\.\.?\/)+live(?:\/.*)?["']/,
57
+ /from ["']@cosmicdrift\/kumiko-renderer\/live(?:\/.*)?["']/,
58
+ ];
59
+
60
+ // dev-console.ts is a dependency-free console wrapper, safe in server bundles.
61
+ const HEADLESS_ALLOWED_LEAF = /(?:^|\/)dev-console(?:\.ts)?$/;
62
+
63
+ const RELATIVE_SPECIFIER = /from ["'](\.\.?\/[^"']*)["']/;
64
+
44
65
  // Runtime symbols that mean DOM/browser. `\b` prevents substring matches
45
66
  // (e.g. "origin" not matched as "origin.foo"). This is a coarse guard — a
46
67
  // ts-morph-based check would be more precise, but not worth the effort here.
@@ -82,6 +103,34 @@ type Violation = {
82
103
  readonly excerpt: string;
83
104
  };
84
105
 
106
+ const RENDERER_SRC = `${path.sep}packages${path.sep}renderer${path.sep}src${path.sep}`;
107
+
108
+ // Absolute path of `packages/renderer/src/<dir>` containing `file`, or "" when it is elsewhere.
109
+ function rendererSrcSubdir(file: string, dir: string): string {
110
+ const marker = `${RENDERER_SRC}${dir}${path.sep}`;
111
+ const index = file.indexOf(marker);
112
+ return index === -1 ? "" : file.slice(0, index + marker.length - 1);
113
+ }
114
+
115
+ function isInside(dir: string, target: string): boolean {
116
+ return target === dir || target.startsWith(dir + path.sep);
117
+ }
118
+
119
+ function escapesHeadless(file: string, line: string): boolean {
120
+ const headlessDir = rendererSrcSubdir(file, "headless");
121
+ const specifier = RELATIVE_SPECIFIER.exec(line)?.[1];
122
+ if (headlessDir === "" || specifier === undefined) return false;
123
+ if (HEADLESS_ALLOWED_LEAF.test(specifier)) return false;
124
+ return !isInside(headlessDir, path.resolve(path.dirname(file), specifier));
125
+ }
126
+
127
+ function isSharedLayer(file: string): boolean {
128
+ return (
129
+ file.includes(RENDERER_SRC) &&
130
+ ["headless", "live", "web"].every((dir) => rendererSrcSubdir(file, dir) === "")
131
+ );
132
+ }
133
+
85
134
  function walk(dir: string, skippedDirs: readonly string[] = []): string[] {
86
135
  const out: string[] = [];
87
136
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -111,15 +160,18 @@ export function findViolations(file: string, root: string, importRulesOnly = fal
111
160
  const trimmed = line.trim();
112
161
  if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue;
113
162
 
114
- for (const pattern of FORBIDDEN_IMPORTS) {
115
- if (pattern.test(line)) {
116
- violations.push({
117
- file: path.relative(root, file),
118
- line: i + 1,
119
- rule: "forbidden-import",
120
- excerpt: trimmed,
121
- });
122
- }
163
+ const importPatterns = [
164
+ ...FORBIDDEN_IMPORTS,
165
+ ...(rendererSrcSubdir(file, "headless") !== "" ? HEADLESS_FORBIDDEN_IMPORTS : []),
166
+ ...(isSharedLayer(file) ? SHARED_FORBIDDEN_IMPORTS : []),
167
+ ];
168
+ if (importPatterns.some((pattern) => pattern.test(line)) || escapesHeadless(file, line)) {
169
+ violations.push({
170
+ file: path.relative(root, file),
171
+ line: i + 1,
172
+ rule: "forbidden-import",
173
+ excerpt: trimmed,
174
+ });
123
175
  }
124
176
  if (importRulesOnly) continue;
125
177
  for (const pattern of FORBIDDEN_SYMBOLS) {
@@ -5,9 +5,8 @@
5
5
  *
6
6
  * `getUnscopedAggregateStreamMaxVersion` / `getUnscopedAggregateStreamTenant`
7
7
  * (packages/framework/src/event-store/event-store.ts) have no tenant
8
- * filter — they are an existence oracle for foreign tenants (see
9
- * kumiko-framework#1269). Legitimate only for seed-/system-internal code
10
- * that deliberately works across tenants.
8
+ * filter — they are an existence oracle for foreign tenants. Legitimate only
9
+ * for seed-/system-internal code that deliberately works across tenants.
11
10
  *
12
11
  * Usage:
13
12
  * bun guards/guard-restricted-symbols.ts
@@ -4,23 +4,22 @@
4
4
  * `packages/bundled-features/src/**` must already be reachable through the
5
5
  * real Tailwind `@source` scan surface (`packages/renderer/src` +
6
6
  * `samples/**\/src`, see `packages/renderer/src/web/styles.css`).
7
- * bundled-features itself is NOT part of that scan surface (infra#654, follow-up to
8
- * kumiko-framework#2498/#2496): expanding `@source` there was verified to
9
- * work in the monorepo build but makes the standalone npm-consumer case
10
- * (no monorepo-relative scan path — #359) falsely green. A class used only
11
- * in bundled-features silently drops out of the compiled CSS at runtime,
12
- * with no build/lint error.
7
+ * bundled-features itself is NOT part of that scan surface: expanding
8
+ * `@source` there was verified to work in the monorepo build but makes the
9
+ * standalone npm-consumer case (no monorepo-relative scan path) falsely
10
+ * green. A class used only in bundled-features silently drops out of the
11
+ * compiled CSS at runtime, with no build/lint error.
13
12
  *
14
- * V1 scope (per issue decision, deliberately not a Tailwind-CLI CSS
15
- * regen): only plain string-literal `className="..."` JSX attributes are
16
- * extracted on the bundled-features side — no `cn(...)`, no template
17
- * literals, no dynamic class maps. The allow-set (the real scan surface)
18
- * is read generously instead — every string/template-literal in those
19
- * files, not just `className` attributes — because Tailwind's own scanner
20
- * is a text scan, not JSX-attribute-aware; under-approximating the allow
21
- * side would turn into false positives on a guard that blocks CI. Tokens
22
- * compare WITH their modifier prefixes intact (`hover:mb-2` is its own
23
- * Tailwind candidate, distinct from `mb-2`).
13
+ * V1 scope, deliberately not a Tailwind-CLI CSS regen: only plain
14
+ * string-literal `className="..."` JSX attributes are extracted on the
15
+ * bundled-features side — no `cn(...)`, no template literals, no dynamic
16
+ * class maps. The allow-set (the real scan surface) is read generously
17
+ * instead — every string/template-literal in those files, not just
18
+ * `className` attributes — because Tailwind's own scanner is a text scan,
19
+ * not JSX-attribute-aware; under-approximating the allow side would turn
20
+ * into false positives on a guard that blocks CI. Tokens compare WITH their
21
+ * modifier prefixes intact (`hover:mb-2` is its own Tailwind candidate,
22
+ * distinct from `mb-2`).
24
23
  *
25
24
  * Baseline-regression guard: pins the
26
25
  * currently-known violation count per file. Reductions are allowed but
@@ -174,11 +173,11 @@ const tailwindScanSurfaceBaseline = (baselineRoot: string) =>
174
173
  // ships .tsx with `className` under its own scan surface (renderer/src +
175
174
  // samples/**/src) needs its own `@source` entry in the app's
176
175
  // styles.css — nothing else scans it. Missed cases so far: kumiko-designer
177
- // in studio (studio#289), kumiko-ai-agent in publicstatus (publicstatus#442)
178
- // — both fixed by hand, both silent (no build/lint error) until noticed
179
- // visually. This does not need ts-morph: it reads package.json + styles.css
180
- // + a plain text scan of the dependency's shipped .tsx files directly off
181
- // disk, independent of the scan-selected SourceFile list above.
176
+ // in studio, kumiko-ai-agent in publicstatus — both fixed by hand, both
177
+ // silent (no build/lint error) until noticed visually. This does not need
178
+ // ts-morph: it reads package.json + styles.css + a plain text scan of the
179
+ // dependency's shipped .tsx files directly off disk, independent of the
180
+ // scan-selected SourceFile list above.
182
181
  const APP_STYLES_REL = "src/styles.css";
183
182
  const PACKAGE_SCOPE = /^@cosmicdrift(?:gamestudio)?\//;
184
183
  const CLASSNAME_ATTR = /\bclassName\s*=/;
@@ -390,14 +389,14 @@ export function scanSourceCoverage(roots: readonly RepoRoot[]): SourceCoverageFi
390
389
  }
391
390
 
392
391
  // Third, independent rule: an `@source` glob that is already present and
393
- // resolves on disk (studio#289's own fix) can still match nothing once the
394
- // referenced package is installed from a registry instead of a workspace
395
- // symlink — the symlink exposes the package's full source checkout (`src/`
396
- // included), a registry tarball only ships what its `package.json` `files`
397
- // allowlists. This is exactly the bug that got past the guard the first
398
- // time, so it's a hard violation, not a warning: no baseline/ratchet, every
399
- // finding fails the run directly (still only under baseline comparison —
400
- // `--no-baseline` reports it like the other rules but doesn't fail).
392
+ // resolves on disk can still match nothing once the referenced package is
393
+ // installed from a registry instead of a workspace symlink — the symlink
394
+ // exposes the package's full source checkout (`src/`) included, a registry
395
+ // tarball only ships what its `package.json` `files` allowlists. This is
396
+ // exactly the bug that got past the guard the first time, so it's a hard
397
+ // violation, not a warning: no baseline/ratchet, every finding fails the run
398
+ // directly (still only under baseline comparison — `--no-baseline` reports it
399
+ // like the other rules but doesn't fail).
401
400
  const NODE_MODULES_PKG_SEGMENT = /node_modules\/((?:@[^/]+\/)?[^/]+)\/([^/]+)/;
402
401
 
403
402
  export interface PublishedScanSurfaceFinding {
@@ -61,8 +61,8 @@ type RoleHandler = { name: string; file: string; line: number };
61
61
  // A `schema:` property that references an outlined const (`schema:
62
62
  // CancelInvitationSchema`) has no `role:`/`roles:` text of its own — the field
63
63
  // lives in the const's declaration. Resolving the identifier to its
64
- // declaration covers that ~43% of write handlers (#1556-adjacent bug: a
65
- // text-only check silently skips them). Falls back to the property's own text
64
+ // declaration covers that ~43% of write handlers; a text-only check silently
65
+ // skips them. Falls back to the property's own text
66
66
  // when the initializer isn't a resolvable identifier.
67
67
  function resolvedSchemaText(schemaProp: Node): string {
68
68
  const init = schemaProp.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
@@ -208,7 +208,7 @@ function globalUserWriteMissingCheck(
208
208
  // Presets that resolve to platform-wide actors only (engine/
209
209
  // config-helpers `access`): no tenant boundary exists to cross.
210
210
  // Everything else — access.admin, and the openToAll handlers that
211
- // gate on isAdminActor at runtime (#1556's shape) — is reachable by
211
+ // gate on isAdminActor at runtime (this shape) — is reachable by
212
212
  // a tenant-scoped TenantAdmin.
213
213
  if (PLATFORM_WIDE_ACCESS.test(accessProp.getText())) return undefined;
214
214
  if (!TARGETS_OTHER_USER.test(resolvedSchemaText(schema))) return undefined;
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
3
  * Guard: apps must not locally reimplement the e2e/screenshot template that
4
- * `@cosmicdrift/kumiko-testing` already owns (#3118). Flags, in Playwright
4
+ * `@cosmicdrift/kumiko-testing` already owns. Flags, in Playwright
5
5
  * configs and e2e/screenshot specs/helpers:
6
6
  * (a) a `playwright*.config.ts` that imports `defineConfig` from
7
7
  * `@playwright/test` directly instead of `defineAppE2eConfig` from
@@ -23,7 +23,7 @@
23
23
  * the message.
24
24
  *
25
25
  * No baseline, no ratchet: any finding
26
- * fails. #3118's transition period (a `.kumiko-test-timeouts-baseline.json`
26
+ * fails. The transition period (a `.kumiko-test-timeouts-baseline.json`
27
27
  * grandfathering pre-existing sleep loops) is over — every consumer must fix
28
28
  * or mark the cause, not accumulate a pinned count.
29
29
  *
@@ -32,7 +32,7 @@
32
32
  * Consumer (via the published `kumiko-guards` CLI):
33
33
  * kumiko-guards guards --guard=test-timeouts
34
34
  */
35
- import { type CallExpression, Node, type SourceFile, SyntaxKind } from "ts-morph";
35
+ import { type CallExpression, Node, type SourceFile, SyntaxKind, type Type } from "ts-morph";
36
36
  import {
37
37
  type AstGuard,
38
38
  type GuardOutcome,
@@ -182,12 +182,20 @@ function declarationBaseCallee(callee: Node): Node {
182
182
  }
183
183
 
184
184
  // An identifier can be the Playwright test body (`test(title, details, body)`) or an options
185
- // variable like `{ retry: 2 }`, so it only counts when its type is a number or carries `timeout`.
185
+ // variable like `{ retry: 2 }`, so only its type decides. Every other non-function expression
186
+ // (conditional, member access, call) is treated as a timeout so a union or untyped value cannot hide one.
186
187
  function isTimeoutArgument(arg: Node): boolean {
187
188
  if (Node.isObjectLiteralExpression(arg)) return hasTimeoutProperty(arg);
188
189
  if (Node.isArrowFunction(arg) || Node.isFunctionExpression(arg)) return false;
190
+ if (!Node.isIdentifier(arg)) return true;
189
191
  const type = arg.getType();
190
- return type.isNumber() || type.isNumberLiteral() || type.getProperty("timeout") !== undefined;
192
+ if (type.isAny() || type.isUnknown()) return true;
193
+ const carriesNumber = (t: Type): boolean => t.isNumber() || t.isNumberLiteral();
194
+ return (
195
+ carriesNumber(type) ||
196
+ (type.isUnion() && type.getUnionTypes().some(carriesNumber)) ||
197
+ type.getProperty("timeout") !== undefined
198
+ );
191
199
  }
192
200
 
193
201
  function declarationTimeoutReason(
@@ -335,16 +335,51 @@ function extractMarkerReason(node: FnNode): string | null {
335
335
  return null;
336
336
  }
337
337
 
338
+ // A typed lookup table (`const T: Record<K, Fn> = {…}` or `{…} satisfies …`) fixes one
339
+ // signature per slot; a one-call entry adapts that signature, so inlining it would mean
340
+ // dropping the table, not the wrapper.
341
+ function isTypedTableLiteral(literal: Node): boolean {
342
+ let parent = literal.getParent();
343
+ while (parent?.isKind(SyntaxKind.AsExpression)) parent = parent.getParent();
344
+ if (parent?.isKind(SyntaxKind.SatisfiesExpression)) return true;
345
+ return parent?.isKind(SyntaxKind.VariableDeclaration) === true && !!parent.getTypeNode();
346
+ }
347
+
348
+ function collectTypedTableValueNames(sf: SourceFile): Set<string> {
349
+ const names = new Set<string>();
350
+ for (const literal of sf.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)) {
351
+ if (!isTypedTableLiteral(literal)) continue;
352
+ for (const property of literal.getProperties()) {
353
+ if (property.isKind(SyntaxKind.ShorthandPropertyAssignment)) names.add(property.getName());
354
+ if (!property.isKind(SyntaxKind.PropertyAssignment)) continue;
355
+ const value = property.getInitializer();
356
+ if (value?.isKind(SyntaxKind.Identifier)) names.add(value.getText());
357
+ }
358
+ }
359
+ return names;
360
+ }
361
+
362
+ function isInlineTypedTableValue(node: FnNode): boolean {
363
+ const property = node.getParent();
364
+ const literal = property?.isKind(SyntaxKind.PropertyAssignment)
365
+ ? property.getParent()
366
+ : undefined;
367
+ return literal !== undefined && isTypedTableLiteral(literal);
368
+ }
369
+
338
370
  // ── Scan ──────────────────────────────────────────────────────────────────
339
371
 
340
372
  export function collectFindings(sf: SourceFile): Finding[] {
341
373
  const file = path.relative(ROOT, sf.getFilePath());
342
374
  const findings: Finding[] = [];
375
+ const typedTableValueNames = collectTypedTableValueNames(sf);
343
376
 
344
377
  const check = (node: FnNode) => {
345
378
  const fnName = getFnName(node);
346
379
  // skip: anonymous functions cannot be reported by name
347
380
  if (!fnName) return;
381
+ // skip: a typed table slot, see isTypedTableLiteral
382
+ if (typedTableValueNames.has(fnName) || isInlineTypedTableValue(node)) return;
348
383
 
349
384
  const callee = getSingleDirectCallee(node);
350
385
  // skip: not a single-callee wrapper
@@ -30,7 +30,7 @@ const SCAN: ScanSpec = {
30
30
  frameworkWithin: ["packages/*/src/**", "samples/**"],
31
31
  };
32
32
 
33
- // Tests nutzen absichtlich generische QNs wie dispatcher.write("x", {}).
33
+ // Tests intentionally use generic QNs such as dispatcher.write("x", {}).
34
34
  const EXCLUDE = /(__tests__|\.test\.tsx$|\.integration\.tsx$|\/node_modules\/|\/dist\/)/;
35
35
 
36
36
  /**
package/src/pre-push.sh CHANGED
@@ -18,8 +18,8 @@
18
18
  # sub-repo's own package.json `test` script — refuses the push if that
19
19
  # script is missing.
20
20
  #
21
- # Before the checks below, the hook runs a gitleaks scan of origin/main..HEAD
22
- # when the binary is installed.
21
+ # Before the checks below, the hook runs a gitleaks scan of origin/main..<pushed sha>
22
+ # for every pushed ref when the binary is installed.
23
23
  #
24
24
  # Extension point: a tracked, executable scripts/pre-push-extra.sh runs
25
25
  # before the main check in every branch above, with KUMIKO_PUSH_REPO_ROOT
@@ -123,11 +123,27 @@ if ! command -v gitleaks >/dev/null 2>&1; then
123
123
  elif ! git -C "$REPO_ROOT" rev-parse --verify --quiet origin/main >/dev/null 2>&1; then
124
124
  echo "[pre-push] origin/main not found — secret scan skipped" >&2
125
125
  else
126
- echo "[pre-push] gitleaks git origin/main..HEAD"
127
- if ! (cd "$REPO_ROOT" && gitleaks git --no-banner --redact --log-opts="origin/main..HEAD" .); then
128
- echo "[pre-push] gitleaks found secrets — refusing push" >&2
129
- exit 1
126
+ # git passes "<local ref> <local sha> <remote ref> <remote sha>" per pushed ref on stdin;
127
+ # scan those tips (not HEAD), skip deletes. Without stdin (direct call) fall back to HEAD.
128
+ SCAN_TIPS=""
129
+ PUSH_LINES=0
130
+ if [ ! -t 0 ]; then
131
+ while read -r _local_ref LOCAL_SHA _remote_ref _remote_sha; do
132
+ [ -n "$LOCAL_SHA" ] || continue
133
+ PUSH_LINES=$((PUSH_LINES + 1))
134
+ case "$LOCAL_SHA" in *[!0]*) SCAN_TIPS="$SCAN_TIPS $LOCAL_SHA" ;; esac
135
+ done
136
+ fi
137
+ if [ "$PUSH_LINES" -eq 0 ]; then
138
+ SCAN_TIPS=" HEAD"
130
139
  fi
140
+ for SCAN_TIP in $SCAN_TIPS; do
141
+ echo "[pre-push] gitleaks git origin/main..$SCAN_TIP"
142
+ if ! (cd "$REPO_ROOT" && gitleaks git --no-banner --redact --log-opts="origin/main..$SCAN_TIP" .); then
143
+ echo "[pre-push] gitleaks found secrets — refusing push" >&2
144
+ exit 1
145
+ fi
146
+ done
131
147
  fi
132
148
 
133
149
  # infra#559/#569: from a worktree, `--git-common-dir` resolves to the main
@@ -39,20 +39,24 @@ function propertyName(node: Node): string | undefined {
39
39
  }
40
40
 
41
41
  function objectProperty(object: ObjectLiteralExpression, name: string): Node | undefined {
42
+ let found: Node | undefined;
43
+ let opaqueAfterFound = false;
42
44
  for (const property of object.getProperties()) {
43
- if (
44
- property.isKind(SyntaxKind.SpreadAssignment) ||
45
- property.isKind(SyntaxKind.ShorthandPropertyAssignment)
46
- ) {
47
- throw new StaticConfigError(
48
- `${name}: spread and shorthand properties cannot be read statically`,
49
- );
50
- }
51
- if (propertyName(property) === name && property.isKind(SyntaxKind.PropertyAssignment)) {
52
- return property.getInitializerOrThrow();
45
+ if (property.isKind(SyntaxKind.SpreadAssignment)) {
46
+ opaqueAfterFound = true;
47
+ } else if (property.isKind(SyntaxKind.ShorthandPropertyAssignment)) {
48
+ if (property.getName() === name) opaqueAfterFound = true;
49
+ } else if (propertyName(property) === name && property.isKind(SyntaxKind.PropertyAssignment)) {
50
+ found = property.getInitializerOrThrow();
51
+ opaqueAfterFound = false;
53
52
  }
54
53
  }
55
- return undefined;
54
+ if (opaqueAfterFound) {
55
+ throw new StaticConfigError(
56
+ `${name}: spread and shorthand properties cannot be read statically`,
57
+ );
58
+ }
59
+ return found;
56
60
  }
57
61
 
58
62
  function numericLiteral(node: Node | undefined, path: string): number {
package/src/run-guards.ts CHANGED
@@ -25,6 +25,7 @@ import { guard as complexity } from "./check-complexity";
25
25
  import { guard as predicateExtraction } from "./check-predicates";
26
26
  import { guard as adminApi } from "./guard-admin-api";
27
27
  import { guard as appFeatureStructure } from "./guard-app-feature-structure";
28
+ import { guard as commentIssueRefOnly } from "./guard-comment-issue-ref-only";
28
29
  import { guard as crossFeatureImports } from "./guard-cross-feature-imports";
29
30
  import { guard as directEntityWrites } from "./guard-direct-entity-writes";
30
31
  import { guard as directFetch } from "./guard-direct-fetch";
@@ -82,6 +83,7 @@ export const GUARDS = [
82
83
  testTimeouts,
83
84
  testTemplateDrift,
84
85
  fileSize,
86
+ commentIssueRefOnly,
85
87
  ];
86
88
 
87
89
  export const GUARD_FLAGS = [
@@ -95,6 +97,8 @@ const GUARD_NAME_PREFIX = "--guard=";
95
97
 
96
98
  export type RunGuardsCliDeps = {
97
99
  readonly writeBaselineRoots?: readonly RepoRoot[];
100
+ readonly acquireSlot?: () => () => void;
101
+ readonly buildProject?: typeof buildSharedProject;
98
102
  };
99
103
 
100
104
  // Shared by the direct `bun run-guards.ts` invocation below and by the
@@ -113,7 +117,7 @@ export function runGuardsCli(argv: readonly string[], deps: RunGuardsCliDeps = {
113
117
  return 1;
114
118
  }
115
119
  if (flags.includes("--explain")) {
116
- for (const line of explainGuards(GUARDS, buildSharedProject(GUARDS))) {
120
+ for (const line of explainGuards(GUARDS, (deps.buildProject ?? buildSharedProject)(GUARDS))) {
117
121
  console.log(line);
118
122
  }
119
123
  return 0;
@@ -153,12 +157,15 @@ export function runGuardsCli(argv: readonly string[], deps: RunGuardsCliDeps = {
153
157
  );
154
158
  return 0;
155
159
  }
156
- const strictSecurityBaseline = flags.includes("--strict-security-baseline");
160
+ return runAllGuards(flags.includes("--strict-security-baseline"), deps);
161
+ }
162
+
163
+ function runAllGuards(strictSecurityBaseline: boolean, deps: RunGuardsCliDeps): number {
157
164
  const guards = strictSecurityBaseline ? GUARDS.filter(isSecurityGuard) : GUARDS;
158
165
  // Taken before the ts-morph project is built: that is where the memory goes.
159
- const releaseSlot = acquireGuardRunSlot();
166
+ const releaseSlot = (deps.acquireSlot ?? acquireGuardRunSlot)();
160
167
  try {
161
- const project = buildSharedProject(guards);
168
+ const project = (deps.buildProject ?? buildSharedProject)(guards);
162
169
  printGuardKitBanner(guards.length, project);
163
170
  return reportResults(runGuards(guards, project, { strictSecurityBaseline }));
164
171
  } finally {
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- // UI-guard bundle (App-Mounting 2.0, infra#208): one process, shared ts-morph
2
+ // UI-guard bundle (App-Mounting 2.0): one process, shared ts-morph
3
3
  // Project over the UI enforcement guards.
4
4
  import {
5
5
  buildSharedProject,
@@ -49,7 +49,6 @@ const PATH_RULES: readonly (readonly [RegExp, Runtime])[] = [
49
49
  // sample and import kumiko-testing/e2e; without this rule they read as
50
50
  // runtime code importing the test tier. Deliberately not any nested e2e/.
51
51
  [/^(?:e2e|packages\/[^/]+\/e2e|samples\/(?:.+\/)?e2e)\//, "test"],
52
- [/(?:^|\/)dev\//, "dev"],
53
52
  [/(?:^|\/)scripts\//, "tooling"],
54
53
  [/(?:^|\/)bin\//, "tooling"],
55
54
  [/\/drizzle\/[^/]+\.ts$/, "tooling"],
@@ -75,6 +74,8 @@ const PATH_RULES: readonly (readonly [RegExp, Runtime])[] = [
75
74
  // some other package.
76
75
  [/(?:^|\/)web\//, "client"],
77
76
  [/\/web\.tsx?$/, "client"],
77
+ // After the web/ carve-out: client code under web/dev/ must stay client.
78
+ [/(?:^|\/)dev\//, "dev"],
78
79
  [/^packages\/framework\/src\/(?:time|utils|engine\/types|errors)\//, "client"],
79
80
 
80
81
  // More single-file carve-outs, same "package marker is coarser than the
@@ -108,7 +109,7 @@ export function classifyByPath(repoRelativePath: string): Runtime | null {
108
109
  * for `kumiko-build` entries (`resolveClientEntries` in kumiko-framework's
109
110
  * `packages/server-runtime/src/build-prod-bundle.ts`): `src/client.tsx`/
110
111
  * `src/client.ts` (single-entry) or `src/client-<suffix>.tsx?` (declared via
111
- * package.json `kumiko.clientEntries`, fw#2320 — no longer auto-discovered
112
+ * package.json `kumiko.clientEntries`; no longer auto-discovered
112
113
  * by this filename alone). Framework/enterprise packages never match —
113
114
  * their sources live under `packages/*\/src/`, not a repo-root `src/`. Kept
114
115
  * independent of the framework's own regex (cross-package import would be a