@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
@@ -21,14 +21,14 @@
21
21
  *
22
22
  * Opt-out: `// kumiko-lint-ignore complexity-budget <reason>` on the function
23
23
  * line, the line above, or (for JSDoc-documented functions) the line above
24
- * the JSDoc block.
24
+ * the JSDoc block. The opt-out does NOT hold above HARD_COMPLEXITY_CAP: such a
25
+ * function in the repo's own files always fails, regardless of baseline or tag.
25
26
  *
26
27
  * Usage:
27
28
  * bun guards/check-complexity.ts # compare against baseline
28
29
  * bun guards/check-complexity.ts --write-baseline # rewrite baseline
29
30
  * bun guards/check-complexity.ts --no-baseline # skip comparison
30
- *
31
- * Rule: issue kumiko-framework#1282
31
+ * bun guards/check-complexity.ts --all # list every hotspot, not the first 30
32
32
  */
33
33
 
34
34
  import * as path from "node:path";
@@ -60,6 +60,7 @@ const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$)/;
60
60
 
61
61
  const COMPLEXITY_THRESHOLD = 15;
62
62
  const MAX_REPORTED = 30;
63
+ export const HARD_COMPLEXITY_CAP = 40;
63
64
  const BUDGET_TAG = "kumiko-lint-ignore complexity-budget";
64
65
 
65
66
  const FUNCTION_KINDS = [
@@ -154,26 +155,49 @@ export function computeComplexity(fn: Node): number {
154
155
  return complexity;
155
156
  }
156
157
 
157
- export function collectHotspots(
158
+ export interface FunctionMetric extends Hotspot {
159
+ budgetTagged: boolean;
160
+ }
161
+
162
+ // Single walk per file: complexity is computed once and feeds both the
163
+ // hotspot list and the hard cap.
164
+ export function collectFunctionMetrics(
158
165
  sf: SourceFile,
159
166
  roots: readonly RepoRoot[] = resolveRepoRoots(),
160
- ): Hotspot[] {
167
+ ): FunctionMetric[] {
161
168
  const file = relFromRepoRoot(sf.getFilePath(), roots);
162
- const hotspots: Hotspot[] = [];
169
+ const metrics: FunctionMetric[] = [];
163
170
  for (const kind of FUNCTION_KINDS) {
164
171
  for (const fn of sf.getDescendantsOfKind(kind)) {
165
172
  const complexity = computeComplexity(fn);
166
173
  if (complexity < COMPLEXITY_THRESHOLD) continue;
167
- if (hasBudgetTag(fn)) continue;
168
- hotspots.push({
174
+ metrics.push({
169
175
  file,
170
176
  line: fn.getStartLineNumber(),
171
177
  name: functionName(fn),
172
178
  complexity,
179
+ budgetTagged: hasBudgetTag(fn),
173
180
  });
174
181
  }
175
182
  }
176
- return hotspots;
183
+ return metrics;
184
+ }
185
+
186
+ export function hotspotsOf(metrics: readonly FunctionMetric[]): Hotspot[] {
187
+ return metrics
188
+ .filter((m) => !m.budgetTagged)
189
+ .map(({ file, line, name, complexity }) => ({ file, line, name, complexity }));
190
+ }
191
+
192
+ export function capBreachesOf(metrics: readonly FunctionMetric[]): FunctionMetric[] {
193
+ return metrics.filter((m) => m.complexity > HARD_COMPLEXITY_CAP && isLocalFinding(m));
194
+ }
195
+
196
+ export function collectHotspots(
197
+ sf: SourceFile,
198
+ roots: readonly RepoRoot[] = resolveRepoRoots(),
199
+ ): Hotspot[] {
200
+ return hotspotsOf(collectFunctionMetrics(sf, roots));
177
201
  }
178
202
 
179
203
  const BASELINE_FILE = ".kumiko-complexity-baseline.json";
@@ -201,26 +225,37 @@ function scan(
201
225
  roots: readonly RepoRoot[] = resolveRepoRoots(),
202
226
  ): {
203
227
  hotspots: Hotspot[];
228
+ capBreaches: FunctionMetric[];
204
229
  scanned: number;
205
230
  } {
206
- const hotspots: Hotspot[] = [];
231
+ const metrics: FunctionMetric[] = [];
207
232
  let scanned = 0;
208
233
  for (const sf of files) {
209
234
  if (EXCLUDE.test(sf.getFilePath())) continue;
210
235
  scanned++;
211
- hotspots.push(...collectHotspots(sf, roots));
236
+ metrics.push(...collectFunctionMetrics(sf, roots));
212
237
  }
213
- hotspots.sort((a, b) => b.complexity - a.complexity);
214
- return { hotspots, scanned };
238
+ const hotspots = hotspotsOf(metrics).sort((a, b) => b.complexity - a.complexity);
239
+ const capBreaches = capBreachesOf(metrics).sort((a, b) => b.complexity - a.complexity);
240
+ return { hotspots, capBreaches, scanned };
215
241
  }
216
242
 
217
- function report(hotspots: readonly Hotspot[], scanned: number): void {
243
+ function report(
244
+ hotspots: readonly Hotspot[],
245
+ scanned: number,
246
+ capBreaches: readonly FunctionMetric[],
247
+ reportLimit: number,
248
+ ): void {
218
249
  console.log(`Complexity Check: ${scanned} files checked.`);
219
250
  console.log(` Hotspots (complexity >= ${COMPLEXITY_THRESHOLD}): ${hotspots.length}`);
251
+ console.log(` Above hard cap (complexity > ${HARD_COMPLEXITY_CAP}): ${capBreaches.length}`);
252
+ for (const b of capBreaches) {
253
+ console.log(` CAP ${b.file}:${b.line} ${b.name}() complexity=${b.complexity}`);
254
+ }
220
255
  if (hotspots.length === 0) {
221
256
  console.log(" Nothing to report.");
222
257
  } else {
223
- const shown = hotspots.slice(0, MAX_REPORTED);
258
+ const shown = hotspots.slice(0, reportLimit);
224
259
  for (const h of shown) {
225
260
  console.log(` ${h.file}:${h.line} ${h.name}() complexity=${h.complexity}`);
226
261
  }
@@ -233,12 +268,25 @@ function report(hotspots: readonly Hotspot[], scanned: number): void {
233
268
  }
234
269
  }
235
270
 
271
+ type Ratchet = ReturnType<typeof baselineRatchet>;
272
+
236
273
  const complexityBaseline = baselineRatchet({
237
274
  file: path.join(ROOT, BASELINE_FILE),
238
275
  formatVersion: BASELINE_FORMAT_VERSION,
239
276
  unit: "Complexity-Hotspot(s)",
240
277
  });
241
278
 
279
+ function capViolations(capBreaches: readonly FunctionMetric[]): GuardViolation[] {
280
+ return capBreaches.map((b) => ({
281
+ file: b.file,
282
+ line: b.line,
283
+ neverFrozen: true,
284
+ message:
285
+ `${b.name}() has complexity ${b.complexity}, above the hard cap of ${HARD_COMPLEXITY_CAP}. ` +
286
+ `A "${BUDGET_TAG}" tag and the baseline do not apply above the cap — split the function.`,
287
+ }));
288
+ }
289
+
242
290
  // `scan` sorts by complexity descending, so the first hit for a file is its
243
291
  // worst function — the one the violation should point at, not whichever
244
292
  // hotspot happens to come first in the file.
@@ -246,9 +294,9 @@ export function resolveHotspotLine(hotspots: readonly Hotspot[], file: string):
246
294
  return hotspots.find((h) => h.file === file)?.line ?? 1;
247
295
  }
248
296
 
249
- function checkBaseline(scanned: readonly Hotspot[]): GuardViolation[] {
297
+ function checkBaseline(scanned: readonly Hotspot[], ratchet: Ratchet): GuardViolation[] {
250
298
  const hotspots = localHotspots(scanned);
251
- return complexityBaseline.check(
299
+ return ratchet.check(
252
300
  countHotspotsByFile(hotspots),
253
301
  `Split the function, or allow it with "// ${BUDGET_TAG} <reason>".`,
254
302
  {
@@ -258,18 +306,21 @@ function checkBaseline(scanned: readonly Hotspot[]): GuardViolation[] {
258
306
  );
259
307
  }
260
308
 
261
- function analyse(
309
+ export function analyse(
262
310
  files: readonly SourceFile[],
263
311
  roots: readonly RepoRoot[],
264
312
  compareBaseline: boolean,
313
+ options: { ratchet?: Ratchet; reportLimit?: number } = {},
265
314
  ): GuardOutcome {
266
- const { hotspots, scanned } = scan(files, roots);
267
- report(hotspots, scanned);
315
+ const { ratchet = complexityBaseline, reportLimit = MAX_REPORTED } = options;
316
+ const { hotspots, capBreaches, scanned } = scan(files, roots);
317
+ report(hotspots, scanned, capBreaches, reportLimit);
318
+ const hardViolations = capViolations(capBreaches);
268
319
  if (!compareBaseline) {
269
320
  console.log(" Baseline comparison skipped (--no-baseline).");
270
- return { violations: [] };
321
+ return { violations: hardViolations };
271
322
  }
272
- return { violations: checkBaseline(hotspots) };
323
+ return { violations: [...hardViolations, ...checkBaseline(hotspots, ratchet)] };
273
324
  }
274
325
 
275
326
  export const guard: AstGuard = {
@@ -281,8 +332,8 @@ export const guard: AstGuard = {
281
332
  hint: 'after a deliberate change: `kumiko-guards guards --write-baseline --guard="Complexity Check"`',
282
333
  run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
283
334
  writeBaseline: (files) => {
284
- const { hotspots, scanned } = scan(files);
285
- report(hotspots, scanned);
335
+ const { hotspots, capBreaches, scanned } = scan(files);
336
+ report(hotspots, scanned, capBreaches, MAX_REPORTED);
286
337
  complexityBaseline.write(countHotspotsByFile(localHotspots(hotspots)));
287
338
  },
288
339
  };
@@ -292,17 +343,23 @@ export const guard: AstGuard = {
292
343
  // silently rewrite the baseline.
293
344
  if (import.meta.main) {
294
345
  const args = process.argv.slice(2);
346
+ const reportLimit = args.includes("--all") ? Number.POSITIVE_INFINITY : MAX_REPORTED;
295
347
  if (args.includes("--write-baseline")) {
296
348
  const project = buildSharedProject([guard]);
297
- const { hotspots, scanned } = scan(filesForGuard(project, guard));
298
- report(hotspots, scanned);
349
+ const { hotspots, capBreaches, scanned } = scan(filesForGuard(project, guard));
350
+ report(hotspots, scanned, capBreaches, reportLimit);
299
351
  complexityBaseline.write(countHotspotsByFile(localHotspots(hotspots)));
300
352
  process.exit(0);
301
353
  }
302
354
  if (args.includes("--no-baseline")) {
303
355
  const project = buildSharedProject([guard]);
304
- analyse(filesForGuard(project, guard), resolveRepoRoots(), false);
305
- process.exit(0);
356
+ const { violations } = analyse(filesForGuard(project, guard), resolveRepoRoots(), false, {
357
+ reportLimit,
358
+ });
359
+ process.exit(violations.length > 0 ? 1 : 0);
306
360
  }
307
- runStandalone(guard);
361
+ runStandalone({
362
+ ...guard,
363
+ run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true, { reportLimit }),
364
+ });
308
365
  }
@@ -107,14 +107,30 @@ export function loadLicenseExceptions(
107
107
  });
108
108
  }
109
109
 
110
+ function normalizeLicenseId(license: string): string {
111
+ return license.replace(/[()]/g, "").trim().toUpperCase();
112
+ }
113
+
114
+ function exceptionMatchesPackage(e: LicenseException, pkg: InstalledPackage): boolean {
115
+ if (!matchesPackageName(e.package, pkg.name)) return false;
116
+ // A package without a license field has nothing to contradict the recorded license.
117
+ if (pkg.license !== null && normalizeLicenseId(e.license) !== normalizeLicenseId(pkg.license)) {
118
+ return false;
119
+ }
120
+ return (
121
+ e.version === "*" || e.version === pkg.version || Bun.semver.satisfies(pkg.version, e.version)
122
+ );
123
+ }
124
+
125
+ // A renewed repo exception must win over an expired bundled one for the same package, so prefer a
126
+ // still-valid match and only fall back to an expired one for the "expired" hint.
110
127
  function findException(
111
128
  pkg: InstalledPackage,
112
129
  exceptions: readonly LicenseException[],
130
+ nowMs: number,
113
131
  ): LicenseException | undefined {
114
- return exceptions.find((e) => {
115
- if (!matchesPackageName(e.package, pkg.name)) return false;
116
- return e.version === "*" || e.version === pkg.version || /^[\^~]/.test(e.version);
117
- });
132
+ const matching = exceptions.filter((e) => exceptionMatchesPackage(e, pkg));
133
+ return matching.find((e) => isExceptionValid(e.expires, nowMs).valid) ?? matching[0];
118
134
  }
119
135
 
120
136
  function splitExpression(expr: string, operator: "OR" | "AND"): string[] {
@@ -132,9 +148,10 @@ function matchesDenylist(license: string): boolean {
132
148
  export function judgeLicense(
133
149
  pkg: InstalledPackage,
134
150
  exceptions: readonly LicenseException[],
151
+ nowMs: number = Date.now(),
135
152
  ): Verdict {
136
153
  const license = pkg.license;
137
- const exception = findException(pkg, exceptions);
154
+ const exception = findException(pkg, exceptions, nowMs);
138
155
  if (!license) return { kind: "unknown", raw: null, exception };
139
156
 
140
157
  const andParts = splitExpression(license, "AND");
@@ -181,7 +198,7 @@ export function evaluateLicenses(
181
198
  exceptions: readonly LicenseException[],
182
199
  nowMs: number = Date.now(),
183
200
  ): LicenseEvaluation {
184
- const judged = packages.map((pkg) => ({ pkg, verdict: judgeLicense(pkg, exceptions) }));
201
+ const judged = packages.map((pkg) => ({ pkg, verdict: judgeLicense(pkg, exceptions, nowMs) }));
185
202
  const blocked = judged.filter(
186
203
  ({ verdict }) => verdict.kind !== "ok" && !hasValidException(verdict, nowMs),
187
204
  );
@@ -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
@@ -157,7 +157,8 @@ export async function runSecurityCheck(options: {
157
157
  const advisories: AuditAdvisory[] = parseAuditReport(raw);
158
158
 
159
159
  const evaluation = evaluateSecurity(advisories, exceptions, nowMs);
160
- const expiringUrgent = findExpiringSoon(exceptions, EXPIRY_BLOCK_DAYS, nowMs);
160
+ // Only exceptions that suppressed an advisory in this run block; unused bundled ones must not break consumer CI.
161
+ const expiringUrgent = findExpiringSoon(evaluation.usedExceptions, EXPIRY_BLOCK_DAYS, nowMs);
161
162
  printReport(evaluation, exceptions, expiringUrgent, nowMs);
162
163
 
163
164
  if (options.reportOnly) return 0;
@@ -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
@@ -41,30 +41,6 @@
41
41
  "reviewedBy": "Marc, 2026-05-06",
42
42
  "expires": "2027-05-06"
43
43
  },
44
- {
45
- "package": "@cosmicdriftgamestudio/*",
46
- "version": "*",
47
- "license": "UNLICENSED",
48
- "reason": "Eigene private CDGS-Workspace-Packages (kumiko-guards, später kumiko-ai-*, kumiko-designer etc.). UNLICENSED = proprietary, kein Public-License-Risk weil intern in der Org bleibt (GH-Packages access:restricted).",
49
- "reviewedBy": "Marc, 2026-05-06",
50
- "expires": "2030-05-06"
51
- },
52
- {
53
- "package": "@cosmicdrift/*",
54
- "version": "*",
55
- "license": "BUSL-1.1",
56
- "reason": "Eigene Public-Workspace-Packages (kumiko-framework + 6 weitere). Wir sind der Licensor — BUSL-1.1 ist unser eigener Lizenz-Choice für die public NPM-Pakete. Der License-Check denied BUSL-1.1 als generic-Pattern (für Third-Party-Deps korrekt), aber für eigene Packages ist's der designierte Lizenz.",
57
- "reviewedBy": "Marc, 2026-05-06",
58
- "expires": "2030-05-06"
59
- },
60
- {
61
- "package": "create-kumiko-app",
62
- "version": "*",
63
- "license": "BUSL-1.1",
64
- "reason": "Unscoped Workspace-Paket — `bun create kumiko-app` resolved zu `bunx create-kumiko-app`, daher KEIN @cosmicdrift/-Scope möglich. Analog zur @cosmicdrift/*-Exception unser eigener Lizenz-Choice; eigene Eintrag weil Wildcard-Pattern unscoped Pakete nicht matched.",
65
- "reviewedBy": "Marc, 2026-06-21",
66
- "expires": "2030-06-21"
67
- },
68
44
  {
69
45
  "package": "khroma",
70
46
  "version": "*",
@@ -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";
@@ -118,6 +116,8 @@ function checkCustomScreens(sf: SourceFile, violations: GuardViolation[]): void
118
116
  const DIRECTIVE_HEAD_CHARS = 600;
119
117
  const FEATURE_RELATIVE_PATH = /(?:src\/features|packages\/bundled-features\/src)\/[^/]+\/(.+)$/;
120
118
 
119
+ const PATH_IMPLIED_RUNTIME_DIRS = ["dev/", "scripts/"];
120
+
121
121
  // 4. runtime-client marker instead of the path
122
122
  function clientMarkerViolation(sf: SourceFile): GuardViolation | undefined {
123
123
  const filePath = sf.getFilePath();
@@ -127,13 +127,13 @@ function clientMarkerViolation(sf: SourceFile): GuardViolation | undefined {
127
127
  directiveInHead(sf.getFullText().slice(0, DIRECTIVE_HEAD_CHARS)) === "client";
128
128
  if (!hasClientMarker || isIgnoredFile(sf)) return undefined;
129
129
  const underWeb = featureRelative.startsWith("web/") || /^web\.tsx?$/.test(featureRelative);
130
- return {
131
- file: filePath,
132
- line: 1,
133
- message: underWeb
134
- ? "redundant @runtime client marker — files under web/ are client by path"
135
- : "client code outside web/ — move it under web/ (bun kumiko codemod feature-layout . --write)",
136
- };
130
+ const runtimeFromPath = PATH_IMPLIED_RUNTIME_DIRS.some((dir) => featureRelative.startsWith(dir));
131
+ const message = underWeb
132
+ ? "redundant @runtime client marker — files under web/ are client by path"
133
+ : runtimeFromPath
134
+ ? "@runtime client marker under dev/ or scripts/ — the path already sets the runtime; remove the marker (bun kumiko codemod feature-layout . --write)"
135
+ : "client code outside web/ — move it under web/ (bun kumiko codemod feature-layout . --write)";
136
+ return { file: filePath, line: 1, message };
137
137
  }
138
138
 
139
139
  export const guard: AstGuard = {
@@ -0,0 +1,223 @@
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
+ // Not preceded by a path/word character and not followed by `/`, `-` or `.ext`, so file names such
46
+ // as `round2.ts` or `phase1.sql` are not planning labels, while a sentence-final "Phase 2." is.
47
+ const PLANNING_WORD_RE =
48
+ /(?<![\w./-])(?:phase|round|welle|wave|sprint)[- ]?\d+(?:\.\d+)*[a-z]?(?![\w/-]|\.\w)|(?<![\w./-])bug-bash(?:[- ]\d+)?(?![\w/-]|\.\w)/gi;
49
+ // Dotted plan labels such as S2.H1, T1.5c, B1.5. Case-sensitive and without V so
50
+ // that version numbers (v1.2, V20.1) never count.
51
+ const DOTTED_LABEL_RE = /(?<![\w./-])[A-UW-Z]\d{1,2}(?:\.(?:\d+[a-z]?|[A-Z]\d+))+\b/g;
52
+ const WORD_RE = /[\p{L}_$][\p{L}\p{N}_$'-]*/gu;
53
+ const MIN_EXPLANATORY_WORD_LENGTH = 3;
54
+
55
+ // Words that only frame a reference ("see #12", "fix for fw#3", "part of Phase 2").
56
+ const FRAMING_WORDS: ReadonlySet<string> = new Set([
57
+ "see",
58
+ "siehe",
59
+ "vgl",
60
+ "ref",
61
+ "refs",
62
+ "issue",
63
+ "issues",
64
+ "fix",
65
+ "fixes",
66
+ "fixed",
67
+ "for",
68
+ "from",
69
+ "per",
70
+ "part",
71
+ "the",
72
+ "and",
73
+ "und",
74
+ "via",
75
+ "follow",
76
+ "followup",
77
+ "follow-up",
78
+ "tracking",
79
+ "tracked",
80
+ "todo",
81
+ "fixme",
82
+ "closes",
83
+ "resolves",
84
+ "related",
85
+ "also",
86
+ "nach",
87
+ "aus",
88
+ "laut",
89
+ ]);
90
+
91
+ function commentBody(text: string): string {
92
+ return text
93
+ .replace(/^\/\*\*?|\*\/$/g, "")
94
+ .split("\n")
95
+ .map((line) => line.replace(/^\s*(?:\/\/+|\*)?/, ""))
96
+ .join("\n");
97
+ }
98
+
99
+ function referenceTokensIn(body: string): number {
100
+ return (
101
+ (body.match(REFERENCE_RE)?.length ?? 0) +
102
+ (body.match(PLANNING_WORD_RE)?.length ?? 0) +
103
+ (body.match(DOTTED_LABEL_RE)?.length ?? 0)
104
+ );
105
+ }
106
+
107
+ function isExplanatoryWord(word: string): boolean {
108
+ return word.length >= MIN_EXPLANATORY_WORD_LENGTH && !FRAMING_WORDS.has(word.toLowerCase());
109
+ }
110
+
111
+ /** True when the comment carries a reference or planning label and no explanatory word besides it. */
112
+ export function isIssueRefOnlyComment(commentText: string): boolean {
113
+ if (lineHasIgnoreTag(commentText, IGNORE_TAG)) return false;
114
+ const body = commentBody(commentText);
115
+ if (referenceTokensIn(body) === 0) return false;
116
+ const rest = body
117
+ .replace(REFERENCE_RE, " ")
118
+ .replace(PLANNING_WORD_RE, " ")
119
+ .replace(DOTTED_LABEL_RE, " ");
120
+ return !(rest.match(WORD_RE) ?? []).some(isExplanatoryWord);
121
+ }
122
+
123
+ type CommentParagraph = { readonly start: number; readonly text: string };
124
+
125
+ function startsOwnLine(fullText: string, start: number): boolean {
126
+ const lineStart = fullText.lastIndexOf("\n", start - 1) + 1;
127
+ return fullText.slice(lineStart, start).trim() === "";
128
+ }
129
+
130
+ function continuesLineParagraph(
131
+ fullText: string,
132
+ previous: CommentTrivia,
133
+ next: CommentTrivia,
134
+ ): boolean {
135
+ if (previous.kind !== COMMENT_KIND_LINE || next.kind !== COMMENT_KIND_LINE) return false;
136
+ if (!startsOwnLine(fullText, previous.start) || !startsOwnLine(fullText, next.start))
137
+ return false;
138
+ return /^[ \t]*\r?\n[ \t]*$/.test(
139
+ fullText.slice(previous.start + previous.text.length, next.start),
140
+ );
141
+ }
142
+
143
+ export function commentParagraphs(fullText: string): CommentParagraph[] {
144
+ const paragraphs: CommentParagraph[] = [];
145
+ let current: { start: number; parts: string[]; last: CommentTrivia } | undefined;
146
+ for (const comment of scanCommentTrivia(fullText)) {
147
+ if (current && continuesLineParagraph(fullText, current.last, comment)) {
148
+ current.parts.push(comment.text);
149
+ current.last = comment;
150
+ continue;
151
+ }
152
+ if (current) paragraphs.push({ start: current.start, text: current.parts.join("\n") });
153
+ current = { start: comment.start, parts: [comment.text], last: comment };
154
+ }
155
+ if (current) paragraphs.push({ start: current.start, text: current.parts.join("\n") });
156
+ return paragraphs;
157
+ }
158
+
159
+ export type IssueRefOnlySite = { readonly line: number; readonly text: string };
160
+
161
+ export function findIssueRefOnlyComments(fullText: string): IssueRefOnlySite[] {
162
+ return commentParagraphs(fullText)
163
+ .filter((paragraph) => isIssueRefOnlyComment(paragraph.text))
164
+ .map((paragraph) => ({
165
+ line: fullText.slice(0, paragraph.start).split("\n").length,
166
+ text: paragraph.text,
167
+ }));
168
+ }
169
+
170
+ export function issueRefOnlyCounts(
171
+ files: readonly SourceFile[],
172
+ roots: ReadonlyArray<{ readonly absPath: string }> = resolveRepoRoots(),
173
+ ): { counts: Record<string, number>; firstLine: Record<string, number> } {
174
+ const counts: Record<string, number> = {};
175
+ const firstLine: Record<string, number> = {};
176
+ for (const sf of files) {
177
+ if (EXCLUDE.test(sf.getFilePath())) continue;
178
+ const sites = findIssueRefOnlyComments(sf.getFullText());
179
+ const first = sites[0];
180
+ if (first === undefined) continue;
181
+ const file = relFromRepoRoot(sf.getFilePath(), roots);
182
+ if (!isLocalFinding({ file })) continue;
183
+ counts[file] = sites.length;
184
+ firstLine[file] = first.line;
185
+ }
186
+ return { counts, firstLine };
187
+ }
188
+
189
+ const issueRefBaseline = baselineRatchet({
190
+ file: path.join(ROOT, ".kumiko-comment-issue-ref-baseline.json"),
191
+ formatVersion: 1,
192
+ unit: "issue-reference-only comment(s)",
193
+ });
194
+
195
+ const REMEDIATION =
196
+ "Replace the reference with the reason it stands for, or delete the comment; the number belongs in the commit message.";
197
+
198
+ export const guard: AstGuard = {
199
+ name: GUARD_NAME,
200
+ scan: SCAN,
201
+ hint: `after removing hits: \`kumiko-guards guards --write-baseline --guard=${GUARD_NAME}\`; deliberate exception: // ${IGNORE_TAG} <reason>`,
202
+ run: (files, roots = resolveRepoRoots()) => {
203
+ const { counts, firstLine } = issueRefOnlyCounts(files, roots);
204
+ return {
205
+ violations: issueRefBaseline.check(counts, REMEDIATION, {
206
+ formatDriftRemediation: `Run \`kumiko-guards guards --write-baseline --guard=${GUARD_NAME}\` once.`,
207
+ resolveLine: (file) => firstLine[file] ?? 1,
208
+ }),
209
+ };
210
+ },
211
+ writeBaseline: (files, roots = resolveRepoRoots()) => {
212
+ issueRefBaseline.write(issueRefOnlyCounts(files, roots).counts);
213
+ },
214
+ };
215
+
216
+ if (import.meta.main) {
217
+ if (process.argv.slice(2).includes("--write-baseline")) {
218
+ const project = buildSharedProject([guard]);
219
+ guard.writeBaseline?.(filesForGuard(project, guard));
220
+ process.exit(0);
221
+ }
222
+ runStandalone(guard);
223
+ }