@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.
- package/package.json +3 -3
- package/src/changes.json +7 -0
- package/src/guard-error-reasons.ts +1 -1
- package/src/guard-screen-conventions.ts +4 -40
- package/src/run-guards.ts +0 -18
- package/src/guard-api-object-form.ts +0 -211
- package/src/guard-broker-subscribe.ts +0 -99
- package/src/guard-escape-hatch-declared.ts +0 -743
- package/src/guard-loadall-events.ts +0 -139
- package/src/guard-open-to-all-reason.ts +0 -119
- package/src/guard-pre-es-patterns.ts +0 -206
- package/src/guard-section-fields-raw.ts +0 -300
- package/src/guard-table-ddl.ts +0 -155
- package/src/guard-text-field-stance.ts +0 -248
|
@@ -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
|
-
}
|
package/src/guard-table-ddl.ts
DELETED
|
@@ -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
|
-
}
|