@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.
- package/LICENSE +21 -57
- package/package.json +4 -4
- package/src/_lib/agent-docs.ts +12 -7
- package/src/_lib/comment-trivia.ts +148 -0
- package/src/_lib/guard-kit.ts +2 -2
- package/src/_lib/guards-doc.ts +4 -1
- package/src/_lib/roots.ts +9 -5
- package/src/_lib/run-slots.ts +39 -10
- package/src/_lib/scan-scope.ts +1 -1
- package/src/_lib/security-audit.ts +15 -8
- package/src/_lib/sql-inventory.ts +2 -2
- package/src/changes.json +45 -0
- package/src/check-as-casts.ts +6 -7
- package/src/check-complexity.ts +86 -29
- package/src/check-licenses.ts +23 -6
- package/src/check-real-provider-isolation.ts +1 -1
- package/src/check-runtime-isolation.ts +2 -2
- package/src/check-security.ts +2 -1
- package/src/check-single-runtime-instance.ts +1 -1
- package/src/dependency-license-exceptions.json +0 -24
- package/src/guard-admin-api.ts +5 -6
- package/src/guard-app-feature-structure.ts +9 -9
- package/src/guard-comment-issue-ref-only.ts +223 -0
- package/src/guard-comment-lang.ts +10 -141
- package/src/guard-direct-entity-writes.ts +43 -31
- package/src/guard-direct-fetch.ts +3 -3
- package/src/guard-feature-integration-tests.ts +1 -1
- package/src/guard-file-size.ts +2 -2
- package/src/guard-i18n-locale-mount.ts +4 -4
- package/src/guard-i18n-locale-terminology.ts +3 -3
- package/src/guard-i18n-ui-strings.ts +5 -5
- package/src/guard-import-extensions.ts +34 -9
- package/src/guard-no-custom-primitives.ts +2 -4
- package/src/guard-no-direct-fs.ts +1 -1
- package/src/guard-no-direct-process-env.ts +2 -2
- package/src/guard-no-inline-styles.ts +1 -1
- package/src/guard-no-raw-hooks.ts +0 -2
- package/src/guard-primitives-discipline.ts +1 -1
- package/src/guard-public-exports-changelog.ts +8 -8
- package/src/guard-raw-classname.ts +1 -1
- package/src/guard-raw-interactive-elements.ts +1 -1
- package/src/guard-renderer-boundaries.ts +61 -9
- package/src/guard-restricted-symbols.ts +2 -3
- package/src/guard-tailwind-scan-surface.ts +28 -29
- package/src/guard-tenant-escalation.ts +3 -3
- package/src/guard-test-template-drift.ts +1 -1
- package/src/guard-test-timeouts.ts +12 -4
- package/src/guard-thin-wrappers.ts +35 -0
- package/src/guard-write-handler-qns.ts +1 -1
- package/src/pre-push.sh +22 -6
- package/src/repo-manifest/app-ports/static-app-config.ts +15 -11
- package/src/run-guards.ts +11 -4
- package/src/run-ui-guards.ts +1 -1
- 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 (
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
9
|
-
*
|
|
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
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
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
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
|
|
65
|
-
//
|
|
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 (
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/run-ui-guards.ts
CHANGED
|
@@ -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
|
|
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
|