@cosmicdrift/kumiko-guards 0.359.0 → 0.361.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.359.0",
3
+ "version": "0.361.0",
4
4
  "description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -28,7 +28,7 @@
28
28
  "kumiko-pre-push": "./src/pre-push.sh"
29
29
  },
30
30
  "dependencies": {
31
- "@cosmicdrift/kumiko-repo-manifest": "0.359.0",
31
+ "@cosmicdrift/kumiko-repo-manifest": "0.361.0",
32
32
  "ts-morph": "^28.0.0"
33
33
  },
34
34
  "publishConfig": {
@@ -0,0 +1,25 @@
1
+ import { basename } from "node:path";
2
+ import { findLocalRepo, localRepoDirName } from "./roots";
3
+
4
+ // A worktree's folder name proves nothing (`kumiko-*` siblings share prefixes), so it is judged by its canonical checkout's directory name.
5
+ function pushRootMatchesCliScope(pushRepoRoot: string, cliScope: string): boolean {
6
+ if (basename(pushRepoRoot) === cliScope) return true;
7
+ const local = findLocalRepo(pushRepoRoot);
8
+ return local !== undefined && (local.name === cliScope || localRepoDirName(local) === cliScope);
9
+ }
10
+
11
+ // A push root left over from an earlier hook run or a debug session must not redirect a guard at the wrong repo, so it only counts when KUMIKO_CLI_SCOPE names it.
12
+ export function resolvePushRepoRoot(
13
+ vars: Readonly<Record<string, string | undefined>>,
14
+ ): string | undefined {
15
+ const cliScope = vars["KUMIKO_CLI_SCOPE"]?.trim();
16
+ const pushRepoRoot = vars["KUMIKO_PUSH_REPO_ROOT"]?.trim();
17
+ if (!cliScope || !pushRepoRoot) return undefined;
18
+ if (!pushRootMatchesCliScope(pushRepoRoot, cliScope)) {
19
+ console.error(
20
+ `[push-root] KUMIKO_PUSH_REPO_ROOT="${pushRepoRoot}" does not match KUMIKO_CLI_SCOPE="${cliScope}" — ignoring inherited/stale push root.`,
21
+ );
22
+ return undefined;
23
+ }
24
+ return pushRepoRoot;
25
+ }
package/src/_lib/roots.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { execFileSync } from "node:child_process";
11
11
  import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
12
- import { dirname, join, resolve } from "node:path";
12
+ import { basename, dirname, join, resolve } from "node:path";
13
13
  import {
14
14
  type LoadedRepoManifest,
15
15
  loadRepoManifest,
@@ -115,6 +115,23 @@ function gitToplevelOf(cwd: string): string | undefined {
115
115
  return spelled;
116
116
  }
117
117
 
118
+ /**
119
+ * The main checkout of the repo containing `from`, independent of what a linked
120
+ * worktree's folder is called. Undefined when git cannot answer or the common
121
+ * dir is not a `<repo>/.git`.
122
+ */
123
+ function canonicalCheckoutOf(from: string): string | undefined {
124
+ const commonDir = git(from, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
125
+ if (commonDir === undefined || basename(commonDir) !== ".git") return undefined;
126
+ const checkout = dirname(commonDir);
127
+ return existsSync(checkout) ? checkout : undefined;
128
+ }
129
+
130
+ /** Directory name of the repo's canonical checkout; `local.name` when git names none. */
131
+ export function localRepoDirName(local: RepoRoot): string {
132
+ return basename(canonicalCheckoutOf(local.absPath) ?? local.name);
133
+ }
134
+
118
135
  function rootFrom(name: string, dir: string, loaded: LoadedRepoManifest): RepoRoot {
119
136
  return {
120
137
  name,
package/src/changes.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.361.0",
4
+ "type": "fix",
5
+ "title": "comment-lang guard scans tooling repos, handles division before a comment and ignores a stale KUMIKO_PUSH_REPO_ROOT",
6
+ "detail": "comment-lang scans tooling repos, no longer rescans a division as a regex literal, and honours KUMIKO_PUSH_REPO_ROOT only when KUMIKO_CLI_SCOPE names that checkout. The scan-scope helpers scanFiles and scanRoots are exported."
7
+ },
8
+ {
9
+ "version": "0.360.0",
10
+ "type": "fix",
11
+ "title": "public-exports-changelog requires the open breaking changeset to name the dropped export",
12
+ "detail": "Before, any open breaking kumiko-changes block of the owning feature let --write-baseline drop every export of that feature. Now the block's title, detail or migration must contain at least one dropped name, the same rule changes.json entries already follow. The finding hint and the report hint name the .changeset block and bun packages/guards/src/cli.ts public-exports-changelog --write-baseline. pendingBreakingFeaturesIn and loadPendingBreakingFeatures are renamed to pendingBreakingChangesIn and loadPendingBreakingChanges and return feature to block texts; featureFlagForCli is removed."
13
+ },
14
+ {
15
+ "version": "0.360.0",
16
+ "type": "improvement",
17
+ "title": "New public-exports-changelog guard (kumiko-guards public-exports-changelog) ratchets the public export surface of framework and bundled-features",
18
+ "detail": "Add the public-exports-changelog guard: a dropped public export of packages/framework or packages/bundled-features fails unless a breaking changelog entry names it before the baseline is refrozen"
19
+ },
2
20
  {
3
21
  "version": "0.357.0",
4
22
  "type": "fix",
package/src/cli.ts CHANGED
@@ -6,11 +6,22 @@
6
6
  // bin and the direct `bun run-*.ts` invocation can never drift apart.
7
7
  import { buildGuardKitInventory, cliFlagsError } from "./_lib/guard-kit";
8
8
  import { COMMENT_LANG_FLAGS, runCommentLangCli } from "./guard-comment-lang";
9
+ import {
10
+ PUBLIC_EXPORTS_CHANGELOG_FLAGS,
11
+ runPublicExportsChangelogCli,
12
+ } from "./guard-public-exports-changelog";
9
13
  import { GUARD_FLAGS, GUARDS, runGuardsCli } from "./run-guards";
10
14
  import { REPO_CHECK_FLAGS, REPO_CHECKS, runRepoChecksCli } from "./run-repo-checks";
11
15
  import { runUiGuardsCli, UI_GUARD_FLAGS, UI_GUARDS } from "./run-ui-guards";
12
16
 
13
- const SUBCOMMANDS = ["guards", "ui", "checks", "comment-lang", "list"] as const;
17
+ const SUBCOMMANDS = [
18
+ "guards",
19
+ "ui",
20
+ "checks",
21
+ "comment-lang",
22
+ "public-exports-changelog",
23
+ "list",
24
+ ] as const;
14
25
  type Subcommand = (typeof SUBCOMMANDS)[number];
15
26
 
16
27
  const SUBCOMMAND_FLAGS: Record<Subcommand, readonly string[]> = {
@@ -18,6 +29,7 @@ const SUBCOMMAND_FLAGS: Record<Subcommand, readonly string[]> = {
18
29
  ui: UI_GUARD_FLAGS,
19
30
  checks: REPO_CHECK_FLAGS,
20
31
  "comment-lang": [...COMMENT_LANG_FLAGS, "--base=<ref>"],
32
+ "public-exports-changelog": PUBLIC_EXPORTS_CHANGELOG_FLAGS,
21
33
  list: [],
22
34
  };
23
35
 
@@ -26,7 +38,9 @@ function isSubcommand(value: string): value is Subcommand {
26
38
  }
27
39
 
28
40
  function printHelp(): void {
29
- console.log("Usage: kumiko-guards [guards|ui|checks|comment-lang|list] [flags]");
41
+ console.log(
42
+ "Usage: kumiko-guards [guards|ui|checks|comment-lang|public-exports-changelog|list] [flags]",
43
+ );
30
44
  console.log();
31
45
  for (const sub of SUBCOMMANDS) {
32
46
  const flags = SUBCOMMAND_FLAGS[sub];
@@ -86,11 +100,16 @@ async function main(): Promise<void> {
86
100
  return;
87
101
  }
88
102
 
89
- // Not part of the no-subcommand run: it needs a baseline or a git base ref.
103
+ // Not part of the no-subcommand run: comment-lang needs a baseline or a git base ref,
104
+ // public-exports-changelog only applies to the kumiko-framework repo.
90
105
  if (subcommand === "comment-lang") {
91
106
  process.exit(runCommentLangCli(flags));
92
107
  }
93
108
 
109
+ if (subcommand === "public-exports-changelog") {
110
+ process.exit(await runPublicExportsChangelogCli(flags));
111
+ }
112
+
94
113
  let failed = 0;
95
114
  if (subcommand === undefined || subcommand === "guards") {
96
115
  failed += runGuardsCli(subcommand === "guards" ? flags : []);
@@ -2,8 +2,9 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { ts } from "ts-morph";
4
4
  import { gitEnv } from "./_lib/git-env";
5
- import { cliFlagsError, isLocalFinding } from "./_lib/guard-kit";
5
+ import { ALL_REPO_KINDS, cliFlagsError, isLocalFinding } from "./_lib/guard-kit";
6
6
  import { lineHasIgnoreTag } from "./_lib/ignore-tag";
7
+ import { resolvePushRepoRoot } from "./_lib/push-root";
7
8
  import { resolveRepoRoots } from "./_lib/roots";
8
9
  import { type ScanSpec, scanFiles } from "./_lib/scan-scope";
9
10
 
@@ -28,6 +29,7 @@ const IGNORE_TAG = "kumiko-lint-ignore comment-lang";
28
29
 
29
30
  const SCAN: ScanSpec = {
30
31
  scope: "source",
32
+ kinds: ALL_REPO_KINDS,
31
33
  extensions: ["ts", "tsx"],
32
34
  frameworkWithin: ["packages/*/src/**"],
33
35
  };
@@ -66,6 +68,48 @@ function germanCommentSite(
66
68
  };
67
69
  }
68
70
 
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,
78
+ ts.SyntaxKind.NumericLiteral,
79
+ ts.SyntaxKind.BigIntLiteral,
80
+ ts.SyntaxKind.StringLiteral,
81
+ ts.SyntaxKind.NoSubstitutionTemplateLiteral,
82
+ ts.SyntaxKind.TemplateTail,
83
+ ts.SyntaxKind.RegularExpressionLiteral,
84
+ ts.SyntaxKind.CloseParenToken,
85
+ ts.SyntaxKind.CloseBracketToken,
86
+ ts.SyntaxKind.CloseBraceToken,
87
+ ts.SyntaxKind.PlusPlusToken,
88
+ ts.SyntaxKind.MinusMinusToken,
89
+ ]);
90
+
91
+ const TRIVIA_KINDS: ReadonlySet<ts.SyntaxKind> = new Set([
92
+ ts.SyntaxKind.SingleLineCommentTrivia,
93
+ ts.SyntaxKind.MultiLineCommentTrivia,
94
+ ts.SyntaxKind.WhitespaceTrivia,
95
+ ts.SyntaxKind.NewLineTrivia,
96
+ ]);
97
+
98
+ // The raw scanner always yields SlashToken for `/`; without the rescan a regex
99
+ // body like /\{/ would shift braceDepth and end a template early. After an
100
+ // operand the rescan would instead swallow `/ 2; // {` as a regex and hide the comment.
101
+ function rescanSlashAsRegex(
102
+ scanner: ts.Scanner,
103
+ kind: ts.SyntaxKind,
104
+ previousSignificant: ts.SyntaxKind | undefined,
105
+ ): ts.SyntaxKind {
106
+ if (previousSignificant !== undefined && DIVISION_PRECEDING_KINDS.has(previousSignificant)) {
107
+ return kind;
108
+ }
109
+ const reScanned = scanner.reScanSlashToken();
110
+ return reScanned === ts.SyntaxKind.RegularExpressionLiteral ? reScanned : kind;
111
+ }
112
+
69
113
  function closesTemplateSubstitution(
70
114
  templateBraceDepths: readonly number[],
71
115
  braceDepth: number,
@@ -91,6 +135,7 @@ export function scanGermanComments(fullText: string, file: string): Site[] {
91
135
 
92
136
  const templateBraceDepths: number[] = [];
93
137
  let braceDepth = 0;
138
+ let previousSignificant: ts.SyntaxKind | undefined;
94
139
 
95
140
  let kind = scanner.scan();
96
141
  while (kind !== ts.SyntaxKind.EndOfFileToken) {
@@ -107,13 +152,9 @@ export function scanGermanComments(fullText: string, file: string): Site[] {
107
152
  break;
108
153
  }
109
154
  case ts.SyntaxKind.SlashToken:
110
- case ts.SyntaxKind.SlashEqualsToken: {
111
- // The raw scanner always yields SlashToken for `/`; without the rescan a
112
- // regex body like /\{/ would shift braceDepth and end a template early.
113
- const reScanned = scanner.reScanSlashToken();
114
- if (reScanned === ts.SyntaxKind.RegularExpressionLiteral) kind = reScanned;
155
+ case ts.SyntaxKind.SlashEqualsToken:
156
+ kind = rescanSlashAsRegex(scanner, kind, previousSignificant);
115
157
  break;
116
- }
117
158
  case ts.SyntaxKind.TemplateHead:
118
159
  case ts.SyntaxKind.TemplateMiddle:
119
160
  templateBraceDepths.push(braceDepth);
@@ -132,6 +173,7 @@ export function scanGermanComments(fullText: string, file: string): Site[] {
132
173
  default:
133
174
  break;
134
175
  }
176
+ if (!TRIVIA_KINDS.has(kind)) previousSignificant = kind;
135
177
  kind = scanner.scan();
136
178
  }
137
179
  return sites;
@@ -422,7 +464,7 @@ export function runCommentLangCli(
422
464
  return 1;
423
465
  }
424
466
 
425
- const pushRoot = env["KUMIKO_PUSH_REPO_ROOT"];
467
+ const pushRoot = resolvePushRepoRoot(env);
426
468
  const root = pushRoot && existsSync(pushRoot) ? pushRoot : cwd;
427
469
 
428
470
  try {
@@ -0,0 +1,691 @@
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { Project } from "ts-morph";
5
+ import { cliFlagsError, type GuardViolation, reportResults } from "./_lib/guard-kit";
6
+ import { frameworkTsConfigPath, resolveRepoRoots } from "./_lib/roots";
7
+
8
+ // Public exports of packages/framework and packages/bundled-features must not
9
+ // disappear (rename/removal) without a breaking changelog entry.
10
+ //
11
+ // Check mode compares the current export surface against the committed
12
+ // baseline UNCONDITIONALLY: any baseline name missing today is a finding, the
13
+ // changelog is never consulted, so one old breaking entry can never blanket-
14
+ // cover later unrelated removals. The changelog gates `--write-baseline`
15
+ // instead: refreezing so that a name drops requires either a `breaking`
16
+ // changes.json entry naming it at or above the OLD baseline's freeze version,
17
+ // or an open .changeset/*.md whose kumiko-changes block is `breaking` for the
18
+ // owning feature (the version PR only writes changes.json at release), or the
19
+ // write is refused.
20
+ // Without an old baseline, `--write-baseline` is a bootstrap freeze.
21
+ //
22
+ // Scope: only the two packages with a changes.json home. packages/framework
23
+ // has ONE changelog (src/changes.json); packages/bundled-features has one PER
24
+ // FEATURE (src/<feature>/changes.json, feature = first export-key segment).
25
+
26
+ export const PUBLIC_EXPORTS_CHANGELOG_FLAGS: readonly string[] = [
27
+ "--write-baseline",
28
+ "--no-baseline",
29
+ ];
30
+
31
+ export type PackageName = "framework" | "bundled-features";
32
+
33
+ const PACKAGE_DIRS: Record<PackageName, string> = {
34
+ framework: "packages/framework",
35
+ "bundled-features": "packages/bundled-features",
36
+ };
37
+
38
+ function isPackageName(v: string): v is PackageName {
39
+ return Object.hasOwn(PACKAGE_DIRS, v);
40
+ }
41
+
42
+ // No changes.json home yet; logged so the gap stays visible instead of a silent cap.
43
+ const OUT_OF_SCOPE_PACKAGES = [
44
+ "types",
45
+ "dev-server",
46
+ "headless",
47
+ "renderer-web",
48
+ "renderer",
49
+ ] as const;
50
+
51
+ const BASELINE_FILE = ".kumiko-public-exports-baseline.json";
52
+ const REFREEZE_COMMAND = "bun packages/guards/src/cli.ts public-exports-changelog --write-baseline";
53
+ const BASELINE_FORMAT_VERSION = 1;
54
+
55
+ export type ExportsByKey = Readonly<Record<string, readonly string[]>>;
56
+ export type PackageExportsMap = Readonly<Record<string, ExportsByKey>>;
57
+
58
+ type BaselinePayload = {
59
+ readonly format: number;
60
+ readonly generated: string;
61
+ readonly asOfVersion: string;
62
+ readonly packages: PackageExportsMap;
63
+ };
64
+
65
+ export type MissingExport = {
66
+ readonly pkg: string;
67
+ readonly key: string;
68
+ readonly names: readonly string[];
69
+ };
70
+
71
+ export type ChangelogEntryShape = {
72
+ readonly type: string;
73
+ readonly version: string;
74
+ readonly title?: string;
75
+ readonly detail?: string;
76
+ };
77
+
78
+ export type UncoveredFinding = {
79
+ readonly pkg: string;
80
+ readonly key: string;
81
+ readonly names: readonly string[];
82
+ readonly changelogPath: string;
83
+ readonly snippet: string;
84
+ readonly cliHint: string;
85
+ };
86
+
87
+ export type PackageScan = {
88
+ readonly pkg: PackageName;
89
+ readonly version: string;
90
+ readonly exportsMap: ExportsByKey;
91
+ };
92
+
93
+ // ── JSON narrowing ───────────────────────────────────────────────────────
94
+
95
+ function isRecord(value: unknown): value is Record<string, unknown> {
96
+ return typeof value === "object" && value !== null && !Array.isArray(value);
97
+ }
98
+
99
+ function isStringArray(value: unknown): value is string[] {
100
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
101
+ }
102
+
103
+ function isExportsByKey(value: unknown): value is ExportsByKey {
104
+ return isRecord(value) && Object.values(value).every(isStringArray);
105
+ }
106
+
107
+ function isPackageExportsMap(value: unknown): value is PackageExportsMap {
108
+ return isRecord(value) && Object.values(value).every(isExportsByKey);
109
+ }
110
+
111
+ type BaselineParse =
112
+ | { readonly kind: "ok"; readonly baseline: BaselinePayload }
113
+ | { readonly kind: "unreadable"; readonly detail: string }
114
+ | { readonly kind: "format-drift"; readonly detail: string };
115
+
116
+ function parseBaseline(raw: unknown): BaselineParse {
117
+ if (!isRecord(raw)) return { kind: "format-drift", detail: "not a JSON object" };
118
+ const { format, generated, asOfVersion, packages } = raw;
119
+ if (format !== BASELINE_FORMAT_VERSION) {
120
+ return {
121
+ kind: "format-drift",
122
+ detail: `expected format=${BASELINE_FORMAT_VERSION}, got ${String(format ?? "<missing>")}`,
123
+ };
124
+ }
125
+ if (
126
+ typeof generated !== "string" ||
127
+ typeof asOfVersion !== "string" ||
128
+ !isPackageExportsMap(packages)
129
+ ) {
130
+ return { kind: "format-drift", detail: "generated/asOfVersion/packages malformed" };
131
+ }
132
+ return { kind: "ok", baseline: { format, generated, asOfVersion, packages } };
133
+ }
134
+
135
+ function readBaselineFile(baselinePath: string): BaselineParse {
136
+ let raw: unknown;
137
+ try {
138
+ raw = JSON.parse(readFileSync(baselinePath, "utf-8"));
139
+ } catch (e) {
140
+ return { kind: "unreadable", detail: e instanceof Error ? e.message : String(e) };
141
+ }
142
+ return parseBaseline(raw);
143
+ }
144
+
145
+ // ── 1. Export names per entry file ──────────────────────────────────────
146
+
147
+ // Barrel files re-export via `export * from "./x"`: real module resolution
148
+ // (no skipFileDependencyResolution) is required to see through those,
149
+ // otherwise getExportedDeclarations() only sees the barrel's own top-level
150
+ // names. addSourceFileAtPath is idempotent, so a shared entry is parsed once.
151
+ export function exportNamesForEntry(project: Project, absEntryPath: string): string[] {
152
+ const sourceFile = project.addSourceFileAtPath(absEntryPath);
153
+ return [...sourceFile.getExportedDeclarations().keys()].sort();
154
+ }
155
+
156
+ // ── 2. Diff logic (pure) ─────────────────────────────────────────────────
157
+
158
+ export function diffExports(
159
+ baseline: PackageExportsMap,
160
+ current: PackageExportsMap,
161
+ ): MissingExport[] {
162
+ const result: MissingExport[] = [];
163
+ for (const [pkg, keys] of Object.entries(baseline)) {
164
+ for (const [key, baselineNames] of Object.entries(keys)) {
165
+ const currentNames = current[pkg]?.[key] ?? [];
166
+ const missing = baselineNames.filter((n) => !currentNames.includes(n));
167
+ if (missing.length > 0) result.push({ pkg, key, names: missing });
168
+ }
169
+ }
170
+ return result;
171
+ }
172
+
173
+ // ── 3. Owning changelog + coverage check (pure) ─────────────────────────
174
+
175
+ function featureNameForBundledKey(key: string): string {
176
+ const stripped = key.startsWith("./") ? key.slice(2) : key;
177
+ return stripped.split("/")[0] ?? stripped;
178
+ }
179
+
180
+ export function owningChangelogPath(pkg: PackageName, key: string): string {
181
+ if (pkg === "framework") return "packages/framework/src/changes.json";
182
+ const feature = featureNameForBundledKey(key);
183
+ return `packages/bundled-features/src/${feature}/changes.json`;
184
+ }
185
+
186
+ // Feature name as written in a changeset's kumiko-changes block.
187
+ function changesetFeatureFor(pkg: PackageName, key: string): string {
188
+ return pkg === "framework" ? "framework" : featureNameForBundledKey(key);
189
+ }
190
+
191
+ // x.y.z tuple compare, numeric per segment; a prerelease suffix is ignored.
192
+ export function compareVersionsLocal(a: string, b: string): number {
193
+ const parse = (v: string): number[] =>
194
+ (v.split("-")[0] ?? v).split(".").map((seg) => {
195
+ const n = Number(seg);
196
+ return Number.isFinite(n) ? n : 0;
197
+ });
198
+ const pa = parse(a);
199
+ const pb = parse(b);
200
+ for (let i = 0; i < 3; i++) {
201
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
202
+ if (diff !== 0) return diff;
203
+ }
204
+ return 0;
205
+ }
206
+
207
+ export function isCoveredByChangelog(
208
+ entries: readonly ChangelogEntryShape[],
209
+ asOfVersion: string,
210
+ exportNames: readonly string[] = [],
211
+ ): boolean {
212
+ // >= not >: package.json only bumps at release, so the entry `kumiko changes
213
+ // 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
+ });
223
+ }
224
+
225
+ function toChangelogEntry(e: unknown): ChangelogEntryShape[] {
226
+ if (!isRecord(e)) return [];
227
+ const { type, version, title, detail } = e;
228
+ if (typeof type !== "string" || typeof version !== "string") return [];
229
+ return [
230
+ {
231
+ type,
232
+ version,
233
+ ...(typeof title === "string" ? { title } : {}),
234
+ ...(typeof detail === "string" ? { detail } : {}),
235
+ },
236
+ ];
237
+ }
238
+
239
+ function readChangelogEntries(absChangelogPath: string): ChangelogEntryShape[] {
240
+ if (!existsSync(absChangelogPath)) return [];
241
+ let raw: unknown;
242
+ try {
243
+ raw = JSON.parse(readFileSync(absChangelogPath, "utf-8"));
244
+ } catch {
245
+ return [];
246
+ }
247
+ return Array.isArray(raw) ? raw.flatMap(toChangelogEntry) : [];
248
+ }
249
+
250
+ // ── Package scan (fs + ts-morph) ─────────────────────────────────────────
251
+
252
+ function entryRelPathForExportValue(value: unknown): string | null {
253
+ if (typeof value === "string") return value.endsWith(".ts") ? value : null;
254
+ if (isRecord(value)) {
255
+ const def = value["default"];
256
+ return typeof def === "string" && def.endsWith(".ts") ? def : null;
257
+ }
258
+ return null;
259
+ }
260
+
261
+ function scanPackage(cwd: string, project: Project, pkg: PackageName): PackageScan | null {
262
+ const pkgDir = resolve(cwd, PACKAGE_DIRS[pkg]);
263
+ const pkgJsonPath = join(pkgDir, "package.json");
264
+ if (!existsSync(pkgJsonPath)) {
265
+ console.log(` ${pkg}: package.json not found (${pkgJsonPath}), skipped.`);
266
+ return null;
267
+ }
268
+ let pkgJson: unknown;
269
+ try {
270
+ pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
271
+ } catch (e) {
272
+ console.error(
273
+ ` ${pkg}: package.json unreadable (${e instanceof Error ? e.message : String(e)}).`,
274
+ );
275
+ return null;
276
+ }
277
+ const manifest = isRecord(pkgJson) ? pkgJson : {};
278
+ const version = typeof manifest["version"] === "string" ? manifest["version"] : "0.0.0";
279
+ const exportsField = manifest["exports"];
280
+ const exportsObj = isRecord(exportsField) ? exportsField : {};
281
+
282
+ const exportsMap: Record<string, string[]> = {};
283
+ for (const [key, value] of Object.entries(exportsObj)) {
284
+ const entryRel = entryRelPathForExportValue(value);
285
+ if (!entryRel) continue;
286
+ exportsMap[key] = exportNamesForEntry(project, resolve(pkgDir, entryRel));
287
+ }
288
+ return { pkg, version, exportsMap };
289
+ }
290
+
291
+ function scanAllPackages(cwd: string, project: Project): PackageScan[] {
292
+ const scans: PackageScan[] = [];
293
+ for (const pkg of Object.keys(PACKAGE_DIRS)) {
294
+ if (!isPackageName(pkg)) continue;
295
+ const scan = scanPackage(cwd, project, pkg);
296
+ if (scan) scans.push(scan);
297
+ }
298
+ return scans;
299
+ }
300
+
301
+ function toPackageExportsMap(scans: readonly PackageScan[]): PackageExportsMap {
302
+ const out: Record<string, ExportsByKey> = {};
303
+ for (const scan of scans) out[scan.pkg] = scan.exportsMap;
304
+ return out;
305
+ }
306
+
307
+ function logOutOfScopeNotice(): void {
308
+ console.log(` Out of scope (no changes.json home yet): ${OUT_OF_SCOPE_PACKAGES.join(", ")}`);
309
+ }
310
+
311
+ // ── 4. Guard main logic (testable, separate from the CLI) ───────────────
312
+
313
+ export type EvaluateMode = "check" | "no-baseline";
314
+
315
+ export type EvaluateResult = {
316
+ readonly scans: readonly PackageScan[];
317
+ readonly baselineStatus: "ok" | "missing" | "format-drift" | "skipped";
318
+ readonly findings: readonly UncoveredFinding[];
319
+ };
320
+
321
+ function buildFinding(missing: MissingExport, version: string): UncoveredFinding {
322
+ if (!isPackageName(missing.pkg)) {
323
+ throw new Error(`baseline package key not in scope: ${missing.pkg}`);
324
+ }
325
+ const pkg = missing.pkg;
326
+ const changelogPath = owningChangelogPath(pkg, missing.key);
327
+ const title = `${missing.names.join(", ")} removed/renamed from ${missing.pkg}${missing.key}`;
328
+ const snippet = JSON.stringify(
329
+ { version, type: "breaking", title, detail: "TODO", migration: "TODO" },
330
+ null,
331
+ 2,
332
+ );
333
+ const cliHint = `add a breaking kumiko-changes block (feature: ${changesetFeatureFor(pkg, missing.key)}) that names ${missing.names.join(", ")} to a .changeset/*.md, then run ${REFREEZE_COMMAND}`;
334
+ return {
335
+ pkg: missing.pkg,
336
+ key: missing.key,
337
+ names: missing.names,
338
+ changelogPath,
339
+ snippet,
340
+ cliHint,
341
+ };
342
+ }
343
+
344
+ function frameworkBaselinePath(cwd: string): string | undefined {
345
+ const frameworkRoot = resolveRepoRoots(cwd).find((r) => r.kind === "framework");
346
+ return frameworkRoot ? join(frameworkRoot.absPath, BASELINE_FILE) : undefined;
347
+ }
348
+
349
+ function vacuousFinding(): UncoveredFinding {
350
+ return {
351
+ pkg: "(none)",
352
+ key: "",
353
+ names: [],
354
+ changelogPath: "",
355
+ snippet: "",
356
+ cliHint:
357
+ "Run from the kumiko-framework checkout (packages/framework, packages/bundled-features)",
358
+ };
359
+ }
360
+
361
+ function findingsAgainstBaseline(
362
+ baseline: BaselinePayload,
363
+ scans: readonly PackageScan[],
364
+ ): UncoveredFinding[] {
365
+ // Only compare packages actually scanned this run: a package absent from
366
+ // this checkout must read as "not applicable", not as "every export of it
367
+ // vanished".
368
+ const scannedPkgNames = new Set(scans.map((s) => s.pkg));
369
+ const baselineForScanned: Record<string, ExportsByKey> = {};
370
+ for (const [pkg, keys] of Object.entries(baseline.packages)) {
371
+ if (!isPackageName(pkg)) {
372
+ console.error(
373
+ ` Baseline contains unknown package key "${pkg}", ignored (only framework|bundled-features).`,
374
+ );
375
+ continue;
376
+ }
377
+ if (scannedPkgNames.has(pkg)) baselineForScanned[pkg] = keys;
378
+ }
379
+
380
+ const missing = diffExports(baselineForScanned, toPackageExportsMap(scans));
381
+ const versionByPkg = new Map(scans.map((s) => [s.pkg, s.version] as const));
382
+ const findings: UncoveredFinding[] = [];
383
+ for (const m of missing) {
384
+ if (!isPackageName(m.pkg)) continue;
385
+ findings.push(buildFinding(m, versionByPkg.get(m.pkg) ?? "0.0.0"));
386
+ }
387
+ return findings;
388
+ }
389
+
390
+ export function evaluatePublicExportsChangelog(args: {
391
+ readonly cwd: string;
392
+ readonly project: Project;
393
+ readonly mode: EvaluateMode;
394
+ /** Tests pass an explicit path; production resolves `<framework root>/.kumiko-public-exports-baseline.json`. */
395
+ readonly baselinePath?: string;
396
+ }): EvaluateResult {
397
+ logOutOfScopeNotice();
398
+ const scans = scanAllPackages(args.cwd, args.project);
399
+ if (scans.length === 0 && args.mode === "check") {
400
+ console.error(" No package scanned: the guard would pass vacuously.");
401
+ return { scans, baselineStatus: "ok", findings: [vacuousFinding()] };
402
+ }
403
+
404
+ if (args.mode === "no-baseline") {
405
+ console.log(" Baseline comparison skipped (--no-baseline).");
406
+ return { scans, baselineStatus: "skipped", findings: [] };
407
+ }
408
+
409
+ const baselinePath = args.baselinePath ?? frameworkBaselinePath(args.cwd);
410
+ if (baselinePath === undefined || !existsSync(baselinePath)) {
411
+ console.error(
412
+ ` No baseline found (${baselinePath ?? BASELINE_FILE}). Freeze one with \`--write-baseline\` and commit it.`,
413
+ );
414
+ return { scans, baselineStatus: "missing", findings: [] };
415
+ }
416
+
417
+ const parsed = readBaselineFile(baselinePath);
418
+ if (parsed.kind !== "ok") {
419
+ console.error(
420
+ ` Baseline ${parsed.kind === "unreadable" ? "unreadable" : "format drift"}: ${parsed.detail}. Re-run with \`--write-baseline\`.`,
421
+ );
422
+ return { scans, baselineStatus: "format-drift", findings: [] };
423
+ }
424
+
425
+ const findings = findingsAgainstBaseline(parsed.baseline, scans);
426
+ return { scans, baselineStatus: "ok", findings };
427
+ }
428
+
429
+ // ── Open changesets ───────────────────────────────────────────────────────
430
+
431
+ type ChangesetParser = (markdown: string, source: string) => readonly unknown[];
432
+
433
+ function isChangesetParser(value: unknown): value is ChangesetParser {
434
+ return typeof value === "function";
435
+ }
436
+
437
+ // The guards package does not depend on the framework package, but this guard
438
+ // only ever runs inside the framework repo, so the parser is loaded from there.
439
+ export async function loadChangesetParser(frameworkRootAbsPath: string): Promise<ChangesetParser> {
440
+ const parserPath = join(
441
+ frameworkRootAbsPath,
442
+ "packages/framework/src/engine/changeset-changes.ts",
443
+ );
444
+ if (!existsSync(parserPath)) throw new Error(`changeset parser not found: ${parserPath}`);
445
+ const loaded: unknown = await import(pathToFileURL(parserPath).href);
446
+ const parse = isRecord(loaded) ? loaded["parseChangesetChanges"] : undefined;
447
+ if (!isChangesetParser(parse)) {
448
+ throw new Error(`${parserPath} does not export parseChangesetChanges as a function`);
449
+ }
450
+ return parse;
451
+ }
452
+
453
+ // Feature name → the text (title, detail, migration) of each of its open
454
+ // breaking changeset blocks.
455
+ export type PendingBreakingChanges = ReadonlyMap<string, readonly string[]>;
456
+
457
+ function textField(record: Record<string, unknown>, key: string): string {
458
+ const value = record[key];
459
+ return typeof value === "string" ? value : "";
460
+ }
461
+
462
+ function breakingChangeOf(change: unknown): { feature: string; text: string } | undefined {
463
+ if (!isRecord(change) || change["type"] !== "breaking") return undefined;
464
+ const feature = change["feature"];
465
+ if (typeof feature !== "string") return undefined;
466
+ const text = ["title", "detail", "migration"].map((key) => textField(change, key)).join(" ");
467
+ return { feature, text };
468
+ }
469
+
470
+ export function pendingBreakingChangesIn(
471
+ changesetDir: string,
472
+ parse: ChangesetParser,
473
+ ): PendingBreakingChanges {
474
+ const textsByFeature = new Map<string, string[]>();
475
+ if (!existsSync(changesetDir)) return textsByFeature;
476
+ for (const name of readdirSync(changesetDir).filter((n) => n.endsWith(".md"))) {
477
+ const source = join(changesetDir, name);
478
+ for (const change of parse(readFileSync(source, "utf-8"), source)) {
479
+ const breaking = breakingChangeOf(change);
480
+ if (breaking === undefined) continue;
481
+ const texts = textsByFeature.get(breaking.feature) ?? [];
482
+ texts.push(breaking.text);
483
+ textsByFeature.set(breaking.feature, texts);
484
+ }
485
+ }
486
+ return textsByFeature;
487
+ }
488
+
489
+ export async function loadPendingBreakingChanges(
490
+ frameworkRootAbsPath: string,
491
+ ): Promise<PendingBreakingChanges> {
492
+ const parse = await loadChangesetParser(frameworkRootAbsPath);
493
+ return pendingBreakingChangesIn(join(frameworkRootAbsPath, ".changeset"), parse);
494
+ }
495
+
496
+ // A feature-wide breaking block must not cover unrelated drops: the block has
497
+ // to name at least one of the dropped exports.
498
+ export function isCoveredByPendingChangeset(
499
+ pending: PendingBreakingChanges,
500
+ feature: string,
501
+ droppedNames: readonly string[],
502
+ ): boolean {
503
+ const texts = pending.get(feature) ?? [];
504
+ return texts.some((text) => droppedNames.some((name) => text.includes(name)));
505
+ }
506
+
507
+ // ── Write baseline ────────────────────────────────────────────────────────
508
+
509
+ // An absent, unreadable or format-drifted old baseline means "nothing frozen
510
+ // yet to protect" (bootstrap): the freeze itself repairs that state.
511
+ function readOldBaselineForRefreeze(baselinePath: string): BaselinePayload | null {
512
+ if (!existsSync(baselinePath)) return null;
513
+ const parsed = readBaselineFile(baselinePath);
514
+ return parsed.kind === "ok" ? parsed.baseline : null;
515
+ }
516
+
517
+ // Ratchet gate for --write-baseline: dropping a name that was in the OLD
518
+ // baseline requires a qualifying breaking changelog entry, or the new baseline
519
+ // is refused entirely.
520
+ function uncoveredDrops(
521
+ cwd: string,
522
+ scans: readonly PackageScan[],
523
+ oldBaseline: BaselinePayload,
524
+ pendingBreakingChanges: PendingBreakingChanges,
525
+ ): UncoveredFinding[] {
526
+ const scannedPkgNames = new Set(scans.map((s) => s.pkg));
527
+ const oldBaselineForScanned: Record<string, ExportsByKey> = {};
528
+ for (const [pkg, keys] of Object.entries(oldBaseline.packages)) {
529
+ if (isPackageName(pkg) && scannedPkgNames.has(pkg)) oldBaselineForScanned[pkg] = keys;
530
+ }
531
+ const missing = diffExports(oldBaselineForScanned, toPackageExportsMap(scans));
532
+ const versionByPkg = new Map(scans.map((s) => [s.pkg, s.version] as const));
533
+ const uncovered: UncoveredFinding[] = [];
534
+ for (const m of missing) {
535
+ if (!isPackageName(m.pkg)) continue;
536
+ if (
537
+ isCoveredByPendingChangeset(
538
+ pendingBreakingChanges,
539
+ changesetFeatureFor(m.pkg, m.key),
540
+ m.names,
541
+ )
542
+ ) {
543
+ continue;
544
+ }
545
+ const entries = readChangelogEntries(join(cwd, owningChangelogPath(m.pkg, m.key)));
546
+ if (isCoveredByChangelog(entries, oldBaseline.asOfVersion, m.names)) continue;
547
+ uncovered.push(buildFinding(m, versionByPkg.get(m.pkg) ?? "0.0.0"));
548
+ }
549
+ return uncovered;
550
+ }
551
+
552
+ export function writeBaseline(
553
+ cwd: string,
554
+ project: Project,
555
+ baselinePath: string,
556
+ pendingBreakingChanges: PendingBreakingChanges = new Map(),
557
+ ): number {
558
+ logOutOfScopeNotice();
559
+ const scans = scanAllPackages(cwd, project);
560
+ const frameworkScan = scans.find((s) => s.pkg === "framework");
561
+ if (!frameworkScan) {
562
+ console.error(
563
+ " framework package.json missing: asOfVersion undetermined, baseline not written.",
564
+ );
565
+ return 1;
566
+ }
567
+
568
+ const oldBaseline = readOldBaselineForRefreeze(baselinePath);
569
+ if (oldBaseline) {
570
+ const uncovered = uncoveredDrops(cwd, scans, oldBaseline, pendingBreakingChanges);
571
+ if (uncovered.length > 0) {
572
+ report({ scans, baselineStatus: "ok", findings: uncovered });
573
+ console.error(
574
+ "\n Baseline not written: the findings above need a breaking changelog entry or an open breaking changeset first.",
575
+ );
576
+ return 1;
577
+ }
578
+ }
579
+
580
+ // A package absent from THIS run's scans keeps its old baseline section
581
+ // verbatim, otherwise a partial-checkout refreeze would silently drop its
582
+ // entire coverage.
583
+ const packages: Record<string, ExportsByKey> = { ...toPackageExportsMap(scans) };
584
+ if (oldBaseline) {
585
+ const scannedPkgNames = new Set(scans.map((s) => s.pkg));
586
+ for (const [pkg, keys] of Object.entries(oldBaseline.packages)) {
587
+ if (isPackageName(pkg) && !scannedPkgNames.has(pkg)) packages[pkg] = keys;
588
+ }
589
+ }
590
+ const payload: BaselinePayload = {
591
+ format: BASELINE_FORMAT_VERSION,
592
+ generated: Temporal.Now.instant().toString().slice(0, 10),
593
+ // framework and bundled-features are lockstep-versioned: one asOfVersion covers both.
594
+ asOfVersion: frameworkScan.version,
595
+ packages,
596
+ };
597
+ writeFileSync(baselinePath, `${JSON.stringify(payload, null, 2)}\n`);
598
+ const total = Object.values(payload.packages).reduce(
599
+ (sum, keys) => sum + Object.keys(keys).length,
600
+ 0,
601
+ );
602
+ console.log(
603
+ ` Baseline written: ${baselinePath} (${total} subpaths, asOfVersion=${payload.asOfVersion})`,
604
+ );
605
+ return 0;
606
+ }
607
+
608
+ // ── Report + CLI ──────────────────────────────────────────────────────────
609
+
610
+ function findingsToViolations(findings: readonly UncoveredFinding[]): GuardViolation[] {
611
+ return findings.map((f) => ({
612
+ file: f.changelogPath || "(no-package-scanned)",
613
+ line: 1,
614
+ message:
615
+ f.names.length > 0
616
+ ? `${f.pkg}${f.key}: ${f.names.join(", ")} — ${f.cliHint}`
617
+ : f.cliHint || "no package scanned",
618
+ }));
619
+ }
620
+
621
+ function report(result: EvaluateResult): void {
622
+ for (const scan of result.scans) {
623
+ console.log(
624
+ ` ${scan.pkg}: ${Object.keys(scan.exportsMap).length} subpaths scanned (version ${scan.version}).`,
625
+ );
626
+ }
627
+ const violations = findingsToViolations(result.findings);
628
+ if (violations.length === 0) {
629
+ console.log(" No uncovered missing exports.");
630
+ } else {
631
+ reportViolations(violations);
632
+ }
633
+ }
634
+
635
+ function reportViolations(violations: readonly GuardViolation[]): void {
636
+ reportResults([
637
+ {
638
+ name: "public-exports-changelog",
639
+ ok: false,
640
+ ms: 0,
641
+ outcome: { violations },
642
+ hint: `Add a breaking kumiko-changes block that names the dropped export to a .changeset/*.md, then run ${REFREEZE_COMMAND}.`,
643
+ },
644
+ ]);
645
+ }
646
+
647
+ export async function runPublicExportsChangelogCli(
648
+ argv: readonly string[],
649
+ cwd: string = process.cwd(),
650
+ ): Promise<number> {
651
+ const flagsError = cliFlagsError(
652
+ "public-exports-changelog",
653
+ argv,
654
+ PUBLIC_EXPORTS_CHANGELOG_FLAGS,
655
+ );
656
+ if (flagsError !== undefined) {
657
+ console.error(flagsError);
658
+ return 1;
659
+ }
660
+
661
+ const frameworkRoot = resolveRepoRoots(cwd).find((r) => r.kind === "framework");
662
+ if (!frameworkRoot) {
663
+ console.log(" public-exports-changelog: no kumiko-framework root found, nothing to scan.");
664
+ return 0;
665
+ }
666
+ const tsConfigFilePath = frameworkTsConfigPath(cwd);
667
+ if (tsConfigFilePath === undefined) {
668
+ console.error(" public-exports-changelog: framework tsconfig.json not found.");
669
+ return 1;
670
+ }
671
+ const project = new Project({ tsConfigFilePath, skipAddingFilesFromTsConfig: true });
672
+ const scanCwd = frameworkRoot.absPath;
673
+
674
+ if (argv.includes("--write-baseline")) {
675
+ let pending: PendingBreakingChanges;
676
+ try {
677
+ pending = await loadPendingBreakingChanges(scanCwd);
678
+ } catch (e) {
679
+ console.error(` Cannot read open changesets: ${e instanceof Error ? e.message : String(e)}`);
680
+ return 1;
681
+ }
682
+ return writeBaseline(scanCwd, project, join(scanCwd, BASELINE_FILE), pending);
683
+ }
684
+
685
+ const mode: EvaluateMode = argv.includes("--no-baseline") ? "no-baseline" : "check";
686
+ const result = evaluatePublicExportsChangelog({ cwd: scanCwd, project, mode });
687
+ report(result);
688
+ const baselineBroken =
689
+ result.baselineStatus === "format-drift" || result.baselineStatus === "missing";
690
+ return baselineBroken || result.findings.length > 0 ? 1 : 0;
691
+ }
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ export {
21
21
  type SuiteInventory,
22
22
  } from "./_lib/guard-kit";
23
23
  export { findLocalRepo, type RepoRoot, resolveRepoRoots } from "./_lib/roots";
24
+ export { type RootScan, type ScanOptions, scanFiles, scanRoots } from "./_lib/scan-scope";
24
25
  export {
25
26
  loadSecurityBaseline,
26
27
  parseSecurityBaseline,