@cosmicdrift/kumiko-guards 0.381.0 → 0.383.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.
@@ -1,300 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Guard: raw iteration over a section's `fields` instead of `sectionFieldSpecs`.
4
- *
5
- * The boot-validator enforces `fields` XOR `groups` on edit sections
6
- * (packages/framework/src/engine/boot-validator/screens.ts), so a section that
7
- * carries `groups` has `fields: []`. A reader that iterates `section.fields`
8
- * walks an empty list there and silently sees no field at all — no crash, no
9
- * empty screen, just a check that checks nothing. The same cause hit three
10
- * independent readers (kumiko-framework#2986, then the boot-guard and the E2E
11
- * generator in #3042). `sectionFieldSpecs` (engine/screen-helpers.ts) is the
12
- * one reader that unions both sources.
13
- *
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 `??`.
17
- * Known gap: `section.fields.length` is not flagged — the fields-XOR-groups
18
- * validator itself needs it, and an emptiness check is not the silent-
19
- * iteration bug.
20
- *
21
- * Scoped to the framework repo (`kinds`) and to the layer where a section is
22
- * a *spec*: renderer components iterate the already-flattened view model,
23
- * where the same expression is correct. Widening to the consumer repos needs
24
- * all nine measured against one pinned version first.
25
- *
26
- * Deliberate raw readers carry `// kumiko-lint-ignore section-fields-raw
27
- * <reason>` on the line itself or the line above — the reason is required, a
28
- * bare tag stays a finding.
29
- *
30
- * `.kumiko-section-fields-raw-baseline.json` in the repo root pins the frozen
31
- * count per file; over baseline fails, at or under passes. Without the file
32
- * the guard is warning-only (bootstrap: `--write-baseline` once).
33
- *
34
- * Usage:
35
- * bun guards/guard-section-fields-raw.ts # compare to baseline
36
- * bun guards/guard-section-fields-raw.ts --write-baseline # freeze current
37
- * bun guards/guard-section-fields-raw.ts --no-baseline # skip comparison
38
- */
39
- import * as path from "node:path";
40
- import {
41
- type Node,
42
- type PropertyAccessExpression,
43
- type SourceFile,
44
- SyntaxKind,
45
- type VariableDeclaration,
46
- } from "ts-morph";
47
- import {
48
- type AstGuard,
49
- baselineRatchet,
50
- buildSharedProject,
51
- filesForGuard,
52
- type GuardOutcome,
53
- type GuardViolation,
54
- isLocalFinding,
55
- relFromRepoRoot,
56
- runStandalone,
57
- type ScanSpec,
58
- } from "./_lib/guard-kit";
59
- import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
60
-
61
- const ROOT = process.cwd();
62
-
63
- const SCAN: ScanSpec = {
64
- scope: "source",
65
- extensions: ["ts", "tsx"],
66
- kinds: ["framework"],
67
- frameworkWithin: [
68
- "packages/framework/src/engine/**",
69
- "packages/framework/src/i18n/**",
70
- "packages/testing/src/helpers/**",
71
- "packages/renderer/src/headless/**",
72
- "packages/renderer/src/app/**",
73
- ],
74
- };
75
-
76
- const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
77
-
78
- const IGNORE_TAG = "kumiko-lint-ignore section-fields-raw";
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*]`);
83
-
84
- const SECTION_RECEIVER = /section/i;
85
-
86
- const ARRAY_READS = new Set([
87
- "at",
88
- "concat",
89
- "entries",
90
- "every",
91
- "filter",
92
- "find",
93
- "findIndex",
94
- "findLast",
95
- "findLastIndex",
96
- "flat",
97
- "flatMap",
98
- "forEach",
99
- "includes",
100
- "indexOf",
101
- "join",
102
- "keys",
103
- "lastIndexOf",
104
- "map",
105
- "reduce",
106
- "reduceRight",
107
- "reverse",
108
- "slice",
109
- "some",
110
- "sort",
111
- "toReversed",
112
- "toSorted",
113
- "toSpliced",
114
- "values",
115
- "with",
116
- ]);
117
-
118
- function relFile(sf: SourceFile, roots: readonly RepoRoot[]): string {
119
- return relFromRepoRoot(sf.getFilePath(), roots);
120
- }
121
-
122
- function hasReasonedIgnoreTag(node: Node): boolean {
123
- const lines = node.getSourceFile().getFullText().split("\n");
124
- const line = node.getStartLineNumber();
125
- return REASONED_TAG.test(lines[line - 1] ?? "") || REASONED_TAG.test(lines[line - 2] ?? "");
126
- }
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
-
158
- function isSequenceRead(access: PropertyAccessExpression): boolean {
159
- const read = unwrapReadContext(access);
160
- const parent = read.getParent();
161
- if (parent === undefined) return false;
162
- if (parent.isKind(SyntaxKind.ForOfStatement)) return parent.getExpression() === read;
163
- if (parent.isKind(SyntaxKind.SpreadElement) || parent.isKind(SyntaxKind.SpreadAssignment)) {
164
- return true;
165
- }
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
- ) {
173
- return parent.getParent()?.isKind(SyntaxKind.CallExpression) === true;
174
- }
175
- return false;
176
- }
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
-
190
- export interface Finding {
191
- file: string;
192
- line: number;
193
- snippet: string;
194
- }
195
-
196
- export function scanRawSectionFieldReads(
197
- sf: SourceFile,
198
- roots: readonly RepoRoot[] = resolveRepoRoots(),
199
- ): Finding[] {
200
- const findings: Finding[] = [];
201
- for (const access of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
202
- if (access.getName() !== "fields") continue;
203
- if (!isSectionReceiver(access.getExpression())) continue;
204
- if (!isSequenceRead(access)) continue;
205
- if (hasReasonedIgnoreTag(access)) continue;
206
- const line = access.getStartLineNumber();
207
- const file = relFile(sf, roots);
208
- findings.push({ file, line, snippet: access.getText().slice(0, 80) });
209
- console.warn(
210
- ` [section-fields-raw WARN] ${file}:${line} raw read of ${access.getText().slice(0, 80)} — use sectionFieldSpecs(section)`,
211
- );
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
- }
223
- return findings;
224
- }
225
-
226
- function scan(
227
- files: readonly SourceFile[],
228
- roots: readonly RepoRoot[] = resolveRepoRoots(),
229
- ): Finding[] {
230
- const findings: Finding[] = [];
231
- for (const sf of files) {
232
- if (EXCLUDE.test(sf.getFilePath())) continue;
233
- findings.push(...scanRawSectionFieldReads(sf, roots));
234
- }
235
- return findings;
236
- }
237
-
238
- const BASELINE_FILE = ".kumiko-section-fields-raw-baseline.json";
239
- const sectionFieldsRawBaseline = baselineRatchet({
240
- file: path.join(ROOT, BASELINE_FILE),
241
- formatVersion: 1,
242
- unit: "raw section.fields read(s)",
243
- });
244
-
245
- const REMEDIATION = `Use sectionFieldSpecs(section) from @cosmicdrift/kumiko-framework/ui-types, or mark the line with \`// ${IGNORE_TAG} <reason>\`.`;
246
-
247
- export function baselineCounts(findings: readonly Finding[]): Record<string, number> {
248
- const counts: Record<string, number> = {};
249
- for (const f of findings.filter(isLocalFinding)) counts[f.file] = (counts[f.file] ?? 0) + 1;
250
- return counts;
251
- }
252
-
253
- function analyse(
254
- files: readonly SourceFile[],
255
- roots: readonly RepoRoot[],
256
- compareBaseline: boolean,
257
- ): GuardOutcome {
258
- const findings = scan(files, roots);
259
- if (!compareBaseline) {
260
- console.log(" Baseline comparison skipped (--no-baseline).");
261
- return { violations: [] };
262
- }
263
- const local = findings.filter(isLocalFinding);
264
- const violations: GuardViolation[] = sectionFieldsRawBaseline.check(
265
- baselineCounts(findings),
266
- REMEDIATION,
267
- {
268
- formatDriftRemediation:
269
- 'Run `kumiko-guards guards --write-baseline --guard="Raw section.fields Guard"` once.',
270
- resolveLine: (file) => local.find((f) => f.file === file)?.line ?? 1,
271
- },
272
- );
273
- return { violations };
274
- }
275
-
276
- export const guard: AstGuard = {
277
- name: "Raw section.fields Guard",
278
- scan: SCAN,
279
- hint: `${REMEDIATION} Known gap: \`section.fields.length\` is not flagged. After a deliberate change: \`kumiko-guards guards --write-baseline --guard="Raw section.fields Guard"\`.`,
280
- run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
281
- writeBaseline: (files) => sectionFieldsRawBaseline.write(baselineCounts(scan(files))),
282
- };
283
-
284
- // Flags are read only here, never in run() — the shared runner drives every
285
- // guard with the same argv, and a --write-baseline meant for another guard
286
- // must not silently refreeze this one.
287
- if (import.meta.main) {
288
- const args = process.argv.slice(2);
289
- if (args.includes("--write-baseline")) {
290
- const project = buildSharedProject([guard]);
291
- sectionFieldsRawBaseline.write(baselineCounts(scan(filesForGuard(project, guard))));
292
- process.exit(0);
293
- }
294
- if (args.includes("--no-baseline")) {
295
- const project = buildSharedProject([guard]);
296
- analyse(filesForGuard(project, guard), resolveRepoRoots(), false);
297
- process.exit(0);
298
- }
299
- runStandalone(guard);
300
- }
@@ -1,155 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Table-DDL Guard (WARNING, no fail).
4
- *
5
- * Finds calls of `unsafePushTables` / `unsafeCreateEntityTable` /
6
- * `unsafeEnsureEntityTable` outside the allowed paths. Apps
7
- * declare tables via `r.entity()` (event-sourced) or
8
- * `r.rawTable()` (declarative bypass with audit marker) — direct
9
- * `unsafe*` calls bypass the event-sourcing system entirely.
10
- *
11
- * Plan: kumiko-platform/docs/plans/architecture/table-ddl-guard.md
12
- * (stage 2). Stage 1 renamed the symbols so every call is
13
- * greppable; stage 3 introduced `r.rawTable()` as the clean alternative;
14
- * stage 2 (this check) enforces consistency.
15
- *
16
- * Allowlist (path regexes, OR-combined):
17
- * - packages/framework/src/event-store/** ES meta tables
18
- * - packages/framework/src/pipeline/event-consumer-state.ts
19
- * - packages/framework/src/pipeline/projection-state.ts
20
- * - packages/framework/src/db/table-push.ts the DDL helpers themselves
21
- * - packages/testing/src/stack/** test-stack helpers, pushEntityProjectionTables, helper definitions
22
- * - **\/__tests__/** test setup
23
- * - **\/drizzle/** drizzle-kit configuration
24
- * - **\/bin/migrate.ts app-owned migrate CLI
25
- * - packages/guards/src/guard-table-ddl.ts the guard itself
26
- * - kumiko-framework/scripts/migrate-rename-table-ddl.ts ts-morph rename
27
- *
28
- * Output: warning + file:line + code snippet. Never blocks.
29
- */
30
-
31
- import path from "node:path";
32
- import { type Identifier, type Node, type SourceFile, SyntaxKind } from "ts-morph";
33
- import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
34
-
35
- const ROOT = process.cwd();
36
-
37
- const SCAN: ScanSpec = { scope: "source", extensions: ["ts"] };
38
-
39
- export const UNSAFE_NAMES: ReadonlySet<string> = new Set([
40
- "unsafePushTables",
41
- "unsafeCreateEntityTable",
42
- "unsafeEnsureEntityTable",
43
- ]);
44
-
45
- export const ALLOWLIST_PATTERNS: ReadonlyArray<RegExp> = [
46
- /\/packages\/framework\/src\/event-store\//,
47
- /\/packages\/framework\/src\/pipeline\/event-consumer-state\.ts$/,
48
- /\/packages\/framework\/src\/pipeline\/projection-state\.ts$/,
49
- /\/packages\/framework\/src\/db\/table-push\.ts$/,
50
- /\/packages\/testing\/src\/stack\//,
51
- /\/__tests__\//,
52
- /\/drizzle\//,
53
- /\/bin\/migrate\.ts$/,
54
- // Guards + the rename script may name the symbols in code — both are
55
- // tooling, not a runtime path.
56
- /\/packages\/guards\/src\/guard-table-ddl\.ts$/,
57
- /\/scripts\/migrate-rename-table-ddl\.ts$/,
58
- ];
59
-
60
- export function isAllowed(absPath: string): boolean {
61
- return ALLOWLIST_PATTERNS.some((re) => re.test(absPath));
62
- }
63
-
64
- export interface Finding {
65
- readonly file: string;
66
- readonly line: number;
67
- readonly symbol: string;
68
- readonly snippet: string;
69
- }
70
-
71
- const NEVER_CALL_SITE_PARENT_KINDS: ReadonlySet<SyntaxKind> = new Set([
72
- SyntaxKind.ImportSpecifier,
73
- SyntaxKind.ExportSpecifier,
74
- SyntaxKind.PropertyAssignment,
75
- SyntaxKind.ShorthandPropertyAssignment,
76
- ]);
77
-
78
- // The identifier names something other than a runtime call of the symbol: an
79
- // import/export specifier (the violation is the call-site, not "having the
80
- // name in scope"; the `as` alias case still flags the call), an object key,
81
- // the right-hand side of a property access (`obj.unsafePushTables` — a method
82
- // on an unrelated type), or a type-level member (`interface { unsafePushTables(): void }`).
83
- function isNotRuntimeCallSite(id: Identifier, parent: Node): boolean {
84
- if (NEVER_CALL_SITE_PARENT_KINDS.has(parent.getKind())) return true;
85
- if (parent.isKind(SyntaxKind.PropertyAccessExpression)) return parent.getNameNode() === id;
86
- if (parent.isKind(SyntaxKind.PropertySignature)) return parent.getNameNode() === id;
87
- if (parent.isKind(SyntaxKind.MethodSignature)) return parent.getNameNode() === id;
88
- return false;
89
- }
90
-
91
- export function collectFindings(sf: SourceFile, repoRoot: string): Finding[] {
92
- const absPath = sf.getFilePath();
93
- if (isAllowed(absPath)) return [];
94
-
95
- const findings: Finding[] = [];
96
- const lines = sf.getFullText().split("\n");
97
-
98
- for (const id of sf.getDescendantsOfKind(SyntaxKind.Identifier)) {
99
- const name = id.getText();
100
- if (!UNSAFE_NAMES.has(name)) continue;
101
-
102
- const parent = id.getParent();
103
- if (!parent) continue;
104
- if (isNotRuntimeCallSite(id, parent)) continue;
105
-
106
- const line = id.getStartLineNumber();
107
- const raw = (lines[line - 1] ?? "").trim();
108
- const snippet = raw.length > 100 ? `${raw.slice(0, 97)}...` : raw;
109
- findings.push({
110
- file: path.relative(repoRoot, absPath),
111
- line,
112
- symbol: name,
113
- snippet,
114
- });
115
- }
116
- return findings;
117
- }
118
-
119
- function report(findings: readonly Finding[], scanned: number): void {
120
- console.log(`Table-DDL Guard: ${scanned} files checked.`);
121
- if (findings.length === 0) {
122
- console.log(" No bypass calls outside the allowlist.");
123
- } else {
124
- console.log(` ${findings.length} bypass call(s) outside the allowlist:`);
125
- for (const f of findings) {
126
- console.log(` ${f.file}:${f.line} ${f.symbol}`);
127
- console.log(` ${f.snippet}`);
128
- }
129
- console.log("");
130
- console.log(" Rule: unsafe* calls are reserved for framework-internal code (event-store,");
131
- console.log(" pipeline-state, stack), test setup (__tests__, drizzle/), app migrate CLIs");
132
- console.log(" (bin/migrate.ts). Apps declare tables via r.entity() or r.rawTable().");
133
- console.log(" Plan: kumiko-platform/docs/plans/architecture/table-ddl-guard.md");
134
- console.log(" Warning, no fail.");
135
- }
136
- }
137
-
138
- export const guard: AstGuard = {
139
- name: "Table-DDL Guard",
140
- scan: SCAN,
141
- run(files) {
142
- const findings: Finding[] = [];
143
- let scanned = 0;
144
- for (const sf of files) {
145
- scanned++;
146
- findings.push(...collectFindings(sf, ROOT));
147
- }
148
- report(findings, scanned);
149
- // Warning, no fail (see module header): bypass calls are reported but
150
- // never block the guard run.
151
- return { violations: [] };
152
- },
153
- };
154
-
155
- if (import.meta.main) runStandalone(guard);
@@ -1,248 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Guard: `createTextField`/`createLongTextField` calls without a `personal`
4
- * stance, with a baseline regression guard like guard-pii-annotations.ts.
5
- *
6
- * kumiko-framework#2810: `createTextField`/`createLongTextField` should
7
- * throw fail-closed when no `personal` (pattern #2558) is declared.
8
- * Workspace-wide `personal` is missing at hundreds of call sites — a throw would
9
- * break the build immediately. This guard covers the full set (every call),
10
- * `guard-pii-annotations.ts` only the name heuristic (PII-suspicious
11
- * field names) — the two baselines are deliberately separate.
12
- *
13
- * `.kumiko-text-field-stance-baseline.json` in the repo root pins the frozen
14
- * finding count per file:
15
- * - current <= baseline per file: PASS
16
- * - current > baseline per file: FAIL (new call without a personal stance)
17
- * Reductions do NOT update the baseline automatically — run `--write-baseline`
18
- * after annotation commits. Without a baseline file the guard stays
19
- * warning-only (bootstrap: run `--write-baseline` once).
20
- *
21
- * Usage:
22
- * bun guards/guard-text-field-stance.ts # compare against baseline
23
- * bun guards/guard-text-field-stance.ts --write-baseline # rewrite baseline
24
- * bun guards/guard-text-field-stance.ts --no-baseline # skip comparison
25
- */
26
- import * as path from "node:path";
27
- import {
28
- type CallExpression,
29
- type Node,
30
- type ObjectLiteralExpression,
31
- type SourceFile,
32
- SyntaxKind,
33
- } from "ts-morph";
34
- import {
35
- type AstGuard,
36
- baselineRatchet,
37
- buildSharedProject,
38
- filesForGuard,
39
- type GuardOutcome,
40
- type GuardViolation,
41
- isLocalFinding,
42
- relFromRepoRoot,
43
- runStandalone,
44
- type ScanSpec,
45
- } from "./_lib/guard-kit";
46
- import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
47
-
48
- const ROOT = process.cwd();
49
-
50
- const SCAN: ScanSpec = {
51
- scope: "source",
52
- extensions: ["ts"],
53
- frameworkWithin: ["packages/*/src/**", "samples/**", "demo/**"],
54
- // `demo/` isn't a declared sourceRoot (kumiko.json) — extraGlobs pulls its
55
- // files in on top of the scope's own hits; frameworkWithin above still
56
- // gates them the same as every other hit.
57
- extraGlobs: ["demo/**/*.ts"],
58
- };
59
- // Unlike guard-pii-annotations.ts, tests are IN scope: kumiko-framework#2810's
60
- // fail-closed throw fires at call time regardless of test vs. production code —
61
- // a test fixture calling createTextField() without `personal` breaks the same
62
- // way prod code would once the throw lands.
63
- const EXCLUDE = /\.d\.ts$/;
64
-
65
- const FIELD_FACTORY_CALLEES = new Set(["createTextField", "createLongTextField"]);
66
-
67
- // These two files deliberately call the factories without a stance to prove
68
- // the fail-closed throw (kumiko-framework#2921) — exact repo-relative match,
69
- // not basename, so a consumer file sharing a name stays flagged.
70
- const STANCE_THROW_TEST_ALLOWLIST = new Set<string>([
71
- "packages/framework/src/engine/__tests__/factories-long-text.test.ts",
72
- "packages/framework/src/engine/__tests__/factories-personal.test.ts",
73
- ]);
74
-
75
- const VALID_PERSONAL_HINT =
76
- '{ personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false } ("false" additionally needs { reason: "..." })';
77
-
78
- function relFile(sf: SourceFile, roots: readonly RepoRoot[]): string {
79
- return relFromRepoRoot(sf.getFilePath(), roots);
80
- }
81
-
82
- function fieldFactoryOptions(call: CallExpression): ObjectLiteralExpression | undefined {
83
- const first = call.getArguments()[0];
84
- return first?.isKind(SyntaxKind.ObjectLiteralExpression) ? first : undefined;
85
- }
86
-
87
- // A spread's source (e.g. `{ ...base, maxLength: 5 }`) may supply `personal`
88
- // without a literal property here — statically undecidable like a non-object
89
- // argument, so treat it the same way (not countable).
90
- function hasSpreadProperty(obj: ObjectLiteralExpression): boolean {
91
- return obj.getProperties().some((prop) => prop.isKind(SyntaxKind.SpreadAssignment));
92
- }
93
-
94
- // Mirrors guard-pii-annotations.ts's `personal` branch: every value counts as
95
- // answered, including `personal: false` (the framework requires a `reason`
96
- // for it) — except `undefined`/`null`, which never answered the question.
97
- function hasPersonalStance(obj: ObjectLiteralExpression): boolean {
98
- const prop = obj.getProperty("personal");
99
- if (!prop?.isKind(SyntaxKind.PropertyAssignment)) return false;
100
- const init: Node | undefined = prop.getInitializer();
101
- return (
102
- init !== undefined &&
103
- init.getKind() !== SyntaxKind.UndefinedKeyword &&
104
- !(init.isKind(SyntaxKind.Identifier) && init.getText() === "undefined") &&
105
- init.getKind() !== SyntaxKind.NullKeyword
106
- );
107
- }
108
-
109
- function enclosingFieldName(call: CallExpression): string | null {
110
- let node = call.getParent();
111
- while (node) {
112
- if (node.isKind(SyntaxKind.PropertyAssignment)) {
113
- return node.getName();
114
- }
115
- node = node.getParent();
116
- }
117
- return null;
118
- }
119
-
120
- export interface Finding {
121
- file: string;
122
- line: number;
123
- place: string;
124
- callee: string;
125
- }
126
-
127
- function scanFieldFactories(sf: SourceFile, roots: readonly RepoRoot[]): Finding[] {
128
- const findings: Finding[] = [];
129
- const file = relFile(sf, roots);
130
- if (STANCE_THROW_TEST_ALLOWLIST.has(file)) return findings;
131
-
132
- for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
133
- const callee = call.getExpression().getText();
134
- if (!FIELD_FACTORY_CALLEES.has(callee)) continue;
135
-
136
- const args = call.getArguments();
137
- if (args.length > 0) {
138
- const options = fieldFactoryOptions(call);
139
- if (!options) continue; // non-literal argument (variable/spread) — not statically decidable
140
- if (hasSpreadProperty(options)) continue;
141
- if (hasPersonalStance(options)) continue;
142
- }
143
-
144
- const place = enclosingFieldName(call) ?? `${callee}(...)`;
145
- const line = call.getStartLineNumber();
146
- findings.push({ file, line, place, callee });
147
- }
148
- return findings;
149
- }
150
-
151
- export function scan(
152
- files: readonly SourceFile[],
153
- roots: readonly RepoRoot[] = resolveRepoRoots(),
154
- ): {
155
- findings: Finding[];
156
- scanned: number;
157
- } {
158
- const findings: Finding[] = [];
159
- let scanned = 0;
160
- for (const sf of files) {
161
- if (EXCLUDE.test(sf.getFilePath())) continue;
162
- scanned++;
163
- findings.push(...scanFieldFactories(sf, roots));
164
- }
165
- return { findings, scanned };
166
- }
167
-
168
- function localFindings(findings: readonly Finding[]): readonly Finding[] {
169
- return findings.filter(isLocalFinding);
170
- }
171
-
172
- function countByFile(findings: readonly Finding[]): Record<string, number> {
173
- const counts: Record<string, number> = {};
174
- for (const f of findings) counts[f.file] = (counts[f.file] ?? 0) + 1;
175
- return counts;
176
- }
177
-
178
- const BASELINE_FILE = ".kumiko-text-field-stance-baseline.json";
179
- const textFieldStanceBaseline = baselineRatchet({
180
- file: path.join(ROOT, BASELINE_FILE),
181
- formatVersion: 1,
182
- unit: "finding(s) without personal stance",
183
- });
184
-
185
- // One place for "what goes into the baseline", used by both the compare and
186
- // the write path — a sibling finding leaking into the written file would turn
187
- // a foreign checkout's code into a local, unfixable red.
188
- export function baselineCounts(findings: readonly Finding[]): Record<string, number> {
189
- return countByFile(localFindings(findings));
190
- }
191
-
192
- function checkBaseline(findings: readonly Finding[]): GuardViolation[] {
193
- const local = localFindings(findings);
194
- const resolveLine = (file: string): number => local.find((f) => f.file === file)?.line ?? 1;
195
- return textFieldStanceBaseline.check(
196
- baselineCounts(findings),
197
- `Annotate the call (${VALID_PERSONAL_HINT}).`,
198
- {
199
- formatDriftRemediation: `Run \`kumiko-guards guards --write-baseline --guard="Text-Field Personal-Stance Guard"\` once.`,
200
- resolveLine,
201
- },
202
- );
203
- }
204
-
205
- function analyse(
206
- files: readonly SourceFile[],
207
- roots: readonly RepoRoot[],
208
- compareBaseline: boolean,
209
- ): GuardOutcome {
210
- const { findings } = scan(files, roots);
211
- if (!compareBaseline) {
212
- for (const f of findings)
213
- console.warn(
214
- ` [text-field-stance WARN] ${f.file}:${f.line} ${f.callee}(...) at "${f.place}" has no personal stance — mark ${VALID_PERSONAL_HINT}`,
215
- );
216
- console.log(" Baseline comparison skipped (--no-baseline).");
217
- return { violations: [] };
218
- }
219
- return { violations: checkBaseline(findings) };
220
- }
221
-
222
- export const guard: AstGuard = {
223
- name: "Text-Field Personal-Stance Guard",
224
- scan: SCAN,
225
- hint: 'after a deliberate annotation: `kumiko-guards guards --write-baseline --guard="Text-Field Personal-Stance Guard"`',
226
- run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
227
- writeBaseline: (files) => textFieldStanceBaseline.write(baselineCounts(scan(files).findings)),
228
- };
229
-
230
- // Flags are read ONLY here, not in run() — the shared runner
231
- // (run-guards.ts) drives all guards with the same argv, and a
232
- // --write-baseline there must not silently rewrite the
233
- // baseline.
234
- if (import.meta.main) {
235
- const args = process.argv.slice(2);
236
- if (args.includes("--write-baseline")) {
237
- const project = buildSharedProject([guard]);
238
- const { findings } = scan(filesForGuard(project, guard));
239
- textFieldStanceBaseline.write(baselineCounts(findings));
240
- process.exit(0);
241
- }
242
- if (args.includes("--no-baseline")) {
243
- const project = buildSharedProject([guard]);
244
- analyse(filesForGuard(project, guard), resolveRepoRoots(), false);
245
- process.exit(0);
246
- }
247
- runStandalone(guard);
248
- }