@cosmicdrift/kumiko-guards 0.334.0 → 0.336.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/package.json +2 -2
- package/src/_lib/framework-import.ts +29 -0
- package/src/_lib/git-env.ts +2 -10
- package/src/_lib/guard-kit.ts +12 -8
- package/src/_lib/scan-scope.ts +1 -1
- package/src/_lib/sql-inventory.ts +5 -1
- package/src/changes.json +26 -0
- package/src/check-as-casts.ts +89 -14
- package/src/check-real-provider-isolation.ts +9 -1
- package/src/check-runtime-isolation.ts +11 -4
- package/src/check-secret-literals.ts +18 -13
- package/src/cli.ts +4 -0
- package/src/guard-access-denied-test.ts +2 -2
- package/src/guard-admin-api.ts +1 -2
- package/src/guard-app-feature-structure.ts +8 -6
- package/src/guard-direct-fetch.ts +1 -1
- package/src/guard-error-reasons.ts +7 -3
- package/src/guard-escape-hatch-declared.ts +25 -13
- package/src/guard-fake-tests.ts +32 -25
- package/src/guard-i18n-keys.ts +0 -1
- package/src/guard-i18n-locale-terminology.ts +3 -3
- package/src/guard-lib-test-coverage.ts +11 -6
- package/src/guard-no-custom-primitives.ts +3 -2
- package/src/guard-no-framed-extension-sections.ts +90 -26
- package/src/guard-no-inline-styles.ts +9 -3
- package/src/guard-no-logic-in-views.ts +8 -2
- package/src/guard-no-raw-hooks.ts +11 -4
- package/src/guard-open-to-all-reason.ts +2 -2
- package/src/guard-primitives-discipline.ts +3 -2
- package/src/guard-raw-classname.ts +8 -2
- package/src/guard-raw-sql.ts +9 -3
- package/src/guard-section-fields-raw.ts +81 -9
- package/src/guard-test-stack-drift.ts +11 -1
- package/src/guard-test-template-drift.ts +48 -2
- package/src/guard-test-timeouts.ts +21 -2
- package/src/guard-thin-wrappers.ts +2 -2
- package/src/guard-upgrade-state.ts +7 -4
- package/src/pre-push.sh +2 -2
- package/src/run-guards.ts +6 -1
- package/src/run-repo-checks.ts +1 -1
- package/src/run-ui-guards.ts +1 -1
- package/src/runtime-isolation-classify.ts +147 -25
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* generator in #3042). `sectionFieldSpecs` (engine/screen-helpers.ts) is the
|
|
12
12
|
* one reader that unions both sources.
|
|
13
13
|
*
|
|
14
|
-
* Flagged: for-of, spread
|
|
14
|
+
* Flagged: for-of, spread, array reads, indexing, call arguments and
|
|
15
|
+
* destructuring over the `fields` of a section (by name or by a `groups`
|
|
16
|
+
* property on its type), also behind `!`, parentheses, `as` and `??`.
|
|
15
17
|
* Known gap: `section.fields.length` is not flagged — the fields-XOR-groups
|
|
16
18
|
* validator itself needs it, and an emptiness check is not the silent-
|
|
17
19
|
* iteration bug.
|
|
@@ -35,7 +37,13 @@
|
|
|
35
37
|
* bun guards/guard-section-fields-raw.ts --no-baseline # skip comparison
|
|
36
38
|
*/
|
|
37
39
|
import * as path from "node:path";
|
|
38
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
type Node,
|
|
42
|
+
type PropertyAccessExpression,
|
|
43
|
+
type SourceFile,
|
|
44
|
+
SyntaxKind,
|
|
45
|
+
type VariableDeclaration,
|
|
46
|
+
} from "ts-morph";
|
|
39
47
|
import {
|
|
40
48
|
type AstGuard,
|
|
41
49
|
baselineRatchet,
|
|
@@ -68,9 +76,10 @@ const SCAN: ScanSpec = {
|
|
|
68
76
|
const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
|
|
69
77
|
|
|
70
78
|
const IGNORE_TAG = "kumiko-lint-ignore section-fields-raw";
|
|
71
|
-
// hasIgnoreTag() from _lib accepts a bare tag;
|
|
72
|
-
//
|
|
73
|
-
|
|
79
|
+
// hasIgnoreTag() from _lib accepts a bare tag; a visible reason is required, so
|
|
80
|
+
// the reason must start with a real character: the `*` of a closing `*/` (block
|
|
81
|
+
// and JSX comments) must not count as one.
|
|
82
|
+
const REASONED_TAG = new RegExp(`${IGNORE_TAG}\\s+(?!\\*/)[^\\s*]`);
|
|
74
83
|
|
|
75
84
|
const SECTION_RECEIVER = /section/i;
|
|
76
85
|
|
|
@@ -83,6 +92,7 @@ const ARRAY_READS = new Set([
|
|
|
83
92
|
"find",
|
|
84
93
|
"findIndex",
|
|
85
94
|
"findLast",
|
|
95
|
+
"findLastIndex",
|
|
86
96
|
"flat",
|
|
87
97
|
"flatMap",
|
|
88
98
|
"forEach",
|
|
@@ -90,6 +100,7 @@ const ARRAY_READS = new Set([
|
|
|
90
100
|
"indexOf",
|
|
91
101
|
"join",
|
|
92
102
|
"keys",
|
|
103
|
+
"lastIndexOf",
|
|
93
104
|
"map",
|
|
94
105
|
"reduce",
|
|
95
106
|
"reduceRight",
|
|
@@ -99,7 +110,9 @@ const ARRAY_READS = new Set([
|
|
|
99
110
|
"sort",
|
|
100
111
|
"toReversed",
|
|
101
112
|
"toSorted",
|
|
113
|
+
"toSpliced",
|
|
102
114
|
"values",
|
|
115
|
+
"with",
|
|
103
116
|
]);
|
|
104
117
|
|
|
105
118
|
function relFile(sf: SourceFile, roots: readonly RepoRoot[]): string {
|
|
@@ -112,19 +125,68 @@ function hasReasonedIgnoreTag(node: Node): boolean {
|
|
|
112
125
|
return REASONED_TAG.test(lines[line - 1] ?? "") || REASONED_TAG.test(lines[line - 2] ?? "");
|
|
113
126
|
}
|
|
114
127
|
|
|
128
|
+
// A section is recognised by its name or, when types resolve, by carrying a
|
|
129
|
+
// `groups` property — so `sec.fields` and `layout.sections.map((s) => s.fields)`
|
|
130
|
+
// are caught too. The name regex stays as the fallback for untyped receivers.
|
|
131
|
+
function isSectionReceiver(receiver: Node): boolean {
|
|
132
|
+
if (SECTION_RECEIVER.test(receiver.getText())) return true;
|
|
133
|
+
return receiver.getType().getProperty("groups") !== undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Climbs `x!`, `(x)`, `x as T` and `x ?? y` so a wrapped read is judged by the
|
|
137
|
+
// context it ends up in.
|
|
138
|
+
function unwrapReadContext(access: Node): Node {
|
|
139
|
+
let current = access;
|
|
140
|
+
for (;;) {
|
|
141
|
+
const parent = current.getParent();
|
|
142
|
+
if (
|
|
143
|
+
parent !== undefined &&
|
|
144
|
+
(parent.isKind(SyntaxKind.NonNullExpression) ||
|
|
145
|
+
parent.isKind(SyntaxKind.ParenthesizedExpression) ||
|
|
146
|
+
parent.isKind(SyntaxKind.AsExpression) ||
|
|
147
|
+
(parent.isKind(SyntaxKind.BinaryExpression) &&
|
|
148
|
+
parent.getOperatorToken().getKind() === SyntaxKind.QuestionQuestionToken &&
|
|
149
|
+
parent.getLeft() === current))
|
|
150
|
+
) {
|
|
151
|
+
current = parent;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
return current;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
115
158
|
function isSequenceRead(access: PropertyAccessExpression): boolean {
|
|
116
|
-
const
|
|
159
|
+
const read = unwrapReadContext(access);
|
|
160
|
+
const parent = read.getParent();
|
|
117
161
|
if (parent === undefined) return false;
|
|
118
|
-
if (parent.isKind(SyntaxKind.ForOfStatement)) return parent.getExpression() ===
|
|
162
|
+
if (parent.isKind(SyntaxKind.ForOfStatement)) return parent.getExpression() === read;
|
|
119
163
|
if (parent.isKind(SyntaxKind.SpreadElement) || parent.isKind(SyntaxKind.SpreadAssignment)) {
|
|
120
164
|
return true;
|
|
121
165
|
}
|
|
122
|
-
if (parent.isKind(SyntaxKind.
|
|
166
|
+
if (parent.isKind(SyntaxKind.ElementAccessExpression)) return parent.getExpression() === read;
|
|
167
|
+
if (parent.isKind(SyntaxKind.CallExpression)) return parent.getArguments().includes(read);
|
|
168
|
+
if (
|
|
169
|
+
parent.isKind(SyntaxKind.PropertyAccessExpression) &&
|
|
170
|
+
parent.getExpression() === read &&
|
|
171
|
+
ARRAY_READS.has(parent.getName())
|
|
172
|
+
) {
|
|
123
173
|
return parent.getParent()?.isKind(SyntaxKind.CallExpression) === true;
|
|
124
174
|
}
|
|
125
175
|
return false;
|
|
126
176
|
}
|
|
127
177
|
|
|
178
|
+
function isDestructuredFieldsRead(declaration: VariableDeclaration): boolean {
|
|
179
|
+
const pattern = declaration.getNameNode();
|
|
180
|
+
const initializer = declaration.getInitializer();
|
|
181
|
+
if (!pattern.isKind(SyntaxKind.ObjectBindingPattern) || initializer === undefined) return false;
|
|
182
|
+
if (!isSectionReceiver(initializer)) return false;
|
|
183
|
+
return pattern
|
|
184
|
+
.getElements()
|
|
185
|
+
.some(
|
|
186
|
+
(element) => (element.getPropertyNameNode() ?? element.getNameNode()).getText() === "fields",
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
128
190
|
export interface Finding {
|
|
129
191
|
file: string;
|
|
130
192
|
line: number;
|
|
@@ -138,7 +200,7 @@ export function scanRawSectionFieldReads(
|
|
|
138
200
|
const findings: Finding[] = [];
|
|
139
201
|
for (const access of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
|
|
140
202
|
if (access.getName() !== "fields") continue;
|
|
141
|
-
if (!
|
|
203
|
+
if (!isSectionReceiver(access.getExpression())) continue;
|
|
142
204
|
if (!isSequenceRead(access)) continue;
|
|
143
205
|
if (hasReasonedIgnoreTag(access)) continue;
|
|
144
206
|
const line = access.getStartLineNumber();
|
|
@@ -148,6 +210,16 @@ export function scanRawSectionFieldReads(
|
|
|
148
210
|
` [section-fields-raw WARN] ${file}:${line} raw read of ${access.getText().slice(0, 80)} — use sectionFieldSpecs(section)`,
|
|
149
211
|
);
|
|
150
212
|
}
|
|
213
|
+
for (const declaration of sf.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
214
|
+
if (!isDestructuredFieldsRead(declaration)) continue;
|
|
215
|
+
if (hasReasonedIgnoreTag(declaration)) continue;
|
|
216
|
+
const line = declaration.getStartLineNumber();
|
|
217
|
+
const file = relFile(sf, roots);
|
|
218
|
+
findings.push({ file, line, snippet: declaration.getText().slice(0, 80) });
|
|
219
|
+
console.warn(
|
|
220
|
+
` [section-fields-raw WARN] ${file}:${line} destructured fields of ${declaration.getText().slice(0, 80)} — use sectionFieldSpecs(section)`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
151
223
|
return findings;
|
|
152
224
|
}
|
|
153
225
|
|
|
@@ -94,8 +94,18 @@ function collectCallNames(sf: SourceFile): Map<string, number[]> {
|
|
|
94
94
|
return calls;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// Only a marker inside a real comment counts: the same text
|
|
98
|
+
// inside a string/template literal is fixture data, not an opt-out.
|
|
97
99
|
function hasOptOutMarker(sf: SourceFile): boolean {
|
|
98
|
-
|
|
100
|
+
const text = sf.getFullText();
|
|
101
|
+
const marker = new RegExp(OPT_OUT_MARKER.source, "gi");
|
|
102
|
+
for (const match of text.matchAll(marker)) {
|
|
103
|
+
const kind = sf.getDescendantAtPos(match.index)?.getKind();
|
|
104
|
+
if (kind === SyntaxKind.SingleLineCommentTrivia || kind === SyntaxKind.MultiLineCommentTrivia) {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
99
109
|
}
|
|
100
110
|
|
|
101
111
|
export function scanFile(sf: SourceFile): Violation | null {
|
|
@@ -74,6 +74,16 @@ const RAW_DEFINE_CONFIG_MODULE = "@playwright/test";
|
|
|
74
74
|
|
|
75
75
|
const TEMPLATE_OWNED_KEYS: ReadonlySet<string> = new Set(["viewport", "deviceScaleFactor"]);
|
|
76
76
|
|
|
77
|
+
// Top-level config keys the template owns; spreading defineAppE2eConfig(...)
|
|
78
|
+
// and then setting one of them re-opens the drift the template closes.
|
|
79
|
+
const TEMPLATE_OWNED_CONFIG_KEYS: ReadonlySet<string> = new Set([
|
|
80
|
+
"fullyParallel",
|
|
81
|
+
"retries",
|
|
82
|
+
"timeout",
|
|
83
|
+
"workers",
|
|
84
|
+
"expect",
|
|
85
|
+
]);
|
|
86
|
+
|
|
77
87
|
export interface Finding {
|
|
78
88
|
file: string;
|
|
79
89
|
line: number;
|
|
@@ -148,7 +158,11 @@ function isPlaywrightUseObject(node: Node): boolean {
|
|
|
148
158
|
function templateOwnedKeyFindings(sf: SourceFile): Finding[] {
|
|
149
159
|
const findings: Finding[] = [];
|
|
150
160
|
const isConfigFile = CONFIG_FILENAME.test(sf.getFilePath());
|
|
151
|
-
|
|
161
|
+
const props = [
|
|
162
|
+
...sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment),
|
|
163
|
+
...sf.getDescendantsOfKind(SyntaxKind.ShorthandPropertyAssignment),
|
|
164
|
+
];
|
|
165
|
+
for (const prop of props) {
|
|
152
166
|
const name = prop.getName();
|
|
153
167
|
if (!TEMPLATE_OWNED_KEYS.has(name)) continue;
|
|
154
168
|
// viewport is only drift in the config's own use/project use — a spec's
|
|
@@ -170,6 +184,34 @@ function templateOwnedKeyFindings(sf: SourceFile): Finding[] {
|
|
|
170
184
|
return findings;
|
|
171
185
|
}
|
|
172
186
|
|
|
187
|
+
function isDefineAppE2eConfigSpread(node: Node): boolean {
|
|
188
|
+
if (!Node.isSpreadAssignment(node)) return false;
|
|
189
|
+
const spread = node.getExpression();
|
|
190
|
+
return (
|
|
191
|
+
Node.isCallExpression(spread) && spread.getExpression().getText() === DEFINE_APP_E2E_CONFIG
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function configOverrideFindings(sf: SourceFile): Finding[] {
|
|
196
|
+
const findings: Finding[] = [];
|
|
197
|
+
for (const literal of sf.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)) {
|
|
198
|
+
const properties = literal.getProperties();
|
|
199
|
+
if (!properties.some(isDefineAppE2eConfigSpread)) continue;
|
|
200
|
+
for (const prop of properties) {
|
|
201
|
+
if (!Node.isPropertyAssignment(prop) && !Node.isShorthandPropertyAssignment(prop)) continue;
|
|
202
|
+
const name = prop.getName();
|
|
203
|
+
if (!TEMPLATE_OWNED_CONFIG_KEYS.has(name)) continue;
|
|
204
|
+
const message = withExceptionNote(
|
|
205
|
+
prop,
|
|
206
|
+
`Config key "${name}" next to a ...defineAppE2eConfig(...) spread — timeouts, retries, workers and expect belong to the template; pass the supported options to defineAppE2eConfig instead.`,
|
|
207
|
+
);
|
|
208
|
+
if (message !== undefined)
|
|
209
|
+
findings.push({ file: "", line: prop.getStartLineNumber(), message });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return findings;
|
|
213
|
+
}
|
|
214
|
+
|
|
173
215
|
function isPageScreenshotCall(call: Node): boolean {
|
|
174
216
|
if (!Node.isCallExpression(call)) return false;
|
|
175
217
|
const callee = call.getExpression();
|
|
@@ -202,7 +244,11 @@ export function scanTemplateDrift(
|
|
|
202
244
|
const findings: Finding[] = [];
|
|
203
245
|
const own = ownConfigFinding(sf);
|
|
204
246
|
if (own !== undefined) findings.push({ ...own, file });
|
|
205
|
-
for (const f of [
|
|
247
|
+
for (const f of [
|
|
248
|
+
...templateOwnedKeyFindings(sf),
|
|
249
|
+
...configOverrideFindings(sf),
|
|
250
|
+
...pageScreenshotFindings(sf),
|
|
251
|
+
]) {
|
|
206
252
|
findings.push({ ...f, file });
|
|
207
253
|
}
|
|
208
254
|
return findings.sort((a, b) => a.line - b.line);
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Guard: tests wait for a condition, not for time. Flags, in test files and
|
|
4
4
|
* Playwright specs/helpers:
|
|
5
|
-
* (a) `test.setTimeout(...)`, `test.slow(...)`
|
|
5
|
+
* (a) `test.setTimeout(...)`, `test.slow(...)`, `setDefaultTimeout(...)`,
|
|
6
|
+
* `*.describe.configure({ timeout })` — raising a timeout hides the
|
|
6
7
|
* cause of a slow or flaky test
|
|
7
8
|
* (b) `<page>.waitForTimeout(...)` — a fixed sleep instead of a condition
|
|
8
9
|
* (c) sleep loops — a `while`/`do`/`for`/`for…of`/`for…in` whose body calls
|
|
@@ -67,6 +68,8 @@ const LOOP_LABELS: ReadonlyMap<SyntaxKind, string> = new Map([
|
|
|
67
68
|
const SLEEP_CALLEES: ReadonlySet<string> = new Set(["sleep", "Bun.sleep"]);
|
|
68
69
|
const TIMEOUT_RAISING_CALLEES: ReadonlySet<string> = new Set(["test.setTimeout", "test.slow"]);
|
|
69
70
|
const SET_TIMEOUT_CALLEE = /(^|\.)setTimeout$/;
|
|
71
|
+
const SET_DEFAULT_TIMEOUT_CALLEE = /(^|\.)setDefaultTimeout$/;
|
|
72
|
+
const DESCRIBE_CONFIGURE_CALLEE = /(^|\.)describe\.configure$/;
|
|
70
73
|
|
|
71
74
|
export interface Finding {
|
|
72
75
|
file: string;
|
|
@@ -127,10 +130,26 @@ function exceptionNote(node: Node): "allowed" | "incomplete" | "none" {
|
|
|
127
130
|
return nearby.some((text) => text.includes(EXCEPTION_TAG)) ? "incomplete" : "none";
|
|
128
131
|
}
|
|
129
132
|
|
|
133
|
+
function hasTimeoutProperty(node: Node | undefined): boolean {
|
|
134
|
+
return (
|
|
135
|
+
node !== undefined &&
|
|
136
|
+
Node.isObjectLiteralExpression(node) &&
|
|
137
|
+
node.getProperties().some((prop) => {
|
|
138
|
+
if (Node.isShorthandPropertyAssignment(prop) || Node.isPropertyAssignment(prop)) {
|
|
139
|
+
return prop.getName() === "timeout";
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
130
146
|
function raisedTimeoutReason(call: CallExpression): string | undefined {
|
|
131
147
|
const callee = call.getExpression();
|
|
132
148
|
const calleeText = callee.getText();
|
|
133
|
-
if (TIMEOUT_RAISING_CALLEES.has(calleeText)) {
|
|
149
|
+
if (TIMEOUT_RAISING_CALLEES.has(calleeText) || SET_DEFAULT_TIMEOUT_CALLEE.test(calleeText)) {
|
|
150
|
+
return `${calleeText}(…) raises the timeout instead of fixing the cause`;
|
|
151
|
+
}
|
|
152
|
+
if (DESCRIBE_CONFIGURE_CALLEE.test(calleeText) && call.getArguments().some(hasTimeoutProperty)) {
|
|
134
153
|
return `${calleeText}(…) raises the timeout instead of fixing the cause`;
|
|
135
154
|
}
|
|
136
155
|
if (Node.isPropertyAccessExpression(callee) && callee.getName() === "waitForTimeout") {
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
} from "ts-morph";
|
|
40
40
|
import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
41
41
|
import { frameworkTsConfigPath } from "./_lib/roots";
|
|
42
|
-
import { type ScanSpec, scanFiles
|
|
42
|
+
import { keepsRootKind, type ScanSpec, scanFiles } from "./_lib/scan-scope";
|
|
43
43
|
|
|
44
44
|
const ROOT = process.cwd();
|
|
45
45
|
|
|
@@ -387,7 +387,7 @@ export const check: RepoCheck = {
|
|
|
387
387
|
// The kind filter (scan-scope.ts keepsRootKind) excludes "tooling" roots by
|
|
388
388
|
// default — that's a deliberate scope choice, not a vacuous scan, so it must
|
|
389
389
|
// report notApplicable instead of matchedFiles: 0.
|
|
390
|
-
if (
|
|
390
|
+
if (!roots.some((root) => keepsRootKind(SCAN, root))) {
|
|
391
391
|
return { violations: [], matchedFiles: 0, notApplicable: true };
|
|
392
392
|
}
|
|
393
393
|
|
|
@@ -109,9 +109,12 @@ export function pendingViolations(json: UpgradeJson, markerVersion: string): Gua
|
|
|
109
109
|
}));
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
function resolveKumikoUpgradeBin(
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
export function resolveKumikoUpgradeBin(
|
|
113
|
+
cwd: string,
|
|
114
|
+
pathEnv: string | undefined = process.env["PATH"],
|
|
115
|
+
): string | undefined {
|
|
116
|
+
// Repo-pinned binary wins over PATH so the marker is measured against the
|
|
117
|
+
// version this checkout actually installs.
|
|
115
118
|
let dir = cwd;
|
|
116
119
|
for (;;) {
|
|
117
120
|
const candidate = join(dir, "node_modules", ".bin", "kumiko-upgrade");
|
|
@@ -120,7 +123,7 @@ function resolveKumikoUpgradeBin(cwd: string): string | undefined {
|
|
|
120
123
|
if (parent === dir) break;
|
|
121
124
|
dir = parent;
|
|
122
125
|
}
|
|
123
|
-
return undefined;
|
|
126
|
+
return Bun.which("kumiko-upgrade", { PATH: pathEnv }) ?? undefined;
|
|
124
127
|
}
|
|
125
128
|
|
|
126
129
|
async function runKumikoUpgrade(
|
package/src/pre-push.sh
CHANGED
|
@@ -123,7 +123,7 @@ fi
|
|
|
123
123
|
# canonical sibling path instead of this worktree — checking the wrong
|
|
124
124
|
# code. Run the worktree-local check only when scripts/check-wt.sh is
|
|
125
125
|
# tracked and executable (untracked/symlink stubs must not hijack push).
|
|
126
|
-
if [ "$(git rev-parse --git-dir)" != "$
|
|
126
|
+
if [ "$(git rev-parse --path-format=absolute --git-dir)" != "$GIT_COMMON_DIR" ] \
|
|
127
127
|
&& git -C "$REPO_ROOT" ls-files --error-unmatch scripts/check-wt.sh >/dev/null 2>&1 \
|
|
128
128
|
&& [ -x "$REPO_ROOT/scripts/check-wt.sh" ]; then
|
|
129
129
|
echo "[pre-push] worktree detected — worktree check (see scripts/check-wt.sh)…"
|
|
@@ -153,7 +153,7 @@ run_guard_bin() {
|
|
|
153
153
|
# worktree's own package.json scripts directly instead (only when nested
|
|
154
154
|
# under the parent workspace; a standalone worktree's own `bun run test`
|
|
155
155
|
# already checks the right code).
|
|
156
|
-
if [ "$(git rev-parse --git-dir)" != "$
|
|
156
|
+
if [ "$(git rev-parse --path-format=absolute --git-dir)" != "$GIT_COMMON_DIR" ] \
|
|
157
157
|
&& [ -n "$PARENT_DIR" ]; then
|
|
158
158
|
cd "$REPO_ROOT"
|
|
159
159
|
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)…"
|
package/src/run-guards.ts
CHANGED
|
@@ -121,6 +121,10 @@ export function runGuardsCli(argv: readonly string[], deps: RunGuardsCliDeps = {
|
|
|
121
121
|
console.error(flagsError);
|
|
122
122
|
return 1;
|
|
123
123
|
}
|
|
124
|
+
if (guardNameArg !== undefined && !flags.includes("--write-baseline")) {
|
|
125
|
+
console.error("--guard=<name> is only valid with --write-baseline");
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
124
128
|
if (flags.includes("--explain")) {
|
|
125
129
|
for (const line of explainGuards(GUARDS, buildSharedProject(GUARDS))) {
|
|
126
130
|
console.log(line);
|
|
@@ -169,5 +173,6 @@ export function runGuardsCli(argv: readonly string[], deps: RunGuardsCliDeps = {
|
|
|
169
173
|
// Only run on direct invocation — otherwise `import { GUARDS }` would kick
|
|
170
174
|
// off the whole suite and the list wouldn't be testable.
|
|
171
175
|
if (import.meta.main) {
|
|
172
|
-
|
|
176
|
+
// Exit codes wrap at 256, so a raw failure count could read as success.
|
|
177
|
+
process.exit(runGuardsCli(process.argv.slice(2)) > 0 ? 1 : 0);
|
|
173
178
|
}
|
package/src/run-repo-checks.ts
CHANGED
package/src/run-ui-guards.ts
CHANGED
|
@@ -47,5 +47,5 @@ export function runUiGuardsCli(argv: readonly string[]): number {
|
|
|
47
47
|
|
|
48
48
|
// Same as run-guards.ts: only run on direct invocation.
|
|
49
49
|
if (import.meta.main) {
|
|
50
|
-
process.exit(runUiGuardsCli(process.argv.slice(2)));
|
|
50
|
+
process.exit(runUiGuardsCli(process.argv.slice(2)) > 0 ? 1 : 0);
|
|
51
51
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// classification rules down so future edits trip a test, not a
|
|
12
12
|
// production drift.
|
|
13
13
|
|
|
14
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
|
|
15
15
|
import * as path from "node:path";
|
|
16
16
|
import type { ImportDeclaration, SourceFile } from "ts-morph";
|
|
17
17
|
|
|
@@ -96,11 +96,54 @@ export function classifyByPath(repoRelativePath: string): Runtime | null {
|
|
|
96
96
|
* their sources live under `packages/*\/src/`, not a repo-root `src/`. Kept
|
|
97
97
|
* independent of the framework's own regex (cross-package import would be a
|
|
98
98
|
* build-vs-lint layering violation) — if apps stop following this naming
|
|
99
|
-
* convention, this drifts and needs a matching update.
|
|
99
|
+
* convention, this drifts and needs a matching update. Entries the app
|
|
100
|
+
* declares itself (`readDeclaredClientEntries`) count in addition, so a
|
|
101
|
+
* `./src/admin/index.tsx` entry is classified as browser code too.
|
|
100
102
|
*/
|
|
101
|
-
export function isClientEntryPath(
|
|
103
|
+
export function isClientEntryPath(
|
|
104
|
+
repoRelativePath: string,
|
|
105
|
+
declaredEntries: ReadonlySet<string> = new Set(),
|
|
106
|
+
): boolean {
|
|
102
107
|
const rel = repoRelativePath.replace(/\\/g, "/");
|
|
103
|
-
return /^src\/client(-[a-z][a-z0-9-]*)?\.tsx?$/.test(rel);
|
|
108
|
+
return declaredEntries.has(rel) || /^src\/client(-[a-z][a-z0-9-]*)?\.tsx?$/.test(rel);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
112
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parses `kumiko.clientEntry` / `kumiko.clientEntries[].sourceFile` out of a
|
|
117
|
+
* package.json value into repo-relative paths. Parsed locally instead of
|
|
118
|
+
* importing the build's resolver (layering: lint must not depend on
|
|
119
|
+
* server-runtime); anything malformed is ignored — `kumiko build` owns
|
|
120
|
+
* rejecting it.
|
|
121
|
+
*/
|
|
122
|
+
export function parseDeclaredClientEntries(packageJson: unknown): ReadonlySet<string> {
|
|
123
|
+
const entries = new Set<string>();
|
|
124
|
+
const kumiko = isRecord(packageJson) ? packageJson["kumiko"] : undefined;
|
|
125
|
+
if (!isRecord(kumiko)) return entries;
|
|
126
|
+
const sources: unknown[] = [kumiko["clientEntry"]];
|
|
127
|
+
const multi = kumiko["clientEntries"];
|
|
128
|
+
if (Array.isArray(multi)) {
|
|
129
|
+
for (const entry of multi) sources.push(isRecord(entry) ? entry["sourceFile"] : undefined);
|
|
130
|
+
}
|
|
131
|
+
for (const source of sources) {
|
|
132
|
+
if (typeof source === "string") {
|
|
133
|
+
entries.add(path.posix.normalize(source.replace(/\\/g, "/")));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return entries;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function readDeclaredClientEntries(repoAbsPath: string): ReadonlySet<string> {
|
|
140
|
+
const file = path.join(repoAbsPath, "package.json");
|
|
141
|
+
if (!existsSync(file)) return new Set();
|
|
142
|
+
try {
|
|
143
|
+
return parseDeclaredClientEntries(JSON.parse(readFileSync(file, "utf8")));
|
|
144
|
+
} catch {
|
|
145
|
+
return new Set();
|
|
146
|
+
}
|
|
104
147
|
}
|
|
105
148
|
|
|
106
149
|
/**
|
|
@@ -122,6 +165,42 @@ export function isValueImport(decl: ImportDeclaration): boolean {
|
|
|
122
165
|
return true;
|
|
123
166
|
}
|
|
124
167
|
|
|
168
|
+
export type ValueModuleEdge = {
|
|
169
|
+
readonly spec: string;
|
|
170
|
+
readonly target: SourceFile;
|
|
171
|
+
readonly line: number;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Every runtime-weighted edge out of a file: value imports plus value
|
|
176
|
+
* re-exports (`export { x } from`, `export * from`) — barrels are how most
|
|
177
|
+
* client entries reach runtime code. Shared by the direct-edge check and the
|
|
178
|
+
* reachability walk so both see the same edge set.
|
|
179
|
+
*/
|
|
180
|
+
export function valueModuleEdges(sf: SourceFile): ValueModuleEdge[] {
|
|
181
|
+
const edges: ValueModuleEdge[] = [];
|
|
182
|
+
for (const decl of sf.getImportDeclarations()) {
|
|
183
|
+
if (!isValueImport(decl)) continue;
|
|
184
|
+
const target = decl.getModuleSpecifierSourceFile();
|
|
185
|
+
if (!target) continue;
|
|
186
|
+
edges.push({
|
|
187
|
+
spec: decl.getModuleSpecifierValue(),
|
|
188
|
+
target,
|
|
189
|
+
line: decl.getStartLineNumber(),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
for (const decl of sf.getExportDeclarations()) {
|
|
193
|
+
if (decl.isTypeOnly()) continue;
|
|
194
|
+
const named = decl.getNamedExports();
|
|
195
|
+
if (named.length > 0 && named.every((n) => n.isTypeOnly())) continue;
|
|
196
|
+
const target = decl.getModuleSpecifierSourceFile();
|
|
197
|
+
const spec = decl.getModuleSpecifierValue();
|
|
198
|
+
if (!target || spec === undefined) continue;
|
|
199
|
+
edges.push({ spec, target, line: decl.getStartLineNumber() });
|
|
200
|
+
}
|
|
201
|
+
return edges;
|
|
202
|
+
}
|
|
203
|
+
|
|
125
204
|
/**
|
|
126
205
|
* Map dist declaration files back to src. Project References make ts-morph
|
|
127
206
|
* resolve imports to `.d.ts` under `dist/`; classification and the reachable
|
|
@@ -159,10 +238,7 @@ export function computeClientReachablePaths(
|
|
|
159
238
|
if (fp.includes("/node_modules/") || fp.includes("/dist/")) continue;
|
|
160
239
|
if (reached.has(fp)) continue;
|
|
161
240
|
reached.add(fp);
|
|
162
|
-
for (const
|
|
163
|
-
if (!isValueImport(decl)) continue;
|
|
164
|
-
const target = decl.getModuleSpecifierSourceFile();
|
|
165
|
-
if (!target) continue;
|
|
241
|
+
for (const { target } of valueModuleEdges(sf)) {
|
|
166
242
|
const targetPath = toEffectivePath(target.getFilePath());
|
|
167
243
|
if (targetPath.includes("/node_modules/") || targetPath.includes("/dist/")) continue;
|
|
168
244
|
if (!reached.has(targetPath)) queue.push(target);
|
|
@@ -171,24 +247,69 @@ export function computeClientReachablePaths(
|
|
|
171
247
|
return reached;
|
|
172
248
|
}
|
|
173
249
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
250
|
+
const DIRECTIVE_HEAD_BYTES = 600;
|
|
251
|
+
const DIRECTIVE_HEAD_LINES = 8;
|
|
252
|
+
|
|
253
|
+
export type HeadReader = (filePath: string) => string | null;
|
|
254
|
+
|
|
255
|
+
// Reads only the file head, so classifying a large source file does not pull
|
|
256
|
+
// it fully into memory.
|
|
257
|
+
export function readFileHead(filePath: string): string | null {
|
|
258
|
+
let fd: number;
|
|
259
|
+
try {
|
|
260
|
+
fd = openSync(filePath, "r");
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
180
264
|
try {
|
|
181
|
-
|
|
265
|
+
const buffer = Buffer.alloc(DIRECTIVE_HEAD_BYTES);
|
|
266
|
+
const bytesRead = readSync(fd, buffer, 0, DIRECTIVE_HEAD_BYTES, 0);
|
|
267
|
+
return buffer.toString("utf8", 0, bytesRead);
|
|
182
268
|
} catch {
|
|
183
269
|
return null;
|
|
270
|
+
} finally {
|
|
271
|
+
closeSync(fd);
|
|
184
272
|
}
|
|
185
|
-
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Classify a file by its top-of-file `// @runtime <kind>` directive.
|
|
277
|
+
* Reads the first 600 bytes only (cap blast radius on huge files).
|
|
278
|
+
*/
|
|
279
|
+
export function classifyByDirective(
|
|
280
|
+
filePath: string,
|
|
281
|
+
readHead: HeadReader = readFileHead,
|
|
282
|
+
): Runtime | null {
|
|
283
|
+
const head = readHead(filePath);
|
|
284
|
+
if (head === null) return null;
|
|
285
|
+
for (const line of head.split("\n").slice(0, DIRECTIVE_HEAD_LINES)) {
|
|
186
286
|
const m = line.match(/\/\/\s*@runtime\s+(\w+)/);
|
|
187
287
|
if (m && ALL_RUNTIMES.has(m[1] ?? "")) return m[1] as Runtime;
|
|
188
288
|
}
|
|
189
289
|
return null;
|
|
190
290
|
}
|
|
191
291
|
|
|
292
|
+
export type DirectiveClassifier = (filePath: string) => Runtime | null;
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Per-run memo of `classifyByDirective`: the guard classifies each file in
|
|
296
|
+
* several passes (client reachability, then violations), and each pass would
|
|
297
|
+
* otherwise re-read the same head. Created per run, not module-global, so
|
|
298
|
+
* tests stay isolated.
|
|
299
|
+
*/
|
|
300
|
+
export function createDirectiveClassifier(
|
|
301
|
+
readHead: HeadReader = readFileHead,
|
|
302
|
+
): DirectiveClassifier {
|
|
303
|
+
const cache = new Map<string, Runtime | null>();
|
|
304
|
+
return (filePath) => {
|
|
305
|
+
const cached = cache.get(filePath);
|
|
306
|
+
if (cached !== undefined) return cached;
|
|
307
|
+
const result = classifyByDirective(filePath, readHead);
|
|
308
|
+
cache.set(filePath, result);
|
|
309
|
+
return result;
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
192
313
|
/**
|
|
193
314
|
* Walk upward from `filePath` looking for the nearest package.json that
|
|
194
315
|
* carries a `kumiko.runtime` marker. Stops at `repoRoot`. Caches per
|
|
@@ -201,7 +322,8 @@ export function findWorkspaceRuntime(
|
|
|
201
322
|
cache: Map<string, Runtime | null>,
|
|
202
323
|
): Runtime | null {
|
|
203
324
|
let dir = path.dirname(filePath);
|
|
204
|
-
|
|
325
|
+
// Includes repoRoot itself: a single-package consumer repo keeps its marker there.
|
|
326
|
+
while (dir.startsWith(repoRoot)) {
|
|
205
327
|
const r = readWorkspaceRuntime(dir, cache);
|
|
206
328
|
if (r) return r;
|
|
207
329
|
// Stop at the first package.json — don't fall through to a parent
|
|
@@ -212,6 +334,7 @@ export function findWorkspaceRuntime(
|
|
|
212
334
|
} catch {
|
|
213
335
|
// No package.json here — keep climbing.
|
|
214
336
|
}
|
|
337
|
+
if (dir === repoRoot) break;
|
|
215
338
|
dir = path.dirname(dir);
|
|
216
339
|
}
|
|
217
340
|
return null;
|
|
@@ -250,12 +373,13 @@ export function classify(
|
|
|
250
373
|
repoRoot: string,
|
|
251
374
|
workspaceCache: Map<string, Runtime | null>,
|
|
252
375
|
clientReachable?: ReadonlySet<string>,
|
|
376
|
+
directiveOf: DirectiveClassifier = (file) => classifyByDirective(file),
|
|
253
377
|
): Runtime {
|
|
254
378
|
const effectivePath = toEffectivePath(filePath);
|
|
255
379
|
|
|
256
380
|
const rel = path.relative(repoRoot, effectivePath);
|
|
257
381
|
return (
|
|
258
|
-
|
|
382
|
+
directiveOf(effectivePath) ??
|
|
259
383
|
classifyByPath(rel) ??
|
|
260
384
|
findWorkspaceRuntime(effectivePath, repoRoot, workspaceCache) ??
|
|
261
385
|
(clientReachable?.has(effectivePath) ? "client" : undefined) ??
|
|
@@ -284,6 +408,7 @@ export function findRuntimeIsolationViolations(
|
|
|
284
408
|
repoRoot: string,
|
|
285
409
|
workspaceCache: Map<string, Runtime | null>,
|
|
286
410
|
clientReachable: ReadonlySet<string> = new Set(),
|
|
411
|
+
directiveOf: DirectiveClassifier = createDirectiveClassifier(),
|
|
287
412
|
): {
|
|
288
413
|
readonly violations: readonly Violation[];
|
|
289
414
|
readonly stats: Record<Runtime, number>;
|
|
@@ -315,13 +440,10 @@ export function findRuntimeIsolationViolations(
|
|
|
315
440
|
noteOutside(fp);
|
|
316
441
|
continue;
|
|
317
442
|
}
|
|
318
|
-
const fileRt = classify(fp, repoRoot, workspaceCache, clientReachable);
|
|
443
|
+
const fileRt = classify(fp, repoRoot, workspaceCache, clientReachable, directiveOf);
|
|
319
444
|
stats[fileRt]++;
|
|
320
445
|
|
|
321
|
-
for (const
|
|
322
|
-
if (!isValueImport(decl)) continue;
|
|
323
|
-
const target = decl.getModuleSpecifierSourceFile();
|
|
324
|
-
if (!target) continue;
|
|
446
|
+
for (const { spec, target, line } of valueModuleEdges(sf)) {
|
|
325
447
|
const targetPath = target.getFilePath();
|
|
326
448
|
if (targetPath.includes("/node_modules/")) continue;
|
|
327
449
|
if (!withinRoot(targetPath)) {
|
|
@@ -333,9 +455,9 @@ export function findRuntimeIsolationViolations(
|
|
|
333
455
|
if (!COMPAT[fileRt].has(targetRt)) {
|
|
334
456
|
violations.push({
|
|
335
457
|
file: fp,
|
|
336
|
-
line
|
|
458
|
+
line,
|
|
337
459
|
fileRuntime: fileRt,
|
|
338
|
-
importedSpec:
|
|
460
|
+
importedSpec: spec,
|
|
339
461
|
importedFile: targetPath,
|
|
340
462
|
importedRuntime: targetRt,
|
|
341
463
|
});
|