@cosmicdrift/kumiko-guards 0.363.0 → 0.365.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.
@@ -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
@@ -74,7 +74,7 @@ function exportedCallables(sf: SourceFile): Export[] {
74
74
  return out;
75
75
  }
76
76
 
77
- // NodeNext-Importe schreiben ".js"/".jsx", obwohl die Quelle ".ts"/".tsx" ist
77
+ // NodeNext imports write ".js"/".jsx" although the source is ".ts"/".tsx"
78
78
  // (and likewise .mjs/.cjs for .mts/.cts) — the extension must therefore be
79
79
  // stripped for all four pair variants when comparing module paths, otherwise
80
80
  // the link breaks.
@@ -121,7 +121,6 @@ const ALLOWLIST: readonly AllowEntry[] = [
121
121
  { repo: "kumiko-framework", pattern: /^packages\/create-kumiko-app\/src\/manifest\.ts$/ },
122
122
 
123
123
  // Sample apps: codegen/screenshot-mount helper, no request input.
124
- { repo: "kumiko-framework", pattern: /^samples\/apps\/use-all-bundled\/schema\/generate\.ts$/ },
125
124
  {
126
125
  repo: "kumiko-framework",
127
126
  pattern: /^samples\/apps\/showcase\/src\/app\/mount-public-screenshots\.ts$/,
@@ -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
 
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
3
  import { join, resolve } from "node:path";
3
4
  import { pathToFileURL } from "node:url";
@@ -19,6 +20,12 @@ import { frameworkTsConfigPath, resolveRepoRoots } from "./_lib/roots";
19
20
  // write is refused.
20
21
  // Without an old baseline, `--write-baseline` is a bootstrap freeze.
21
22
  //
23
+ // The committed baseline is a file the PR itself can edit (or delete and
24
+ // bootstrap-freeze), so check mode with `--base=<ref>` additionally reads the
25
+ // baseline from the merge-base with <ref> and runs every drop against THAT
26
+ // baseline through the same ratchet gate as `--write-baseline`. Only when the
27
+ // base has no baseline either is there nothing to protect.
28
+ //
22
29
  // Scope: only the two packages with a changes.json home. packages/framework
23
30
  // has ONE changelog (src/changes.json); packages/bundled-features has one PER
24
31
  // FEATURE (src/<feature>/changes.json, feature = first export-key segment).
@@ -48,6 +55,7 @@ const OUT_OF_SCOPE_PACKAGES = [
48
55
  "renderer",
49
56
  ] as const;
50
57
 
58
+ const BASE_FLAG_PREFIX = "--base=";
51
59
  const BASELINE_FILE = ".kumiko-public-exports-baseline.json";
52
60
  const REFREEZE_COMMAND = "bun packages/guards/src/cli.ts public-exports-changelog --write-baseline";
53
61
  const BASELINE_FORMAT_VERSION = 1;
@@ -204,6 +212,19 @@ export function compareVersionsLocal(a: string, b: string): number {
204
212
  return 0;
205
213
  }
206
214
 
215
+ function escapeRegExp(value: string): string {
216
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
217
+ }
218
+
219
+ // Identifier boundaries, not substrings: `getUserById` must not cover a dropped `getUser`.
220
+ export function mentionsExportName(text: string, name: string): boolean {
221
+ return new RegExp(`(?<![A-Za-z0-9_$])${escapeRegExp(name)}(?![A-Za-z0-9_$])`).test(text);
222
+ }
223
+
224
+ function everyNameMentioned(texts: readonly string[], names: readonly string[]): boolean {
225
+ return names.every((name) => texts.some((text) => mentionsExportName(text, name)));
226
+ }
227
+
207
228
  export function isCoveredByChangelog(
208
229
  entries: readonly ChangelogEntryShape[],
209
230
  asOfVersion: string,
@@ -211,15 +232,16 @@ export function isCoveredByChangelog(
211
232
  ): boolean {
212
233
  // >= not >: package.json only bumps at release, so the entry `kumiko changes
213
234
  // add` writes in the removing PR carries the SAME version as asOfVersion.
214
- // With exportNames, the entry must also name at least one dropped export, so
215
- // a single breaking entry cannot blanket-cover unrelated drops.
216
- return entries.some((e) => {
217
- if (e.type !== "breaking") return false;
218
- if (compareVersionsLocal(e.version, asOfVersion) < 0) return false;
219
- if (exportNames.length === 0) return true;
220
- const haystack = `${e.title ?? ""} ${e.detail ?? ""}`;
221
- return exportNames.some((name) => haystack.includes(name));
222
- });
235
+ // With exportNames, every dropped export must be named by some qualifying
236
+ // entry, so a single breaking entry cannot blanket-cover unrelated drops.
237
+ const qualifying = entries.filter(
238
+ (e) => e.type === "breaking" && compareVersionsLocal(e.version, asOfVersion) >= 0,
239
+ );
240
+ if (qualifying.length === 0) return false;
241
+ return everyNameMentioned(
242
+ qualifying.map((e) => `${e.title ?? ""} ${e.detail ?? ""}`),
243
+ exportNames,
244
+ );
223
245
  }
224
246
 
225
247
  function toChangelogEntry(e: unknown): ChangelogEntryShape[] {
@@ -241,7 +263,10 @@ function readChangelogEntries(absChangelogPath: string): ChangelogEntryShape[] {
241
263
  let raw: unknown;
242
264
  try {
243
265
  raw = JSON.parse(readFileSync(absChangelogPath, "utf-8"));
244
- } catch {
266
+ } catch (e) {
267
+ console.error(
268
+ ` ${absChangelogPath}: changelog unreadable (${e instanceof Error ? e.message : String(e)}); its entries cannot cover a dropped export until the JSON is fixed.`,
269
+ );
245
270
  return [];
246
271
  }
247
272
  return Array.isArray(raw) ? raw.flatMap(toChangelogEntry) : [];
@@ -270,7 +295,7 @@ function scanPackage(cwd: string, project: Project, pkg: PackageName): PackageSc
270
295
  pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
271
296
  } catch (e) {
272
297
  console.error(
273
- ` ${pkg}: package.json unreadable (${e instanceof Error ? e.message : String(e)}).`,
298
+ ` ${pkg}: package.json unreadable (${e instanceof Error ? e.message : String(e)}). Fix the file so it is valid JSON.`,
274
299
  );
275
300
  return null;
276
301
  }
@@ -320,7 +345,9 @@ export type EvaluateResult = {
320
345
 
321
346
  function buildFinding(missing: MissingExport, version: string): UncoveredFinding {
322
347
  if (!isPackageName(missing.pkg)) {
323
- throw new Error(`baseline package key not in scope: ${missing.pkg}`);
348
+ throw new Error(
349
+ `baseline package key not in scope: ${missing.pkg}. Remove the key from the baseline or re-freeze it with --write-baseline.`,
350
+ );
324
351
  }
325
352
  const pkg = missing.pkg;
326
353
  const changelogPath = owningChangelogPath(pkg, missing.key);
@@ -370,7 +397,7 @@ function findingsAgainstBaseline(
370
397
  for (const [pkg, keys] of Object.entries(baseline.packages)) {
371
398
  if (!isPackageName(pkg)) {
372
399
  console.error(
373
- ` Baseline contains unknown package key "${pkg}", ignored (only framework|bundled-features).`,
400
+ ` 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
401
  );
375
402
  continue;
376
403
  }
@@ -393,11 +420,16 @@ export function evaluatePublicExportsChangelog(args: {
393
420
  readonly mode: EvaluateMode;
394
421
  /** Tests pass an explicit path; production resolves `<framework root>/.kumiko-public-exports-baseline.json`. */
395
422
  readonly baselinePath?: string;
423
+ /** Baseline as committed on the PR's merge-base; its drops go through the ratchet gate. */
424
+ readonly baseBaseline?: BaselinePayload;
425
+ readonly pendingBreakingChanges?: PendingBreakingChanges;
396
426
  }): EvaluateResult {
397
427
  logOutOfScopeNotice();
398
428
  const scans = scanAllPackages(args.cwd, args.project);
399
429
  if (scans.length === 0 && args.mode === "check") {
400
- console.error(" No package scanned: the guard would pass vacuously.");
430
+ console.error(
431
+ " No package scanned: the guard would pass vacuously. Run it from a kumiko-framework checkout that contains the framework package.",
432
+ );
401
433
  return { scans, baselineStatus: "ok", findings: [vacuousFinding()] };
402
434
  }
403
435
 
@@ -423,9 +455,74 @@ export function evaluatePublicExportsChangelog(args: {
423
455
  }
424
456
 
425
457
  const findings = findingsAgainstBaseline(parsed.baseline, scans);
458
+ if (args.baseBaseline) {
459
+ const alreadyReported = new Set(findings.map(findingIdentity));
460
+ const ratchet = uncoveredDrops(
461
+ args.cwd,
462
+ scans,
463
+ args.baseBaseline,
464
+ args.pendingBreakingChanges ?? new Map(),
465
+ );
466
+ findings.push(...ratchet.filter((f) => !alreadyReported.has(findingIdentity(f))));
467
+ }
426
468
  return { scans, baselineStatus: "ok", findings };
427
469
  }
428
470
 
471
+ function findingIdentity(f: UncoveredFinding): string {
472
+ return `${f.pkg}|${f.key}|${f.names.join(",")}`;
473
+ }
474
+
475
+ // ── Baseline of the merge-base ───────────────────────────────────────────
476
+
477
+ export type BaseBaselineRead =
478
+ | { readonly kind: "absent" }
479
+ | { readonly kind: "ok"; readonly baseline: BaselinePayload }
480
+ | { readonly kind: "error"; readonly detail: string };
481
+
482
+ function git(args: readonly string[], cwd: string): { ok: true; out: string } | { ok: false } {
483
+ const res = spawnSync("git", args, { cwd, encoding: "utf-8" });
484
+ return res.status === 0 ? { ok: true, out: res.stdout } : { ok: false };
485
+ }
486
+
487
+ export function readBaseBaseline(cwd: string, baseRef: string): BaseBaselineRead {
488
+ if (baseRef === "" || baseRef.startsWith("-")) {
489
+ return {
490
+ kind: "error",
491
+ detail: `--base must be a ref name such as origin/main (got "${baseRef}"); an empty value or a leading "-" would be read as a git option.`,
492
+ };
493
+ }
494
+ const mergeBase = git(["merge-base", baseRef, "HEAD"], cwd);
495
+ if (!mergeBase.ok) {
496
+ return {
497
+ kind: "error",
498
+ detail: `git merge-base ${baseRef} HEAD failed — is ${baseRef} known locally? On shallow checkouts fetch the full history or the ref.`,
499
+ };
500
+ }
501
+ const commit = mergeBase.out.trim();
502
+ const listed = git(["ls-tree", "--name-only", commit, "--", BASELINE_FILE], cwd);
503
+ if (!listed.ok) return { kind: "error", detail: `git ls-tree ${commit} failed.` };
504
+ if (listed.out.trim() === "") return { kind: "absent" };
505
+ const shown = git(["show", `${commit}:./${BASELINE_FILE}`], cwd);
506
+ if (!shown.ok) return { kind: "error", detail: `git show ${commit}:${BASELINE_FILE} failed.` };
507
+ let raw: unknown;
508
+ try {
509
+ raw = JSON.parse(shown.out);
510
+ } catch (e) {
511
+ console.error(
512
+ ` Baseline on ${baseRef} unreadable (${e instanceof Error ? e.message : String(e)}); nothing to ratchet against.`,
513
+ );
514
+ return { kind: "absent" };
515
+ }
516
+ const parsed = parseBaseline(raw);
517
+ if (parsed.kind !== "ok") {
518
+ console.error(
519
+ ` Baseline on ${baseRef} is ${parsed.kind} (${parsed.detail}); nothing to ratchet against.`,
520
+ );
521
+ return { kind: "absent" };
522
+ }
523
+ return { kind: "ok", baseline: parsed.baseline };
524
+ }
525
+
429
526
  // ── Open changesets ───────────────────────────────────────────────────────
430
527
 
431
528
  type ChangesetParser = (markdown: string, source: string) => readonly unknown[];
@@ -441,11 +538,16 @@ export async function loadChangesetParser(frameworkRootAbsPath: string): Promise
441
538
  frameworkRootAbsPath,
442
539
  "packages/framework/src/engine/changeset-changes.ts",
443
540
  );
444
- if (!existsSync(parserPath)) throw new Error(`changeset parser not found: ${parserPath}`);
541
+ if (!existsSync(parserPath))
542
+ throw new Error(
543
+ `changeset parser not found: ${parserPath}. Run the guard from a complete kumiko-framework checkout.`,
544
+ );
445
545
  const loaded: unknown = await import(pathToFileURL(parserPath).href);
446
546
  const parse = isRecord(loaded) ? loaded["parseChangesetChanges"] : undefined;
447
547
  if (!isChangesetParser(parse)) {
448
- throw new Error(`${parserPath} does not export parseChangesetChanges as a function`);
548
+ throw new Error(
549
+ `${parserPath} does not export parseChangesetChanges as a function. Export it from that file.`,
550
+ );
449
551
  }
450
552
  return parse;
451
553
  }
@@ -493,15 +595,15 @@ export async function loadPendingBreakingChanges(
493
595
  return pendingBreakingChangesIn(join(frameworkRootAbsPath, ".changeset"), parse);
494
596
  }
495
597
 
496
- // A feature-wide breaking block must not cover unrelated drops: the block has
497
- // to name at least one of the dropped exports.
598
+ // A feature-wide breaking block must not cover unrelated drops: every dropped
599
+ // export has to be named by some block of the feature.
498
600
  export function isCoveredByPendingChangeset(
499
601
  pending: PendingBreakingChanges,
500
602
  feature: string,
501
603
  droppedNames: readonly string[],
502
604
  ): boolean {
503
605
  const texts = pending.get(feature) ?? [];
504
- return texts.some((text) => droppedNames.some((name) => text.includes(name)));
606
+ return texts.length > 0 && everyNameMentioned(texts, droppedNames);
505
607
  }
506
608
 
507
609
  // ── Write baseline ────────────────────────────────────────────────────────
@@ -560,7 +662,7 @@ export function writeBaseline(
560
662
  const frameworkScan = scans.find((s) => s.pkg === "framework");
561
663
  if (!frameworkScan) {
562
664
  console.error(
563
- " framework package.json missing: asOfVersion undetermined, baseline not written.",
665
+ " framework package.json missing: asOfVersion undetermined, baseline not written. Run it from a kumiko-framework checkout.",
564
666
  );
565
667
  return 1;
566
668
  }
@@ -644,13 +746,40 @@ function reportViolations(violations: readonly GuardViolation[]): void {
644
746
  ]);
645
747
  }
646
748
 
749
+ // undefined = the ratchet inputs could not be read; the diagnosis is printed already.
750
+ async function baseRatchetFor(
751
+ scanCwd: string,
752
+ mode: EvaluateMode,
753
+ baseRef: string | undefined,
754
+ ): Promise<
755
+ { baseBaseline?: BaselinePayload; pendingBreakingChanges?: PendingBreakingChanges } | undefined
756
+ > {
757
+ if (baseRef === undefined || mode !== "check") return {};
758
+ const base = readBaseBaseline(scanCwd, baseRef);
759
+ if (base.kind === "error") {
760
+ console.error(` ${base.detail}`);
761
+ return undefined;
762
+ }
763
+ if (base.kind === "absent") return {};
764
+ try {
765
+ const pendingBreakingChanges = await loadPendingBreakingChanges(scanCwd);
766
+ return { baseBaseline: base.baseline, pendingBreakingChanges };
767
+ } catch (e) {
768
+ console.error(
769
+ ` Cannot read open changesets: ${e instanceof Error ? e.message : String(e)}. Fix the changeset file named above.`,
770
+ );
771
+ return undefined;
772
+ }
773
+ }
774
+
647
775
  export async function runPublicExportsChangelogCli(
648
776
  argv: readonly string[],
649
777
  cwd: string = process.cwd(),
650
778
  ): Promise<number> {
779
+ const baseArg = argv.find((arg) => arg.startsWith(BASE_FLAG_PREFIX));
651
780
  const flagsError = cliFlagsError(
652
781
  "public-exports-changelog",
653
- argv,
782
+ argv.filter((arg) => !arg.startsWith(BASE_FLAG_PREFIX)),
654
783
  PUBLIC_EXPORTS_CHANGELOG_FLAGS,
655
784
  );
656
785
  if (flagsError !== undefined) {
@@ -665,7 +794,9 @@ export async function runPublicExportsChangelogCli(
665
794
  }
666
795
  const tsConfigFilePath = frameworkTsConfigPath(cwd);
667
796
  if (tsConfigFilePath === undefined) {
668
- console.error(" public-exports-changelog: framework tsconfig.json not found.");
797
+ console.error(
798
+ " public-exports-changelog: framework tsconfig.json not found. Run it from a complete kumiko-framework checkout.",
799
+ );
669
800
  return 1;
670
801
  }
671
802
  const project = new Project({ tsConfigFilePath, skipAddingFilesFromTsConfig: true });
@@ -676,14 +807,18 @@ export async function runPublicExportsChangelogCli(
676
807
  try {
677
808
  pending = await loadPendingBreakingChanges(scanCwd);
678
809
  } catch (e) {
679
- console.error(` Cannot read open changesets: ${e instanceof Error ? e.message : String(e)}`);
810
+ console.error(
811
+ ` Cannot read open changesets: ${e instanceof Error ? e.message : String(e)}. Fix the changeset file named above.`,
812
+ );
680
813
  return 1;
681
814
  }
682
815
  return writeBaseline(scanCwd, project, join(scanCwd, BASELINE_FILE), pending);
683
816
  }
684
817
 
685
818
  const mode: EvaluateMode = argv.includes("--no-baseline") ? "no-baseline" : "check";
686
- const result = evaluatePublicExportsChangelog({ cwd: scanCwd, project, mode });
819
+ const ratchet = await baseRatchetFor(scanCwd, mode, baseArg?.slice(BASE_FLAG_PREFIX.length));
820
+ if (ratchet === undefined) return 1;
821
+ const result = evaluatePublicExportsChangelog({ cwd: scanCwd, project, mode, ...ratchet });
687
822
  report(result);
688
823
  const baselineBroken =
689
824
  result.baselineStatus === "format-drift" || result.baselineStatus === "missing";
@@ -177,9 +177,13 @@ function declarationBaseCallee(callee: Node): Node {
177
177
  return Node.isCallExpression(callee) ? declarationBaseCallee(callee.getExpression()) : callee;
178
178
  }
179
179
 
180
+ // An identifier can be the Playwright test body (`test(title, details, body)`) or an options
181
+ // variable like `{ retry: 2 }`, so it only counts when its type is a number or carries `timeout`.
180
182
  function isTimeoutArgument(arg: Node): boolean {
181
183
  if (Node.isObjectLiteralExpression(arg)) return hasTimeoutProperty(arg);
182
- return !Node.isArrowFunction(arg) && !Node.isFunctionExpression(arg);
184
+ if (Node.isArrowFunction(arg) || Node.isFunctionExpression(arg)) return false;
185
+ const type = arg.getType();
186
+ return type.isNumber() || type.isNumberLiteral() || type.getProperty("timeout") !== undefined;
183
187
  }
184
188
 
185
189
  function declarationTimeoutReason(
@@ -377,6 +377,9 @@ const SCAN: ScanSpec = {
377
377
  frameworkWithin: ["packages/*/src/**"],
378
378
  };
379
379
 
380
+ const WRAPPER_FIX =
381
+ "call the delegate directly, or mark a deliberate wrapper with // @wrapper-known <reason>";
382
+
380
383
  export const check: RepoCheck = {
381
384
  name: "Thin-Wrappers Guard",
382
385
  run(roots) {
@@ -403,12 +406,16 @@ export const check: RepoCheck = {
403
406
  scanned++;
404
407
  for (const f of collectFindings(sf)) {
405
408
  if (f.markerReason === null) {
406
- 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
+ });
407
414
  } else if (!isKnownReason(f.markerReason)) {
408
415
  warnings.push({
409
416
  file: f.file,
410
417
  line: f.line,
411
- 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)`,
412
419
  });
413
420
  }
414
421
  }