@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 +2 -2
- package/src/changes.json +6 -0
- package/src/guard-section-fields-raw.ts +213 -0
- package/src/run-guards.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "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.
|
|
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,
|