@cosmicdrift/kumiko-guards 0.385.0 → 0.387.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 (37) hide show
  1. package/LICENSE +21 -57
  2. package/package.json +4 -4
  3. package/src/_lib/comment-trivia.ts +148 -0
  4. package/src/_lib/guard-kit.ts +2 -2
  5. package/src/_lib/scan-scope.ts +1 -1
  6. package/src/_lib/sql-inventory.ts +2 -2
  7. package/src/changes.json +7 -0
  8. package/src/check-as-casts.ts +6 -7
  9. package/src/check-complexity.ts +0 -2
  10. package/src/check-real-provider-isolation.ts +1 -1
  11. package/src/check-runtime-isolation.ts +2 -2
  12. package/src/check-single-runtime-instance.ts +1 -1
  13. package/src/guard-admin-api.ts +5 -6
  14. package/src/guard-app-feature-structure.ts +0 -2
  15. package/src/guard-comment-issue-ref-only.ts +221 -0
  16. package/src/guard-comment-lang.ts +4 -135
  17. package/src/guard-direct-fetch.ts +3 -3
  18. package/src/guard-feature-integration-tests.ts +1 -1
  19. package/src/guard-i18n-locale-mount.ts +4 -4
  20. package/src/guard-i18n-locale-terminology.ts +3 -3
  21. package/src/guard-i18n-ui-strings.ts +5 -5
  22. package/src/guard-no-custom-primitives.ts +2 -4
  23. package/src/guard-no-direct-process-env.ts +2 -2
  24. package/src/guard-no-inline-styles.ts +1 -1
  25. package/src/guard-no-raw-hooks.ts +0 -2
  26. package/src/guard-primitives-discipline.ts +1 -1
  27. package/src/guard-raw-classname.ts +1 -1
  28. package/src/guard-raw-interactive-elements.ts +1 -1
  29. package/src/guard-restricted-symbols.ts +2 -3
  30. package/src/guard-tailwind-scan-surface.ts +28 -29
  31. package/src/guard-tenant-escalation.ts +3 -3
  32. package/src/guard-test-template-drift.ts +1 -1
  33. package/src/guard-test-timeouts.ts +1 -1
  34. package/src/guard-write-handler-qns.ts +1 -1
  35. package/src/run-guards.ts +2 -0
  36. package/src/run-ui-guards.ts +1 -1
  37. package/src/runtime-isolation-classify.ts +1 -1
package/LICENSE CHANGED
@@ -1,57 +1,21 @@
1
- Business Source License 1.1
2
-
3
- Parameters
4
-
5
- Licensor: Marc Frost
6
-
7
- Licensed Work: @cosmicdrift/kumiko-guards
8
- The Licensed Work is © 2026 Marc Frost.
9
-
10
- Additional Use Grant:
11
- You may use the Licensed Work in production for any purpose, including
12
- commercially, EXCEPT for the Restricted Use.
13
-
14
- "Restricted Use" is defined as using the Licensed Work to provide a platform
15
- or service to third parties that allows them to host, deploy, or run their
16
- own applications built with the Licensed Work. This includes, but is not
17
- limited to: managed hosting services, software-as-a-service (SaaS) platforms,
18
- platform-as-a-service (PaaS), developer platforms, or any multi-tenant
19
- managed offering of the Licensed Work.
20
-
21
- This restriction does not apply to the Licensor, any entity controlled by,
22
- controlling, or under common control with the Licensor ("Affiliates"), or
23
- contractors acting on their behalf. The Licensor remains free to use the
24
- Licensed Work for any purpose, including for the operation of kumiko.rocks.
25
-
26
- Change Date: 2030-05-05
27
- Change License: Apache License, Version 2.0
28
-
29
-
30
- Terms
31
-
32
- The Licensor hereby grants you the right to copy, modify, create derivative works,
33
- redistribute, and make non-production use of the Licensed Work. The Licensor may
34
- make an Additional Use Grant, above, permitting limited production use.
35
-
36
- Effective on the Change Date, or the fourth anniversary of the first publicly
37
- available distribution of the Licensed Work under this License, whichever comes
38
- first, this License will convert to the Change License.
39
-
40
- This Business Source License governs use of the Licensed Work in all cases, except
41
- as to any use that is explicitly granted in the Additional Use Grant above or
42
- under the Change License after the Change Date.
43
-
44
- If your use of the Licensed Work does not comply with the requirements of this
45
- License, you must cease use of the Licensed Work immediately.
46
-
47
- All copies of the Licensed Work, and all derivative works thereof, must include
48
- this License.
49
-
50
- This License does not grant you any right, title, or interest in any trademark,
51
- logo, or branding of the Licensor, except as required to comply with this License.
52
-
53
- TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN
54
- “AS IS” BASIS. LICENSOR DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
55
- WITHOUT LIMITATION WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
56
- TITLE, AND NON-INFRINGEMENT. IN NO EVENT WILL LICENSOR BE LIABLE FOR ANY DAMAGES
57
- ARISING OUT OF OR RELATED TO THIS LICENSE OR THE USE OF THE LICENSED WORK.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marc Frost
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.385.0",
3
+ "version": "0.387.0",
4
4
  "description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
5
- "license": "BUSL-1.1",
5
+ "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
7
7
  "repository": {
8
8
  "type": "git",
@@ -51,7 +51,7 @@
51
51
  "LICENSE"
52
52
  ],
53
53
  "peerDependencies": {
54
- "@cosmicdrift/kumiko-framework": "0.385.0"
54
+ "@cosmicdrift/kumiko-framework": "0.387.0"
55
55
  },
56
56
  "peerDependenciesMeta": {
57
57
  "@cosmicdrift/kumiko-framework": {
@@ -59,6 +59,6 @@
59
59
  }
60
60
  },
61
61
  "devDependencies": {
62
- "@cosmicdrift/kumiko-framework": "0.385.0"
62
+ "@cosmicdrift/kumiko-framework": "0.387.0"
63
63
  }
64
64
  }
@@ -0,0 +1,148 @@
1
+ import { ts } from "ts-morph";
2
+
3
+ export const COMMENT_KIND_LINE = "line";
4
+ export const COMMENT_KIND_BLOCK = "block";
5
+
6
+ export type CommentTrivia = {
7
+ readonly start: number;
8
+ readonly kind: typeof COMMENT_KIND_LINE | typeof COMMENT_KIND_BLOCK;
9
+ readonly text: string;
10
+ };
11
+
12
+ // Tokens that end an operand without being identifiers or keywords.
13
+ const OPERAND_END_PUNCTUATION_KINDS: ReadonlySet<ts.SyntaxKind> = new Set([
14
+ ts.SyntaxKind.NumericLiteral,
15
+ ts.SyntaxKind.BigIntLiteral,
16
+ ts.SyntaxKind.StringLiteral,
17
+ ts.SyntaxKind.NoSubstitutionTemplateLiteral,
18
+ ts.SyntaxKind.TemplateTail,
19
+ ts.SyntaxKind.RegularExpressionLiteral,
20
+ ts.SyntaxKind.CloseParenToken,
21
+ ts.SyntaxKind.CloseBracketToken,
22
+ ts.SyntaxKind.CloseBraceToken,
23
+ ts.SyntaxKind.PlusPlusToken,
24
+ ts.SyntaxKind.MinusMinusToken,
25
+ ]);
26
+
27
+ // Reserved words after which an expression (so possibly a regex) starts.
28
+ const EXPRESSION_INTRODUCING_KEYWORDS: ReadonlySet<ts.SyntaxKind> = new Set([
29
+ ts.SyntaxKind.ReturnKeyword,
30
+ ts.SyntaxKind.TypeOfKeyword,
31
+ ts.SyntaxKind.InstanceOfKeyword,
32
+ ts.SyntaxKind.InKeyword,
33
+ ts.SyntaxKind.CaseKeyword,
34
+ ts.SyntaxKind.VoidKeyword,
35
+ ts.SyntaxKind.DeleteKeyword,
36
+ ts.SyntaxKind.ThrowKeyword,
37
+ ts.SyntaxKind.NewKeyword,
38
+ ts.SyntaxKind.YieldKeyword,
39
+ ts.SyntaxKind.AwaitKeyword,
40
+ ts.SyntaxKind.ElseKeyword,
41
+ ts.SyntaxKind.DoKeyword,
42
+ ts.SyntaxKind.ExtendsKeyword,
43
+ ts.SyntaxKind.DefaultKeyword,
44
+ ]);
45
+
46
+ function isIdentifierOrKeyword(kind: ts.SyntaxKind): boolean {
47
+ return (
48
+ kind === ts.SyntaxKind.Identifier ||
49
+ (kind >= ts.SyntaxKind.FirstKeyword && kind <= ts.SyntaxKind.LastKeyword)
50
+ );
51
+ }
52
+
53
+ // After an operand end a `/` is division, not the start of a regex literal.
54
+ // Contextual keywords (`from`, `type`, `of`, ...) scan as keyword kinds but are
55
+ // valid member and variable names, so every non-introducing keyword counts. A `!`
56
+ // is the non-null postfix only when the token before it ended an operand.
57
+ function isOperandEnd(kind: ts.SyntaxKind, previousEndedOperand: boolean): boolean {
58
+ if (kind === ts.SyntaxKind.ExclamationToken) return previousEndedOperand;
59
+ if (OPERAND_END_PUNCTUATION_KINDS.has(kind)) return true;
60
+ return isIdentifierOrKeyword(kind) && !EXPRESSION_INTRODUCING_KEYWORDS.has(kind);
61
+ }
62
+
63
+ const TRIVIA_KINDS: ReadonlySet<ts.SyntaxKind> = new Set([
64
+ ts.SyntaxKind.SingleLineCommentTrivia,
65
+ ts.SyntaxKind.MultiLineCommentTrivia,
66
+ ts.SyntaxKind.WhitespaceTrivia,
67
+ ts.SyntaxKind.NewLineTrivia,
68
+ ]);
69
+
70
+ // The raw scanner always yields SlashToken for `/`; without the rescan a regex
71
+ // body like /\{/ would shift braceDepth and end a template early. After an
72
+ // operand the rescan would instead swallow `/ 2; // {` as a regex and hide the comment.
73
+ function rescanSlashAsRegex(
74
+ scanner: ts.Scanner,
75
+ kind: ts.SyntaxKind,
76
+ previousEndedOperand: boolean,
77
+ ): ts.SyntaxKind {
78
+ if (previousEndedOperand) return kind;
79
+ const reScanned = scanner.reScanSlashToken();
80
+ return reScanned === ts.SyntaxKind.RegularExpressionLiteral ? reScanned : kind;
81
+ }
82
+
83
+ function closesTemplateSubstitution(
84
+ templateBraceDepths: readonly number[],
85
+ braceDepth: number,
86
+ ): boolean {
87
+ return (
88
+ templateBraceDepths.length > 0 &&
89
+ braceDepth === templateBraceDepths[templateBraceDepths.length - 1]
90
+ );
91
+ }
92
+
93
+ // Template substitutions can nest braces (object literals, arrow bodies, other
94
+ // templates). The raw scanner tokenizes a `}` as a plain CloseBraceToken and
95
+ // never resumes template mode, so reScanTemplateToken() must run once brace
96
+ // depth returns to the level the substitution opened at.
97
+ export function scanCommentTrivia(fullText: string): CommentTrivia[] {
98
+ const comments: CommentTrivia[] = [];
99
+ const scanner = ts.createScanner(
100
+ ts.ScriptTarget.Latest,
101
+ /* skipTrivia */ false,
102
+ ts.LanguageVariant.Standard,
103
+ fullText,
104
+ );
105
+
106
+ const templateBraceDepths: number[] = [];
107
+ let braceDepth = 0;
108
+ let previousEndedOperand = false;
109
+
110
+ let kind = scanner.scan();
111
+ while (kind !== ts.SyntaxKind.EndOfFileToken) {
112
+ switch (kind) {
113
+ case ts.SyntaxKind.SingleLineCommentTrivia:
114
+ case ts.SyntaxKind.MultiLineCommentTrivia:
115
+ comments.push({
116
+ start: scanner.getTokenStart(),
117
+ kind:
118
+ kind === ts.SyntaxKind.SingleLineCommentTrivia ? COMMENT_KIND_LINE : COMMENT_KIND_BLOCK,
119
+ text: scanner.getTokenText(),
120
+ });
121
+ break;
122
+ case ts.SyntaxKind.SlashToken:
123
+ case ts.SyntaxKind.SlashEqualsToken:
124
+ kind = rescanSlashAsRegex(scanner, kind, previousEndedOperand);
125
+ break;
126
+ case ts.SyntaxKind.TemplateHead:
127
+ case ts.SyntaxKind.TemplateMiddle:
128
+ templateBraceDepths.push(braceDepth);
129
+ break;
130
+ case ts.SyntaxKind.OpenBraceToken:
131
+ braceDepth++;
132
+ break;
133
+ case ts.SyntaxKind.CloseBraceToken:
134
+ if (closesTemplateSubstitution(templateBraceDepths, braceDepth)) {
135
+ templateBraceDepths.pop();
136
+ kind = scanner.reScanTemplateToken(false);
137
+ continue;
138
+ }
139
+ if (braceDepth > 0) braceDepth--;
140
+ break;
141
+ default:
142
+ break;
143
+ }
144
+ if (!TRIVIA_KINDS.has(kind)) previousEndedOperand = isOperandEnd(kind, previousEndedOperand);
145
+ kind = scanner.scan();
146
+ }
147
+ return comments;
148
+ }
@@ -339,7 +339,7 @@ export type RunResult = {
339
339
  readonly scanKinds?: readonly string[];
340
340
  /** Files the scan matched — the runner's own count, basis of the floor. */
341
341
  readonly matchedFiles?: number;
342
- /** Roots whose declared sourceRoots hold no .ts/.tsx at all (D4, infra#427). */
342
+ /** Roots whose declared sourceRoots hold no .ts/.tsx at all. */
343
343
  readonly violatingRoots?: readonly string[];
344
344
  /** Violations excused by the per-repo security baseline — security guards only. */
345
345
  readonly frozenFindings?: number;
@@ -597,7 +597,7 @@ function guardKitVersion(): string {
597
597
 
598
598
  /**
599
599
  * Pure preflight verdict, injectable for tests: which of the two globally
600
- * silent-green cases (infra#2863) applies, if any. Not to be confused with a
600
+ * silent-green cases applies, if any. Not to be confused with a
601
601
  * single guard finding no target repos — that stays a per-guard `skipped`
602
602
  * line in `reportResults`.
603
603
  */
@@ -35,7 +35,7 @@ export type RootScan = {
35
35
  readonly root: RepoRoot;
36
36
  /** Absolute paths handed to the guard, sorted. */
37
37
  readonly files: readonly string[];
38
- /** All .ts/.tsx under the declared sourceRoots minus excludes, before extension/within filters — the D4 floor input. */
38
+ /** All .ts/.tsx under the declared sourceRoots minus excludes, before extension/within filters — the floor input. */
39
39
  readonly sourceSurface: number;
40
40
  };
41
41
 
@@ -58,7 +58,7 @@ export type SqlInventoryReport = {
58
58
  };
59
59
  };
60
60
 
61
- /** Paths where `.unsafe()` / `asRawClient()` are permitted (Phase 5 guard). */
61
+ /** Paths where `.unsafe()` / `asRawClient()` are permitted. */
62
62
  export const RAW_SQL_ALLOWLIST: ReadonlyArray<RegExp> = [
63
63
  // Layout-agnostic: framework packages/... AND flat app src/... AND enterprise packages/<pkg>/...
64
64
  /(^|\/)(packages\/[^/]+\/)?src\/db\/queries\//,
@@ -107,7 +107,7 @@ const PATTERNS: ReadonlyArray<{
107
107
  { kind: "execute", re: /\.execute\s*\(/ },
108
108
  ];
109
109
 
110
- /** Only these kinds fail CI; delete_from/execute stay inventory-only (infra#610). */
110
+ /** Only these kinds fail CI; delete_from/execute stay inventory-only. */
111
111
  export const BLOCKING_SQL_KINDS: ReadonlyArray<SqlInventoryKind> = ["unsafe", "asRawClient"];
112
112
 
113
113
  const TS_GLOB = new Bun.Glob("**/*.{ts,tsx}");
package/src/changes.json CHANGED
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.387.0",
4
+ "type": "improvement",
5
+ "title": "Comment Issue-Ref guard",
6
+ "detail": "The comment-issue-ref-only guard flags comments that consist only of an issue or PR reference or a planning label (`#1234`, `fw#1234`, `Phase 3`, `S2.H1`) with no explanatory word besides it. Consecutive own-line `//` comments count as one paragraph, so a reference at the end of an explaining paragraph passes. Hits are frozen per file in `.kumiko-comment-issue-ref-baseline.json` and may not grow; without the baseline file the guard only warns. Deliberate exceptions take `// kumiko-lint-ignore comment-issue-ref-only <reason>`.",
7
+ "migration": "Optional. Freeze existing hits with `kumiko-guards guards --write-baseline --guard=comment-issue-ref-only`."
8
+ },
2
9
  {
3
10
  "version": "0.385.0",
4
11
  "type": "improvement",
@@ -117,7 +117,7 @@ export function isConstAssertion(cast: AsExpression): boolean {
117
117
  // optional ParenthesizedExpression in between (depending on parenthesisation):
118
118
  // `x as unknown as Y` → AsExpr(target=Y) → AsExpr(target=unknown) → x
119
119
  // `(x as unknown) as Y` → AsExpr(target=Y) → ParenExpr → AsExpr(target=unknown) → x
120
- // Wir gehen durch Parens transparent durch.
120
+ // Traverse parentheses transparently.
121
121
  function unwrapParens(node: Node | undefined): Node | undefined {
122
122
  while (node?.getKind() === SyntaxKind.ParenthesizedExpression) {
123
123
  node = node.asKindOrThrow(SyntaxKind.ParenthesizedExpression).getExpression();
@@ -277,7 +277,7 @@ function restOfLine(fullText: string, from: number): string {
277
277
  export function extractBoundaryReason(cast: AsExpression): string | null {
278
278
  const fullText = cast.getSourceFile().getFullText();
279
279
 
280
- // (1) Leading comments des enclosing statement
280
+ // (1) Leading comments of the enclosing statement
281
281
  const stmt =
282
282
  cast.getFirstAncestorByKind(SyntaxKind.VariableStatement) ??
283
283
  cast.getFirstAncestorByKind(SyntaxKind.ExpressionStatement) ??
@@ -288,7 +288,7 @@ export function extractBoundaryReason(cast: AsExpression): string | null {
288
288
  if (reason !== null) return reason;
289
289
  }
290
290
 
291
- // (2) Cast-Range selbst (multi-line casts, inline comments innerhalb)
291
+ // (2) Cast range itself (multi-line casts, inline comments inside)
292
292
  const inlineReason = boundaryReasonIn(fullText.slice(cast.getStart(), cast.getEnd()));
293
293
  if (inlineReason !== null) return inlineReason;
294
294
 
@@ -327,9 +327,8 @@ const FILE_DEFAULT_REASONS: ReadonlyArray<{
327
327
  boundaryReason: BoundaryReason;
328
328
  }> = [
329
329
  {
330
- // feature-AST extractors: alle Casts gehen vom erased ts-morph-Parse-
331
- // Result zu typed feature-Definitionen (EntityDefinition, RelationDef,
332
- // NavDef, ConfigKeys, …). Per Konstruktion ein schema-walk.
330
+ // Feature-AST extractors cast erased ts-morph parse results to typed feature definitions
331
+ // (EntityDefinition, RelationDef, NavDef, ConfigKeys, …), so every cast is a schema walk.
333
332
  pattern: /\/engine\/feature-ast\//,
334
333
  boundaryReason: "schema-walk",
335
334
  },
@@ -605,7 +604,7 @@ function writeBaseline(all: readonly Site[]): void {
605
604
  legitRatchet.write(legitCountsByPackage(all));
606
605
  }
607
606
 
608
- // Per File:target: aktueller Count gegen baseline. Mehr → Regression.
607
+ // Per file:target: current count against the baseline. More → regression.
609
608
  // A cast swap (cast A removed, cast B with a different target added) is detected
610
609
  // this way although the total stays stable.
611
610
  // Merge-conflict markers, truncated writes and hand edits all land here; the
@@ -27,8 +27,6 @@
27
27
  * bun guards/check-complexity.ts # compare against baseline
28
28
  * bun guards/check-complexity.ts --write-baseline # rewrite baseline
29
29
  * bun guards/check-complexity.ts --no-baseline # skip comparison
30
- *
31
- * Rule: issue kumiko-framework#1282
32
30
  */
33
31
 
34
32
  import * as path from "node:path";
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Real-Provider-Isolation-Guard (stack-agnostisch, #3118).
3
+ * Real-Provider-Isolation-Guard (stack-agnostic).
4
4
  *
5
5
  * Real-provider specs (`*.real.test.ts`, `*.real.spec.ts`) hit live third-party
6
6
  * services and must only ever run opt-in (manually or scheduled) via `bun run test:real` /
@@ -43,8 +43,8 @@
43
43
  // A file/import edge classified "client" importing "runtime" can be a real
44
44
  // finding this guard needs to see, but can also be a known, reviewed
45
45
  // exception (e.g. samples/recipes are worked examples, not the framework's
46
- // own production surface — see kumiko-framework#2337 for the one currently
47
- // frozen here). Exceptions are tracked via a per-repo baseline file
46
+ // own production surface — one exception is currently frozen here).
47
+ // Exceptions are tracked via a per-repo baseline file
48
48
  // (`.kumiko-runtime-isolation-baseline.json`, same `baselineRatchet`
49
49
  // mechanism as check-complexity/etc.), not a
50
50
  // hardcoded list in this source file: a consumer repo can freeze or clear
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Single-Runtime-Instance Guard (kumiko-studio#229).
3
+ * Single-Runtime-Instance Guard.
4
4
  *
5
5
  * A Kumiko runtime library resolved at two versions in one bun.lock (a nested
6
6
  * copy under a feature package next to the top-level install) loads twice in
@@ -3,11 +3,10 @@
3
3
  * Guard: blocks calls to the event-store admin API (`appendRaw`/`appendRawBatch`)
4
4
  * outside allowed paths.
5
5
  *
6
- * The admin API is a Marten bypass for legacy data imports (prod-readiness
7
- * wave 3, step 3.1). It BYPASSES the pipeline — no projections, no
8
- * postSave hooks, no SSE/search/audit. Used accidentally from application
9
- * code, that causes state inconsistencies that surface very late (at the
10
- * next projection rebuild, or never).
6
+ * The admin API is a Marten bypass for legacy data imports. It BYPASSES the
7
+ * pipeline — no projections, no postSave hooks, no SSE/search/audit. Used
8
+ * accidentally from application code, that causes state inconsistencies that
9
+ * surface very late (at the next projection rebuild, or never).
11
10
  *
12
11
  * Second line of defense next to the deep-import path: even if someone pulls
13
12
  * `@kubiko/framework/event-store/admin-api` directly, this guard catches it
@@ -114,7 +113,7 @@ function findEnclosingName(call: CallExpression): string {
114
113
  export const guard: AstGuard = {
115
114
  name: "Admin-API Guard",
116
115
  scan: SCAN,
117
- // App repos are not exempt — appendRaw/appendRawBatch bypasses the pipeline there too (infra#502).
116
+ // App repos are not exempt — appendRaw/appendRawBatch bypasses the pipeline.
118
117
  security: true,
119
118
  hint: "Admin API (appendRaw/appendRawBatch) bypasses the pipeline — only allowed in samples/*/migration/ or scripts/migrations/. For domain events: ctx.appendEvent / write handler.",
120
119
  run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
@@ -13,8 +13,6 @@
13
13
  // ponytail: the handler file convention (*.query.ts/*.write.ts under handlers/)
14
14
  // is not enforced yet — add it once the registration API shapes are
15
15
  // inventoried and stable.
16
- //
17
- // Part of App-Mounting 2.0 (infra#208).
18
16
 
19
17
  import * as path from "node:path";
20
18
  import { type SourceFile, SyntaxKind } from "ts-morph";
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Comment Issue-Ref Guard: a comment that holds only an issue or PR reference or a planning
4
+ * label (`#1234`, `fw#1234`, `Phase 3`, `S2.H1`) and no explanatory word is a violation.
5
+ * The number belongs in the commit message; the comment has to carry the reason. Existing
6
+ * hits are frozen per file in `.kumiko-comment-issue-ref-baseline.json` and may not grow;
7
+ * without a baseline file (consumer repos) the guard only warns.
8
+ *
9
+ * Consecutive own-line `//` comments count as one paragraph, so a reference on the last
10
+ * line of an explaining paragraph is not a hit.
11
+ */
12
+
13
+ import path from "node:path";
14
+ import type { SourceFile } from "ts-morph";
15
+ import { COMMENT_KIND_LINE, type CommentTrivia, scanCommentTrivia } from "./_lib/comment-trivia";
16
+ import {
17
+ ALL_REPO_KINDS,
18
+ type AstGuard,
19
+ baselineRatchet,
20
+ buildSharedProject,
21
+ filesForGuard,
22
+ isLocalFinding,
23
+ relFromRepoRoot,
24
+ runStandalone,
25
+ type ScanSpec,
26
+ } from "./_lib/guard-kit";
27
+ import { lineHasIgnoreTag } from "./_lib/ignore-tag";
28
+ import { resolveRepoRoots } from "./_lib/roots";
29
+
30
+ const ROOT = process.cwd();
31
+ const GUARD_NAME = "comment-issue-ref-only";
32
+ const IGNORE_TAG = "kumiko-lint-ignore comment-issue-ref-only";
33
+
34
+ const SCAN: ScanSpec = {
35
+ scope: "source",
36
+ kinds: ALL_REPO_KINDS,
37
+ extensions: ["ts", "tsx"],
38
+ frameworkWithin: ["packages/*/src/**"],
39
+ };
40
+
41
+ const EXCLUDE = /(\.d\.(ts|tsx)$|\/dist\/)/;
42
+
43
+ // `fw#12`, `#1234`, `kumiko-framework#3`, `oven-sh/bun#5`; `&#39;` is an HTML entity.
44
+ const REFERENCE_RE = /(?<!&)[\w./-]*#\d+(?![\w])/g;
45
+ const PLANNING_WORD_RE =
46
+ /\b(?:phase|round|welle|wave|sprint)[- ]?\d+(?:\.\d+)*[a-z]?\b|\bbug-bash(?:[- ]\d+)?\b/gi;
47
+ // Dotted plan labels such as S2.H1, T1.5c, B1.5. Case-sensitive and without V so
48
+ // that version numbers (v1.2, V20.1) never count.
49
+ const DOTTED_LABEL_RE = /\b[A-UW-Z]\d{1,2}(?:\.(?:\d+[a-z]?|[A-Z]\d+))+\b/g;
50
+ const WORD_RE = /[\p{L}_$][\p{L}\p{N}_$'-]*/gu;
51
+ const MIN_EXPLANATORY_WORD_LENGTH = 3;
52
+
53
+ // Words that only frame a reference ("see #12", "fix for fw#3", "part of Phase 2").
54
+ const FRAMING_WORDS: ReadonlySet<string> = new Set([
55
+ "see",
56
+ "siehe",
57
+ "vgl",
58
+ "ref",
59
+ "refs",
60
+ "issue",
61
+ "issues",
62
+ "fix",
63
+ "fixes",
64
+ "fixed",
65
+ "for",
66
+ "from",
67
+ "per",
68
+ "part",
69
+ "the",
70
+ "and",
71
+ "und",
72
+ "via",
73
+ "follow",
74
+ "followup",
75
+ "follow-up",
76
+ "tracking",
77
+ "tracked",
78
+ "todo",
79
+ "fixme",
80
+ "closes",
81
+ "resolves",
82
+ "related",
83
+ "also",
84
+ "nach",
85
+ "aus",
86
+ "laut",
87
+ ]);
88
+
89
+ function commentBody(text: string): string {
90
+ return text
91
+ .replace(/^\/\*\*?|\*\/$/g, "")
92
+ .split("\n")
93
+ .map((line) => line.replace(/^\s*(?:\/\/+|\*)?/, ""))
94
+ .join("\n");
95
+ }
96
+
97
+ function referenceTokensIn(body: string): number {
98
+ return (
99
+ (body.match(REFERENCE_RE)?.length ?? 0) +
100
+ (body.match(PLANNING_WORD_RE)?.length ?? 0) +
101
+ (body.match(DOTTED_LABEL_RE)?.length ?? 0)
102
+ );
103
+ }
104
+
105
+ function isExplanatoryWord(word: string): boolean {
106
+ return word.length >= MIN_EXPLANATORY_WORD_LENGTH && !FRAMING_WORDS.has(word.toLowerCase());
107
+ }
108
+
109
+ /** True when the comment carries a reference or planning label and no explanatory word besides it. */
110
+ export function isIssueRefOnlyComment(commentText: string): boolean {
111
+ if (lineHasIgnoreTag(commentText, IGNORE_TAG)) return false;
112
+ const body = commentBody(commentText);
113
+ if (referenceTokensIn(body) === 0) return false;
114
+ const rest = body
115
+ .replace(REFERENCE_RE, " ")
116
+ .replace(PLANNING_WORD_RE, " ")
117
+ .replace(DOTTED_LABEL_RE, " ");
118
+ return !(rest.match(WORD_RE) ?? []).some(isExplanatoryWord);
119
+ }
120
+
121
+ type CommentParagraph = { readonly start: number; readonly text: string };
122
+
123
+ function startsOwnLine(fullText: string, start: number): boolean {
124
+ const lineStart = fullText.lastIndexOf("\n", start - 1) + 1;
125
+ return fullText.slice(lineStart, start).trim() === "";
126
+ }
127
+
128
+ function continuesLineParagraph(
129
+ fullText: string,
130
+ previous: CommentTrivia,
131
+ next: CommentTrivia,
132
+ ): boolean {
133
+ if (previous.kind !== COMMENT_KIND_LINE || next.kind !== COMMENT_KIND_LINE) return false;
134
+ if (!startsOwnLine(fullText, previous.start) || !startsOwnLine(fullText, next.start))
135
+ return false;
136
+ return /^[ \t]*\r?\n[ \t]*$/.test(
137
+ fullText.slice(previous.start + previous.text.length, next.start),
138
+ );
139
+ }
140
+
141
+ export function commentParagraphs(fullText: string): CommentParagraph[] {
142
+ const paragraphs: CommentParagraph[] = [];
143
+ let current: { start: number; parts: string[]; last: CommentTrivia } | undefined;
144
+ for (const comment of scanCommentTrivia(fullText)) {
145
+ if (current && continuesLineParagraph(fullText, current.last, comment)) {
146
+ current.parts.push(comment.text);
147
+ current.last = comment;
148
+ continue;
149
+ }
150
+ if (current) paragraphs.push({ start: current.start, text: current.parts.join("\n") });
151
+ current = { start: comment.start, parts: [comment.text], last: comment };
152
+ }
153
+ if (current) paragraphs.push({ start: current.start, text: current.parts.join("\n") });
154
+ return paragraphs;
155
+ }
156
+
157
+ export type IssueRefOnlySite = { readonly line: number; readonly text: string };
158
+
159
+ export function findIssueRefOnlyComments(fullText: string): IssueRefOnlySite[] {
160
+ return commentParagraphs(fullText)
161
+ .filter((paragraph) => isIssueRefOnlyComment(paragraph.text))
162
+ .map((paragraph) => ({
163
+ line: fullText.slice(0, paragraph.start).split("\n").length,
164
+ text: paragraph.text,
165
+ }));
166
+ }
167
+
168
+ export function issueRefOnlyCounts(
169
+ files: readonly SourceFile[],
170
+ roots: ReadonlyArray<{ readonly absPath: string }> = resolveRepoRoots(),
171
+ ): { counts: Record<string, number>; firstLine: Record<string, number> } {
172
+ const counts: Record<string, number> = {};
173
+ const firstLine: Record<string, number> = {};
174
+ for (const sf of files) {
175
+ if (EXCLUDE.test(sf.getFilePath())) continue;
176
+ const sites = findIssueRefOnlyComments(sf.getFullText());
177
+ const first = sites[0];
178
+ if (first === undefined) continue;
179
+ const file = relFromRepoRoot(sf.getFilePath(), roots);
180
+ if (!isLocalFinding({ file })) continue;
181
+ counts[file] = sites.length;
182
+ firstLine[file] = first.line;
183
+ }
184
+ return { counts, firstLine };
185
+ }
186
+
187
+ const issueRefBaseline = baselineRatchet({
188
+ file: path.join(ROOT, ".kumiko-comment-issue-ref-baseline.json"),
189
+ formatVersion: 1,
190
+ unit: "issue-reference-only comment(s)",
191
+ });
192
+
193
+ const REMEDIATION =
194
+ "Replace the reference with the reason it stands for, or delete the comment; the number belongs in the commit message.";
195
+
196
+ export const guard: AstGuard = {
197
+ name: GUARD_NAME,
198
+ scan: SCAN,
199
+ hint: `after removing hits: \`kumiko-guards guards --write-baseline --guard=${GUARD_NAME}\`; deliberate exception: // ${IGNORE_TAG} <reason>`,
200
+ run: (files, roots = resolveRepoRoots()) => {
201
+ const { counts, firstLine } = issueRefOnlyCounts(files, roots);
202
+ return {
203
+ violations: issueRefBaseline.check(counts, REMEDIATION, {
204
+ formatDriftRemediation: `Run \`kumiko-guards guards --write-baseline --guard=${GUARD_NAME}\` once.`,
205
+ resolveLine: (file) => firstLine[file] ?? 1,
206
+ }),
207
+ };
208
+ },
209
+ writeBaseline: (files, roots = resolveRepoRoots()) => {
210
+ issueRefBaseline.write(issueRefOnlyCounts(files, roots).counts);
211
+ },
212
+ };
213
+
214
+ if (import.meta.main) {
215
+ if (process.argv.slice(2).includes("--write-baseline")) {
216
+ const project = buildSharedProject([guard]);
217
+ guard.writeBaseline?.(filesForGuard(project, guard));
218
+ process.exit(0);
219
+ }
220
+ runStandalone(guard);
221
+ }
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import * as path from "node:path";
3
- import { ts } from "ts-morph";
3
+ import { scanCommentTrivia } from "./_lib/comment-trivia";
4
4
  import { gitEnv } from "./_lib/git-env";
5
5
  import { ALL_REPO_KINDS, cliFlagsError, isLocalFinding } from "./_lib/guard-kit";
6
6
  import { lineHasIgnoreTag } from "./_lib/ignore-tag";
@@ -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
  }
@@ -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
@@ -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`
@@ -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
@@ -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.
@@ -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";
@@ -9,7 +9,7 @@
9
9
  * <button>/<input>/<select>/<textarea>/<dialog>/<form> are deliberately NOT
10
10
  * in this guard: <button>/<input>/<select>/<textarea>/<dialog> are already
11
11
  * enforced by guard-primitives-discipline.ts (its own CI step, not this
12
- * bundle), and <form> is a documented exception (PR #488, agent-layer.tsx:71
12
+ * bundle), and <form> is a documented exception (agent-layer.tsx:71
13
13
  * uses a native <form onSubmit> on purpose) — adding either back here would
14
14
  * duplicate or contradict an existing rule.
15
15
  *
@@ -5,9 +5,8 @@
5
5
  *
6
6
  * `getUnscopedAggregateStreamMaxVersion` / `getUnscopedAggregateStreamTenant`
7
7
  * (packages/framework/src/event-store/event-store.ts) have no tenant
8
- * filter — they are an existence oracle for foreign tenants (see
9
- * kumiko-framework#1269). Legitimate only for seed-/system-internal code
10
- * that deliberately works across tenants.
8
+ * filter — they are an existence oracle for foreign tenants. Legitimate only
9
+ * for seed-/system-internal code that deliberately works across tenants.
11
10
  *
12
11
  * Usage:
13
12
  * bun guards/guard-restricted-symbols.ts
@@ -4,23 +4,22 @@
4
4
  * `packages/bundled-features/src/**` must already be reachable through the
5
5
  * real Tailwind `@source` scan surface (`packages/renderer/src` +
6
6
  * `samples/**\/src`, see `packages/renderer/src/web/styles.css`).
7
- * bundled-features itself is NOT part of that scan surface (infra#654, follow-up to
8
- * kumiko-framework#2498/#2496): expanding `@source` there was verified to
9
- * work in the monorepo build but makes the standalone npm-consumer case
10
- * (no monorepo-relative scan path — #359) falsely green. A class used only
11
- * in bundled-features silently drops out of the compiled CSS at runtime,
12
- * with no build/lint error.
7
+ * bundled-features itself is NOT part of that scan surface: expanding
8
+ * `@source` there was verified to work in the monorepo build but makes the
9
+ * standalone npm-consumer case (no monorepo-relative scan path) falsely
10
+ * green. A class used only in bundled-features silently drops out of the
11
+ * compiled CSS at runtime, with no build/lint error.
13
12
  *
14
- * V1 scope (per issue decision, deliberately not a Tailwind-CLI CSS
15
- * regen): only plain string-literal `className="..."` JSX attributes are
16
- * extracted on the bundled-features side — no `cn(...)`, no template
17
- * literals, no dynamic class maps. The allow-set (the real scan surface)
18
- * is read generously instead — every string/template-literal in those
19
- * files, not just `className` attributes — because Tailwind's own scanner
20
- * is a text scan, not JSX-attribute-aware; under-approximating the allow
21
- * side would turn into false positives on a guard that blocks CI. Tokens
22
- * compare WITH their modifier prefixes intact (`hover:mb-2` is its own
23
- * Tailwind candidate, distinct from `mb-2`).
13
+ * V1 scope, deliberately not a Tailwind-CLI CSS regen: only plain
14
+ * string-literal `className="..."` JSX attributes are extracted on the
15
+ * bundled-features side — no `cn(...)`, no template literals, no dynamic
16
+ * class maps. The allow-set (the real scan surface) is read generously
17
+ * instead — every string/template-literal in those files, not just
18
+ * `className` attributes — because Tailwind's own scanner is a text scan,
19
+ * not JSX-attribute-aware; under-approximating the allow side would turn
20
+ * into false positives on a guard that blocks CI. Tokens compare WITH their
21
+ * modifier prefixes intact (`hover:mb-2` is its own Tailwind candidate,
22
+ * distinct from `mb-2`).
24
23
  *
25
24
  * Baseline-regression guard: pins the
26
25
  * currently-known violation count per file. Reductions are allowed but
@@ -174,11 +173,11 @@ const tailwindScanSurfaceBaseline = (baselineRoot: string) =>
174
173
  // ships .tsx with `className` under its own scan surface (renderer/src +
175
174
  // samples/**/src) needs its own `@source` entry in the app's
176
175
  // styles.css — nothing else scans it. Missed cases so far: kumiko-designer
177
- // in studio (studio#289), kumiko-ai-agent in publicstatus (publicstatus#442)
178
- // — both fixed by hand, both silent (no build/lint error) until noticed
179
- // visually. This does not need ts-morph: it reads package.json + styles.css
180
- // + a plain text scan of the dependency's shipped .tsx files directly off
181
- // disk, independent of the scan-selected SourceFile list above.
176
+ // in studio, kumiko-ai-agent in publicstatus — both fixed by hand, both
177
+ // silent (no build/lint error) until noticed visually. This does not need
178
+ // ts-morph: it reads package.json + styles.css + a plain text scan of the
179
+ // dependency's shipped .tsx files directly off disk, independent of the
180
+ // scan-selected SourceFile list above.
182
181
  const APP_STYLES_REL = "src/styles.css";
183
182
  const PACKAGE_SCOPE = /^@cosmicdrift(?:gamestudio)?\//;
184
183
  const CLASSNAME_ATTR = /\bclassName\s*=/;
@@ -390,14 +389,14 @@ export function scanSourceCoverage(roots: readonly RepoRoot[]): SourceCoverageFi
390
389
  }
391
390
 
392
391
  // Third, independent rule: an `@source` glob that is already present and
393
- // resolves on disk (studio#289's own fix) can still match nothing once the
394
- // referenced package is installed from a registry instead of a workspace
395
- // symlink — the symlink exposes the package's full source checkout (`src/`
396
- // included), a registry tarball only ships what its `package.json` `files`
397
- // allowlists. This is exactly the bug that got past the guard the first
398
- // time, so it's a hard violation, not a warning: no baseline/ratchet, every
399
- // finding fails the run directly (still only under baseline comparison —
400
- // `--no-baseline` reports it like the other rules but doesn't fail).
392
+ // resolves on disk can still match nothing once the referenced package is
393
+ // installed from a registry instead of a workspace symlink — the symlink
394
+ // exposes the package's full source checkout (`src/`) included, a registry
395
+ // tarball only ships what its `package.json` `files` allowlists. This is
396
+ // exactly the bug that got past the guard the first time, so it's a hard
397
+ // violation, not a warning: no baseline/ratchet, every finding fails the run
398
+ // directly (still only under baseline comparison — `--no-baseline` reports it
399
+ // like the other rules but doesn't fail).
401
400
  const NODE_MODULES_PKG_SEGMENT = /node_modules\/((?:@[^/]+\/)?[^/]+)\/([^/]+)/;
402
401
 
403
402
  export interface PublishedScanSurfaceFinding {
@@ -61,8 +61,8 @@ type RoleHandler = { name: string; file: string; line: number };
61
61
  // A `schema:` property that references an outlined const (`schema:
62
62
  // CancelInvitationSchema`) has no `role:`/`roles:` text of its own — the field
63
63
  // lives in the const's declaration. Resolving the identifier to its
64
- // declaration covers that ~43% of write handlers (#1556-adjacent bug: a
65
- // text-only check silently skips them). Falls back to the property's own text
64
+ // declaration covers that ~43% of write handlers; a text-only check silently
65
+ // skips them. Falls back to the property's own text
66
66
  // when the initializer isn't a resolvable identifier.
67
67
  function resolvedSchemaText(schemaProp: Node): string {
68
68
  const init = schemaProp.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
@@ -208,7 +208,7 @@ function globalUserWriteMissingCheck(
208
208
  // Presets that resolve to platform-wide actors only (engine/
209
209
  // config-helpers `access`): no tenant boundary exists to cross.
210
210
  // Everything else — access.admin, and the openToAll handlers that
211
- // gate on isAdminActor at runtime (#1556's shape) — is reachable by
211
+ // gate on isAdminActor at runtime (this shape) — is reachable by
212
212
  // a tenant-scoped TenantAdmin.
213
213
  if (PLATFORM_WIDE_ACCESS.test(accessProp.getText())) return undefined;
214
214
  if (!TARGETS_OTHER_USER.test(resolvedSchemaText(schema))) return undefined;
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
3
  * Guard: apps must not locally reimplement the e2e/screenshot template that
4
- * `@cosmicdrift/kumiko-testing` already owns (#3118). Flags, in Playwright
4
+ * `@cosmicdrift/kumiko-testing` already owns. Flags, in Playwright
5
5
  * configs and e2e/screenshot specs/helpers:
6
6
  * (a) a `playwright*.config.ts` that imports `defineConfig` from
7
7
  * `@playwright/test` directly instead of `defineAppE2eConfig` from
@@ -23,7 +23,7 @@
23
23
  * the message.
24
24
  *
25
25
  * No baseline, no ratchet: any finding
26
- * fails. #3118's transition period (a `.kumiko-test-timeouts-baseline.json`
26
+ * fails. The transition period (a `.kumiko-test-timeouts-baseline.json`
27
27
  * grandfathering pre-existing sleep loops) is over — every consumer must fix
28
28
  * or mark the cause, not accumulate a pinned count.
29
29
  *
@@ -30,7 +30,7 @@ const SCAN: ScanSpec = {
30
30
  frameworkWithin: ["packages/*/src/**", "samples/**"],
31
31
  };
32
32
 
33
- // Tests nutzen absichtlich generische QNs wie dispatcher.write("x", {}).
33
+ // Tests intentionally use generic QNs such as dispatcher.write("x", {}).
34
34
  const EXCLUDE = /(__tests__|\.test\.tsx$|\.integration\.tsx$|\/node_modules\/|\/dist\/)/;
35
35
 
36
36
  /**
package/src/run-guards.ts CHANGED
@@ -25,6 +25,7 @@ import { guard as complexity } from "./check-complexity";
25
25
  import { guard as predicateExtraction } from "./check-predicates";
26
26
  import { guard as adminApi } from "./guard-admin-api";
27
27
  import { guard as appFeatureStructure } from "./guard-app-feature-structure";
28
+ import { guard as commentIssueRefOnly } from "./guard-comment-issue-ref-only";
28
29
  import { guard as crossFeatureImports } from "./guard-cross-feature-imports";
29
30
  import { guard as directEntityWrites } from "./guard-direct-entity-writes";
30
31
  import { guard as directFetch } from "./guard-direct-fetch";
@@ -82,6 +83,7 @@ export const GUARDS = [
82
83
  testTimeouts,
83
84
  testTemplateDrift,
84
85
  fileSize,
86
+ commentIssueRefOnly,
85
87
  ];
86
88
 
87
89
  export const GUARD_FLAGS = [
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- // UI-guard bundle (App-Mounting 2.0, infra#208): one process, shared ts-morph
2
+ // UI-guard bundle (App-Mounting 2.0): one process, shared ts-morph
3
3
  // Project over the UI enforcement guards.
4
4
  import {
5
5
  buildSharedProject,
@@ -108,7 +108,7 @@ export function classifyByPath(repoRelativePath: string): Runtime | null {
108
108
  * for `kumiko-build` entries (`resolveClientEntries` in kumiko-framework's
109
109
  * `packages/server-runtime/src/build-prod-bundle.ts`): `src/client.tsx`/
110
110
  * `src/client.ts` (single-entry) or `src/client-<suffix>.tsx?` (declared via
111
- * package.json `kumiko.clientEntries`, fw#2320 — no longer auto-discovered
111
+ * package.json `kumiko.clientEntries`; no longer auto-discovered
112
112
  * by this filename alone). Framework/enterprise packages never match —
113
113
  * their sources live under `packages/*\/src/`, not a repo-root `src/`. Kept
114
114
  * independent of the framework's own regex (cross-package import would be a