@cosmicdrift/kumiko-guards 0.386.0 → 0.388.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 (54) hide show
  1. package/LICENSE +21 -57
  2. package/package.json +4 -4
  3. package/src/_lib/agent-docs.ts +12 -7
  4. package/src/_lib/comment-trivia.ts +148 -0
  5. package/src/_lib/guard-kit.ts +2 -2
  6. package/src/_lib/guards-doc.ts +4 -1
  7. package/src/_lib/roots.ts +9 -5
  8. package/src/_lib/run-slots.ts +39 -10
  9. package/src/_lib/scan-scope.ts +1 -1
  10. package/src/_lib/security-audit.ts +15 -8
  11. package/src/_lib/sql-inventory.ts +2 -2
  12. package/src/changes.json +45 -0
  13. package/src/check-as-casts.ts +6 -7
  14. package/src/check-complexity.ts +86 -29
  15. package/src/check-licenses.ts +23 -6
  16. package/src/check-real-provider-isolation.ts +1 -1
  17. package/src/check-runtime-isolation.ts +2 -2
  18. package/src/check-security.ts +2 -1
  19. package/src/check-single-runtime-instance.ts +1 -1
  20. package/src/dependency-license-exceptions.json +0 -24
  21. package/src/guard-admin-api.ts +5 -6
  22. package/src/guard-app-feature-structure.ts +9 -9
  23. package/src/guard-comment-issue-ref-only.ts +223 -0
  24. package/src/guard-comment-lang.ts +10 -141
  25. package/src/guard-direct-entity-writes.ts +43 -31
  26. package/src/guard-direct-fetch.ts +3 -3
  27. package/src/guard-feature-integration-tests.ts +1 -1
  28. package/src/guard-file-size.ts +2 -2
  29. package/src/guard-i18n-locale-mount.ts +4 -4
  30. package/src/guard-i18n-locale-terminology.ts +3 -3
  31. package/src/guard-i18n-ui-strings.ts +5 -5
  32. package/src/guard-import-extensions.ts +34 -9
  33. package/src/guard-no-custom-primitives.ts +2 -4
  34. package/src/guard-no-direct-fs.ts +1 -1
  35. package/src/guard-no-direct-process-env.ts +2 -2
  36. package/src/guard-no-inline-styles.ts +1 -1
  37. package/src/guard-no-raw-hooks.ts +0 -2
  38. package/src/guard-primitives-discipline.ts +1 -1
  39. package/src/guard-public-exports-changelog.ts +8 -8
  40. package/src/guard-raw-classname.ts +1 -1
  41. package/src/guard-raw-interactive-elements.ts +1 -1
  42. package/src/guard-renderer-boundaries.ts +61 -9
  43. package/src/guard-restricted-symbols.ts +2 -3
  44. package/src/guard-tailwind-scan-surface.ts +28 -29
  45. package/src/guard-tenant-escalation.ts +3 -3
  46. package/src/guard-test-template-drift.ts +1 -1
  47. package/src/guard-test-timeouts.ts +12 -4
  48. package/src/guard-thin-wrappers.ts +35 -0
  49. package/src/guard-write-handler-qns.ts +1 -1
  50. package/src/pre-push.sh +22 -6
  51. package/src/repo-manifest/app-ports/static-app-config.ts +15 -11
  52. package/src/run-guards.ts +11 -4
  53. package/src/run-ui-guards.ts +1 -1
  54. package/src/runtime-isolation-classify.ts +3 -2
@@ -1,6 +1,12 @@
1
+ // Comment-language ratchet: no new German comments in product code. No NLP —
2
+ // German markers are common function words or umlaut characters in the comment text.
3
+ // Baseline mode compares per-file counts against a frozen file (more than the
4
+ // baseline fails, fewer is allowed but does not rewrite it); `--touched` fails
5
+ // on any German comment that overlaps a line added since `--base`.
6
+
1
7
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
8
  import * as path from "node:path";
3
- import { ts } from "ts-morph";
9
+ import { scanCommentTrivia } from "./_lib/comment-trivia";
4
10
  import { gitEnv } from "./_lib/git-env";
5
11
  import { ALL_REPO_KINDS, cliFlagsError, isLocalFinding } from "./_lib/guard-kit";
6
12
  import { lineHasIgnoreTag } from "./_lib/ignore-tag";
@@ -8,12 +14,6 @@ import { resolvePushRepoRoot } from "./_lib/push-root";
8
14
  import { resolveRepoRoots } from "./_lib/roots";
9
15
  import { type ScanSpec, scanFiles } from "./_lib/scan-scope";
10
16
 
11
- // Comment-language ratchet: no new German comments in product code. No NLP —
12
- // German markers are common function words or umlaut characters in the comment text.
13
- // Baseline mode compares per-file counts against a frozen file (more than the
14
- // baseline fails, fewer is allowed but does not rewrite it); `--touched` fails
15
- // on any German comment that overlaps a line added since `--base`.
16
-
17
17
  export const COMMENT_LANG_FLAGS: readonly string[] = [
18
18
  "--touched",
19
19
  "--list",
@@ -72,142 +72,11 @@ function germanCommentSite(
72
72
  };
73
73
  }
74
74
 
75
- // Tokens that end an operand without being identifiers or keywords.
76
- const OPERAND_END_PUNCTUATION_KINDS: ReadonlySet<ts.SyntaxKind> = new Set([
77
- ts.SyntaxKind.NumericLiteral,
78
- ts.SyntaxKind.BigIntLiteral,
79
- ts.SyntaxKind.StringLiteral,
80
- ts.SyntaxKind.NoSubstitutionTemplateLiteral,
81
- ts.SyntaxKind.TemplateTail,
82
- ts.SyntaxKind.RegularExpressionLiteral,
83
- ts.SyntaxKind.CloseParenToken,
84
- ts.SyntaxKind.CloseBracketToken,
85
- ts.SyntaxKind.CloseBraceToken,
86
- ts.SyntaxKind.PlusPlusToken,
87
- ts.SyntaxKind.MinusMinusToken,
88
- ]);
89
-
90
- // Reserved words after which an expression (so possibly a regex) starts.
91
- const EXPRESSION_INTRODUCING_KEYWORDS: ReadonlySet<ts.SyntaxKind> = new Set([
92
- ts.SyntaxKind.ReturnKeyword,
93
- ts.SyntaxKind.TypeOfKeyword,
94
- ts.SyntaxKind.InstanceOfKeyword,
95
- ts.SyntaxKind.InKeyword,
96
- ts.SyntaxKind.CaseKeyword,
97
- ts.SyntaxKind.VoidKeyword,
98
- ts.SyntaxKind.DeleteKeyword,
99
- ts.SyntaxKind.ThrowKeyword,
100
- ts.SyntaxKind.NewKeyword,
101
- ts.SyntaxKind.YieldKeyword,
102
- ts.SyntaxKind.AwaitKeyword,
103
- ts.SyntaxKind.ElseKeyword,
104
- ts.SyntaxKind.DoKeyword,
105
- ts.SyntaxKind.ExtendsKeyword,
106
- ts.SyntaxKind.DefaultKeyword,
107
- ]);
108
-
109
- function isIdentifierOrKeyword(kind: ts.SyntaxKind): boolean {
110
- return (
111
- kind === ts.SyntaxKind.Identifier ||
112
- (kind >= ts.SyntaxKind.FirstKeyword && kind <= ts.SyntaxKind.LastKeyword)
113
- );
114
- }
115
-
116
- // After an operand end a `/` is division, not the start of a regex literal.
117
- // Contextual keywords (`from`, `type`, `of`, ...) scan as keyword kinds but are
118
- // valid member and variable names, so every non-introducing keyword counts. A `!`
119
- // is the non-null postfix only when the token before it ended an operand.
120
- function isOperandEnd(kind: ts.SyntaxKind, previousEndedOperand: boolean): boolean {
121
- if (kind === ts.SyntaxKind.ExclamationToken) return previousEndedOperand;
122
- if (OPERAND_END_PUNCTUATION_KINDS.has(kind)) return true;
123
- return isIdentifierOrKeyword(kind) && !EXPRESSION_INTRODUCING_KEYWORDS.has(kind);
124
- }
125
-
126
- const TRIVIA_KINDS: ReadonlySet<ts.SyntaxKind> = new Set([
127
- ts.SyntaxKind.SingleLineCommentTrivia,
128
- ts.SyntaxKind.MultiLineCommentTrivia,
129
- ts.SyntaxKind.WhitespaceTrivia,
130
- ts.SyntaxKind.NewLineTrivia,
131
- ]);
132
-
133
- // The raw scanner always yields SlashToken for `/`; without the rescan a regex
134
- // body like /\{/ would shift braceDepth and end a template early. After an
135
- // operand the rescan would instead swallow `/ 2; // {` as a regex and hide the comment.
136
- function rescanSlashAsRegex(
137
- scanner: ts.Scanner,
138
- kind: ts.SyntaxKind,
139
- previousEndedOperand: boolean,
140
- ): ts.SyntaxKind {
141
- if (previousEndedOperand) return kind;
142
- const reScanned = scanner.reScanSlashToken();
143
- return reScanned === ts.SyntaxKind.RegularExpressionLiteral ? reScanned : kind;
144
- }
145
-
146
- function closesTemplateSubstitution(
147
- templateBraceDepths: readonly number[],
148
- braceDepth: number,
149
- ): boolean {
150
- return (
151
- templateBraceDepths.length > 0 &&
152
- braceDepth === templateBraceDepths[templateBraceDepths.length - 1]
153
- );
154
- }
155
-
156
- // Template substitutions can nest braces (object literals, arrow bodies, other
157
- // templates). The raw scanner tokenizes a `}` as a plain CloseBraceToken and
158
- // never resumes template mode, so reScanTemplateToken() must run once brace
159
- // depth returns to the level the substitution opened at.
160
75
  export function scanGermanComments(fullText: string, file: string): Site[] {
161
76
  const sites: Site[] = [];
162
- const scanner = ts.createScanner(
163
- ts.ScriptTarget.Latest,
164
- /* skipTrivia */ false,
165
- ts.LanguageVariant.Standard,
166
- fullText,
167
- );
168
-
169
- const templateBraceDepths: number[] = [];
170
- let braceDepth = 0;
171
- let previousEndedOperand = false;
172
-
173
- let kind = scanner.scan();
174
- while (kind !== ts.SyntaxKind.EndOfFileToken) {
175
- switch (kind) {
176
- case ts.SyntaxKind.SingleLineCommentTrivia:
177
- case ts.SyntaxKind.MultiLineCommentTrivia: {
178
- const site = germanCommentSite(
179
- fullText,
180
- file,
181
- scanner.getTokenStart(),
182
- scanner.getTokenText(),
183
- );
184
- if (site) sites.push(site);
185
- break;
186
- }
187
- case ts.SyntaxKind.SlashToken:
188
- case ts.SyntaxKind.SlashEqualsToken:
189
- kind = rescanSlashAsRegex(scanner, kind, previousEndedOperand);
190
- break;
191
- case ts.SyntaxKind.TemplateHead:
192
- case ts.SyntaxKind.TemplateMiddle:
193
- templateBraceDepths.push(braceDepth);
194
- break;
195
- case ts.SyntaxKind.OpenBraceToken:
196
- braceDepth++;
197
- break;
198
- case ts.SyntaxKind.CloseBraceToken:
199
- if (closesTemplateSubstitution(templateBraceDepths, braceDepth)) {
200
- templateBraceDepths.pop();
201
- kind = scanner.reScanTemplateToken(false);
202
- continue;
203
- }
204
- if (braceDepth > 0) braceDepth--;
205
- break;
206
- default:
207
- break;
208
- }
209
- if (!TRIVIA_KINDS.has(kind)) previousEndedOperand = isOperandEnd(kind, previousEndedOperand);
210
- kind = scanner.scan();
77
+ for (const comment of scanCommentTrivia(fullText)) {
78
+ const site = germanCommentSite(fullText, file, comment.start, comment.text);
79
+ if (site) sites.push(site);
211
80
  }
212
81
  return sites;
213
82
  }
@@ -206,10 +206,7 @@ function entityRegistrationOf(call: CallExpression): EntityRegistration | undefi
206
206
  return undefined;
207
207
  }
208
208
  const definition = call.getArguments()[0]?.asKind(SyntaxKind.ObjectLiteralExpression);
209
- const nameProp = definition?.getProperty("name");
210
- if (!definition || nameProp?.getKind() !== SyntaxKind.PropertyAssignment) return undefined;
211
- const nameValue = nameProp.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer();
212
- if (nameValue?.getKind() !== SyntaxKind.StringLiteral) return undefined;
209
+ if (!definition?.getProperty("name")) return undefined;
213
210
  return {
214
211
  entityDid: spreadEntityDefinitionId(definition),
215
212
  overrideTableDid: entityTableOverrideId(definition),
@@ -601,7 +598,7 @@ function deriveCatalogs(
601
598
  }
602
599
 
603
600
  const REWRITE_CATALOGS_COMMAND =
604
- 'bun packages/guards/src/run-guards.ts --write-baseline --guard="Direct-Entity-Writes Guard"';
601
+ 'bunx kumiko-guards guards --write-baseline --guard="Direct-Entity-Writes Guard"';
605
602
 
606
603
  // Published packages whose catalog exists on disk although they define no ES table any more.
607
604
  function publishedRootsWithCatalog(files: readonly SourceFile[]): Set<string> {
@@ -670,6 +667,13 @@ function writeCatalogs(files: readonly SourceFile[], esTables: ReadonlySet<Table
670
667
  }
671
668
  }
672
669
 
670
+ const MAX_RECEIVER_TEXT_LENGTH = 60;
671
+
672
+ // Function-form receivers are arbitrary expressions; keep the message on one greppable line.
673
+ function compactReceiverText(node: Node): string {
674
+ return node.getText().replace(/\s+/g, "").slice(0, MAX_RECEIVER_TEXT_LENGTH);
675
+ }
676
+
673
677
  export function scanDirectWrites(
674
678
  sf: SourceFile,
675
679
  esTables: ReadonlySet<TableId>,
@@ -706,7 +710,7 @@ export function scanDirectWrites(
706
710
  if (isInsideProjectionApply(call)) continue;
707
711
  out.push({
708
712
  line: call.getStartLineNumber(),
709
- receiver: w.receiver.getText(),
713
+ receiver: compactReceiverText(w.receiver),
710
714
  op: w.op,
711
715
  table: w.tableArg.getText(),
712
716
  snippet: call.getText().slice(0, 120),
@@ -717,7 +721,7 @@ export function scanDirectWrites(
717
721
 
718
722
  out.push({
719
723
  line: call.getStartLineNumber(),
720
- receiver: w.receiver.getText(),
724
+ receiver: compactReceiverText(w.receiver),
721
725
  op: w.op,
722
726
  table: w.tableArg.getText(),
723
727
  snippet: call.getText().slice(0, 120),
@@ -727,6 +731,13 @@ export function scanDirectWrites(
727
731
  return out;
728
732
  }
729
733
 
734
+ type DirectWriteViolation = {
735
+ file: string;
736
+ line: number;
737
+ message: string;
738
+ neverFrozen?: boolean;
739
+ };
740
+
730
741
  export const guard: AstGuard = {
731
742
  name: "Direct-Entity-Writes Guard",
732
743
  scan: SCAN,
@@ -738,40 +749,18 @@ export const guard: AstGuard = {
738
749
  // Merge all collections BEFORE the empty-set check — otherwise a repo that only uses r.entity
739
750
  // would skip every scan: the canary fires falsely and writes on entity tables go undetected.
740
751
  const esTables = collectScopeEsTables(files);
741
- if (esTables.size === 0) {
742
- // An empty set means either the repo has no event store at all, or the
743
- // scan lost the ES definitions while table writes are still present.
744
- if (!hasAnyTableWriteOrDeclaration(files)) return { violations: [] };
745
- return {
746
- violations: [
747
- {
748
- file: "<scan>",
749
- line: 0,
750
- message:
751
- "BLOCKED: guard found table writes or table declarations but no createEventStoreExecutor or r.entity projection tables — scan is probably misconfigured.",
752
- },
753
- ],
754
- };
755
- }
756
-
757
- const violations: Array<{
758
- file: string;
759
- line: number;
760
- message: string;
761
- neverFrozen?: boolean;
762
- }> = catalogViolations(files, esTables);
763
-
764
752
  const firstFile = files[0];
765
753
  const externalEsTables = firstFile
766
754
  ? createExternalEsTables(firstFile.getProject())
767
755
  : { has: () => false };
768
756
 
757
+ const writeViolations: DirectWriteViolation[] = [];
769
758
  for (const sf of files) {
770
759
  const file = sf.getFilePath();
771
760
  if (EXCLUDE.test(file)) continue;
772
761
 
773
762
  for (const hit of scanDirectWrites(sf, esTables, externalEsTables)) {
774
- violations.push({
763
+ writeViolations.push({
775
764
  file: path.relative(ROOT, file),
776
765
  line: hit.line,
777
766
  message: `[${hit.kind}] ${hit.receiver}.${hit.op}(${hit.table}) — ${hit.snippet}`,
@@ -779,6 +768,29 @@ export const guard: AstGuard = {
779
768
  }
780
769
  }
781
770
 
771
+ if (esTables.size === 0) {
772
+ // Writes on catalog tables of installed packages are real findings even without own ES tables.
773
+ if (writeViolations.length > 0) return { violations: writeViolations };
774
+ // An empty set means either the repo has no event store at all, or the
775
+ // scan lost the ES definitions while table writes are still present.
776
+ if (!hasAnyTableWriteOrDeclaration(files)) return { violations: [] };
777
+ return {
778
+ violations: [
779
+ {
780
+ file: "<scan>",
781
+ line: 0,
782
+ message:
783
+ "BLOCKED: guard found table writes or table declarations but no createEventStoreExecutor or r.entity projection tables — scan is probably misconfigured.",
784
+ },
785
+ ],
786
+ };
787
+ }
788
+
789
+ const violations: DirectWriteViolation[] = [
790
+ ...catalogViolations(files, esTables),
791
+ ...writeViolations,
792
+ ];
793
+
782
794
  return { violations };
783
795
  },
784
796
  writeBaseline(files) {
@@ -3,7 +3,7 @@
3
3
  * Guard: finds raw `fetch(` calls in server code outside the client-scoped
4
4
  * exceptions.
5
5
  *
6
- * `egress(policy)` (@cosmicdrift/kumiko-framework/http, framework#2147) is
6
+ * `egress(policy)` (@cosmicdrift/kumiko-framework/http) is
7
7
  * the single exported way for server code to speak outward — it enforces an
8
8
  * SSRF policy (external/internal/tenant-supplied) at the call site instead
9
9
  * of leaving that to convention. This guard fails on raw `fetch(` in server
@@ -45,7 +45,7 @@ const EXCLUDE =
45
45
  /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$|(?:^|\/)web\/|(?:^|\/)public\/)/;
46
46
 
47
47
  // Prefixed by RepoRoot.name (not kind): kind "app" is shared across
48
- // unregistered repos (infra#560).
48
+ // unregistered repos.
49
49
 
50
50
  // packages/framework/src/http/egress.ts IS the egress() implementation.
51
51
  const ALLOWLIST = [/^kumiko-framework\/packages\/framework\/src\/http\/egress\.ts$/];
@@ -59,7 +59,7 @@ interface Violation {
59
59
  }
60
60
 
61
61
  // `self` omitted: Worker globals live under web/public (excluded); a local
62
- // `const self = this` binding was a systematic FP (infra#582).
62
+ // `const self = this` binding was a systematic FP.
63
63
  const GLOBAL_FETCH_RECEIVERS = new Set(["globalThis", "window"]);
64
64
 
65
65
  // The marker is a per-line skip for a security guard, so it only counts with a
@@ -115,7 +115,7 @@ function collectImportedFeatureIds(project: Project, roots: readonly RepoRoot[])
115
115
  }
116
116
 
117
117
  /**
118
- * Baseline from the infra#436 measurement: features with no integration
118
+ * Baseline from the measurement: features with no integration
119
119
  * test reaching their directory at all — only covered (if at all) by unit
120
120
  * tests (`feature.test.ts`). Pre-existing at the first sharp run, not
121
121
  * introduced by this change. Backfilling is its own scope per feature, not
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * File Size Guard — production files above 2000 lines are frozen in
3
+ * File Size Guard — production files above 1200 lines are frozen in
4
4
  * `.kumiko-file-size-baseline.json` (key = file, value = line count) and may
5
5
  * not grow; a new file above the threshold is a violation. Without a baseline
6
6
  * file (consumer repos) it only warns.
@@ -31,7 +31,7 @@ const SCAN: ScanSpec = {
31
31
  frameworkWithin: ["packages/*/src/**"],
32
32
  };
33
33
 
34
- export const MAX_FILE_LINES = 2000;
34
+ export const MAX_FILE_LINES = 1200;
35
35
 
36
36
  const EXCLUDE =
37
37
  /(__tests__|\.test\.(ts|tsx)$|\.integration\.(ts|tsx)$|\.d\.(ts|tsx)$|\.generated\.(ts|tsx)$|\/generated\/|\/locale-[^/]+\/src\/strings\.ts$)/;
@@ -6,7 +6,7 @@
6
6
  * guard-i18n-keys.ts only checks whether a used t() key is defined —
7
7
  * and has no concept of a "mount point". A mount that never calls
8
8
  * localeDeClient() (client) or localeDe() (server) slips through there
9
- * completely (kumiko-studio#191, publicstatus#365, infra#533).
9
+ * completely.
10
10
  *
11
11
  * Scope gate: the package.json closest to the file must point at
12
12
  * kumiko-locale-de (currently kumiko-studio, publicstatus, solon,
@@ -83,9 +83,9 @@ function gateFor(sf: SourceFile, cache: Map<string, Gate>): Gate | undefined {
83
83
  return gate;
84
84
  }
85
85
 
86
- // localeDeClient() (clientFeatures) matcht per Call-Text; `{ de: ... }` deckt
87
- // die rohe <LocaleProvider fallbackBundles={[{ de: bundle }, ...]}>-Komposition
88
- // ab, die keinen localeDeClient()-Call verwendet.
86
+ // localeDeClient() in clientFeatures is matched by call text; `{ de: ... }` also
87
+ // covers a raw <LocaleProvider fallbackBundles={[{ de: bundle }, ...]}> composition
88
+ // that does not use a localeDeClient() call.
89
89
  function localeDeClientRef(node: Node): boolean {
90
90
  if (node.isKind(SyntaxKind.Identifier) && node.getText() === "localeDeClient") return true;
91
91
  if (node.isKind(SyntaxKind.CallExpression)) return localeDeClientRef(node.getExpression());
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Guard: locale bundle values must follow Mandant/Organización terminology (#2200, fw#2355).
3
+ * Guard: locale bundle values must follow Mandant/Organización terminology.
4
4
  *
5
5
  * German UI copy always says "Mandant" (never "Tenant"/"Organisation"); Spanish, French
6
6
  * and Italian always say "Organización"/"organisation"/"organizzazione" (never the
@@ -50,8 +50,8 @@ const RULES: Record<TerminologyLocale, LocaleRule> = {
50
50
  de: {
51
51
  preferred: "Mandant",
52
52
  // Tenants? covers plural; Organisation(en|s)? without trailing \b so
53
- // compounds like Organisations-ID / Organisationsstruktur still match
54
- // (infra#603). TenantAdmin stays safe: \b after Tenant fails on 'A'.
53
+ // compounds like Organisations-ID / Organisationsstruktur still match.
54
+ // TenantAdmin stays safe: \b after Tenant fails on 'A'.
55
55
  forbidden: [/\bTenants?\b/i, /\bOrganisation(en|s)?/i],
56
56
  },
57
57
  es: {
@@ -4,7 +4,7 @@
4
4
  // that the keys are defined — this guard closes the gap before that:
5
5
  // strings that never became keys, e.g. "Loading tenants…").
6
6
  //
7
- // Part of App-Mounting 2.0 (infra#208).
7
+ // Part of App-Mounting 2.0.
8
8
 
9
9
  import { type CallExpression, type Node, type SourceFile, SyntaxKind } from "ts-morph";
10
10
  import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
@@ -13,7 +13,7 @@ import { hasIgnoreTag } from "./_lib/ignore-tag";
13
13
  // Web/public code only — server code (handler error texts) goes through the
14
14
  // error i18n pipeline and has its own guards.
15
15
  //
16
- // samples/ deliberately NOT scanned (tried in infra#478, rolled back): the
16
+ // samples/ deliberately NOT scanned (tried and rolled back): the
17
17
  // showcase/gallery pages are dev docs about the framework API itself
18
18
  // ("Form.title / toolbarTitle", "children — auto from schema.navs"), not
19
19
  // app UI text a real user sees. 354 hits in the trial run, almost all such
@@ -21,7 +21,7 @@ import { hasIgnoreTag } from "./_lib/ignore-tag";
21
21
  // r.translations definitions); this guard stays scoped to app repos.
22
22
  //
23
23
  // src/features/**/feature.ts covers feature.ts registrar calls (r.nav/r.screen
24
- // object-literal arguments) — infra#504: those sit outside JSX and were a
24
+ // object-literal arguments) — those sit outside JSX and were a
25
25
  // blind spot. Other server code (handlers/, lib/) has its own i18n pipeline
26
26
  // and is filtered out below by requiring an r.*-call ancestor.
27
27
  const SCAN: ScanSpec = {
@@ -131,7 +131,7 @@ function collectTernaryLogicText(node: Node): TextPiece[] {
131
131
  return textPieces(node);
132
132
  }
133
133
 
134
- // infra#711: a hop out of a helper is only taken while that helper call is
134
+ // A hop out of a helper is only taken while that helper call is
135
135
  // itself an argument of the next call — a call nested deeper (inside an object
136
136
  // or array, e.g. `createTextField({ label })` under `createEntity({ fields })`)
137
137
  // builds a value instead of forwarding the registrar's argument. The full chain
@@ -220,7 +220,7 @@ function checkRegistrarLabelProperties(sf: SourceFile, violations: GuardViolatio
220
220
  if (!HUMAN_TEXT.test(value) || I18N_KEY_SHAPE.test(value)) continue;
221
221
  const callChain = registrarCallChain(prop);
222
222
  if (callChain === undefined) continue;
223
- // The agent-doc slot (kumiko-framework#2615) is LLM metadata for the
223
+ // The agent-doc slot is LLM metadata for the
224
224
  // AI tool catalog: English by design, never rendered as UI text. The
225
225
  // slot is the options object handed to a call on the registrar
226
226
  // argument chain, so forwarding it through a `defineEntity*Handler`
@@ -14,7 +14,8 @@
14
14
  * Exit 1 on violations, 0 when clean.
15
15
  */
16
16
 
17
- import { type Node, type SourceFile, SyntaxKind } from "ts-morph";
17
+ import { dirname, resolve } from "node:path";
18
+ import { Node, type SourceFile, SyntaxKind } from "ts-morph";
18
19
  import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
19
20
 
20
21
  const SCAN: ScanSpec = {
@@ -23,7 +24,7 @@ const SCAN: ScanSpec = {
23
24
  };
24
25
 
25
26
  const EXCLUDE = /(\.d\.ts$|\/fixtures\/)/;
26
- const RELATIVE_JS_SPECIFIER = /^\.{1,2}\/.*\.jsx?$/;
27
+ const RELATIVE_JS_SPECIFIER = /^(\.{1,2}\/.*)\.(jsx?)$/;
27
28
  // A published bin that imports its own build output (`../dist/cli.js`) has no
28
29
  // `.ts` sibling to name; the `.js` there is the only file that exists.
29
30
  const BUILD_OUTPUT_SEGMENT = /(^|\/)dist\//;
@@ -33,17 +34,34 @@ interface Finding {
33
34
  readonly specifier: string;
34
35
  }
35
36
 
36
- function relativeJsSpecifier(node: Node | undefined): string | undefined {
37
- const literal = node?.asKind(SyntaxKind.StringLiteral);
38
- const value = literal?.getLiteralValue();
39
- if (value === undefined || !RELATIVE_JS_SPECIFIER.test(value)) return undefined;
40
- return BUILD_OUTPUT_SEGMENT.test(value) ? undefined : value;
37
+ // Mirrors the import-extensions codemod: only a specifier with a `.ts`/`.tsx` sibling is fixable.
38
+ // A genuine `.js` file (generated or vendored) has nothing to rename to and must not fail the guard.
39
+ function hasSourceSibling(sf: SourceFile, base: string, extension: string): boolean {
40
+ const candidates = extension === "jsx" ? ["tsx"] : ["ts", "tsx"];
41
+ const project = sf.getProject();
42
+ return candidates.some((candidate) => {
43
+ const path = resolve(dirname(sf.getFilePath()), `${base}.${candidate}`);
44
+ return (
45
+ project.getSourceFile(path) !== undefined || project.getFileSystem().fileExistsSync(path)
46
+ );
47
+ });
48
+ }
49
+
50
+ function relativeJsSpecifier(sf: SourceFile, node: Node | undefined): string | undefined {
51
+ const value =
52
+ node !== undefined && (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node))
53
+ ? node.getLiteralValue()
54
+ : undefined;
55
+ if (value === undefined || BUILD_OUTPUT_SEGMENT.test(value)) return undefined;
56
+ const [, base = "", extension = ""] = RELATIVE_JS_SPECIFIER.exec(value) ?? [];
57
+ if (base === "" || !hasSourceSibling(sf, base, extension)) return undefined;
58
+ return value;
41
59
  }
42
60
 
43
61
  function findJsSpecifiers(sf: SourceFile): Finding[] {
44
62
  const findings: Finding[] = [];
45
63
  const add = (node: Node | undefined): void => {
46
- const specifier = relativeJsSpecifier(node);
64
+ const specifier = relativeJsSpecifier(sf, node);
47
65
  if (node !== undefined && specifier !== undefined) {
48
66
  findings.push({ line: node.getStartLineNumber(), specifier });
49
67
  }
@@ -51,8 +69,15 @@ function findJsSpecifiers(sf: SourceFile): Finding[] {
51
69
  for (const declaration of [...sf.getImportDeclarations(), ...sf.getExportDeclarations()]) {
52
70
  add(declaration.getModuleSpecifier());
53
71
  }
72
+ for (const importType of sf.getDescendantsOfKind(SyntaxKind.ImportType)) {
73
+ const argument = importType.getArgument();
74
+ if (Node.isLiteralTypeNode(argument)) add(argument.getLiteral());
75
+ }
54
76
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
55
- if (call.getExpression().getKind() === SyntaxKind.ImportKeyword) add(call.getArguments()[0]);
77
+ const callee = call.getExpression();
78
+ if (callee.getKind() === SyntaxKind.ImportKeyword || callee.getText() === "mock.module") {
79
+ add(call.getArguments()[0]);
80
+ }
56
81
  }
57
82
  return findings;
58
83
  }
@@ -4,7 +4,7 @@
4
4
  // exist in the framework (usePrimitives() resp. widgets/) — app rebuilds
5
5
  // drift visually and cost every migration twice.
6
6
  //
7
- // Second rule (infra#748): raw form HTML (<input>/<select>/<textarea>/
7
+ // Second rule: raw form HTML (<input>/<select>/<textarea>/
8
8
  // <label>) in app/feature web code, regardless of what the file imports —
9
9
  // an import gate here previously let the designer's raw <input>s pass just
10
10
  // by importing from @cosmicdrift/kumiko-renderer (types only) instead of
@@ -15,8 +15,6 @@
15
15
  // closed against an empty baseline while no baseline file exists —
16
16
  // otherwise the rule would stay silent in every app repo until someone
17
17
  // manually runs --write-baseline there.
18
- //
19
- // Part of App-Mounting 2.0 (infra#208).
20
18
 
21
19
  import { existsSync } from "node:fs";
22
20
  import * as path from "node:path";
@@ -93,7 +91,7 @@ const RAW_FORM_HTML_REMEDIATION =
93
91
  `or usePrimitives(). Real exception: // ${IGNORE_TAG} <reason>`;
94
92
 
95
93
  // The rule is new: measured backlog is 0 in every locally checked repo
96
- // (infra#748). Without a baseline file, check fail-closed against an empty
94
+ // . Without a baseline file, check fail-closed against an empty
97
95
  // baseline instead of (as usual) warn-only until the first
98
96
  // `--write-baseline` — otherwise the rule would stay silent in every app
99
97
  // repo until it separately commits its own baseline file (every baseline
@@ -90,7 +90,7 @@ const ALLOWLIST: readonly AllowEntry[] = [
90
90
  // Framework: migrations/schema tooling, DB codegen, boot validator —
91
91
  // CLI/build time, no request input.
92
92
  { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/migrations\/kumiko-drift\.ts$/ },
93
- { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/schema-cli\.ts$/ },
93
+ { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/schema-cli(\.ts|\/[^/]+\.ts)$/ },
94
94
  // same CLI-tooling pattern as schema-cli.ts — repo-tree/node_modules walking for the upgrade command, not app-level file storage
95
95
  { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/upgrade-cli\.ts$/ },
96
96
  { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/es-ops\/runner\.ts$/ },
@@ -59,7 +59,7 @@ export function isScannableBinFile(rel: string): boolean {
59
59
  // convention at all — "solon" is deliberately not on it) and intersected
60
60
  // with the resolved roots, so a restricted scope actually narrows what this
61
61
  // guard scans instead of always walking every repo regardless of scope
62
- // (infra#721 follow-up — a money-horse-scoped push must not fail on a
62
+ // (follow-up — a money-horse-scoped push must not fail on a
63
63
  // finding in publicstatus).
64
64
  const CANDIDATE_ENV_TS_REPOS: ReadonlySet<string> = new Set([
65
65
  "kumiko-studio",
@@ -81,7 +81,7 @@ export function findDirectProcessEnv(roots?: ReadonlyArray<RepoRoot>): ProcessEn
81
81
  /**
82
82
  * Like findDirectProcessEnv, additionally reports the number of files read
83
83
  * and repos with bin/env.ts — without that an empty scan can't be told apart
84
- * from a clean one (infra#433).
84
+ * from a clean one.
85
85
  */
86
86
  export function scanDirectProcessEnv(roots: ReadonlyArray<RepoRoot> = resolveRepoRoots()): {
87
87
  readonly findings: ProcessEnvFinding[];
@@ -2,7 +2,7 @@
2
2
  // No inline CSS in app code: `style={...}` props and `React.CSSProperties`
3
3
  // style objects bypass theme tokens and dark mode. Use widgets/tokens.
4
4
  //
5
- // Part of App-Mounting 2.0 (infra#208).
5
+ // Part of App-Mounting 2.0.
6
6
 
7
7
  import { type SourceFile, SyntaxKind } from "ts-morph";
8
8
  import {
@@ -7,8 +7,6 @@
7
7
  // pattern that produces them.
8
8
  //
9
9
  // Local UI state via useState stays allowed.
10
- //
11
- // Part of App-Mounting 2.0 (infra#208).
12
10
 
13
11
  import { Node, type SourceFile, SyntaxKind } from "ts-morph";
14
12
  import {
@@ -96,7 +96,7 @@ type ScanScope = {
96
96
 
97
97
  // Framework checkout enforces bundled-features + samples. App-repo checkout
98
98
  // scans the app's web/ screens — primitives-discipline as a ratchet per app
99
- // repo (infra#224). Every other resolved repo (enterprise, platform, ...)
99
+ // repo. Every other resolved repo (enterprise, platform, ...)
100
100
  // scans alongside the others in one run — flatMap all of them instead of
101
101
  // cascading, which would silently skip every root after the first, exactly
102
102
  // the silent-skip this guard fixes.
@@ -1,11 +1,3 @@
1
- import { spawnSync } from "node:child_process";
2
- import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { join, resolve } from "node:path";
4
- import { pathToFileURL } from "node:url";
5
- import { Project } from "ts-morph";
6
- import { cliFlagsError, type GuardViolation, reportResults } from "./_lib/guard-kit";
7
- import { frameworkTsConfigPath, resolveRepoRoots } from "./_lib/roots";
8
-
9
1
  // Public exports of packages/framework and packages/bundled-features must not
10
2
  // disappear (rename/removal) without a breaking changelog entry.
11
3
  //
@@ -30,6 +22,14 @@ import { frameworkTsConfigPath, resolveRepoRoots } from "./_lib/roots";
30
22
  // has ONE changelog (src/changes.json); packages/bundled-features has one PER
31
23
  // FEATURE (src/<feature>/changes.json, feature = first export-key segment).
32
24
 
25
+ import { spawnSync } from "node:child_process";
26
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
27
+ import { join, resolve } from "node:path";
28
+ import { pathToFileURL } from "node:url";
29
+ import { Project } from "ts-morph";
30
+ import { cliFlagsError, type GuardViolation, reportResults } from "./_lib/guard-kit";
31
+ import { frameworkTsConfigPath, resolveRepoRoots } from "./_lib/roots";
32
+
33
33
  export const PUBLIC_EXPORTS_CHANGELOG_FLAGS: readonly string[] = [
34
34
  "--write-baseline",
35
35
  "--no-baseline",
@@ -5,7 +5,7 @@
5
5
  // Layout utilities (flex/grid/gap/p-/m-/w-) and token classes
6
6
  // (bg-primary, text-status-ok, …) stay allowed.
7
7
  //
8
- // Part of App-Mounting 2.0 (infra#208). Enabled per app repo in the
8
+ // Part of App-Mounting 2.0. Enabled per app repo in the
9
9
  // respective migration PR (ui-guards input in _app-test.yml).
10
10
 
11
11
  import { type JsxAttribute, type SourceFile, SyntaxKind } from "ts-morph";