@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.
@@ -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 === undefined || init.getKind() !== SyntaxKind.ObjectLiteralExpression) continue;
69
- for (const member of init.asKindOrThrow(SyntaxKind.ObjectLiteralExpression).getProperties()) {
70
- // { NOTES_SECTION_EXTENSION_NAME: NotesSection } (PropertyAssignment,
71
- // possibly computed-key) or { NotesSection } (ShorthandPropertyAssignment).
72
- if (member.getKind() === SyntaxKind.PropertyAssignment) {
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: bun guards/run-guards.ts --write-security-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: `bun guards/run-guards.ts --write-security-baseline`',
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) => ({
@@ -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 { BLOCKING_SQL_KINDS, scanRepo, sqlScanDirsFor } from "./_lib/sql-inventory";
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
- // kumiko-platform's deliberate empty scan-dir list must not read as vacuous (infra#610).
69
- const applicableRoots = roots.filter((r) => sqlScanDirsFor(r).length > 0);
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 and array reads over `<…section…>.fields`.
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 { type Node, type PropertyAccessExpression, type SourceFile, SyntaxKind } from "ts-morph";
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; the issue's premise 2 wants a
72
- // visible reason, so the trailing `\S` is the whole point of not reusing it.
73
- const REASONED_TAG = new RegExp(`${IGNORE_TAG}\\s+\\S`);
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 parent = access.getParent();
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() === access;
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.PropertyAccessExpression) && ARRAY_READS.has(parent.getName())) {
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 (!SECTION_RECEIVER.test(access.getExpression().getText())) continue;
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
- return OPT_OUT_MARKER.test(sf.getFullText());
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
- for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
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 [...templateOwnedKeyFindings(sf), ...pageScreenshotFindings(sf)]) {
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(cwd: string): string | undefined {
113
- const which = Bun.which("kumiko-upgrade");
114
- if (which) return which;
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
- process.exit(runGuardsCli(process.argv.slice(2)));
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
  }
@@ -49,5 +49,5 @@ export async function runRepoChecksCli(argv: readonly string[]): Promise<number>
49
49
  }
50
50
 
51
51
  if (import.meta.main) {
52
- process.exit(await runRepoChecksCli(process.argv.slice(2)));
52
+ process.exit((await runRepoChecksCli(process.argv.slice(2))) > 0 ? 1 : 0);
53
53
  }
@@ -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
  }