@cosmicdrift/kumiko-guards 0.355.0 → 0.357.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/_lib/bun-lock-packages.ts +5 -5
- package/src/_lib/guard-kit.ts +180 -131
- package/src/_lib/qn.ts +9 -9
- package/src/_lib/roots.ts +4 -3
- package/src/_lib/security-baseline.ts +193 -116
- package/src/_lib/sql-inventory.ts +34 -32
- package/src/changes.json +6 -0
- package/src/check-as-casts.ts +196 -180
- package/src/check-complexity.ts +68 -69
- package/src/check-predicates.ts +93 -67
- package/src/check-secret-literals.ts +49 -38
- package/src/check-single-runtime-instance.ts +1 -1
- package/src/cli.ts +30 -17
- package/src/guard-access-denied-test.ts +49 -23
- package/src/guard-app-feature-structure.ts +92 -73
- package/src/guard-broker-subscribe.ts +17 -17
- package/src/guard-comment-lang.ts +126 -54
- package/src/guard-direct-entity-writes.ts +58 -45
- package/src/guard-escape-hatch-declared.ts +26 -23
- package/src/guard-html-escape.ts +112 -86
- package/src/guard-i18n-keys.ts +39 -36
- package/src/guard-i18n-locale-mount.ts +47 -40
- package/src/guard-i18n-ui-strings.ts +108 -92
- package/src/guard-lib-test-coverage.ts +37 -37
- package/src/guard-loadall-events.ts +17 -17
- package/src/guard-no-framed-extension-sections.ts +4 -4
- package/src/guard-no-temporal-polyfill-import.ts +19 -1
- package/src/guard-pii-annotations.ts +16 -16
- package/src/guard-primitives-discipline.ts +61 -65
- package/src/guard-screen-conventions.ts +2 -0
- package/src/guard-table-ddl.ts +56 -62
- package/src/guard-tenant-escalation.ts +42 -34
- package/src/guard-test-stack-drift.ts +6 -6
- package/src/guard-test-timeouts.ts +5 -4
- package/src/guard-text-field-stance.ts +22 -22
- package/src/guard-thin-wrappers.ts +42 -45
- package/src/guard-write-handler-qns.ts +29 -29
- package/src/runtime-isolation-classify.ts +4 -3
|
@@ -17,7 +17,12 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import * as path from "node:path";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
type CallExpression,
|
|
22
|
+
type ObjectLiteralExpression,
|
|
23
|
+
type SourceFile,
|
|
24
|
+
SyntaxKind,
|
|
25
|
+
} from "ts-morph";
|
|
21
26
|
import { findRepoRootFor } from "./_lib/baseline-compare";
|
|
22
27
|
import {
|
|
23
28
|
ALL_REPO_KINDS,
|
|
@@ -49,6 +54,47 @@ const OPEN_ROLE_RE = /["'`](?:anonymous|all)["'`]|\baccess\.(?:all|anonymous)\b/
|
|
|
49
54
|
|
|
50
55
|
type RoleRestrictedHandler = { name: string; file: string; line: number };
|
|
51
56
|
|
|
57
|
+
function writeHandlerOptions(call: CallExpression): ObjectLiteralExpression | undefined {
|
|
58
|
+
const callee = call.getExpression().getText();
|
|
59
|
+
if (callee !== "defineWriteHandler" && !/\.writeHandler$/.test(callee)) return undefined;
|
|
60
|
+
const arg = call.getArguments()[0];
|
|
61
|
+
if (!arg || arg.getKind() !== SyntaxKind.ObjectLiteralExpression) return undefined;
|
|
62
|
+
return arg.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function accessObjectOf(obj: ObjectLiteralExpression): ObjectLiteralExpression | undefined {
|
|
66
|
+
const accessProp = obj.getProperty("access");
|
|
67
|
+
if (!accessProp || accessProp.getKind() !== SyntaxKind.PropertyAssignment) return undefined;
|
|
68
|
+
const accessInit = accessProp.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer();
|
|
69
|
+
if (!accessInit || accessInit.getKind() !== SyntaxKind.ObjectLiteralExpression) return undefined;
|
|
70
|
+
return accessInit.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function restrictsToRoles(accessObj: ObjectLiteralExpression): boolean {
|
|
74
|
+
if (accessObj.getProperty("openToAll")) return false;
|
|
75
|
+
const rolesProp = accessObj.getProperty("roles");
|
|
76
|
+
if (!rolesProp) return false;
|
|
77
|
+
if (
|
|
78
|
+
rolesProp.getKind() !== SyntaxKind.PropertyAssignment &&
|
|
79
|
+
rolesProp.getKind() !== SyntaxKind.ShorthandPropertyAssignment
|
|
80
|
+
)
|
|
81
|
+
return false;
|
|
82
|
+
return !OPEN_ROLE_RE.test(rolesProp.getText());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function roleRestrictedHandlerOf(
|
|
86
|
+
call: CallExpression,
|
|
87
|
+
filePath: string,
|
|
88
|
+
): RoleRestrictedHandler | undefined {
|
|
89
|
+
const obj = writeHandlerOptions(call);
|
|
90
|
+
if (!obj) return undefined;
|
|
91
|
+
const name = literalStringOf(obj.getProperty("name"));
|
|
92
|
+
if (!name) return undefined;
|
|
93
|
+
const accessObj = accessObjectOf(obj);
|
|
94
|
+
if (!accessObj || !restrictsToRoles(accessObj)) return undefined;
|
|
95
|
+
return { name, file: filePath, line: call.getStartLineNumber() };
|
|
96
|
+
}
|
|
97
|
+
|
|
52
98
|
export function findRoleRestrictedWriteHandlers(
|
|
53
99
|
files: readonly SourceFile[],
|
|
54
100
|
): RoleRestrictedHandler[] {
|
|
@@ -56,28 +102,8 @@ export function findRoleRestrictedWriteHandlers(
|
|
|
56
102
|
for (const sf of files) {
|
|
57
103
|
if (TEST_FILE_RE.test(sf.getFilePath())) continue;
|
|
58
104
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
59
|
-
const
|
|
60
|
-
if (
|
|
61
|
-
const arg = call.getArguments()[0];
|
|
62
|
-
if (!arg || arg.getKind() !== SyntaxKind.ObjectLiteralExpression) continue;
|
|
63
|
-
const obj = arg.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
64
|
-
const name = literalStringOf(obj.getProperty("name"));
|
|
65
|
-
if (!name) continue;
|
|
66
|
-
const accessProp = obj.getProperty("access");
|
|
67
|
-
if (!accessProp || accessProp.getKind() !== SyntaxKind.PropertyAssignment) continue;
|
|
68
|
-
const accessInit = accessProp.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer();
|
|
69
|
-
if (!accessInit || accessInit.getKind() !== SyntaxKind.ObjectLiteralExpression) continue;
|
|
70
|
-
const accessObj = accessInit.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
71
|
-
if (accessObj.getProperty("openToAll")) continue;
|
|
72
|
-
const rolesProp = accessObj.getProperty("roles");
|
|
73
|
-
if (!rolesProp) continue;
|
|
74
|
-
if (
|
|
75
|
-
rolesProp.getKind() !== SyntaxKind.PropertyAssignment &&
|
|
76
|
-
rolesProp.getKind() !== SyntaxKind.ShorthandPropertyAssignment
|
|
77
|
-
)
|
|
78
|
-
continue;
|
|
79
|
-
if (OPEN_ROLE_RE.test(rolesProp.getText())) continue;
|
|
80
|
-
out.push({ name, file: sf.getFilePath(), line: call.getStartLineNumber() });
|
|
105
|
+
const handler = roleRestrictedHandlerOf(call, sf.getFilePath());
|
|
106
|
+
if (handler) out.push(handler);
|
|
81
107
|
}
|
|
82
108
|
}
|
|
83
109
|
return out;
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
// App
|
|
3
|
-
// feature.ts =
|
|
4
|
-
//
|
|
5
|
-
// 1. web.tsx/web.ts
|
|
6
|
-
// 2. feature.ts
|
|
7
|
-
// 3. r.screen({ type: "custom" })
|
|
8
|
-
//
|
|
2
|
+
// App features follow the bundled-features convention (reference: tenant/):
|
|
3
|
+
// feature.ts = registration only, screens under web/, handlers under handlers/.
|
|
4
|
+
// This guard flags the three most expensive deviations:
|
|
5
|
+
// 1. web.tsx/web.ts monolith or JSX screens directly at the feature root
|
|
6
|
+
// 2. feature.ts as a logic dump (> MAX_FEATURE_TS_LINES lines)
|
|
7
|
+
// 3. r.screen({ type: "custom" }) without an allowlist tag — declarative
|
|
8
|
+
// screen types (entityList/dashboard/data-table) are the default.
|
|
9
9
|
//
|
|
10
|
-
// ponytail:
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// ponytail: the handler file convention (*.query.ts/*.write.ts under handlers/)
|
|
11
|
+
// is not enforced yet — add it once the registration API shapes are
|
|
12
|
+
// inventoried and stable.
|
|
13
13
|
//
|
|
14
|
-
//
|
|
14
|
+
// Part of App-Mounting 2.0 (infra#208).
|
|
15
15
|
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
@@ -29,13 +29,88 @@ const IGNORE_TAG = "kumiko-lint-ignore app-feature-structure";
|
|
|
29
29
|
|
|
30
30
|
const MAX_FEATURE_TS_LINES = 300;
|
|
31
31
|
|
|
32
|
-
// src/features/<name>/<file>
|
|
33
|
-
// —
|
|
32
|
+
// src/features/<name>/<file> or packages/bundled-features/src/<name>/<file>
|
|
33
|
+
// — exactly one level below the feature folder.
|
|
34
34
|
function isFeatureRootFile(filePath: string): boolean {
|
|
35
35
|
const m = filePath.match(/(src\/features|packages\/bundled-features\/src)\/[^/]+\/[^/]+$/);
|
|
36
36
|
return m !== null;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
function isIgnoredFile(sf: SourceFile): boolean {
|
|
40
|
+
return hasIgnoreTag(sf.getChildren()[0] ?? sf, IGNORE_TAG);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 1a. web.ts(x) monolith at the feature root
|
|
44
|
+
function checkWebMonolith(sf: SourceFile, base: string, violations: GuardViolation[]): void {
|
|
45
|
+
const filePath = sf.getFilePath();
|
|
46
|
+
if (isFeatureRootFile(filePath) && (base === "web.tsx" || base === "web.ts")) {
|
|
47
|
+
if (!isIgnoredFile(sf)) {
|
|
48
|
+
violations.push({
|
|
49
|
+
file: filePath,
|
|
50
|
+
line: 1,
|
|
51
|
+
message:
|
|
52
|
+
"web monolith at feature root — screens/client def belong under web/ (index.ts + one file per screen)",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 1b. JSX directly at the feature root (screens belong under web/)
|
|
59
|
+
function checkRootJsx(sf: SourceFile, base: string, violations: GuardViolation[]): void {
|
|
60
|
+
const filePath = sf.getFilePath();
|
|
61
|
+
if (
|
|
62
|
+
isFeatureRootFile(filePath) &&
|
|
63
|
+
filePath.endsWith(".tsx") &&
|
|
64
|
+
base !== "web.tsx" &&
|
|
65
|
+
sf.getDescendantsOfKind(SyntaxKind.JsxElement).length +
|
|
66
|
+
sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement).length >
|
|
67
|
+
0 &&
|
|
68
|
+
!isIgnoredFile(sf)
|
|
69
|
+
) {
|
|
70
|
+
violations.push({
|
|
71
|
+
file: filePath,
|
|
72
|
+
line: 1,
|
|
73
|
+
message: "JSX component at feature root — move it under web/",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2. feature.ts as a logic dump
|
|
79
|
+
function checkFeatureTsSize(sf: SourceFile, base: string, violations: GuardViolation[]): void {
|
|
80
|
+
const filePath = sf.getFilePath();
|
|
81
|
+
if (base === "feature.ts" && isFeatureRootFile(filePath)) {
|
|
82
|
+
const lines = sf.getEndLineNumber();
|
|
83
|
+
if (lines > MAX_FEATURE_TS_LINES && !isIgnoredFile(sf)) {
|
|
84
|
+
violations.push({
|
|
85
|
+
file: filePath,
|
|
86
|
+
line: 1,
|
|
87
|
+
message: `feature.ts has ${lines} lines (max ${MAX_FEATURE_TS_LINES}) — move handlers to handlers/, schemas to schema/, logic to lib/`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 3. type: "custom" without an allowlist tag
|
|
94
|
+
function checkCustomScreens(sf: SourceFile, violations: GuardViolation[]): void {
|
|
95
|
+
for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
|
|
96
|
+
if (prop.getName() !== "type") continue;
|
|
97
|
+
const init = prop.getInitializer();
|
|
98
|
+
if (init === undefined || init.getKind() !== SyntaxKind.StringLiteral) continue;
|
|
99
|
+
if (init.asKindOrThrow(SyntaxKind.StringLiteral).getLiteralText() !== "custom") continue;
|
|
100
|
+
// r.screen context only: the surrounding call target must end in .screen.
|
|
101
|
+
const call = prop.getFirstAncestorByKind(SyntaxKind.CallExpression);
|
|
102
|
+
if (call === undefined || !call.getExpression().getText().endsWith(".screen")) continue;
|
|
103
|
+
// Only the screen's own config declares its type; nested `{ type: "custom" }` objects are widget config.
|
|
104
|
+
if (prop.getParent() !== call.getArguments()[0]) continue;
|
|
105
|
+
if (hasIgnoreTag(call, IGNORE_TAG) || hasIgnoreTag(prop, IGNORE_TAG)) continue;
|
|
106
|
+
violations.push({
|
|
107
|
+
file: sf.getFilePath(),
|
|
108
|
+
line: prop.getStartLineNumber(),
|
|
109
|
+
message: `r.screen type:"custom" without allowlist tag — use a declarative screen type or // ${IGNORE_TAG} <reason>`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
39
114
|
export const guard: AstGuard = {
|
|
40
115
|
name: "App-Feature-Structure Guard (App-Repos)",
|
|
41
116
|
scan: SCAN,
|
|
@@ -48,66 +123,10 @@ export const guard: AstGuard = {
|
|
|
48
123
|
const filePath = sf.getFilePath();
|
|
49
124
|
if (EXCLUDE.test(filePath)) continue;
|
|
50
125
|
const base = path.basename(filePath);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
violations.push({
|
|
56
|
-
file: filePath,
|
|
57
|
-
line: 1,
|
|
58
|
-
message:
|
|
59
|
-
"web monolith at feature root — screens/client def belong under web/ (index.ts + one file per screen)",
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// 1b. JSX direkt am Feature-Root (Screens gehören unter web/)
|
|
65
|
-
if (
|
|
66
|
-
isFeatureRootFile(filePath) &&
|
|
67
|
-
filePath.endsWith(".tsx") &&
|
|
68
|
-
base !== "web.tsx" &&
|
|
69
|
-
sf.getDescendantsOfKind(SyntaxKind.JsxElement).length +
|
|
70
|
-
sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement).length >
|
|
71
|
-
0 &&
|
|
72
|
-
!hasIgnoreTag(sf.getChildren()[0] ?? sf, IGNORE_TAG)
|
|
73
|
-
) {
|
|
74
|
-
violations.push({
|
|
75
|
-
file: filePath,
|
|
76
|
-
line: 1,
|
|
77
|
-
message: "JSX component at feature root — move it under web/",
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// 2. feature.ts als Logik-Dump
|
|
82
|
-
if (base === "feature.ts" && isFeatureRootFile(filePath)) {
|
|
83
|
-
const lines = sf.getEndLineNumber();
|
|
84
|
-
if (lines > MAX_FEATURE_TS_LINES && !hasIgnoreTag(sf.getChildren()[0] ?? sf, IGNORE_TAG)) {
|
|
85
|
-
violations.push({
|
|
86
|
-
file: filePath,
|
|
87
|
-
line: 1,
|
|
88
|
-
message: `feature.ts has ${lines} lines (max ${MAX_FEATURE_TS_LINES}) — move handlers to handlers/, schemas to schema/, logic to lib/`,
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// 3. type: "custom" ohne Allowlist-Tag
|
|
94
|
-
for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
|
|
95
|
-
if (prop.getName() !== "type") continue;
|
|
96
|
-
const init = prop.getInitializer();
|
|
97
|
-
if (init === undefined || init.getKind() !== SyntaxKind.StringLiteral) continue;
|
|
98
|
-
if (init.asKindOrThrow(SyntaxKind.StringLiteral).getLiteralText() !== "custom") continue;
|
|
99
|
-
// Nur r.screen-Kontext: das umgebende Call-Target muss auf .screen enden.
|
|
100
|
-
const call = prop.getFirstAncestorByKind(SyntaxKind.CallExpression);
|
|
101
|
-
if (call === undefined || !call.getExpression().getText().endsWith(".screen")) continue;
|
|
102
|
-
// Only the screen's own config declares its type; nested `{ type: "custom" }` objects are widget config.
|
|
103
|
-
if (prop.getParent() !== call.getArguments()[0]) continue;
|
|
104
|
-
if (hasIgnoreTag(call, IGNORE_TAG) || hasIgnoreTag(prop, IGNORE_TAG)) continue;
|
|
105
|
-
violations.push({
|
|
106
|
-
file: filePath,
|
|
107
|
-
line: prop.getStartLineNumber(),
|
|
108
|
-
message: `r.screen type:"custom" without allowlist tag — use a declarative screen type or // ${IGNORE_TAG} <reason>`,
|
|
109
|
-
});
|
|
110
|
-
}
|
|
126
|
+
checkWebMonolith(sf, base, violations);
|
|
127
|
+
checkRootJsx(sf, base, violations);
|
|
128
|
+
checkFeatureTsSize(sf, base, violations);
|
|
129
|
+
checkCustomScreens(sf, violations);
|
|
111
130
|
}
|
|
112
131
|
return { violations };
|
|
113
132
|
},
|
|
@@ -1,30 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Guard R8:
|
|
3
|
+
* Guard R8: forbids `broker.subscribe(...)` outside the framework.
|
|
4
4
|
*
|
|
5
|
-
* Feature
|
|
6
|
-
*
|
|
5
|
+
* Feature and app code must NEVER subscribe to the event broker directly — the
|
|
6
|
+
* registrar API is the only allowed way to consume events:
|
|
7
7
|
* - `r.onEvent("event-name", handler)`
|
|
8
8
|
* - `r.job({ trigger: { on: "event-name" }, handler })`
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* framework-
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* A direct `broker.subscribe(...)` bypasses lifecycle, idempotency, dedup
|
|
11
|
+
* and replay of the dispatcher — exactly the guarantees the registrar API
|
|
12
|
+
* gives. Today there is NO such call in the Kumiko code (the broker is
|
|
13
|
+
* framework-internal, not exported) — the guard is a tripwire that fires as
|
|
14
|
+
* soon as someone introduces a broker abstraction and hooks feature code
|
|
15
|
+
* directly onto it.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* insensitive —
|
|
20
|
-
* Store
|
|
21
|
-
*
|
|
17
|
+
* Detection (name heuristic, no type resolution): `X.subscribe(...)` where the
|
|
18
|
+
* terminal identifier of `X` is `broker` or `eventBroker` (case-
|
|
19
|
+
* insensitive — catches `broker`, `eventBroker`, `ctx.broker`, `this.eventBroker`).
|
|
20
|
+
* Store/observable `.subscribe` (RxJS, React `controller.subscribe`) is not
|
|
21
|
+
* named `broker` → no hit.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* Exception: `packages/framework/src/pipeline/**` — the broker plumbing
|
|
24
|
+
* itself lives there (framework-internal, allowed).
|
|
25
25
|
*
|
|
26
26
|
* Usage: bun guards/guard-broker-subscribe.ts
|
|
27
|
-
* Exit 1
|
|
27
|
+
* Exit 1 on a finding, 0 when clean.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import * as path from "node:path";
|
|
@@ -50,6 +50,32 @@ export type Site = {
|
|
|
50
50
|
readonly snippet: string;
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
function germanCommentSite(
|
|
54
|
+
fullText: string,
|
|
55
|
+
file: string,
|
|
56
|
+
start: number,
|
|
57
|
+
text: string,
|
|
58
|
+
): Site | undefined {
|
|
59
|
+
if (lineHasIgnoreTag(text, IGNORE_TAG) || !isGermanComment(text)) return undefined;
|
|
60
|
+
const line = fullText.slice(0, start).split("\n").length;
|
|
61
|
+
return {
|
|
62
|
+
file,
|
|
63
|
+
line,
|
|
64
|
+
endLine: line + (text.match(/\n/g)?.length ?? 0),
|
|
65
|
+
snippet: text.length > 80 ? `${text.slice(0, 77)}...` : text,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function closesTemplateSubstitution(
|
|
70
|
+
templateBraceDepths: readonly number[],
|
|
71
|
+
braceDepth: number,
|
|
72
|
+
): boolean {
|
|
73
|
+
return (
|
|
74
|
+
templateBraceDepths.length > 0 &&
|
|
75
|
+
braceDepth === templateBraceDepths[templateBraceDepths.length - 1]
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
53
79
|
// Template substitutions can nest braces (object literals, arrow bodies, other
|
|
54
80
|
// templates). The raw scanner tokenizes a `}` as a plain CloseBraceToken and
|
|
55
81
|
// never resumes template mode, so reScanTemplateToken() must run once brace
|
|
@@ -71,16 +97,13 @@ export function scanGermanComments(fullText: string, file: string): Site[] {
|
|
|
71
97
|
switch (kind) {
|
|
72
98
|
case ts.SyntaxKind.SingleLineCommentTrivia:
|
|
73
99
|
case ts.SyntaxKind.MultiLineCommentTrivia: {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
snippet: text.length > 80 ? `${text.slice(0, 77)}...` : text,
|
|
82
|
-
});
|
|
83
|
-
}
|
|
100
|
+
const site = germanCommentSite(
|
|
101
|
+
fullText,
|
|
102
|
+
file,
|
|
103
|
+
scanner.getTokenStart(),
|
|
104
|
+
scanner.getTokenText(),
|
|
105
|
+
);
|
|
106
|
+
if (site) sites.push(site);
|
|
84
107
|
break;
|
|
85
108
|
}
|
|
86
109
|
case ts.SyntaxKind.SlashToken:
|
|
@@ -99,10 +122,7 @@ export function scanGermanComments(fullText: string, file: string): Site[] {
|
|
|
99
122
|
braceDepth++;
|
|
100
123
|
break;
|
|
101
124
|
case ts.SyntaxKind.CloseBraceToken:
|
|
102
|
-
if (
|
|
103
|
-
templateBraceDepths.length > 0 &&
|
|
104
|
-
braceDepth === templateBraceDepths[templateBraceDepths.length - 1]
|
|
105
|
-
) {
|
|
125
|
+
if (closesTemplateSubstitution(templateBraceDepths, braceDepth)) {
|
|
106
126
|
templateBraceDepths.pop();
|
|
107
127
|
kind = scanner.reScanTemplateToken(false);
|
|
108
128
|
continue;
|
|
@@ -262,47 +282,71 @@ function sumCounts(counts: Readonly<Record<string, number>>): number {
|
|
|
262
282
|
return Object.values(counts).reduce((total, count) => total + count, 0);
|
|
263
283
|
}
|
|
264
284
|
|
|
265
|
-
function
|
|
266
|
-
|
|
285
|
+
function isBaselineCandidate(value: unknown): value is Partial<Baseline> {
|
|
286
|
+
return typeof value === "object" && value !== null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function hasPerFileObject(baseline: Partial<Baseline>): baseline is Partial<Baseline> & {
|
|
290
|
+
readonly perFile: Record<string, number>;
|
|
291
|
+
} {
|
|
292
|
+
return typeof baseline.perFile === "object" && baseline.perFile !== null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function countFindingsByFile(all: readonly Site[]): Record<string, number> {
|
|
267
296
|
const countByFile: Record<string, number> = {};
|
|
268
297
|
for (const site of all.filter(isLocalFinding)) {
|
|
269
298
|
countByFile[site.file] = (countByFile[site.file] ?? 0) + 1;
|
|
270
299
|
}
|
|
271
|
-
|
|
300
|
+
return countByFile;
|
|
301
|
+
}
|
|
272
302
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
if (!existsSync(baselinePath)) {
|
|
291
|
-
console.log(" No baseline found. Freeze one first with `--write-baseline`.");
|
|
292
|
-
return 1;
|
|
293
|
-
}
|
|
303
|
+
function writeBaselineFile(
|
|
304
|
+
baselinePath: string,
|
|
305
|
+
countByFile: Readonly<Record<string, number>>,
|
|
306
|
+
currentTotal: number,
|
|
307
|
+
): void {
|
|
308
|
+
const payload: Baseline = {
|
|
309
|
+
format: BASELINE_FORMAT_VERSION,
|
|
310
|
+
generated: Temporal.Now.instant().toString().slice(0, 10),
|
|
311
|
+
total: currentTotal,
|
|
312
|
+
perFile: Object.fromEntries(Object.entries(countByFile).sort(([a], [b]) => a.localeCompare(b))),
|
|
313
|
+
};
|
|
314
|
+
writeFileSync(baselinePath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
315
|
+
console.log(` Baseline written: ${baselinePath} (total ${currentTotal})`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
type LoadedBaseline = Partial<Baseline> & { readonly perFile: Record<string, number> };
|
|
294
319
|
|
|
295
|
-
|
|
296
|
-
|
|
320
|
+
// Prints the diagnosis itself and returns undefined when the baseline cannot be used.
|
|
321
|
+
function readBaselineOrReport(baselinePath: string): LoadedBaseline | undefined {
|
|
322
|
+
let parsed: unknown;
|
|
323
|
+
try {
|
|
324
|
+
parsed = JSON.parse(readFileSync(baselinePath, "utf-8"));
|
|
325
|
+
} catch (error) {
|
|
326
|
+
console.log(
|
|
327
|
+
` Baseline unreadable: ${error instanceof Error ? error.message : String(error)}. Rewrite it once with: kumiko-guards comment-lang --write-baseline`,
|
|
328
|
+
);
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
// JSON `null`, numbers and strings parse fine but carry no baseline: report them as format drift.
|
|
332
|
+
const rawBaseline: Partial<Baseline> = isBaselineCandidate(parsed) ? parsed : {};
|
|
333
|
+
if (rawBaseline.format !== BASELINE_FORMAT_VERSION || !hasPerFileObject(rawBaseline)) {
|
|
297
334
|
console.log(
|
|
298
335
|
` Baseline format drift: expected format=${BASELINE_FORMAT_VERSION}, got format=${rawBaseline.format ?? "<missing>"}.`,
|
|
299
336
|
);
|
|
300
337
|
console.log(" Rewrite it once with: kumiko-guards comment-lang --write-baseline");
|
|
301
|
-
return
|
|
338
|
+
return undefined;
|
|
302
339
|
}
|
|
303
|
-
|
|
340
|
+
return rawBaseline;
|
|
341
|
+
}
|
|
304
342
|
|
|
305
|
-
|
|
343
|
+
type BaselineRegression = { file: string; baseline: number; current: number };
|
|
344
|
+
|
|
345
|
+
function compareCountsToBaseline(
|
|
346
|
+
countByFile: Readonly<Record<string, number>>,
|
|
347
|
+
baselinePerFile: Readonly<Record<string, number>>,
|
|
348
|
+
): { regressions: BaselineRegression[]; reduced: number } {
|
|
349
|
+
const regressions: BaselineRegression[] = [];
|
|
306
350
|
let reduced = 0;
|
|
307
351
|
for (const file of new Set([...Object.keys(countByFile), ...Object.keys(baselinePerFile)])) {
|
|
308
352
|
const expected = baselinePerFile[file] ?? 0;
|
|
@@ -310,24 +354,52 @@ function runBaselineMode(all: readonly Site[], argv: readonly string[], root: st
|
|
|
310
354
|
if (current > expected) regressions.push({ file, baseline: expected, current });
|
|
311
355
|
else if (current < expected) reduced += expected - current;
|
|
312
356
|
}
|
|
357
|
+
return { regressions, reduced };
|
|
358
|
+
}
|
|
313
359
|
|
|
314
|
-
|
|
360
|
+
function printRegressions(all: readonly Site[], regressions: readonly BaselineRegression[]): void {
|
|
361
|
+
console.log(
|
|
362
|
+
`\n REGRESSION: ${regressions.length} file(s) have more German comments than the baseline:`,
|
|
363
|
+
);
|
|
364
|
+
for (const regression of regressions) {
|
|
315
365
|
console.log(
|
|
316
|
-
|
|
366
|
+
` ${regression.file} baseline=${regression.baseline} current=${regression.current} (+${regression.current - regression.baseline})`,
|
|
317
367
|
);
|
|
318
|
-
for (const
|
|
319
|
-
console.log(
|
|
320
|
-
` ${regression.file} baseline=${regression.baseline} current=${regression.current} (+${regression.current - regression.baseline})`,
|
|
321
|
-
);
|
|
322
|
-
for (const site of all.filter((candidate) => candidate.file === regression.file)) {
|
|
323
|
-
console.log(` :${site.line} ${site.snippet}`);
|
|
324
|
-
}
|
|
368
|
+
for (const site of all.filter((candidate) => candidate.file === regression.file)) {
|
|
369
|
+
console.log(` :${site.line} ${site.snippet}`);
|
|
325
370
|
}
|
|
326
|
-
|
|
371
|
+
}
|
|
372
|
+
console.log(`\n ${REWRITE_HINT}`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function runBaselineMode(all: readonly Site[], argv: readonly string[], root: string): number {
|
|
376
|
+
const baselinePath = path.join(root, BASELINE_FILE);
|
|
377
|
+
const countByFile = countFindingsByFile(all);
|
|
378
|
+
const currentTotal = sumCounts(countByFile);
|
|
379
|
+
|
|
380
|
+
if (argv.includes("--write-baseline")) {
|
|
381
|
+
writeBaselineFile(baselinePath, countByFile, currentTotal);
|
|
382
|
+
return 0;
|
|
383
|
+
}
|
|
384
|
+
if (argv.includes("--no-baseline")) {
|
|
385
|
+
console.log(" Baseline comparison skipped (--no-baseline).");
|
|
386
|
+
return 0;
|
|
387
|
+
}
|
|
388
|
+
if (!existsSync(baselinePath)) {
|
|
389
|
+
console.log(" No baseline found. Freeze one first with `--write-baseline`.");
|
|
390
|
+
return 1;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const baseline = readBaselineOrReport(baselinePath);
|
|
394
|
+
if (baseline === undefined) return 1;
|
|
395
|
+
|
|
396
|
+
const { regressions, reduced } = compareCountsToBaseline(countByFile, baseline.perFile);
|
|
397
|
+
if (regressions.length > 0) {
|
|
398
|
+
printRegressions(all, regressions);
|
|
327
399
|
return 1;
|
|
328
400
|
}
|
|
329
401
|
|
|
330
|
-
console.log(` ✓ Baseline (${
|
|
402
|
+
console.log(` ✓ Baseline (${baseline.total}) — current ${currentTotal}`);
|
|
331
403
|
if (reduced > 0) {
|
|
332
404
|
console.log(
|
|
333
405
|
` ✓ ${reduced} German comment(s) removed since the baseline. Consider \`--write-baseline\`.`,
|