@cosmicdrift/kumiko-guards 0.290.0 → 0.291.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.290.0",
3
+ "version": "0.291.0",
4
4
  "description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -27,7 +27,7 @@
27
27
  "kumiko-guards": "./src/cli.ts"
28
28
  },
29
29
  "dependencies": {
30
- "@cosmicdrift/kumiko-repo-manifest": "0.290.0",
30
+ "@cosmicdrift/kumiko-repo-manifest": "0.291.0",
31
31
  "ts-morph": "^28.0.0"
32
32
  },
33
33
  "publishConfig": {
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.291.0",
4
+ "type": "improvement",
5
+ "title": "New Raw section.fields Guard",
6
+ "detail": "The boot-validator enforces `fields` XOR `groups` on edit sections, so a section carrying `groups` has `fields: []` — every reader that iterates `section.fields` walks an empty list there and silently sees no field at all. That same cause produced three independent regressions (kumiko-framework#2986, then the boot-guard and the E2E generator in #3042). The new guard flags for-of, spread and array reads over a section's `fields` in the layer where a section is a spec (framework engine/i18n/testing, headless, renderer app shims) and points at `sectionFieldSpecs(section)`, the one reader that unions both sources. A deliberate raw reader declares itself with `// kumiko-lint-ignore section-fields-raw <reason>` on the line or the line above — a bare tag without a reason stays a finding, so there is no silent exception list. `section.fields.length` is a known gap: the fields-XOR-groups validator itself needs it and an emptiness check is not the silent-iteration bug. Ratcheted against `.kumiko-section-fields-raw-baseline.json`, warning-only in a repo that has not frozen one yet."
7
+ },
2
8
  {
3
9
  "version": "0.285.1",
4
10
  "type": "fix",
@@ -0,0 +1,213 @@
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 and array reads over `<…section…>.fields`.
15
+ * Known gap: `section.fields.length` is not flagged — the fields-XOR-groups
16
+ * validator itself needs it, and an emptiness check is not the silent-
17
+ * iteration bug.
18
+ *
19
+ * Scoped to the framework repo (`kinds`) and to the layer where a section is
20
+ * a *spec*: renderer components iterate the already-flattened view model,
21
+ * where the same expression is correct. Widening to the consumer repos needs
22
+ * all nine measured against one pinned version first.
23
+ *
24
+ * Deliberate raw readers carry `// kumiko-lint-ignore section-fields-raw
25
+ * <reason>` on the line itself or the line above — the reason is required, a
26
+ * bare tag stays a finding.
27
+ *
28
+ * `.kumiko-section-fields-raw-baseline.json` in the repo root pins the frozen
29
+ * count per file; over baseline fails, at or under passes. Without the file
30
+ * the guard is warning-only (bootstrap: `--write-baseline` once).
31
+ *
32
+ * Usage:
33
+ * bun guards/guard-section-fields-raw.ts # compare to baseline
34
+ * bun guards/guard-section-fields-raw.ts --write-baseline # freeze current
35
+ * bun guards/guard-section-fields-raw.ts --no-baseline # skip comparison
36
+ */
37
+ import * as path from "node:path";
38
+ import { type Node, type PropertyAccessExpression, type SourceFile, SyntaxKind } from "ts-morph";
39
+ import {
40
+ type AstGuard,
41
+ baselineRatchet,
42
+ buildSharedProject,
43
+ filesForGuard,
44
+ type GuardOutcome,
45
+ type GuardViolation,
46
+ isLocalFinding,
47
+ runStandalone,
48
+ type ScanSpec,
49
+ } from "./_lib/guard-kit";
50
+
51
+ const ROOT = process.cwd();
52
+
53
+ const SCAN: ScanSpec = {
54
+ scope: "source",
55
+ extensions: ["ts", "tsx"],
56
+ kinds: ["framework"],
57
+ frameworkWithin: [
58
+ "packages/framework/src/engine/**",
59
+ "packages/framework/src/i18n/**",
60
+ "packages/framework/src/testing/**",
61
+ "packages/headless/src/**",
62
+ "packages/renderer/src/app/**",
63
+ ],
64
+ };
65
+
66
+ const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
67
+
68
+ const IGNORE_TAG = "kumiko-lint-ignore section-fields-raw";
69
+ // hasIgnoreTag() from _lib accepts a bare tag; the issue's premise 2 wants a
70
+ // visible reason, so the trailing `\S` is the whole point of not reusing it.
71
+ const REASONED_TAG = new RegExp(`${IGNORE_TAG}\\s+\\S`);
72
+
73
+ const SECTION_RECEIVER = /section/i;
74
+
75
+ const ARRAY_READS = new Set([
76
+ "at",
77
+ "concat",
78
+ "entries",
79
+ "every",
80
+ "filter",
81
+ "find",
82
+ "findIndex",
83
+ "findLast",
84
+ "flat",
85
+ "flatMap",
86
+ "forEach",
87
+ "includes",
88
+ "indexOf",
89
+ "join",
90
+ "keys",
91
+ "map",
92
+ "reduce",
93
+ "reduceRight",
94
+ "reverse",
95
+ "slice",
96
+ "some",
97
+ "sort",
98
+ "toReversed",
99
+ "toSorted",
100
+ "values",
101
+ ]);
102
+
103
+ function relFile(sf: SourceFile): string {
104
+ return path.relative(ROOT, sf.getFilePath());
105
+ }
106
+
107
+ function hasReasonedIgnoreTag(node: Node): boolean {
108
+ const lines = node.getSourceFile().getFullText().split("\n");
109
+ const line = node.getStartLineNumber();
110
+ return REASONED_TAG.test(lines[line - 1] ?? "") || REASONED_TAG.test(lines[line - 2] ?? "");
111
+ }
112
+
113
+ function isSequenceRead(access: PropertyAccessExpression): boolean {
114
+ const parent = access.getParent();
115
+ if (parent === undefined) return false;
116
+ if (parent.isKind(SyntaxKind.ForOfStatement)) return parent.getExpression() === access;
117
+ if (parent.isKind(SyntaxKind.SpreadElement) || parent.isKind(SyntaxKind.SpreadAssignment)) {
118
+ return true;
119
+ }
120
+ if (parent.isKind(SyntaxKind.PropertyAccessExpression) && ARRAY_READS.has(parent.getName())) {
121
+ return parent.getParent()?.isKind(SyntaxKind.CallExpression) === true;
122
+ }
123
+ return false;
124
+ }
125
+
126
+ export interface Finding {
127
+ file: string;
128
+ line: number;
129
+ snippet: string;
130
+ }
131
+
132
+ export function scanRawSectionFieldReads(sf: SourceFile): Finding[] {
133
+ const findings: Finding[] = [];
134
+ for (const access of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
135
+ if (access.getName() !== "fields") continue;
136
+ if (!SECTION_RECEIVER.test(access.getExpression().getText())) continue;
137
+ if (!isSequenceRead(access)) continue;
138
+ if (hasReasonedIgnoreTag(access)) continue;
139
+ const line = access.getStartLineNumber();
140
+ findings.push({ file: relFile(sf), line, snippet: access.getText().slice(0, 80) });
141
+ console.warn(
142
+ ` [section-fields-raw WARN] ${relFile(sf)}:${line} raw read of ${access.getText().slice(0, 80)} — use sectionFieldSpecs(section)`,
143
+ );
144
+ }
145
+ return findings;
146
+ }
147
+
148
+ function scan(files: readonly SourceFile[]): Finding[] {
149
+ const findings: Finding[] = [];
150
+ for (const sf of files) {
151
+ if (EXCLUDE.test(sf.getFilePath())) continue;
152
+ findings.push(...scanRawSectionFieldReads(sf));
153
+ }
154
+ return findings;
155
+ }
156
+
157
+ const BASELINE_FILE = ".kumiko-section-fields-raw-baseline.json";
158
+ const sectionFieldsRawBaseline = baselineRatchet({
159
+ file: path.join(ROOT, BASELINE_FILE),
160
+ formatVersion: 1,
161
+ unit: "raw section.fields read(s)",
162
+ });
163
+
164
+ const REMEDIATION = `Use sectionFieldSpecs(section) from @cosmicdrift/kumiko-framework/ui-types, or mark the line with \`// ${IGNORE_TAG} <reason>\`.`;
165
+
166
+ export function baselineCounts(findings: readonly Finding[]): Record<string, number> {
167
+ const counts: Record<string, number> = {};
168
+ for (const f of findings.filter(isLocalFinding)) counts[f.file] = (counts[f.file] ?? 0) + 1;
169
+ return counts;
170
+ }
171
+
172
+ function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
173
+ const findings = scan(files);
174
+ if (!compareBaseline) {
175
+ console.log(" Baseline comparison skipped (--no-baseline).");
176
+ return { violations: [] };
177
+ }
178
+ const local = findings.filter(isLocalFinding);
179
+ const violations: GuardViolation[] = sectionFieldsRawBaseline.check(
180
+ baselineCounts(findings),
181
+ REMEDIATION,
182
+ {
183
+ formatDriftRemediation: "Run `bun guards/guard-section-fields-raw.ts --write-baseline` once.",
184
+ resolveLine: (file) => local.find((f) => f.file === file)?.line ?? 1,
185
+ },
186
+ );
187
+ return { violations };
188
+ }
189
+
190
+ export const guard: AstGuard = {
191
+ name: "Raw section.fields Guard",
192
+ scan: SCAN,
193
+ hint: `${REMEDIATION} Known gap: \`section.fields.length\` is not flagged. After a deliberate change: \`bun guards/guard-section-fields-raw.ts --write-baseline\`.`,
194
+ run: (files) => analyse(files, true),
195
+ };
196
+
197
+ // Flags are read only here, never in run() — the shared runner drives every
198
+ // guard with the same argv, and a --write-baseline meant for another guard
199
+ // must not silently refreeze this one.
200
+ if (import.meta.main) {
201
+ const args = process.argv.slice(2);
202
+ if (args.includes("--write-baseline")) {
203
+ const project = buildSharedProject([guard]);
204
+ sectionFieldsRawBaseline.write(baselineCounts(scan(filesForGuard(project, guard))));
205
+ process.exit(0);
206
+ }
207
+ if (args.includes("--no-baseline")) {
208
+ const project = buildSharedProject([guard]);
209
+ analyse(filesForGuard(project, guard), false);
210
+ process.exit(0);
211
+ }
212
+ runStandalone(guard);
213
+ }
package/src/run-guards.ts CHANGED
@@ -44,6 +44,7 @@ import { guard as piiAnnotations } from "./guard-pii-annotations";
44
44
  import { guard as preEsPatterns } from "./guard-pre-es-patterns";
45
45
  import { guard as restrictedSymbols } from "./guard-restricted-symbols";
46
46
  import { guard as screenConventions } from "./guard-screen-conventions";
47
+ import { guard as sectionFieldsRaw } from "./guard-section-fields-raw";
47
48
  import { guard as silentSkip } from "./guard-silent-skip";
48
49
  import { guard as tableDdl } from "./guard-table-ddl";
49
50
  import { guard as tenantEscalation } from "./guard-tenant-escalation";
@@ -79,6 +80,7 @@ export const GUARDS = [
79
80
  complexity,
80
81
  predicateExtraction,
81
82
  screenConventions,
83
+ sectionFieldsRaw,
82
84
  writeHandlerQns,
83
85
  asCasts,
84
86
  loadallEvents,