@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,184 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: every feature in packages/bundled-features/src/<name>/feature.ts
|
|
4
|
+
* must be imported by at least one *.integration.ts. Otherwise it never ran
|
|
5
|
+
* through the full stack — exactly the "feature built, never wired up" case
|
|
6
|
+
* from CLAUDE.md.
|
|
7
|
+
*
|
|
8
|
+
* Coverage rule: a feature counts as covered when an integration test
|
|
9
|
+
* imports anything — relative or via the `@cosmicdrift/kumiko-bundled-features`
|
|
10
|
+
* package — that resolves into its `packages/bundled-features/src/<name>/`
|
|
11
|
+
* directory. That's deliberately broader than "imports feature.ts itself":
|
|
12
|
+
* a test that imports a sibling module of the feature (e.g. its resolver,
|
|
13
|
+
* its defaults helper) still proves the directory ran, and a barrel import
|
|
14
|
+
* of the whole package still proves the feature was composed. Since the
|
|
15
|
+
* `<name>-feature.ts` -> `feature.ts` rename (df3f6b5b) the file basename is
|
|
16
|
+
* identical for all 56 features — the directory name is the only usable
|
|
17
|
+
* identifier.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* bun guards/guard-feature-integration-tests.ts
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { Project, SyntaxKind } from "ts-morph";
|
|
25
|
+
import {
|
|
26
|
+
type GuardViolation,
|
|
27
|
+
type RepoCheck,
|
|
28
|
+
reportResults,
|
|
29
|
+
runRepoChecks,
|
|
30
|
+
} from "./_lib/guard-kit";
|
|
31
|
+
import { frameworkPackageTsConfigPath, type RepoRoot } from "./_lib/roots";
|
|
32
|
+
import { type ScanSpec, scanFiles } from "./_lib/scan-scope";
|
|
33
|
+
|
|
34
|
+
const ROOT = process.cwd();
|
|
35
|
+
|
|
36
|
+
const FEATURES_SCAN: ScanSpec = {
|
|
37
|
+
scope: "source",
|
|
38
|
+
extensions: ["ts"],
|
|
39
|
+
kinds: ["framework"],
|
|
40
|
+
frameworkWithin: ["packages/bundled-features/src/**/feature.ts"],
|
|
41
|
+
};
|
|
42
|
+
// Both suffixes: `.integration.ts` (legacy) and `.integration.test.ts`
|
|
43
|
+
// (canonical after the bun-test cutover). dev-server covers features
|
|
44
|
+
// indirectly (walkthrough tests that compose real bundled features) — must
|
|
45
|
+
// be scanned too, otherwise those feature references stay invisible.
|
|
46
|
+
const INTEGRATION_SCAN: ScanSpec = {
|
|
47
|
+
scope: "source",
|
|
48
|
+
extensions: ["ts"],
|
|
49
|
+
kinds: ["framework"],
|
|
50
|
+
frameworkWithin: [
|
|
51
|
+
"packages/bundled-features/src/**/*.integration.ts",
|
|
52
|
+
"packages/bundled-features/src/**/*.integration.test.ts",
|
|
53
|
+
"packages/framework/src/**/*.integration.ts",
|
|
54
|
+
"packages/framework/src/**/*.integration.test.ts",
|
|
55
|
+
"packages/dev-server/src/**/*.integration.ts",
|
|
56
|
+
"packages/dev-server/src/**/*.integration.test.ts",
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const BUNDLED_FEATURES_PACKAGE_SPEC = /^@cosmicdrift\/kumiko-bundled-features\/([^/]+)/;
|
|
61
|
+
const BUNDLED_FEATURES_SRC_DIR = /bundled-features\/src\/([^/]+)\//;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolves an import specifier to the feature ID it reaches, or `null` if
|
|
65
|
+
* the import doesn't land inside a bundled feature's directory. Pure —
|
|
66
|
+
* testable without a Project.
|
|
67
|
+
*/
|
|
68
|
+
export function extractFeatureId(spec: string, importingFilePath: string): string | null {
|
|
69
|
+
const packageMatch = spec.match(BUNDLED_FEATURES_PACKAGE_SPEC);
|
|
70
|
+
if (packageMatch) return packageMatch[1] ?? null;
|
|
71
|
+
|
|
72
|
+
if (!spec.startsWith(".")) return null;
|
|
73
|
+
const resolved = path.resolve(path.dirname(importingFilePath), spec);
|
|
74
|
+
// A relative import that never leaves the __tests__ dir (./helpers,
|
|
75
|
+
// ./fixtures) doesn't prove the feature itself is imported anywhere —
|
|
76
|
+
// only that its test has co-located helpers.
|
|
77
|
+
if (resolved.split(path.sep).includes("__tests__")) return null;
|
|
78
|
+
const dirMatch = `${resolved}/`.match(BUNDLED_FEATURES_SRC_DIR);
|
|
79
|
+
return dirMatch?.[1] ?? null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function collectFeatures(project: Project, paths: readonly string[]): Map<string, string> {
|
|
83
|
+
const features = new Map<string, string>();
|
|
84
|
+
for (const p of paths) {
|
|
85
|
+
const sf = project.getSourceFile(p) ?? project.addSourceFileAtPath(p);
|
|
86
|
+
const filePath = sf.getFilePath();
|
|
87
|
+
if (path.basename(filePath) !== "feature.ts") continue;
|
|
88
|
+
// Same derivation as extractFeatureId (producer/consumer must agree, or
|
|
89
|
+
// a nested feature dir orphans permanently — no import could ever
|
|
90
|
+
// satisfy a mismatched ID). Also filters out non-feature `feature.ts`
|
|
91
|
+
// fixtures (e.g. under __tests__) that don't sit in a bundled-features
|
|
92
|
+
// src dir.
|
|
93
|
+
const dirMatch = `${filePath}`.match(BUNDLED_FEATURES_SRC_DIR);
|
|
94
|
+
const featureId = dirMatch?.[1];
|
|
95
|
+
if (!featureId) continue;
|
|
96
|
+
features.set(featureId, path.relative(ROOT, filePath));
|
|
97
|
+
}
|
|
98
|
+
return features;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function collectImportedFeatureIds(project: Project, roots: readonly RepoRoot[]): Set<string> {
|
|
102
|
+
for (const p of scanFiles(INTEGRATION_SCAN, roots)) {
|
|
103
|
+
if (!project.getSourceFile(p)) project.addSourceFileAtPath(p);
|
|
104
|
+
}
|
|
105
|
+
const imported = new Set<string>();
|
|
106
|
+
for (const sf of project.getSourceFiles()) {
|
|
107
|
+
if (!/\.integration(\.test)?\.ts$/.test(sf.getFilePath())) continue;
|
|
108
|
+
for (const imp of sf.getDescendantsOfKind(SyntaxKind.ImportDeclaration)) {
|
|
109
|
+
const featureId = extractFeatureId(imp.getModuleSpecifierValue(), sf.getFilePath());
|
|
110
|
+
if (featureId) imported.add(featureId);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return imported;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Baseline from the infra#436 measurement: features with no integration
|
|
118
|
+
* test reaching their directory at all — only covered (if at all) by unit
|
|
119
|
+
* tests (`feature.test.ts`). Pre-existing at the first sharp run, not
|
|
120
|
+
* introduced by this change. Backfilling is its own scope per feature, not
|
|
121
|
+
* a sweep.
|
|
122
|
+
*/
|
|
123
|
+
const ALLOWLIST: ReadonlySet<string> = new Set(["step-dispatcher"]);
|
|
124
|
+
|
|
125
|
+
export function computeOrphans(
|
|
126
|
+
features: ReadonlyMap<string, string>,
|
|
127
|
+
imported: ReadonlySet<string>,
|
|
128
|
+
allowlist: ReadonlySet<string> = ALLOWLIST,
|
|
129
|
+
): Array<{ name: string; file: string }> {
|
|
130
|
+
const orphans: Array<{ name: string; file: string }> = [];
|
|
131
|
+
for (const [id, file] of features) {
|
|
132
|
+
if (!imported.has(id) && !allowlist.has(id)) orphans.push({ name: id, file });
|
|
133
|
+
}
|
|
134
|
+
return orphans;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const check: RepoCheck = {
|
|
138
|
+
name: "Feature-Integration-Test Guard",
|
|
139
|
+
hint:
|
|
140
|
+
"Every feature needs a *.integration.ts that uses it in " +
|
|
141
|
+
"setupTestStack({ features: [...] }).",
|
|
142
|
+
run(roots) {
|
|
143
|
+
if (!roots.some((r) => r.kind === "framework")) {
|
|
144
|
+
return { violations: [], matchedFiles: 0, notApplicable: true };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const project = new Project({
|
|
148
|
+
tsConfigFilePath: frameworkPackageTsConfigPath("bundled-features"),
|
|
149
|
+
skipAddingFilesFromTsConfig: true,
|
|
150
|
+
skipFileDependencyResolution: true,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const featurePaths = scanFiles(FEATURES_SCAN, roots);
|
|
154
|
+
const features = collectFeatures(project, featurePaths);
|
|
155
|
+
const imported = collectImportedFeatureIds(project, roots);
|
|
156
|
+
|
|
157
|
+
const violations: GuardViolation[] = [];
|
|
158
|
+
|
|
159
|
+
const stale = [...ALLOWLIST].filter((id) => imported.has(id));
|
|
160
|
+
for (const id of stale) {
|
|
161
|
+
violations.push({
|
|
162
|
+
file: features.get(id) ?? id,
|
|
163
|
+
line: 1,
|
|
164
|
+
message: `Allowlist entry "${id}" is now covered by an integration test — remove it from ALLOWLIST.`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const orphan of computeOrphans(features, imported)) {
|
|
169
|
+
violations.push({
|
|
170
|
+
file: orphan.file,
|
|
171
|
+
line: 1,
|
|
172
|
+
message:
|
|
173
|
+
"Feature without an integration test — needs a *.integration.ts that uses it in setupTestStack({ features: [...] }).",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { violations, matchedFiles: featurePaths.length, notApplicable: false };
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
if (import.meta.main) {
|
|
182
|
+
const failed = reportResults(await runRepoChecks([check]));
|
|
183
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
184
|
+
}
|
package/src/guard-html-escape.ts
CHANGED
|
@@ -325,7 +325,7 @@ function scanFile(sf: SourceFile): UnsafeSite[] {
|
|
|
325
325
|
export const guard: AstGuard = {
|
|
326
326
|
name: "HTML-Escape Guard",
|
|
327
327
|
scan: SCAN,
|
|
328
|
-
hint: "
|
|
328
|
+
hint: "Escape interpolation in an HTML template literal: escapeHtml()/escapeHtmlAttr() from @cosmicdrift/kumiko-headless. Mark pre-rendered HTML with a `*Html` name; type static copy tables `as const`; deliberate exception with `// html-ok: <why>`.",
|
|
329
329
|
run(files) {
|
|
330
330
|
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
331
331
|
for (const sf of files) {
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: i18n-Keys muessen definiert sein, bevor sie verwendet werden.
|
|
4
|
+
*
|
|
5
|
+
* Scan: t()-Calls in App-tsx, Definitionen aus r.translations und i18n-Bundles.
|
|
6
|
+
* Deklarative Screen-/Nav-Keys: validateBoot (Runtime).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* bun guards/guard-i18n-keys.ts
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { type Node, type ObjectLiteralExpression, type SourceFile, SyntaxKind } from "ts-morph";
|
|
15
|
+
import { type AstGuard, type GuardOutcome, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
16
|
+
|
|
17
|
+
const ROOT = process.cwd();
|
|
18
|
+
|
|
19
|
+
const SCAN: ScanSpec = {
|
|
20
|
+
scope: "source",
|
|
21
|
+
extensions: ["ts", "tsx"],
|
|
22
|
+
frameworkWithin: ["packages/*/src/**", "samples/apps/*/src/**", "samples/recipes/*/src/**"],
|
|
23
|
+
};
|
|
24
|
+
const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
|
|
25
|
+
|
|
26
|
+
interface UsedKey {
|
|
27
|
+
key: string;
|
|
28
|
+
file: string;
|
|
29
|
+
line: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface DefinedKey {
|
|
33
|
+
fullKey: string;
|
|
34
|
+
locales: Set<string>;
|
|
35
|
+
file: string;
|
|
36
|
+
line: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function relFile(sf: SourceFile): string {
|
|
40
|
+
return path.relative(ROOT, sf.getFilePath());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function featureFromPath(filePath: string): string | null {
|
|
44
|
+
const rel = path.relative(ROOT, filePath);
|
|
45
|
+
const m = rel.match(/src\/features\/([^/]+)\//);
|
|
46
|
+
return m?.[1] ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isI18nBundleFile(filePath: string): boolean {
|
|
50
|
+
return (
|
|
51
|
+
/\/i18n\//.test(filePath) ||
|
|
52
|
+
// Flat single-file bundle (src/i18n.ts), same status as src/i18n/index.ts.
|
|
53
|
+
/\/i18n\.ts$/.test(filePath) ||
|
|
54
|
+
/\/features\/[^/]+\/i18n\./.test(filePath) ||
|
|
55
|
+
// Deckt beliebige Tiefe unter bundled-features/src/<feature>/ ab
|
|
56
|
+
// (z.B. auch .../schema/i18n.ts), statt nur genau eine Ebene.
|
|
57
|
+
/bundled-features\/src\/.*\/i18n\.ts$/.test(filePath)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function collectUsedKeys(sf: SourceFile): UsedKey[] {
|
|
62
|
+
const keys: UsedKey[] = [];
|
|
63
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
64
|
+
const exprText = call.getExpression().getText();
|
|
65
|
+
if (exprText !== "t" && !/(^|\.)t$/.test(exprText)) continue;
|
|
66
|
+
if (exprText === "test" || exprText === "expect") continue;
|
|
67
|
+
const args = call.getArguments();
|
|
68
|
+
if (args.length === 0) continue;
|
|
69
|
+
const first = args[0];
|
|
70
|
+
if (
|
|
71
|
+
!first?.isKind(SyntaxKind.StringLiteral) &&
|
|
72
|
+
!first?.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)
|
|
73
|
+
)
|
|
74
|
+
continue;
|
|
75
|
+
const literal = first.getText().slice(1, -1);
|
|
76
|
+
if (!literal.includes(":")) continue;
|
|
77
|
+
keys.push({
|
|
78
|
+
key: literal,
|
|
79
|
+
file: relFile(sf),
|
|
80
|
+
line: call.getStartLineNumber(),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return keys;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function findEnclosingFeatureName(node: Node): string | null {
|
|
87
|
+
let cur: Node | undefined = node.getParent();
|
|
88
|
+
while (cur) {
|
|
89
|
+
if (cur.isKind(SyntaxKind.CallExpression)) {
|
|
90
|
+
const call = cur;
|
|
91
|
+
if (call.getExpression().getText() === "defineFeature") {
|
|
92
|
+
const first = call.getArguments()[0];
|
|
93
|
+
if (first?.isKind(SyntaxKind.StringLiteral)) {
|
|
94
|
+
return first.getText().slice(1, -1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
cur = cur.getParent();
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function extractKeysFromTranslationsObject(
|
|
104
|
+
obj: ObjectLiteralExpression,
|
|
105
|
+
): Array<{ key: string; locales: Set<string>; line: number }> {
|
|
106
|
+
const out: Array<{ key: string; locales: Set<string>; line: number }> = [];
|
|
107
|
+
for (const prop of obj.getProperties()) {
|
|
108
|
+
if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
|
|
109
|
+
const nameNode = prop.getNameNode();
|
|
110
|
+
let keyName: string;
|
|
111
|
+
if (nameNode.isKind(SyntaxKind.StringLiteral)) {
|
|
112
|
+
keyName = nameNode.getText().slice(1, -1);
|
|
113
|
+
} else if (nameNode.isKind(SyntaxKind.Identifier)) {
|
|
114
|
+
keyName = nameNode.getText();
|
|
115
|
+
} else continue;
|
|
116
|
+
|
|
117
|
+
const initializer = prop.getInitializer();
|
|
118
|
+
if (!initializer?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
|
|
119
|
+
const locales = new Set<string>();
|
|
120
|
+
for (const localeProp of initializer.getProperties()) {
|
|
121
|
+
if (!localeProp.isKind(SyntaxKind.PropertyAssignment)) continue;
|
|
122
|
+
const localeName = localeProp.getNameNode();
|
|
123
|
+
if (localeName.isKind(SyntaxKind.StringLiteral))
|
|
124
|
+
locales.add(localeName.getText().slice(1, -1));
|
|
125
|
+
else if (localeName.isKind(SyntaxKind.Identifier)) locales.add(localeName.getText());
|
|
126
|
+
}
|
|
127
|
+
out.push({ key: keyName, locales, line: prop.getStartLineNumber() });
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function extractLocaleFirstKeys(
|
|
133
|
+
obj: ObjectLiteralExpression,
|
|
134
|
+
): Array<{ key: string; locales: Set<string>; line: number }> {
|
|
135
|
+
const localeMaps = new Map<string, ObjectLiteralExpression>();
|
|
136
|
+
for (const prop of obj.getProperties()) {
|
|
137
|
+
if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
|
|
138
|
+
const localeName = prop.getNameNode().getText();
|
|
139
|
+
const init = prop.getInitializer();
|
|
140
|
+
if (init?.isKind(SyntaxKind.ObjectLiteralExpression)) {
|
|
141
|
+
localeMaps.set(localeName, init);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!localeMaps.has("de") && !localeMaps.has("en")) return [];
|
|
145
|
+
|
|
146
|
+
const keySet = new Set<string>();
|
|
147
|
+
for (const map of localeMaps.values()) {
|
|
148
|
+
for (const prop of map.getProperties()) {
|
|
149
|
+
if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
|
|
150
|
+
const nameNode = prop.getNameNode();
|
|
151
|
+
if (nameNode.isKind(SyntaxKind.StringLiteral)) {
|
|
152
|
+
keySet.add(nameNode.getText().slice(1, -1));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const out: Array<{ key: string; locales: Set<string>; line: number }> = [];
|
|
158
|
+
for (const key of keySet) {
|
|
159
|
+
const locales = new Set<string>();
|
|
160
|
+
for (const [locale, map] of localeMaps) {
|
|
161
|
+
const has = map.getProperties().some((prop) => {
|
|
162
|
+
if (!prop.isKind(SyntaxKind.PropertyAssignment)) return false;
|
|
163
|
+
const nameNode = prop.getNameNode();
|
|
164
|
+
return nameNode.isKind(SyntaxKind.StringLiteral) && nameNode.getText().slice(1, -1) === key;
|
|
165
|
+
});
|
|
166
|
+
if (has) locales.add(locale);
|
|
167
|
+
}
|
|
168
|
+
out.push({ key, locales, line: obj.getStartLineNumber() });
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function pushDefined(
|
|
174
|
+
defined: DefinedKey[],
|
|
175
|
+
fullKey: string,
|
|
176
|
+
locales: Set<string>,
|
|
177
|
+
file: string,
|
|
178
|
+
line: number,
|
|
179
|
+
): void {
|
|
180
|
+
defined.push({ fullKey, locales, file, line });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function addDefinedEntries(
|
|
184
|
+
defined: DefinedKey[],
|
|
185
|
+
entries: Array<{ key: string; locales: Set<string>; line: number }>,
|
|
186
|
+
file: string,
|
|
187
|
+
featureName: string | null,
|
|
188
|
+
): void {
|
|
189
|
+
for (const entry of entries) {
|
|
190
|
+
if (entry.key.includes(":")) {
|
|
191
|
+
pushDefined(defined, entry.key, entry.locales, file, entry.line);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (featureName) {
|
|
195
|
+
pushDefined(defined, `${featureName}:${entry.key}`, entry.locales, file, entry.line);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function objectLiteralFromInitializer(node: Node | undefined): ObjectLiteralExpression | undefined {
|
|
201
|
+
if (!node) return undefined;
|
|
202
|
+
if (node.isKind(SyntaxKind.ObjectLiteralExpression)) return node;
|
|
203
|
+
if (
|
|
204
|
+
node.isKind(SyntaxKind.AsExpression) ||
|
|
205
|
+
node.isKind(SyntaxKind.ParenthesizedExpression) ||
|
|
206
|
+
node.isKind(SyntaxKind.SatisfiesExpression)
|
|
207
|
+
) {
|
|
208
|
+
return objectLiteralFromInitializer(node.getExpression());
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isLocaleFirstBundle(obj: ObjectLiteralExpression): boolean {
|
|
214
|
+
const props = obj.getProperties().filter((p) => p.isKind(SyntaxKind.PropertyAssignment));
|
|
215
|
+
if (props.length === 0) return false;
|
|
216
|
+
const localeRe = /^(de|en|fr|es|it|nl|pt)$/;
|
|
217
|
+
return props.every((p) => localeRe.test(p.getNameNode().getText()));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function collectBundleDefinedKeys(sf: SourceFile): DefinedKey[] {
|
|
221
|
+
const filePath = sf.getFilePath();
|
|
222
|
+
if (!isI18nBundleFile(filePath)) return [];
|
|
223
|
+
|
|
224
|
+
const defined: DefinedKey[] = [];
|
|
225
|
+
const featureName = featureFromPath(filePath);
|
|
226
|
+
|
|
227
|
+
for (const decl of sf.getVariableDeclarations()) {
|
|
228
|
+
const obj = objectLiteralFromInitializer(decl.getInitializer());
|
|
229
|
+
if (!obj) continue;
|
|
230
|
+
|
|
231
|
+
if (isLocaleFirstBundle(obj)) {
|
|
232
|
+
addDefinedEntries(defined, extractLocaleFirstKeys(obj), relFile(sf), featureName);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const keyFirst = extractKeysFromTranslationsObject(obj);
|
|
237
|
+
if (keyFirst.length > 0) {
|
|
238
|
+
addDefinedEntries(defined, keyFirst, relFile(sf), featureName);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return defined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function collectInlineTranslationsDefinedKeys(sf: SourceFile): DefinedKey[] {
|
|
246
|
+
const defined: DefinedKey[] = [];
|
|
247
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
248
|
+
const expr = call.getExpression();
|
|
249
|
+
if (!expr.getText().endsWith(".translations")) continue;
|
|
250
|
+
const args = call.getArguments();
|
|
251
|
+
const first = args[0];
|
|
252
|
+
if (!first?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
|
|
253
|
+
|
|
254
|
+
const featureName = findEnclosingFeatureName(call);
|
|
255
|
+
if (!featureName) continue;
|
|
256
|
+
|
|
257
|
+
for (const prop of first.getProperties()) {
|
|
258
|
+
if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
|
|
259
|
+
if (prop.getNameNode().getText() !== "keys") continue;
|
|
260
|
+
const initializer = prop.getInitializer();
|
|
261
|
+
if (initializer?.isKind(SyntaxKind.ObjectLiteralExpression)) {
|
|
262
|
+
for (const entry of extractKeysFromTranslationsObject(initializer)) {
|
|
263
|
+
pushDefined(
|
|
264
|
+
defined,
|
|
265
|
+
`${featureName}:${entry.key}`,
|
|
266
|
+
entry.locales,
|
|
267
|
+
relFile(sf),
|
|
268
|
+
entry.line,
|
|
269
|
+
);
|
|
270
|
+
if (entry.key.includes(":")) {
|
|
271
|
+
pushDefined(defined, entry.key, entry.locales, relFile(sf), entry.line);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} else if (initializer?.isKind(SyntaxKind.Identifier)) {
|
|
275
|
+
// getDefinitionNodes() follows "go to definition" through an
|
|
276
|
+
// import alias to the real declaration (possibly in another
|
|
277
|
+
// file) instead of stopping at the ImportSpecifier — plain
|
|
278
|
+
// getSymbol().getDeclarations() only resolved a same-file const.
|
|
279
|
+
const decl = initializer
|
|
280
|
+
.getDefinitionNodes()
|
|
281
|
+
.find((n) => n.isKind(SyntaxKind.VariableDeclaration));
|
|
282
|
+
if (decl?.isKind(SyntaxKind.VariableDeclaration)) {
|
|
283
|
+
const bundleInit = decl.getInitializer();
|
|
284
|
+
if (bundleInit?.isKind(SyntaxKind.ObjectLiteralExpression)) {
|
|
285
|
+
for (const entry of extractKeysFromTranslationsObject(bundleInit)) {
|
|
286
|
+
addDefinedEntries(defined, [entry], relFile(sf), featureName);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return defined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Framework monorepo ships English-only core bundles; locale packages opt in separately. */
|
|
297
|
+
export function isFrameworkMonorepo(root: string): boolean {
|
|
298
|
+
return existsSync(path.join(root, "packages/framework/package.json"));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Parse `export const NAME = [...]` locale literals. Returns null if absent;
|
|
302
|
+
* throws if the export exists but is not a static string-literal array
|
|
303
|
+
* (spread / alias / region-tag-only without literals) — silent de/en fallback
|
|
304
|
+
* would hide a real declaration (infra#602). */
|
|
305
|
+
export function parseLocaleConstArray(source: string, constName: string): string[] | null {
|
|
306
|
+
const exportRe = new RegExp(`export\\s+const\\s+${constName}\\s*(?::[^=]+)?=\\s*([^;]+)`);
|
|
307
|
+
const match = source.match(exportRe);
|
|
308
|
+
if (!match?.[1]) return null;
|
|
309
|
+
const rhs = match[1].trim();
|
|
310
|
+
if (!rhs.startsWith("[")) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
`i18n-keys: export const ${constName} is not a static array literal — refuse silent de/en fallback`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
if (rhs.includes("...")) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`i18n-keys: export const ${constName} uses array spreads — refuse partial/silent locale set`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
const locales = [...rhs.matchAll(/["']([a-z]{2}(?:-[A-Z]{2})?)["']/g)]
|
|
321
|
+
.map((m) => m[1])
|
|
322
|
+
.filter((l): l is string => l !== undefined);
|
|
323
|
+
if (locales.length === 0) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`i18n-keys: export const ${constName} = [...] has no string locale literals — refuse silent de/en fallback`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
return locales;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Resolve owning repo root from a guard-relative path (may be `../…`). */
|
|
332
|
+
export function repoRootForRelFile(rel: string): string {
|
|
333
|
+
const abs = path.isAbsolute(rel) ? rel : path.resolve(ROOT, rel);
|
|
334
|
+
let curr = path.dirname(abs);
|
|
335
|
+
let fallback: string | undefined;
|
|
336
|
+
while (curr !== path.dirname(curr)) {
|
|
337
|
+
const base = path.basename(curr);
|
|
338
|
+
// Never treat worktree/parent markers as a repo root (infra#602).
|
|
339
|
+
if (base === ".." || base === ".wt") {
|
|
340
|
+
curr = path.dirname(curr);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (existsSync(path.join(curr, "packages/framework/package.json"))) {
|
|
344
|
+
return curr;
|
|
345
|
+
}
|
|
346
|
+
if (existsSync(path.join(curr, "package.json"))) {
|
|
347
|
+
// Outermost package.json wins so nested packages/* resolve to the repo.
|
|
348
|
+
fallback = curr;
|
|
349
|
+
}
|
|
350
|
+
curr = path.dirname(curr);
|
|
351
|
+
}
|
|
352
|
+
return fallback ?? ROOT;
|
|
353
|
+
}
|
|
354
|
+
/** Repo-declared locales for translation completeness; empty set skips the check. */
|
|
355
|
+
export function resolveExpectedLocales(root: string = ROOT): Set<string> {
|
|
356
|
+
if (isFrameworkMonorepo(root)) return new Set();
|
|
357
|
+
|
|
358
|
+
const declPaths: Array<{ rel: string; constName: string }> = [
|
|
359
|
+
{ rel: "src/i18n-guard-locales.ts", constName: "I18N_GUARD_LOCALES" },
|
|
360
|
+
{ rel: "src/marketing/locale-routes.ts", constName: "LOCALES" },
|
|
361
|
+
];
|
|
362
|
+
for (const { rel, constName } of declPaths) {
|
|
363
|
+
const abs = path.join(root, rel);
|
|
364
|
+
if (!existsSync(abs)) continue;
|
|
365
|
+
const locales = parseLocaleConstArray(readFileSync(abs, "utf-8"), constName);
|
|
366
|
+
if (locales) return new Set(locales);
|
|
367
|
+
// parseLocaleConstArray throws on unreadable decls; null = no export.
|
|
368
|
+
}
|
|
369
|
+
return new Set(["de", "en"]);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export type I18nKeysRunOptions = {
|
|
373
|
+
/** Inject expected locales per owning root — tests must not rely on process.cwd(). */
|
|
374
|
+
readonly expectedLocalesForRoot?: (root: string) => Set<string>;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
export function checkI18nKeys(
|
|
378
|
+
files: readonly SourceFile[],
|
|
379
|
+
options: I18nKeysRunOptions = {},
|
|
380
|
+
): GuardOutcome {
|
|
381
|
+
const usedKeys: UsedKey[] = [];
|
|
382
|
+
const definedKeys: DefinedKey[] = [];
|
|
383
|
+
|
|
384
|
+
for (const sf of files) {
|
|
385
|
+
const filePath = sf.getFilePath();
|
|
386
|
+
if (EXCLUDE.test(filePath)) continue;
|
|
387
|
+
usedKeys.push(...collectUsedKeys(sf));
|
|
388
|
+
definedKeys.push(...collectInlineTranslationsDefinedKeys(sf));
|
|
389
|
+
definedKeys.push(...collectBundleDefinedKeys(sf));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const definedSet = new Set(definedKeys.map((d) => d.fullKey));
|
|
393
|
+
const missing = usedKeys.filter((u) => !definedSet.has(u.key));
|
|
394
|
+
const resolveLocales = options.expectedLocalesForRoot ?? resolveExpectedLocales;
|
|
395
|
+
const expectedByRoot = new Map<string, Set<string>>();
|
|
396
|
+
const expectedForFile = (rel: string): Set<string> => {
|
|
397
|
+
const root = repoRootForRelFile(rel);
|
|
398
|
+
let locales = expectedByRoot.get(root);
|
|
399
|
+
if (!locales) {
|
|
400
|
+
locales = resolveLocales(root);
|
|
401
|
+
expectedByRoot.set(root, locales);
|
|
402
|
+
}
|
|
403
|
+
return locales;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const localeViolations = definedKeys.flatMap((d) => {
|
|
407
|
+
const expectedLocales = expectedForFile(d.file);
|
|
408
|
+
const missingLocales = [...expectedLocales].filter((l) => !d.locales.has(l));
|
|
409
|
+
if (missingLocales.length === 0) return [];
|
|
410
|
+
return [
|
|
411
|
+
{
|
|
412
|
+
file: d.file,
|
|
413
|
+
line: d.line,
|
|
414
|
+
message: `Key "${d.fullKey}" is missing locale: ${missingLocales.join(", ")}`,
|
|
415
|
+
},
|
|
416
|
+
];
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
violations: [
|
|
421
|
+
...missing.map((m) => ({
|
|
422
|
+
file: m.file,
|
|
423
|
+
line: m.line,
|
|
424
|
+
message: `used key without a definition: "${m.key}"`,
|
|
425
|
+
})),
|
|
426
|
+
...localeViolations,
|
|
427
|
+
],
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export const guard: AstGuard = {
|
|
432
|
+
name: "i18n-Keys Guard",
|
|
433
|
+
scan: SCAN,
|
|
434
|
+
hint: "Used i18n key without a definition, or missing locale — add the key/locale to the feature's translations map (locales from src/i18n-guard-locales.ts or src/marketing/locale-routes.ts).",
|
|
435
|
+
run(files) {
|
|
436
|
+
return checkI18nKeys(files);
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
if (import.meta.main) runStandalone(guard);
|