@cosmicdrift/kumiko-guards 0.363.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.
@@ -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
@@ -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);
@@ -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
  }
@@ -21,6 +21,7 @@
21
21
  * bun guard-upgrade-state.ts
22
22
  */
23
23
 
24
+ import { createHash } from "node:crypto";
24
25
  import { existsSync, readFileSync } from "node:fs";
25
26
  import { join } from "node:path";
26
27
  import {
@@ -84,19 +85,45 @@ export function isUpgradeJson(value: unknown): value is UpgradeJson {
84
85
  );
85
86
  }
86
87
 
87
- export function isPendingManualStep(value: unknown): value is PendingManualStep {
88
- if (!isRecord(value)) return false;
89
- return (
90
- typeof value["id"] === "string" &&
91
- value["id"].length > 0 &&
92
- typeof value["version"] === "string" &&
93
- typeof value["title"] === "string"
94
- );
88
+ // Same id scheme as manualEntryId in @cosmicdrift/kumiko-framework/upgrade-cli. Duplicated because
89
+ // this package must stay installable without the framework; a test pins both to the same value.
90
+ const MANUAL_ID_HASH_LENGTH = 8;
91
+
92
+ export function manualEntryId(version: string, title: string): string {
93
+ const hash = createHash("sha256").update(title).digest("hex").slice(0, MANUAL_ID_HASH_LENGTH);
94
+ return `${version}:${hash}`;
95
95
  }
96
96
 
97
- function readPendingManual(parsed: unknown): readonly PendingManualStep[] {
97
+ /** Markers written before entries carried an `id` get the id the CLI derives for them. */
98
+ export function parsePendingManualStep(value: unknown): PendingManualStep | null {
99
+ if (!isRecord(value)) return null;
100
+ const { id, version, title } = value;
101
+ if (typeof version !== "string" || typeof title !== "string") return null;
102
+ return {
103
+ id: typeof id === "string" && id.length > 0 ? id : manualEntryId(version, title),
104
+ version,
105
+ title,
106
+ };
107
+ }
108
+
109
+ function readPendingManual(
110
+ parsed: unknown,
111
+ ): { readonly steps: readonly PendingManualStep[] } | { readonly error: string } {
98
112
  const raw = isRecord(parsed) ? parsed["pendingManual"] : undefined;
99
- return Array.isArray(raw) ? raw.filter(isPendingManualStep) : [];
113
+ if (raw === undefined) return { steps: [] };
114
+ if (!Array.isArray(raw))
115
+ return { error: `${MARKER_REL} has a "pendingManual" that is not an array` };
116
+ const steps: PendingManualStep[] = [];
117
+ for (const [index, entry] of raw.entries()) {
118
+ const step = parsePendingManualStep(entry);
119
+ if (step === null) {
120
+ return {
121
+ error: `${MARKER_REL} has a malformed pendingManual entry at index ${index} (needs string "version" and "title")`,
122
+ };
123
+ }
124
+ steps.push(step);
125
+ }
126
+ return { steps };
100
127
  }
101
128
 
102
129
  export function readMarker(root: string): UpgradeMarker | { error: string } {
@@ -118,7 +145,9 @@ export function readMarker(root: string): UpgradeMarker | { error: string } {
118
145
  error: `${MARKER_REL} is missing a valid "version" field (expected semver x.y.z[-pre][+build]) — broken marker file`,
119
146
  };
120
147
  }
121
- return { version, pendingManual: readPendingManual(parsed) };
148
+ const pendingManual = readPendingManual(parsed);
149
+ if ("error" in pendingManual) return pendingManual;
150
+ return { version, pendingManual: pendingManual.steps };
122
151
  }
123
152
 
124
153
  /** Pending changelog entries turned into guard violations — pure, testable without a subprocess. */
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { AGENT_DOCS_DIR, renderAgentDocs, writeAgentDocs } from "./_lib/agent-docs";
1
2
  export { gitEnv } from "./_lib/git-env";
2
3
  export {
3
4
  type AstGuard,
package/src/pre-push.sh CHANGED
@@ -18,6 +18,9 @@
18
18
  # sub-repo's own package.json `test` script — refuses the push if that
19
19
  # script is missing.
20
20
  #
21
+ # Before the checks below, the hook runs a gitleaks scan of origin/main..HEAD
22
+ # when the binary is installed.
23
+ #
21
24
  # Extension point: a tracked, executable scripts/pre-push-extra.sh runs
22
25
  # before the main check in every branch above, with KUMIKO_PUSH_REPO_ROOT
23
26
  # and KUMIKO_PUSH_PARENT_DIR available inline (never exported).
@@ -118,6 +121,22 @@ if [ -f "$REPO_ROOT/.kumiko/upgrade-state.json" ]; then
118
121
  fi
119
122
  fi
120
123
 
124
+ # Secret scan: runs before check-wt.sh/bun check so a leaked credential is
125
+ # caught in seconds, not after the long checks. A missing binary or a missing
126
+ # origin/main only warns, so a machine without gitleaks is not locked out.
127
+ # No --config: gitleaks reads a .gitleaks.toml in the repo root itself.
128
+ if ! command -v gitleaks >/dev/null 2>&1; then
129
+ echo "[pre-push] gitleaks not installed — secret scan skipped. Install: brew install gitleaks" >&2
130
+ elif ! git -C "$REPO_ROOT" rev-parse --verify --quiet origin/main >/dev/null 2>&1; then
131
+ echo "[pre-push] origin/main not found — secret scan skipped" >&2
132
+ else
133
+ echo "[pre-push] gitleaks git origin/main..HEAD"
134
+ if ! (cd "$REPO_ROOT" && gitleaks git --no-banner --redact --log-opts="origin/main..HEAD" .); then
135
+ echo "[pre-push] gitleaks found secrets — refusing push" >&2
136
+ exit 1
137
+ fi
138
+ fi
139
+
121
140
  # infra#559/#569: from a worktree, `--git-common-dir` resolves to the main
122
141
  # checkout's .git, so the branch below would scope `bun check` to the
123
142
  # canonical sibling path instead of this worktree — checking the wrong
@@ -7,6 +7,7 @@ import { check as realProviderIsolation } from "./check-real-provider-isolation"
7
7
  import { check as runtimeIsolation } from "./check-runtime-isolation";
8
8
  import { check as secretLiterals } from "./check-secret-literals";
9
9
  import { check as singleRuntimeInstance } from "./check-single-runtime-instance";
10
+ import { check as agentDocsDrift } from "./guard-agent-docs-drift";
10
11
  import { check as featureIntegrationTests } from "./guard-feature-integration-tests";
11
12
  import { check as noDirectProcessEnv } from "./guard-no-direct-process-env";
12
13
  import { check as primitivesDiscipline } from "./guard-primitives-discipline";
@@ -29,6 +30,7 @@ export const REPO_CHECKS = [
29
30
  upgradeState,
30
31
  realProviderIsolation,
31
32
  singleRuntimeInstance,
33
+ agentDocsDrift,
32
34
  ];
33
35
 
34
36
  export const REPO_CHECK_FLAGS: readonly string[] = ["--write-baseline"];