@cosmicdrift/kumiko-guards 0.358.0 → 0.360.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 +2 -2
- package/src/changes.json +12 -0
- package/src/cli.ts +22 -3
- package/src/guard-public-exports-changelog.ts +691 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.360.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.
|
|
31
|
+
"@cosmicdrift/kumiko-repo-manifest": "0.360.0",
|
|
32
32
|
"ts-morph": "^28.0.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.360.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "public-exports-changelog requires the open breaking changeset to name the dropped export",
|
|
6
|
+
"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."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.360.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "New public-exports-changelog guard (kumiko-guards public-exports-changelog) ratchets the public export surface of framework and bundled-features",
|
|
12
|
+
"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"
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.357.0",
|
|
4
16
|
"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 = [
|
|
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(
|
|
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:
|
|
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 : []);
|
|
@@ -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
|
+
}
|