@cosmicdrift/kumiko-guards 0.362.0 → 0.364.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.
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: the generated agent docs under `packages/framework/agents/` must
4
+ * match what `bun run gen:agent-docs` renders from the current sources
5
+ * (feature manifest, renderer-web exports, sample recipes), and every
6
+ * `agents/<file>.md` path the hand-written `packages/framework/AGENTS.md`
7
+ * points at must exist in that generated set.
8
+ *
9
+ * Only the framework repo itself has these files; consumer repos are
10
+ * `notApplicable`.
11
+ *
12
+ * Usage:
13
+ * bun guard-agent-docs-drift.ts
14
+ */
15
+
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { AGENT_DOCS_DIR, readAgentDocsOnDisk, renderAgentDocs } from "./_lib/agent-docs";
19
+ import {
20
+ type GuardViolation,
21
+ type RepoCheck,
22
+ type RepoCheckOutcome,
23
+ reportResults,
24
+ runRepoChecks,
25
+ } from "./_lib/guard-kit";
26
+ import type { RepoRoot } from "./_lib/roots";
27
+
28
+ const FRAMEWORK_PACKAGE_JSON_REL = "packages/framework/package.json";
29
+ const FRAMEWORK_PACKAGE_NAME = "@cosmicdrift/kumiko-framework";
30
+ const AGENTS_MD_REL = "packages/framework/AGENTS.md";
31
+ const AGENTS_REF_RE = /`agents\/([A-Za-z0-9._/-]+\.md)`/g;
32
+ const REGENERATE_HINT = "run `bun run gen:agent-docs` and commit the result";
33
+
34
+ function isFrameworkRepo(root: RepoRoot): boolean {
35
+ const packageJsonPath = join(root.absPath, FRAMEWORK_PACKAGE_JSON_REL);
36
+ if (!existsSync(packageJsonPath)) return false;
37
+ try {
38
+ const parsed: unknown = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
39
+ return (
40
+ typeof parsed === "object" &&
41
+ parsed !== null &&
42
+ "name" in parsed &&
43
+ parsed.name === FRAMEWORK_PACKAGE_NAME
44
+ );
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ function firstDifferingLine(expected: string, actual: string): number {
51
+ const expectedLines = expected.split("\n");
52
+ const actualLines = actual.split("\n");
53
+ const length = Math.max(expectedLines.length, actualLines.length);
54
+ for (let index = 0; index < length; index++) {
55
+ if (expectedLines[index] !== actualLines[index]) return index + 1;
56
+ }
57
+ return 1;
58
+ }
59
+
60
+ function agentsMdReferenceViolations(
61
+ absPath: string,
62
+ generated: ReadonlyMap<string, string>,
63
+ ): GuardViolation[] {
64
+ const agentsMdPath = join(absPath, AGENTS_MD_REL);
65
+ if (!existsSync(agentsMdPath)) return [];
66
+ const violations: GuardViolation[] = [];
67
+ const lines = readFileSync(agentsMdPath, "utf-8").split("\n");
68
+ lines.forEach((text, index) => {
69
+ for (const match of text.matchAll(AGENTS_REF_RE)) {
70
+ const key = match[1];
71
+ if (key === undefined || generated.has(key)) continue;
72
+ violations.push({
73
+ file: AGENTS_MD_REL,
74
+ line: index + 1,
75
+ message: `references agents/${key}, which is not generated. Fix the reference or add the source (an example needs samples/recipes/<slug>/package.json).`,
76
+ });
77
+ }
78
+ });
79
+ return violations;
80
+ }
81
+
82
+ function rootViolations(absPath: string): { violations: GuardViolation[]; fileCount: number } {
83
+ const generated = renderAgentDocs(absPath);
84
+ const onDisk = readAgentDocsOnDisk(absPath);
85
+ const violations: GuardViolation[] = [];
86
+ for (const [rel, expected] of generated) {
87
+ const file = `${AGENT_DOCS_DIR}/${rel}`;
88
+ const actual = onDisk.get(rel);
89
+ if (actual === undefined) {
90
+ violations.push({
91
+ file,
92
+ line: 1,
93
+ message: `generated file is missing; ${REGENERATE_HINT}`,
94
+ });
95
+ } else if (actual !== expected) {
96
+ violations.push({
97
+ file,
98
+ line: firstDifferingLine(expected, actual),
99
+ message: `content differs from the generated output; ${REGENERATE_HINT}`,
100
+ });
101
+ }
102
+ }
103
+ for (const rel of onDisk.keys()) {
104
+ if (generated.has(rel)) continue;
105
+ violations.push({
106
+ file: `${AGENT_DOCS_DIR}/${rel}`,
107
+ line: 1,
108
+ message: `stale file no longer produced by the generator; ${REGENERATE_HINT}`,
109
+ });
110
+ }
111
+ violations.push(...agentsMdReferenceViolations(absPath, generated));
112
+ return { violations, fileCount: generated.size };
113
+ }
114
+
115
+ export const check: RepoCheck = {
116
+ name: "agent-docs-drift",
117
+ hint: "Run `bun run gen:agent-docs` and commit packages/framework/agents/.",
118
+ run(roots): RepoCheckOutcome {
119
+ const frameworkRoots = roots.filter(isFrameworkRepo);
120
+ if (frameworkRoots.length === 0) {
121
+ return { violations: [], matchedFiles: 0, notApplicable: true };
122
+ }
123
+ const violations: GuardViolation[] = [];
124
+ let matchedFiles = 0;
125
+ for (const root of frameworkRoots) {
126
+ const result = rootViolations(root.absPath);
127
+ violations.push(...result.violations);
128
+ matchedFiles += result.fileCount;
129
+ }
130
+ return { violations, matchedFiles, notApplicable: false };
131
+ },
132
+ };
133
+
134
+ if (import.meta.main) {
135
+ const failed = reportResults(await runRepoChecks([check]));
136
+ process.exit(failed > 0 ? 1 : 0);
137
+ }
@@ -36,12 +36,16 @@ const SCAN: ScanSpec = {
36
36
 
37
37
  const EXCLUDE = /(\.d\.(ts|tsx)$|\/dist\/)/;
38
38
 
39
+ // Case-sensitive lower case: an upper-case licence name or a capitalised name
40
+ // must not count, and a single short marker that is also an English word is
41
+ // not enough.
39
42
  const DE_MARKER_RE =
40
- /\b(und|oder|für|wenn|nicht|dann|auch|noch|wird|sind|mit|zum|zur|bei|nach|ohne|über|kein|eine|einen|dieser|diese)\b/i;
43
+ /\b(und|oder|für|wenn|nicht|dann|auch|noch|wird|sind|mit|zum|zur|bei|nach|ohne|über|kein|eine|einen|dieser|diese)\b/g;
44
+ const MIN_DE_MARKER_HITS = 2;
41
45
  const UMLAUT_RE = /[äöüÄÖÜß]/;
42
46
 
43
47
  export function isGermanComment(text: string): boolean {
44
- return DE_MARKER_RE.test(text) || UMLAUT_RE.test(text);
48
+ return UMLAUT_RE.test(text) || (text.match(DE_MARKER_RE)?.length ?? 0) >= MIN_DE_MARKER_HITS;
45
49
  }
46
50
 
47
51
  export type Site = {
@@ -68,13 +72,8 @@ function germanCommentSite(
68
72
  };
69
73
  }
70
74
 
71
- // After these tokens a `/` is division, not the start of a regex literal.
72
- const DIVISION_PRECEDING_KINDS: ReadonlySet<ts.SyntaxKind> = new Set([
73
- ts.SyntaxKind.Identifier,
74
- ts.SyntaxKind.ThisKeyword,
75
- ts.SyntaxKind.TrueKeyword,
76
- ts.SyntaxKind.FalseKeyword,
77
- ts.SyntaxKind.NullKeyword,
75
+ // Tokens that end an operand without being identifiers or keywords.
76
+ const OPERAND_END_PUNCTUATION_KINDS: ReadonlySet<ts.SyntaxKind> = new Set([
78
77
  ts.SyntaxKind.NumericLiteral,
79
78
  ts.SyntaxKind.BigIntLiteral,
80
79
  ts.SyntaxKind.StringLiteral,
@@ -88,6 +87,42 @@ const DIVISION_PRECEDING_KINDS: ReadonlySet<ts.SyntaxKind> = new Set([
88
87
  ts.SyntaxKind.MinusMinusToken,
89
88
  ]);
90
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
+
91
126
  const TRIVIA_KINDS: ReadonlySet<ts.SyntaxKind> = new Set([
92
127
  ts.SyntaxKind.SingleLineCommentTrivia,
93
128
  ts.SyntaxKind.MultiLineCommentTrivia,
@@ -101,11 +136,9 @@ const TRIVIA_KINDS: ReadonlySet<ts.SyntaxKind> = new Set([
101
136
  function rescanSlashAsRegex(
102
137
  scanner: ts.Scanner,
103
138
  kind: ts.SyntaxKind,
104
- previousSignificant: ts.SyntaxKind | undefined,
139
+ previousEndedOperand: boolean,
105
140
  ): ts.SyntaxKind {
106
- if (previousSignificant !== undefined && DIVISION_PRECEDING_KINDS.has(previousSignificant)) {
107
- return kind;
108
- }
141
+ if (previousEndedOperand) return kind;
109
142
  const reScanned = scanner.reScanSlashToken();
110
143
  return reScanned === ts.SyntaxKind.RegularExpressionLiteral ? reScanned : kind;
111
144
  }
@@ -135,7 +168,7 @@ export function scanGermanComments(fullText: string, file: string): Site[] {
135
168
 
136
169
  const templateBraceDepths: number[] = [];
137
170
  let braceDepth = 0;
138
- let previousSignificant: ts.SyntaxKind | undefined;
171
+ let previousEndedOperand = false;
139
172
 
140
173
  let kind = scanner.scan();
141
174
  while (kind !== ts.SyntaxKind.EndOfFileToken) {
@@ -153,7 +186,7 @@ export function scanGermanComments(fullText: string, file: string): Site[] {
153
186
  }
154
187
  case ts.SyntaxKind.SlashToken:
155
188
  case ts.SyntaxKind.SlashEqualsToken:
156
- kind = rescanSlashAsRegex(scanner, kind, previousSignificant);
189
+ kind = rescanSlashAsRegex(scanner, kind, previousEndedOperand);
157
190
  break;
158
191
  case ts.SyntaxKind.TemplateHead:
159
192
  case ts.SyntaxKind.TemplateMiddle:
@@ -173,7 +206,7 @@ export function scanGermanComments(fullText: string, file: string): Site[] {
173
206
  default:
174
207
  break;
175
208
  }
176
- if (!TRIVIA_KINDS.has(kind)) previousSignificant = kind;
209
+ if (!TRIVIA_KINDS.has(kind)) previousEndedOperand = isOperandEnd(kind, previousEndedOperand);
177
210
  kind = scanner.scan();
178
211
  }
179
212
  return sites;
@@ -234,21 +267,20 @@ function gitOrFail(args: readonly string[], cwd: string, message: string): strin
234
267
  const REWRITE_HINT = `Translate new German comments to English (why, not what), or if deliberate:\n // ${IGNORE_TAG} <reason>`;
235
268
 
236
269
  function runTouchedMode(
237
- all: readonly Site[],
238
270
  argv: readonly string[],
239
271
  root: string,
240
- repoLocalScannedFiles: number,
272
+ repoLocalScannedFiles: ReadonlySet<string>,
241
273
  ): number {
242
274
  const baseArg = argv.find((arg) => arg.startsWith(BASE_FLAG_PREFIX));
243
275
  const baseRef = baseArg ? baseArg.slice(BASE_FLAG_PREFIX.length) : "origin/main";
244
276
  if (baseRef.startsWith("-")) {
245
277
  throw new CommentLangFailure(
246
- `--base must not start with "-" (${baseRef}): it would be read as a git option, not a ref.`,
278
+ `--base must not start with "-" (${baseRef}): it would be read as a git option, not a ref. Pass a ref name such as origin/main.`,
247
279
  );
248
280
  }
249
281
 
250
282
  // No repo-local file scanned at all must not read as clean (vacuous pass).
251
- if (repoLocalScannedFiles === 0) {
283
+ if (repoLocalScannedFiles.size === 0) {
252
284
  console.log(" Touched mode: 0 repo-local files scanned — not applicable, no failure.");
253
285
  return 0;
254
286
  }
@@ -256,7 +288,7 @@ function runTouchedMode(
256
288
  const gitToplevel = gitOrFail(
257
289
  ["rev-parse", "--show-toplevel"],
258
290
  root,
259
- "Touched mode: no git repository found.",
291
+ "Touched mode: no git repository found. Run it inside a git checkout.",
260
292
  ).trim();
261
293
  const mergeBase = gitOrFail(
262
294
  ["merge-base", baseRef, "HEAD"],
@@ -265,35 +297,37 @@ function runTouchedMode(
265
297
  ).trim();
266
298
  // -z: NUL-terminated and unquoted, so non-ASCII paths still match the lookup below.
267
299
  const touchedFiles = gitOrFail(
268
- ["diff", "--name-only", "-z", "--diff-filter=ACMR", mergeBase],
300
+ ["diff", "--name-only", "-z", "--diff-filter=ACMR", mergeBase, "HEAD"],
269
301
  gitToplevel,
270
- "Touched mode: git diff --name-only failed.",
302
+ "Touched mode: git diff --name-only failed. Check the git error below and that the base ref is reachable.",
271
303
  )
272
304
  .split("\0")
273
305
  .filter(Boolean);
274
306
 
275
- // Sites are keyed relative to root, git output relative to the toplevel;
276
- // a silent mismatch would read as "no violations".
277
- const sitesByGitFile = new Map<string, Site[]>();
278
- for (const site of all.filter(isLocalFinding)) {
279
- const gitRelPath = path.relative(gitToplevel, path.resolve(root, site.file));
280
- sitesByGitFile.set(gitRelPath, [...(sitesByGitFile.get(gitRelPath) ?? []), site]);
281
- }
282
-
307
+ // The commits being pushed are checked, not the working tree: contents come from
308
+ // HEAD so line numbers match the `mergeBase..HEAD` diff. Scan scope is keyed
309
+ // relative to root, git output relative to the toplevel.
283
310
  const violations: Array<{ file: string; sites: Site[] }> = [];
284
311
  for (const file of touchedFiles) {
285
- const candidates = sitesByGitFile.get(file);
286
- if (!candidates || candidates.length === 0) continue;
312
+ const rootRelFile = path.relative(root, path.resolve(gitToplevel, file));
313
+ if (!repoLocalScannedFiles.has(rootRelFile)) continue;
287
314
  // --literal-pathspecs: filenames with glob magic must match themselves.
315
+ const headContent = gitOrFail(
316
+ ["--literal-pathspecs", "show", `HEAD:${file}`],
317
+ gitToplevel,
318
+ `Touched mode: git show HEAD:${file} failed. Check the git error below.`,
319
+ );
320
+ const candidates = scanGermanComments(headContent, rootRelFile);
321
+ if (candidates.length === 0) continue;
288
322
  const addedLines = parseAddedLineRanges(
289
323
  gitOrFail(
290
- ["--literal-pathspecs", "diff", "--unified=0", mergeBase, "--", file],
324
+ ["--literal-pathspecs", "diff", "--unified=0", mergeBase, "HEAD", "--", file],
291
325
  gitToplevel,
292
- `Touched mode: git diff --unified=0 failed for ${file}.`,
326
+ `Touched mode: git diff --unified=0 failed for ${file}. Check the git error below.`,
293
327
  ),
294
328
  );
295
329
  const touchedSites = sitesTouchedByAddedLines(candidates, addedLines);
296
- if (touchedSites.length > 0) violations.push({ file, sites: touchedSites });
330
+ if (touchedSites.length > 0) violations.push({ file: rootRelFile, sites: touchedSites });
297
331
  }
298
332
 
299
333
  if (violations.length === 0) {
@@ -470,25 +504,27 @@ export function runCommentLangCli(
470
504
  try {
471
505
  const all: Site[] = [];
472
506
  let scanned = 0;
473
- let repoLocalScanned = 0;
507
+ const repoLocalScanned = new Set<string>();
474
508
  for (const absPath of scanFiles(SCAN, resolveRepoRoots(root))) {
475
509
  if (EXCLUDE.test(absPath)) continue;
476
510
  scanned++;
477
511
  const file = path.relative(root, absPath);
478
- if (isLocalFinding({ file })) repoLocalScanned++;
512
+ if (isLocalFinding({ file })) repoLocalScanned.add(file);
479
513
  all.push(...scanGermanComments(readFileSync(absPath, "utf-8"), file));
480
514
  }
481
515
 
482
516
  // Zero scanned files must not read as a clean pass (misresolved root, empty scope).
483
517
  if (scanned === 0) {
484
- throw new CommentLangFailure("Scanned 0 files — repo root or scan scope did not resolve.");
518
+ throw new CommentLangFailure(
519
+ "Scanned 0 files — repo root or scan scope did not resolve. Run it from inside a repo checkout whose kumiko.json declares the source roots.",
520
+ );
485
521
  }
486
522
 
487
523
  console.log(
488
524
  `Comment-Language Audit: ${scanned} files checked, ${all.length} German comment(s) found.`,
489
525
  );
490
526
 
491
- if (argv.includes("--touched")) return runTouchedMode(all, argv, root, repoLocalScanned);
527
+ if (argv.includes("--touched")) return runTouchedMode(argv, root, repoLocalScanned);
492
528
  if (argv.includes("--list")) {
493
529
  for (const site of all.slice(0, LIST_LIMIT)) {
494
530
  console.log(` ${site.file}:${site.line} ${site.snippet}`);
@@ -461,6 +461,9 @@ export const guard: AstGuard = {
461
461
  name: "Direct-Entity-Writes Guard",
462
462
  scan: SCAN,
463
463
  security: true,
464
+ hint:
465
+ "Write ES-entity tables through the entity write handlers / event-store executor or inside an r.projection apply (tx receiver); a direct insert/update/delete lets the projection row drift from the event stream. " +
466
+ "For a BLOCKED scan, check that the scan still covers the files declaring r.entity / createEventStoreExecutor.",
464
467
  run(files) {
465
468
  // Merge both collections BEFORE the empty-set check — otherwise a repo
466
469
  // that only uses r.entity (no createEventStoreExecutor anymore) would
@@ -83,7 +83,7 @@ function templateStaticText(tpl: Node): string {
83
83
  if (tpl.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)) {
84
84
  return tpl.getLiteralText();
85
85
  }
86
- const t = tpl as TemplateExpression;
86
+ const t = tpl.asKindOrThrow(SyntaxKind.TemplateExpression);
87
87
  return [
88
88
  t.getHead().getLiteralText(),
89
89
  ...t.getTemplateSpans().map((s) => s.getLiteral().getLiteralText()),
@@ -172,7 +172,7 @@ type LocalFunction = FunctionDeclaration | ArrowFunction | FunctionExpression;
172
172
 
173
173
  function localFunctionsOf(callee: Node, sf: SourceFile): LocalFunction[] | undefined {
174
174
  if (!callee.isKind(SyntaxKind.Identifier)) return undefined;
175
- const decls = localDeclarations(callee as Identifier, sf);
175
+ const decls = localDeclarations(callee, sf);
176
176
  if (decls.length === 0) return undefined;
177
177
  const fns: LocalFunction[] = [];
178
178
  for (const d of decls) {
@@ -163,6 +163,9 @@ const PATTERNS: readonly Pattern[] = [
163
163
  export const guard: AstGuard = {
164
164
  name: "Pre-ES-Patterns Guard",
165
165
  scan: SCAN,
166
+ hint:
167
+ "These APIs were removed with the event-sourcing migration. Use the events table with ctx.loadAggregate / queryProjection, ctx.appendEvent on aggregate streams, " +
168
+ 'r.multiStreamProjection for cross-aggregate consumers, and configValue.created/updated/deleted events (aggregateType: "configValue") for config changes.',
166
169
  run(files) {
167
170
  const violations: Array<{ file: string; line: number; message: string }> = [];
168
171
 
@@ -270,7 +270,7 @@ function scanPackage(cwd: string, project: Project, pkg: PackageName): PackageSc
270
270
  pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
271
271
  } catch (e) {
272
272
  console.error(
273
- ` ${pkg}: package.json unreadable (${e instanceof Error ? e.message : String(e)}).`,
273
+ ` ${pkg}: package.json unreadable (${e instanceof Error ? e.message : String(e)}). Fix the file so it is valid JSON.`,
274
274
  );
275
275
  return null;
276
276
  }
@@ -320,7 +320,9 @@ export type EvaluateResult = {
320
320
 
321
321
  function buildFinding(missing: MissingExport, version: string): UncoveredFinding {
322
322
  if (!isPackageName(missing.pkg)) {
323
- throw new Error(`baseline package key not in scope: ${missing.pkg}`);
323
+ throw new Error(
324
+ `baseline package key not in scope: ${missing.pkg}. Remove the key from the baseline or re-freeze it with --write-baseline.`,
325
+ );
324
326
  }
325
327
  const pkg = missing.pkg;
326
328
  const changelogPath = owningChangelogPath(pkg, missing.key);
@@ -370,7 +372,7 @@ function findingsAgainstBaseline(
370
372
  for (const [pkg, keys] of Object.entries(baseline.packages)) {
371
373
  if (!isPackageName(pkg)) {
372
374
  console.error(
373
- ` Baseline contains unknown package key "${pkg}", ignored (only framework|bundled-features).`,
375
+ ` Baseline contains unknown package key "${pkg}", ignored (only framework|bundled-features). Remove the key from the baseline or re-freeze it with --write-baseline.`,
374
376
  );
375
377
  continue;
376
378
  }
@@ -397,7 +399,9 @@ export function evaluatePublicExportsChangelog(args: {
397
399
  logOutOfScopeNotice();
398
400
  const scans = scanAllPackages(args.cwd, args.project);
399
401
  if (scans.length === 0 && args.mode === "check") {
400
- console.error(" No package scanned: the guard would pass vacuously.");
402
+ console.error(
403
+ " No package scanned: the guard would pass vacuously. Run it from a kumiko-framework checkout that contains the framework package.",
404
+ );
401
405
  return { scans, baselineStatus: "ok", findings: [vacuousFinding()] };
402
406
  }
403
407
 
@@ -441,11 +445,16 @@ export async function loadChangesetParser(frameworkRootAbsPath: string): Promise
441
445
  frameworkRootAbsPath,
442
446
  "packages/framework/src/engine/changeset-changes.ts",
443
447
  );
444
- if (!existsSync(parserPath)) throw new Error(`changeset parser not found: ${parserPath}`);
448
+ if (!existsSync(parserPath))
449
+ throw new Error(
450
+ `changeset parser not found: ${parserPath}. Run the guard from a complete kumiko-framework checkout.`,
451
+ );
445
452
  const loaded: unknown = await import(pathToFileURL(parserPath).href);
446
453
  const parse = isRecord(loaded) ? loaded["parseChangesetChanges"] : undefined;
447
454
  if (!isChangesetParser(parse)) {
448
- throw new Error(`${parserPath} does not export parseChangesetChanges as a function`);
455
+ throw new Error(
456
+ `${parserPath} does not export parseChangesetChanges as a function. Export it from that file.`,
457
+ );
449
458
  }
450
459
  return parse;
451
460
  }
@@ -560,7 +569,7 @@ export function writeBaseline(
560
569
  const frameworkScan = scans.find((s) => s.pkg === "framework");
561
570
  if (!frameworkScan) {
562
571
  console.error(
563
- " framework package.json missing: asOfVersion undetermined, baseline not written.",
572
+ " framework package.json missing: asOfVersion undetermined, baseline not written. Run it from a kumiko-framework checkout.",
564
573
  );
565
574
  return 1;
566
575
  }
@@ -665,7 +674,9 @@ export async function runPublicExportsChangelogCli(
665
674
  }
666
675
  const tsConfigFilePath = frameworkTsConfigPath(cwd);
667
676
  if (tsConfigFilePath === undefined) {
668
- console.error(" public-exports-changelog: framework tsconfig.json not found.");
677
+ console.error(
678
+ " public-exports-changelog: framework tsconfig.json not found. Run it from a complete kumiko-framework checkout.",
679
+ );
669
680
  return 1;
670
681
  }
671
682
  const project = new Project({ tsConfigFilePath, skipAddingFilesFromTsConfig: true });
@@ -676,7 +687,9 @@ export async function runPublicExportsChangelogCli(
676
687
  try {
677
688
  pending = await loadPendingBreakingChanges(scanCwd);
678
689
  } catch (e) {
679
- console.error(` Cannot read open changesets: ${e instanceof Error ? e.message : String(e)}`);
690
+ console.error(
691
+ ` Cannot read open changesets: ${e instanceof Error ? e.message : String(e)}. Fix the changeset file named above.`,
692
+ );
680
693
  return 1;
681
694
  }
682
695
  return writeBaseline(scanCwd, project, join(scanCwd, BASELINE_FILE), pending);
@@ -78,7 +78,7 @@ async function scanAllRepos(roots: readonly RepoRoot[]): Promise<{
78
78
  for (const hit of report.hits) {
79
79
  if (hit.allowed) continue;
80
80
  if (hit.file.includes("/__tests__/")) continue;
81
- if (!(BLOCKING_SQL_KINDS as readonly string[]).includes(hit.kind)) continue;
81
+ if (!BLOCKING_SQL_KINDS.some((kind) => kind === hit.kind)) continue;
82
82
  if (hit.markerSuppressed) {
83
83
  // One line like `asRawClient(db).unsafe(` yields two hits for one marker.
84
84
  if (hit.markerReason !== undefined && hit.markerLine !== undefined) {
@@ -36,7 +36,6 @@ import {
36
36
  Project,
37
37
  type SourceFile,
38
38
  SyntaxKind,
39
- type VariableDeclaration,
40
39
  } from "ts-morph";
41
40
  import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
42
41
  import { frameworkTsConfigPath } from "./_lib/roots";
@@ -62,7 +61,7 @@ export const KNOWN_WRAPPER_REASONS = [
62
61
  export type WrapperReason = (typeof KNOWN_WRAPPER_REASONS)[number];
63
62
 
64
63
  function isKnownReason(r: string): r is WrapperReason {
65
- return (KNOWN_WRAPPER_REASONS as readonly string[]).includes(r);
64
+ return KNOWN_WRAPPER_REASONS.some((known) => known === r);
66
65
  }
67
66
 
68
67
  // ── Built-in filter ─────────────────────────────────────────────────────
@@ -222,7 +221,7 @@ function getFnName(node: FnNode): string | undefined {
222
221
  if (node.isKind(SyntaxKind.FunctionDeclaration)) return node.getName() ?? undefined;
223
222
  const parent = node.getParent();
224
223
  if (parent?.isKind(SyntaxKind.VariableDeclaration)) {
225
- return (parent as VariableDeclaration).getName();
224
+ return parent.getName();
226
225
  }
227
226
  if (parent?.isKind(SyntaxKind.PropertyAssignment)) {
228
227
  return parent.getFirstChild()?.getText();
@@ -378,6 +377,9 @@ const SCAN: ScanSpec = {
378
377
  frameworkWithin: ["packages/*/src/**"],
379
378
  };
380
379
 
380
+ const WRAPPER_FIX =
381
+ "call the delegate directly, or mark a deliberate wrapper with // @wrapper-known <reason>";
382
+
381
383
  export const check: RepoCheck = {
382
384
  name: "Thin-Wrappers Guard",
383
385
  run(roots) {
@@ -404,12 +406,16 @@ export const check: RepoCheck = {
404
406
  scanned++;
405
407
  for (const f of collectFindings(sf)) {
406
408
  if (f.markerReason === null) {
407
- warnings.push({ file: f.file, line: f.line, message: `${f.fnName} → ${f.callee}` });
409
+ warnings.push({
410
+ file: f.file,
411
+ line: f.line,
412
+ message: `${f.fnName} → ${f.callee} (${WRAPPER_FIX})`,
413
+ });
408
414
  } else if (!isKnownReason(f.markerReason)) {
409
415
  warnings.push({
410
416
  file: f.file,
411
417
  line: f.line,
412
- message: `${f.fnName} → ${f.callee} (reason="${f.markerReason}")`,
418
+ message: `${f.fnName} → ${f.callee} (reason="${f.markerReason}"; use a known reason from the list in this guard)`,
413
419
  });
414
420
  }
415
421
  }