@cosmicdrift/kumiko-guards 0.1.1 → 0.3.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 +4 -1
- package/src/_lib/guard-kit.ts +74 -19
- package/src/_lib/qn.ts +21 -0
- package/src/_lib/security-baseline-cli.ts +3 -3
- package/src/_lib/security-baseline.ts +8 -8
- package/src/changes.json +32 -0
- package/src/check-as-casts.ts +648 -0
- package/src/check-complexity.ts +292 -0
- package/src/check-predicates.ts +218 -0
- package/src/check-secret-literals.ts +126 -0
- package/src/cli.ts +59 -0
- package/src/guard-admin-api.ts +1 -1
- package/src/guard-app-feature-structure.ts +114 -0
- package/src/guard-broker-subscribe.ts +99 -0
- package/src/guard-error-reasons.ts +185 -0
- package/src/guard-escape-hatch-declared.ts +26 -19
- package/src/guard-fake-tests.ts +1 -1
- package/src/guard-feature-integration-tests.ts +184 -0
- package/src/guard-html-escape.ts +1 -1
- package/src/guard-i18n-keys.ts +440 -0
- package/src/guard-i18n-locale-mount.ts +317 -0
- package/src/guard-i18n-locale-terminology.ts +117 -0
- package/src/guard-i18n-ui-strings.ts +248 -0
- package/src/guard-lib-test-coverage.ts +156 -0
- package/src/guard-loadall-events.ts +133 -0
- package/src/guard-no-custom-primitives.ts +9 -10
- package/src/guard-no-date-api.ts +1 -1
- package/src/guard-no-direct-fs.ts +1 -1
- package/src/guard-no-inline-styles.ts +4 -4
- package/src/guard-no-logic-in-views.ts +3 -3
- package/src/guard-no-raw-hooks.ts +4 -5
- package/src/guard-open-to-all-reason.ts +1 -1
- package/src/guard-pii-annotations.ts +267 -0
- package/src/guard-pre-es-patterns.ts +1 -1
- package/src/guard-primitives-discipline.ts +3 -3
- package/src/guard-raw-classname.ts +3 -3
- package/src/guard-raw-interactive-elements.ts +3 -3
- package/src/guard-raw-sql.ts +2 -2
- package/src/guard-renderer-boundaries.ts +1 -1
- package/src/guard-restricted-symbols.ts +1 -1
- package/src/guard-screen-conventions.ts +161 -0
- package/src/guard-silent-skip.ts +1 -1
- package/src/guard-table-ddl.ts +159 -0
- package/src/guard-tailwind-scan-surface.ts +12 -12
- package/src/guard-test-stack-drift.ts +147 -0
- package/src/guard-text-field-stance.ts +222 -0
- package/src/guard-thin-wrappers.ts +6 -1
- package/src/guard-unsafe-json-parse.ts +1 -1
- package/src/guard-write-handler-qns.ts +242 -0
- package/src/run-guards.ts +36 -3
- package/src/run-repo-checks.ts +10 -1
- package/src/run-ui-guards.ts +11 -2
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: PII-typische Entity-Feldnamen ohne Annotation, mit Baseline-
|
|
4
|
+
* Regression-Guard wie check-complexity.ts.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors the boot heuristic from validatePiiAndRetention. Authors mark
|
|
7
|
+
* fields with { pii: true }, { userOwned }, { tenantOwned: true },
|
|
8
|
+
* { allowPlaintext: "reason" } (legacy) or { personal: ... } (0.210.0
|
|
9
|
+
* successor to the four legacy subject annotations).
|
|
10
|
+
*
|
|
11
|
+
* `.kumiko-pii-annotations-baseline.json` im Repo-Root pinnt pro File die
|
|
12
|
+
* eingefrorene Fund-Anzahl:
|
|
13
|
+
* - aktuell <= baseline pro File: PASS
|
|
14
|
+
* - aktuell > baseline pro File: FAIL (neues unannotiertes PII-Feld)
|
|
15
|
+
* Reduktionen updaten die Baseline NICHT automatisch — nach Annotations-
|
|
16
|
+
* Commits `--write-baseline` aufrufen. Ohne Baseline-Datei bleibt der Guard
|
|
17
|
+
* warning-only (Bootstrap: einmalig `--write-baseline`).
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* bun guards/guard-pii-annotations.ts # Vergleich gegen Baseline
|
|
21
|
+
* bun guards/guard-pii-annotations.ts --write-baseline # Baseline neu schreiben
|
|
22
|
+
* bun guards/guard-pii-annotations.ts --no-baseline # Vergleich überspringen
|
|
23
|
+
*/
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
import {
|
|
26
|
+
type CallExpression,
|
|
27
|
+
type Node,
|
|
28
|
+
type ObjectLiteralExpression,
|
|
29
|
+
type SourceFile,
|
|
30
|
+
SyntaxKind,
|
|
31
|
+
} from "ts-morph";
|
|
32
|
+
import {
|
|
33
|
+
type AstGuard,
|
|
34
|
+
baselineRatchet,
|
|
35
|
+
buildSharedProject,
|
|
36
|
+
filesForGuard,
|
|
37
|
+
type GuardOutcome,
|
|
38
|
+
type GuardViolation,
|
|
39
|
+
isLocalFinding,
|
|
40
|
+
runStandalone,
|
|
41
|
+
type ScanSpec,
|
|
42
|
+
} from "./_lib/guard-kit";
|
|
43
|
+
|
|
44
|
+
const ROOT = process.cwd();
|
|
45
|
+
|
|
46
|
+
const SCAN: ScanSpec = {
|
|
47
|
+
scope: "source",
|
|
48
|
+
extensions: ["ts"],
|
|
49
|
+
frameworkWithin: ["packages/*/src/**"],
|
|
50
|
+
};
|
|
51
|
+
const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
|
|
52
|
+
|
|
53
|
+
// Keep in sync with boot-validator/entity-handler.ts PII_*_NAME_HINTS.
|
|
54
|
+
const PII_DIRECT_NAME_HINTS = new Set([
|
|
55
|
+
"email",
|
|
56
|
+
"phone",
|
|
57
|
+
"phonenumber",
|
|
58
|
+
"mobile",
|
|
59
|
+
"address",
|
|
60
|
+
"street",
|
|
61
|
+
"postalcode",
|
|
62
|
+
"zipcode",
|
|
63
|
+
"zip",
|
|
64
|
+
"city",
|
|
65
|
+
"displayname",
|
|
66
|
+
"firstname",
|
|
67
|
+
"lastname",
|
|
68
|
+
"fullname",
|
|
69
|
+
"birthday",
|
|
70
|
+
"birthdate",
|
|
71
|
+
"dateofbirth",
|
|
72
|
+
"dob",
|
|
73
|
+
"ssn",
|
|
74
|
+
"taxid",
|
|
75
|
+
"vatid",
|
|
76
|
+
"passport",
|
|
77
|
+
"iban",
|
|
78
|
+
"bic",
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
const PII_USER_OWNED_NAME_HINTS = new Set([
|
|
82
|
+
"body",
|
|
83
|
+
"text",
|
|
84
|
+
"content",
|
|
85
|
+
"message",
|
|
86
|
+
"comment",
|
|
87
|
+
"description",
|
|
88
|
+
"note",
|
|
89
|
+
"notes",
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
function relFile(sf: SourceFile): string {
|
|
93
|
+
return path.relative(ROOT, sf.getFilePath());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function fieldFactoryOptions(call: CallExpression): ObjectLiteralExpression | undefined {
|
|
97
|
+
const first = call.getArguments()[0];
|
|
98
|
+
return first?.isKind(SyntaxKind.ObjectLiteralExpression) ? first : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Subject annotations answering "is this PII" (kept in sync with the
|
|
102
|
+
// author-facing PersonalAnnotations type, @cosmicdrift/kumiko-types
|
|
103
|
+
// packages/types/src/fields.ts). `personal` (0.210.0) is the successor
|
|
104
|
+
// to the four legacy fields below — every value counts as answered,
|
|
105
|
+
// including `personal: false` (the framework requires a `reason` for
|
|
106
|
+
// it). `find` is NOT a silencer: it resolves to lookupable/searchable/
|
|
107
|
+
// sensitive, which never answered the PII question either. `encrypted`
|
|
108
|
+
// isn't one either — the boot validator checks it only together with
|
|
109
|
+
// `sensitive` (ciphertext-at-rest), never as a substitute.
|
|
110
|
+
function isPiiSilencerAssignment(name: string, init: Node | undefined): boolean {
|
|
111
|
+
if (name === "personal") {
|
|
112
|
+
return (
|
|
113
|
+
init !== undefined &&
|
|
114
|
+
init.getKind() !== SyntaxKind.UndefinedKeyword &&
|
|
115
|
+
!(init.isKind(SyntaxKind.Identifier) && init.getText() === "undefined") &&
|
|
116
|
+
init.getKind() !== SyntaxKind.NullKeyword
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (name === "allowPlaintext") return init?.isKind(SyntaxKind.StringLiteral) ?? false;
|
|
120
|
+
if (name === "pii") return init?.getKind() === SyntaxKind.TrueKeyword;
|
|
121
|
+
if (name === "tenantOwned") return init?.getKind() === SyntaxKind.TrueKeyword;
|
|
122
|
+
if (name === "userOwned") return init?.isKind(SyntaxKind.ObjectLiteralExpression) ?? false;
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function objectHasPiiSilencer(obj: ObjectLiteralExpression): boolean {
|
|
127
|
+
for (const prop of obj.getProperties()) {
|
|
128
|
+
if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
|
|
129
|
+
if (isPiiSilencerAssignment(prop.getName(), prop.getInitializer())) return true;
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function enclosingFieldName(call: CallExpression): string | null {
|
|
135
|
+
let node = call.getParent();
|
|
136
|
+
while (node) {
|
|
137
|
+
if (node.isKind(SyntaxKind.PropertyAssignment)) {
|
|
138
|
+
return node.getName();
|
|
139
|
+
}
|
|
140
|
+
node = node.getParent();
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface Finding {
|
|
146
|
+
file: string;
|
|
147
|
+
line: number;
|
|
148
|
+
fieldName: string;
|
|
149
|
+
hint: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function scanFieldFactories(sf: SourceFile): Finding[] {
|
|
153
|
+
const findings: Finding[] = [];
|
|
154
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
155
|
+
const callee = call.getExpression().getText();
|
|
156
|
+
if (callee !== "createTextField" && callee !== "createLongTextField") continue;
|
|
157
|
+
|
|
158
|
+
const fieldName = enclosingFieldName(call);
|
|
159
|
+
if (!fieldName) continue;
|
|
160
|
+
|
|
161
|
+
const options = fieldFactoryOptions(call);
|
|
162
|
+
if (options && objectHasPiiSilencer(options)) continue;
|
|
163
|
+
|
|
164
|
+
const lower = fieldName.toLowerCase();
|
|
165
|
+
let hint: string | null = null;
|
|
166
|
+
if (PII_DIRECT_NAME_HINTS.has(lower)) {
|
|
167
|
+
hint = `{ personal: ... } or { pii: true } or { allowPlaintext: "is-business-data" }`;
|
|
168
|
+
} else if (PII_USER_OWNED_NAME_HINTS.has(lower)) {
|
|
169
|
+
hint = `{ personal: { of: "<authorIdField>" }, find: "exact" } or { userOwned: { ownerField: "<authorIdField>" } } or { allowPlaintext: "..." }`;
|
|
170
|
+
}
|
|
171
|
+
if (!hint) continue;
|
|
172
|
+
|
|
173
|
+
const line = call.getStartLineNumber();
|
|
174
|
+
findings.push({ file: relFile(sf), line, fieldName, hint });
|
|
175
|
+
console.warn(
|
|
176
|
+
` [pii-annotations WARN] ${relFile(sf)}:${line} field "${fieldName}" looks like PII — mark ${hint}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return findings;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function scan(files: readonly SourceFile[]): {
|
|
183
|
+
findings: Finding[];
|
|
184
|
+
scanned: number;
|
|
185
|
+
} {
|
|
186
|
+
const findings: Finding[] = [];
|
|
187
|
+
let scanned = 0;
|
|
188
|
+
for (const sf of files) {
|
|
189
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
190
|
+
scanned++;
|
|
191
|
+
findings.push(...scanFieldFactories(sf));
|
|
192
|
+
}
|
|
193
|
+
return { findings, scanned };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function localFindings(findings: readonly Finding[]): readonly Finding[] {
|
|
197
|
+
return findings.filter(isLocalFinding);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function countByFile(findings: readonly Finding[]): Record<string, number> {
|
|
201
|
+
const counts: Record<string, number> = {};
|
|
202
|
+
for (const f of findings) counts[f.file] = (counts[f.file] ?? 0) + 1;
|
|
203
|
+
return counts;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const BASELINE_FILE = ".kumiko-pii-annotations-baseline.json";
|
|
207
|
+
const piiBaseline = baselineRatchet({
|
|
208
|
+
file: path.join(ROOT, BASELINE_FILE),
|
|
209
|
+
formatVersion: 1,
|
|
210
|
+
unit: "PII finding(s)",
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// One place for "what goes into the baseline", used by both the compare and
|
|
214
|
+
// the write path — a sibling finding leaking into the written file would turn
|
|
215
|
+
// a foreign checkout's code into a local, unfixable red.
|
|
216
|
+
export function baselineCounts(findings: readonly Finding[]): Record<string, number> {
|
|
217
|
+
return countByFile(localFindings(findings));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function checkBaseline(findings: readonly Finding[]): GuardViolation[] {
|
|
221
|
+
const local = localFindings(findings);
|
|
222
|
+
const resolveLine = (file: string): number => local.find((f) => f.file === file)?.line ?? 1;
|
|
223
|
+
return piiBaseline.check(
|
|
224
|
+
baselineCounts(findings),
|
|
225
|
+
`Annotate the field ({ personal: ... } / { pii: true } / { userOwned: ... } / { tenantOwned: true } / { allowPlaintext: "..." }).`,
|
|
226
|
+
{
|
|
227
|
+
formatDriftRemediation: `Run \`bun guards/guard-pii-annotations.ts --write-baseline\` once.`,
|
|
228
|
+
resolveLine,
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
|
|
234
|
+
const { findings } = scan(files);
|
|
235
|
+
if (!compareBaseline) {
|
|
236
|
+
console.log(" Baseline comparison skipped (--no-baseline).");
|
|
237
|
+
return { violations: [] };
|
|
238
|
+
}
|
|
239
|
+
return { violations: checkBaseline(findings) };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export const guard: AstGuard = {
|
|
243
|
+
name: "PII-Annotations Guard",
|
|
244
|
+
scan: SCAN,
|
|
245
|
+
hint: "after a deliberate annotation: `bun guards/guard-pii-annotations.ts --write-baseline`",
|
|
246
|
+
run: (files) => analyse(files, true),
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// Flags werden NUR hier gelesen, nicht in run() — der Shared-Runner
|
|
250
|
+
// (run-guards.ts) faehrt alle Guards mit derselben argv, ein
|
|
251
|
+
// --write-baseline dort duerfte die Baseline nicht stillschweigend
|
|
252
|
+
// neu schreiben.
|
|
253
|
+
if (import.meta.main) {
|
|
254
|
+
const args = process.argv.slice(2);
|
|
255
|
+
if (args.includes("--write-baseline")) {
|
|
256
|
+
const project = buildSharedProject([guard]);
|
|
257
|
+
const { findings } = scan(filesForGuard(project, guard));
|
|
258
|
+
piiBaseline.write(baselineCounts(findings));
|
|
259
|
+
process.exit(0);
|
|
260
|
+
}
|
|
261
|
+
if (args.includes("--no-baseline")) {
|
|
262
|
+
const project = buildSharedProject([guard]);
|
|
263
|
+
analyse(filesForGuard(project, guard), false);
|
|
264
|
+
process.exit(0);
|
|
265
|
+
}
|
|
266
|
+
runStandalone(guard);
|
|
267
|
+
}
|
|
@@ -188,7 +188,7 @@ export const guard: AstGuard = {
|
|
|
188
188
|
file: "packages/framework/src/pipeline/event-log.ts",
|
|
189
189
|
line: 0,
|
|
190
190
|
message:
|
|
191
|
-
"BLOCKED: pipeline/event-log.ts
|
|
191
|
+
"BLOCKED: pipeline/event-log.ts was restored. The file must be deleted — the events table now takes over its role.",
|
|
192
192
|
});
|
|
193
193
|
}
|
|
194
194
|
|
|
@@ -50,7 +50,7 @@ const FORBIDDEN_TAGS: ReadonlyArray<{
|
|
|
50
50
|
{ tag: "td", counterpart: "<DataTable>" },
|
|
51
51
|
{ tag: "th", counterpart: "<DataTable>" },
|
|
52
52
|
{ tag: "form", counterpart: "<Form>" },
|
|
53
|
-
{ tag: "input", counterpart: "<Input> (
|
|
53
|
+
{ tag: "input", counterpart: "<Input> (via <Field>)" },
|
|
54
54
|
{ tag: "button", counterpart: "<Button>" },
|
|
55
55
|
{ tag: "select", counterpart: "<ComboboxInput>" },
|
|
56
56
|
{ tag: "textarea", counterpart: '<Input kind="textarea">' },
|
|
@@ -60,7 +60,7 @@ const FORBIDDEN_TAGS: ReadonlyArray<{
|
|
|
60
60
|
const FORBIDDEN_CALLS: ReadonlyArray<{
|
|
61
61
|
readonly call: string;
|
|
62
62
|
readonly counterpart: string;
|
|
63
|
-
}> = [{ call: "alert", counterpart: "<DefaultDialog>
|
|
63
|
+
}> = [{ call: "alert", counterpart: "<DefaultDialog> from @cosmicdrift/kumiko-renderer-web" }];
|
|
64
64
|
|
|
65
65
|
// Forbidden className tokens: an allowed tag (`<div>`), but the class gives
|
|
66
66
|
// away hand-rolled primitive chrome. `bg-card` is the dedicated card-surface
|
|
@@ -263,7 +263,7 @@ function violationMessage(v: Violation): string {
|
|
|
263
263
|
}
|
|
264
264
|
|
|
265
265
|
const HINT =
|
|
266
|
-
"Migration: usePrimitives() in
|
|
266
|
+
"Migration: usePrimitives() in a custom screen component, or schema-driven via EntityListScreenDefinition / EntityEditScreenDefinition where possible. Per-line override: // kumiko-lint-ignore primitives-discipline <reason>";
|
|
267
267
|
|
|
268
268
|
export const check: RepoCheck = {
|
|
269
269
|
name: "Primitives-Discipline Guard",
|
|
@@ -84,8 +84,8 @@ export const guard: AstGuard = {
|
|
|
84
84
|
name: "Raw-ClassName Guard (App-Repos)",
|
|
85
85
|
scan: SCAN,
|
|
86
86
|
hint:
|
|
87
|
-
"Design-
|
|
88
|
-
`
|
|
87
|
+
"Design-bearing classes belong in widgets/theme tokens (@cosmicdrift/kumiko-renderer-web widgets/, --color-status-*). " +
|
|
88
|
+
`Justified exception: // ${IGNORE_TAG} <reason>`,
|
|
89
89
|
run(files: readonly SourceFile[]) {
|
|
90
90
|
const violations: GuardViolation[] = [];
|
|
91
91
|
for (const sf of files) {
|
|
@@ -99,7 +99,7 @@ export const guard: AstGuard = {
|
|
|
99
99
|
violations.push({
|
|
100
100
|
file: sf.getFilePath(),
|
|
101
101
|
line: part.line,
|
|
102
|
-
message: `design-
|
|
102
|
+
message: `design-bearing Tailwind classes in app code: ${bad.join(", ")}`,
|
|
103
103
|
});
|
|
104
104
|
}
|
|
105
105
|
}
|
|
@@ -104,7 +104,7 @@ const BASELINE_FILE = ".kumiko-raw-interactive-elements-baseline.json";
|
|
|
104
104
|
const rawInteractiveElementsBaseline = baselineRatchet({
|
|
105
105
|
file: path.join(ROOT, BASELINE_FILE),
|
|
106
106
|
formatVersion: 1,
|
|
107
|
-
unit: "
|
|
107
|
+
unit: "raw interactive HTML finding(s)",
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
const REMEDIATION =
|
|
@@ -114,14 +114,14 @@ const REMEDIATION =
|
|
|
114
114
|
function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
|
|
115
115
|
const findings = scan(files);
|
|
116
116
|
if (!compareBaseline) {
|
|
117
|
-
console.log(" Baseline
|
|
117
|
+
console.log(" Baseline comparison skipped (--no-baseline).");
|
|
118
118
|
return { violations: [] };
|
|
119
119
|
}
|
|
120
120
|
const resolveLine = (file: string): number => findings.find((f) => f.file === file)?.line ?? 1;
|
|
121
121
|
return {
|
|
122
122
|
violations: rawInteractiveElementsBaseline.check(baselineCounts(findings), REMEDIATION, {
|
|
123
123
|
formatDriftRemediation:
|
|
124
|
-
"
|
|
124
|
+
"Run `bun guards/guard-raw-interactive-elements.ts --write-baseline` once.",
|
|
125
125
|
resolveLine,
|
|
126
126
|
}),
|
|
127
127
|
};
|
package/src/guard-raw-sql.ts
CHANGED
|
@@ -62,8 +62,8 @@ export async function collectRawSqlFindings(
|
|
|
62
62
|
export const check: RepoCheck = {
|
|
63
63
|
name: "guard-raw-sql",
|
|
64
64
|
hint:
|
|
65
|
-
"
|
|
66
|
-
"// kumiko-lint-ignore raw-sql <
|
|
65
|
+
"Rule: runtime SQL only in db/queries/*, bun-db/query.ts, testing/*, or with " +
|
|
66
|
+
"// kumiko-lint-ignore raw-sql <reason> on the line or the line above.",
|
|
67
67
|
async run(roots) {
|
|
68
68
|
// kumiko-platform's deliberate empty scan-dir list must not read as vacuous (infra#610).
|
|
69
69
|
const applicableRoots = roots.filter((r) => sqlScanLayoutFor(r) !== "none");
|
|
@@ -128,7 +128,7 @@ export function findViolations(file: string, root: string): Violation[] {
|
|
|
128
128
|
|
|
129
129
|
export const check: RepoCheck = {
|
|
130
130
|
name: "Renderer-Boundaries Guard",
|
|
131
|
-
hint: "@cosmicdrift/kumiko-renderer
|
|
131
|
+
hint: "@cosmicdrift/kumiko-renderer must not use DOM/browser/platform APIs. Platform-specific code belongs in @cosmicdrift/kumiko-renderer-web (or renderer-native).",
|
|
132
132
|
run(roots) {
|
|
133
133
|
const frameworkRoots = roots.filter((r) => r.kind === "framework");
|
|
134
134
|
const scanDirs = frameworkRoots
|
|
@@ -112,7 +112,7 @@ function findRestrictedSymbolReferences(sf: SourceFile): Violation[] {
|
|
|
112
112
|
export const guard: AstGuard = {
|
|
113
113
|
name: "Restricted-Symbols Guard",
|
|
114
114
|
scan: SCAN,
|
|
115
|
-
hint: 'getUnscopedAggregateStream{MaxVersion,Tenant}
|
|
115
|
+
hint: 'getUnscopedAggregateStream{MaxVersion,Tenant} is an existence oracle for foreign tenants — only seed-/system-internal code may reference them. New caller needed? Extend the allowlist in guard-restricted-symbols.ts, with a reason. Known gap: `export * from "...event-store"` is not detected (named imports, re-exports, and namespace property access are covered).',
|
|
116
116
|
run(files) {
|
|
117
117
|
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
118
118
|
const roots = resolveRepoRoots();
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Usability mistakes in screen definitions that are statically decidable and
|
|
3
|
+
// otherwise come back screen by screen: a metric without a label, a
|
|
4
|
+
// relatedList section whose rows lead nowhere, a row action labelled "edit"
|
|
5
|
+
// that does something else. Opt-in per app repo via _app-test.yml.
|
|
6
|
+
|
|
7
|
+
import { type ObjectLiteralExpression, type SourceFile, SyntaxKind } from "ts-morph";
|
|
8
|
+
import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
9
|
+
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
10
|
+
|
|
11
|
+
const SCAN: ScanSpec = {
|
|
12
|
+
scope: "source",
|
|
13
|
+
extensions: ["ts"],
|
|
14
|
+
frameworkWithin: ["packages/*/src/**"],
|
|
15
|
+
};
|
|
16
|
+
const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
|
|
17
|
+
const IGNORE_TAG = "kumiko-lint-ignore screen-conventions";
|
|
18
|
+
|
|
19
|
+
const SCREEN_TYPE_SUFFIX = "ScreenDefinition";
|
|
20
|
+
|
|
21
|
+
// Anchor: only object literals that are unambiguously a screen definition —
|
|
22
|
+
// either a `const x: XScreenDefinition = {...}` (the house convention, see
|
|
23
|
+
// solon's screens.ts) or an inline literal passed straight to `r.screen(...)`.
|
|
24
|
+
// Everything else (unrelated object literals with a coincidental `kind`/
|
|
25
|
+
// `metrics` property) is deliberately left unchecked — a missed spot beats a
|
|
26
|
+
// false positive here.
|
|
27
|
+
function findScreenObjectLiterals(sf: SourceFile): ObjectLiteralExpression[] {
|
|
28
|
+
const roots: ObjectLiteralExpression[] = [];
|
|
29
|
+
for (const decl of sf.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
30
|
+
const typeNode = decl.getTypeNode();
|
|
31
|
+
if (!typeNode?.getText().endsWith(SCREEN_TYPE_SUFFIX)) continue;
|
|
32
|
+
const init = decl.getInitializer();
|
|
33
|
+
if (init?.isKind(SyntaxKind.ObjectLiteralExpression)) roots.push(init);
|
|
34
|
+
}
|
|
35
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
36
|
+
const expr = call.getExpression();
|
|
37
|
+
if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
|
|
38
|
+
if (expr.getName() !== "screen") continue;
|
|
39
|
+
const arg = call.getArguments()[0];
|
|
40
|
+
if (arg?.isKind(SyntaxKind.ObjectLiteralExpression)) roots.push(arg);
|
|
41
|
+
}
|
|
42
|
+
return roots;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function stringLiteralValue(obj: ObjectLiteralExpression, name: string): string | undefined {
|
|
46
|
+
const prop = obj.getProperty(name);
|
|
47
|
+
if (!prop?.isKind(SyntaxKind.PropertyAssignment)) return undefined;
|
|
48
|
+
const init = prop.getInitializer();
|
|
49
|
+
return init?.isKind(SyntaxKind.StringLiteral) ? init.getLiteralValue() : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hasDeclaredProperty(obj: ObjectLiteralExpression, name: string): boolean {
|
|
53
|
+
const prop = obj.getProperty(name);
|
|
54
|
+
if (!prop?.isKind(SyntaxKind.PropertyAssignment)) return false;
|
|
55
|
+
const init = prop.getInitializer();
|
|
56
|
+
return init !== undefined && init.getKind() !== SyntaxKind.UndefinedKeyword;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// R1: MetricSpec's plain-string shorthand carries no label — every metrics
|
|
60
|
+
// entry on a projectionDetail screen must be the object form with `label`.
|
|
61
|
+
function checkMetrics(
|
|
62
|
+
root: ObjectLiteralExpression,
|
|
63
|
+
sf: SourceFile,
|
|
64
|
+
violations: GuardViolation[],
|
|
65
|
+
): void {
|
|
66
|
+
const metricsProp = root.getProperty("metrics");
|
|
67
|
+
if (!metricsProp?.isKind(SyntaxKind.PropertyAssignment)) return;
|
|
68
|
+
const init = metricsProp.getInitializer();
|
|
69
|
+
if (!init?.isKind(SyntaxKind.ArrayLiteralExpression)) return;
|
|
70
|
+
for (const el of init.getElements()) {
|
|
71
|
+
if (hasIgnoreTag(el, IGNORE_TAG)) continue;
|
|
72
|
+
if (el.isKind(SyntaxKind.StringLiteral)) {
|
|
73
|
+
violations.push({
|
|
74
|
+
file: sf.getFilePath(),
|
|
75
|
+
line: el.getStartLineNumber(),
|
|
76
|
+
message: `metrics entry "${el.getLiteralValue()}" is a plain string (a number with no meaning) — use the object form with \`label\`.`,
|
|
77
|
+
});
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (el.isKind(SyntaxKind.ObjectLiteralExpression) && !hasDeclaredProperty(el, "label")) {
|
|
81
|
+
violations.push({
|
|
82
|
+
file: sf.getFilePath(),
|
|
83
|
+
line: el.getStartLineNumber(),
|
|
84
|
+
message:
|
|
85
|
+
"metrics entry without `label` (a number with no meaning) — the object form needs a label.",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// R2: a relatedList section with neither rowClick nor rowActions renders a
|
|
92
|
+
// list nobody can act on.
|
|
93
|
+
function checkRelatedListSections(
|
|
94
|
+
root: ObjectLiteralExpression,
|
|
95
|
+
sf: SourceFile,
|
|
96
|
+
violations: GuardViolation[],
|
|
97
|
+
): void {
|
|
98
|
+
for (const obj of root.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)) {
|
|
99
|
+
if (stringLiteralValue(obj, "kind") !== "relatedList") continue;
|
|
100
|
+
if (hasIgnoreTag(obj, IGNORE_TAG)) continue;
|
|
101
|
+
if (hasDeclaredProperty(obj, "rowClick") || hasDeclaredProperty(obj, "rowActions")) continue;
|
|
102
|
+
violations.push({
|
|
103
|
+
file: sf.getFilePath(),
|
|
104
|
+
line: obj.getStartLineNumber(),
|
|
105
|
+
message: "relatedList section without `rowClick`/`rowActions` — the row leads nowhere.",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// R3: a rowAction whose id isn't "edit" but whose label resolves to an
|
|
111
|
+
// ".action.edit" i18n key promises "Bearbeiten" while doing something else.
|
|
112
|
+
function checkRowActions(
|
|
113
|
+
root: ObjectLiteralExpression,
|
|
114
|
+
sf: SourceFile,
|
|
115
|
+
violations: GuardViolation[],
|
|
116
|
+
): void {
|
|
117
|
+
for (const prop of root.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
|
|
118
|
+
const name = prop.getName();
|
|
119
|
+
if (name !== "rowActions" && name !== "actions") continue;
|
|
120
|
+
const init = prop.getInitializer();
|
|
121
|
+
if (!init?.isKind(SyntaxKind.ArrayLiteralExpression)) continue;
|
|
122
|
+
for (const el of init.getElements()) {
|
|
123
|
+
if (!el.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
|
|
124
|
+
if (hasIgnoreTag(el, IGNORE_TAG)) continue;
|
|
125
|
+
const id = stringLiteralValue(el, "id");
|
|
126
|
+
const label = stringLiteralValue(el, "label");
|
|
127
|
+
if (id === undefined || label === undefined) continue;
|
|
128
|
+
if (id !== "edit" && label.endsWith(".action.edit")) {
|
|
129
|
+
violations.push({
|
|
130
|
+
file: sf.getFilePath(),
|
|
131
|
+
line: el.getStartLineNumber(),
|
|
132
|
+
message: `rowAction "${id}" has label "${label}" (sounds like edit), but id is not "edit" — label/action mismatch.`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export const guard: AstGuard = {
|
|
140
|
+
name: "Screen-Conventions Guard",
|
|
141
|
+
scan: SCAN,
|
|
142
|
+
hint:
|
|
143
|
+
"metrics entries need the object form with `label`; relatedList sections need " +
|
|
144
|
+
'`rowClick` or `rowActions`; a rowAction label must not end in ".action.edit" ' +
|
|
145
|
+
'when the `id` is not "edit". ' +
|
|
146
|
+
`Justified exception: // ${IGNORE_TAG} <reason>`,
|
|
147
|
+
run(files: readonly SourceFile[]) {
|
|
148
|
+
const violations: GuardViolation[] = [];
|
|
149
|
+
for (const sf of files) {
|
|
150
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
151
|
+
for (const root of findScreenObjectLiterals(sf)) {
|
|
152
|
+
checkMetrics(root, sf, violations);
|
|
153
|
+
checkRelatedListSections(root, sf, violations);
|
|
154
|
+
checkRowActions(root, sf, violations);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { violations };
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (import.meta.main) runStandalone(guard);
|
package/src/guard-silent-skip.ts
CHANGED
|
@@ -158,7 +158,7 @@ function hasLeadingSkipComment(site: SkipSite): boolean {
|
|
|
158
158
|
export const guard: AstGuard = {
|
|
159
159
|
name: "Silent-Skip Guard",
|
|
160
160
|
scan: SCAN,
|
|
161
|
-
hint: "
|
|
161
|
+
hint: "A bare `return;` needs one of these before it: a log call, a `// skip: <reason>` comment, or a preceding throw.",
|
|
162
162
|
run(files) {
|
|
163
163
|
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
164
164
|
|