@cosmicdrift/kumiko-guards 0.333.0 → 0.335.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 +8 -8
- package/src/_lib/sql-inventory.ts +5 -1
- package/src/changes.json +13 -0
- package/src/check-as-casts.ts +82 -14
- package/src/check-real-provider-isolation.ts +9 -1
- package/src/check-runtime-isolation.ts +9 -2
- 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 +12 -11
- package/src/guard-i18n-keys.ts +0 -1
- package/src/guard-i18n-locale-terminology.ts +2 -2
- package/src/guard-lib-test-coverage.ts +11 -6
- package/src/guard-no-framed-extension-sections.ts +53 -20
- package/src/guard-open-to-all-reason.ts +2 -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-upgrade-state.ts +7 -4
- package/src/run-guards.ts +2 -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
|
@@ -59,32 +59,65 @@ function isExternalSourceFile(sf: SourceFile): boolean {
|
|
|
59
59
|
return sf.getFilePath().includes("/node_modules/");
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
function addRegistryMembers(
|
|
63
|
+
sf: SourceFile,
|
|
64
|
+
literal: ObjectLiteralExpression,
|
|
65
|
+
entries: Map<string, string>,
|
|
66
|
+
visited: Set<Node>,
|
|
67
|
+
): void {
|
|
68
|
+
if (visited.has(literal)) return;
|
|
69
|
+
visited.add(literal);
|
|
70
|
+
for (const member of literal.getProperties()) {
|
|
71
|
+
// { NOTES_SECTION_EXTENSION_NAME: NotesSection } (PropertyAssignment,
|
|
72
|
+
// possibly computed-key) or { NotesSection } (ShorthandPropertyAssignment).
|
|
73
|
+
if (Node.isPropertyAssignment(member)) {
|
|
74
|
+
const valueInit = member.getInitializer();
|
|
75
|
+
if (valueInit === undefined || valueInit.getKind() !== SyntaxKind.Identifier) continue;
|
|
76
|
+
const nameNode = member.getNameNode();
|
|
77
|
+
const key = Node.isComputedPropertyName(nameNode)
|
|
78
|
+
? nameNode.getExpression().getText()
|
|
79
|
+
: Node.isStringLiteral(nameNode)
|
|
80
|
+
? nameNode.getLiteralValue()
|
|
81
|
+
: member.getName();
|
|
82
|
+
entries.set(valueInit.getText(), key);
|
|
83
|
+
} else if (Node.isShorthandPropertyAssignment(member)) {
|
|
84
|
+
const name = member.getName();
|
|
85
|
+
entries.set(name, name);
|
|
86
|
+
} else if (Node.isSpreadAssignment(member)) {
|
|
87
|
+
addRegistryFromExpression(sf, member.getExpression(), entries, visited);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The registry may be inline, a same-file variable (`extensionSectionComponents:
|
|
93
|
+
// shared`, `{ extensionSectionComponents }`) or spread in (`{ ...shared }`).
|
|
94
|
+
function addRegistryFromExpression(
|
|
95
|
+
sf: SourceFile,
|
|
96
|
+
expression: Node,
|
|
97
|
+
entries: Map<string, string>,
|
|
98
|
+
visited: Set<Node>,
|
|
99
|
+
): void {
|
|
100
|
+
if (Node.isObjectLiteralExpression(expression)) {
|
|
101
|
+
addRegistryMembers(sf, expression, entries, visited);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (!Node.isIdentifier(expression)) return;
|
|
105
|
+
const init = sf.getVariableDeclaration(expression.getText())?.getInitializer();
|
|
106
|
+
if (init !== undefined) addRegistryFromExpression(sf, init, entries, visited);
|
|
107
|
+
}
|
|
108
|
+
|
|
62
109
|
/** Maps each registered component's identifier to the registry key usage sites reference via `__component: SOME_CONST`. */
|
|
63
110
|
function findExtensionComponentEntries(sf: SourceFile): Map<string, string> {
|
|
64
111
|
const entries = new Map<string, string>();
|
|
112
|
+
const visited = new Set<Node>();
|
|
65
113
|
for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
|
|
66
114
|
if (prop.getName() !== "extensionSectionComponents") continue;
|
|
67
115
|
const init = prop.getInitializer();
|
|
68
|
-
if (init
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const pa = member.asKindOrThrow(SyntaxKind.PropertyAssignment);
|
|
74
|
-
const valueInit = pa.getInitializer();
|
|
75
|
-
if (valueInit === undefined || valueInit.getKind() !== SyntaxKind.Identifier) continue;
|
|
76
|
-
const nameNode = pa.getNameNode();
|
|
77
|
-
const key = Node.isComputedPropertyName(nameNode)
|
|
78
|
-
? nameNode.getExpression().getText()
|
|
79
|
-
: Node.isStringLiteral(nameNode)
|
|
80
|
-
? nameNode.getLiteralValue()
|
|
81
|
-
: pa.getName();
|
|
82
|
-
entries.set(valueInit.getText(), key);
|
|
83
|
-
} else if (member.getKind() === SyntaxKind.ShorthandPropertyAssignment) {
|
|
84
|
-
const name = member.asKindOrThrow(SyntaxKind.ShorthandPropertyAssignment).getName();
|
|
85
|
-
entries.set(name, name);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
116
|
+
if (init !== undefined) addRegistryFromExpression(sf, init, entries, visited);
|
|
117
|
+
}
|
|
118
|
+
for (const prop of sf.getDescendantsOfKind(SyntaxKind.ShorthandPropertyAssignment)) {
|
|
119
|
+
if (prop.getName() !== "extensionSectionComponents") continue;
|
|
120
|
+
addRegistryFromExpression(sf, prop.getNameNode(), entries, visited);
|
|
88
121
|
}
|
|
89
122
|
return entries;
|
|
90
123
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Usage:
|
|
14
14
|
* bun guards/guard-open-to-all-reason.ts
|
|
15
|
-
* Baseline:
|
|
15
|
+
* Baseline: kumiko-guards guards --write-security-baseline
|
|
16
16
|
*/
|
|
17
17
|
import * as path from "node:path";
|
|
18
18
|
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
@@ -85,7 +85,7 @@ export function createOpenToAllReasonGuard(opts: { root: string }): AstGuard {
|
|
|
85
85
|
name: "Open-To-All-Reason Guard",
|
|
86
86
|
scan: SCAN,
|
|
87
87
|
security: true,
|
|
88
|
-
hint: 'Provide openToAll: { reason: "<why any authenticated user may call this>" } (+ personalData: "tenant-members" for write handlers with unbound personal data). Baseline after a deliberate reduction: `
|
|
88
|
+
hint: 'Provide openToAll: { reason: "<why any authenticated user may call this>" } (+ personalData: "tenant-members" for write handlers with unbound personal data). Baseline after a deliberate reduction: `kumiko-guards guards --write-security-baseline`',
|
|
89
89
|
run(files) {
|
|
90
90
|
const violations: GuardViolation[] = [
|
|
91
91
|
...findGenericOpenToAllReasons(files, opts.root).map((f) => ({
|
package/src/guard-raw-sql.ts
CHANGED
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
|
|
15
15
|
import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
16
16
|
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
BLOCKING_SQL_KINDS,
|
|
19
|
+
isSqlScanExcluded,
|
|
20
|
+
scanRepo,
|
|
21
|
+
sqlScanDirsFor,
|
|
22
|
+
} from "./_lib/sql-inventory";
|
|
18
23
|
|
|
19
24
|
export type RawSqlFinding = {
|
|
20
25
|
readonly repo: string;
|
|
@@ -65,8 +70,9 @@ export const check: RepoCheck = {
|
|
|
65
70
|
"Rule: runtime SQL only in db/queries/*, bun-db/query.ts, testing/*, or with " +
|
|
66
71
|
"// kumiko-lint-ignore raw-sql <reason> on the line or the line above.",
|
|
67
72
|
async run(roots) {
|
|
68
|
-
//
|
|
69
|
-
|
|
73
|
+
// Only the deliberate exclusion is skipped; a repo whose declared source roots
|
|
74
|
+
// are all missing stays in the scan so 0 files surfaces as vacuous.
|
|
75
|
+
const applicableRoots = roots.filter((r) => !isSqlScanExcluded(r));
|
|
70
76
|
if (applicableRoots.length === 0) {
|
|
71
77
|
return { violations: [], matchedFiles: 0, notApplicable: true };
|
|
72
78
|
}
|
|
@@ -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);
|
|
@@ -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/run-guards.ts
CHANGED
|
@@ -169,5 +169,6 @@ export function runGuardsCli(argv: readonly string[], deps: RunGuardsCliDeps = {
|
|
|
169
169
|
// Only run on direct invocation — otherwise `import { GUARDS }` would kick
|
|
170
170
|
// off the whole suite and the list wouldn't be testable.
|
|
171
171
|
if (import.meta.main) {
|
|
172
|
-
|
|
172
|
+
// Exit codes wrap at 256, so a raw failure count could read as success.
|
|
173
|
+
process.exit(runGuardsCli(process.argv.slice(2)) > 0 ? 1 : 0);
|
|
173
174
|
}
|
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
|
}
|