@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.
Files changed (52) hide show
  1. package/package.json +4 -1
  2. package/src/_lib/guard-kit.ts +74 -19
  3. package/src/_lib/qn.ts +21 -0
  4. package/src/_lib/security-baseline-cli.ts +3 -3
  5. package/src/_lib/security-baseline.ts +8 -8
  6. package/src/changes.json +32 -0
  7. package/src/check-as-casts.ts +648 -0
  8. package/src/check-complexity.ts +292 -0
  9. package/src/check-predicates.ts +218 -0
  10. package/src/check-secret-literals.ts +126 -0
  11. package/src/cli.ts +59 -0
  12. package/src/guard-admin-api.ts +1 -1
  13. package/src/guard-app-feature-structure.ts +114 -0
  14. package/src/guard-broker-subscribe.ts +99 -0
  15. package/src/guard-error-reasons.ts +185 -0
  16. package/src/guard-escape-hatch-declared.ts +26 -19
  17. package/src/guard-fake-tests.ts +1 -1
  18. package/src/guard-feature-integration-tests.ts +184 -0
  19. package/src/guard-html-escape.ts +1 -1
  20. package/src/guard-i18n-keys.ts +440 -0
  21. package/src/guard-i18n-locale-mount.ts +317 -0
  22. package/src/guard-i18n-locale-terminology.ts +117 -0
  23. package/src/guard-i18n-ui-strings.ts +248 -0
  24. package/src/guard-lib-test-coverage.ts +156 -0
  25. package/src/guard-loadall-events.ts +133 -0
  26. package/src/guard-no-custom-primitives.ts +9 -10
  27. package/src/guard-no-date-api.ts +1 -1
  28. package/src/guard-no-direct-fs.ts +1 -1
  29. package/src/guard-no-inline-styles.ts +4 -4
  30. package/src/guard-no-logic-in-views.ts +3 -3
  31. package/src/guard-no-raw-hooks.ts +4 -5
  32. package/src/guard-open-to-all-reason.ts +1 -1
  33. package/src/guard-pii-annotations.ts +267 -0
  34. package/src/guard-pre-es-patterns.ts +1 -1
  35. package/src/guard-primitives-discipline.ts +3 -3
  36. package/src/guard-raw-classname.ts +3 -3
  37. package/src/guard-raw-interactive-elements.ts +3 -3
  38. package/src/guard-raw-sql.ts +2 -2
  39. package/src/guard-renderer-boundaries.ts +1 -1
  40. package/src/guard-restricted-symbols.ts +1 -1
  41. package/src/guard-screen-conventions.ts +161 -0
  42. package/src/guard-silent-skip.ts +1 -1
  43. package/src/guard-table-ddl.ts +159 -0
  44. package/src/guard-tailwind-scan-surface.ts +12 -12
  45. package/src/guard-test-stack-drift.ts +147 -0
  46. package/src/guard-text-field-stance.ts +222 -0
  47. package/src/guard-thin-wrappers.ts +6 -1
  48. package/src/guard-unsafe-json-parse.ts +1 -1
  49. package/src/guard-write-handler-qns.ts +242 -0
  50. package/src/run-guards.ts +36 -3
  51. package/src/run-repo-checks.ts +10 -1
  52. 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
+ }
@@ -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: "Interpolation in HTML-Template-Literal escapen: escapeHtml()/escapeHtmlAttr() aus @cosmicdrift/kumiko-headless. Vorgerendertes HTML per `*Html`-Namen kennzeichnen; statische Copy-Tabellen `as const` typen; bewusste Ausnahme mit `// html-ok: <warum>`.",
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);