@cosmicdrift/kumiko-guards 0.364.0 → 0.365.1

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.364.0",
3
+ "version": "0.365.1",
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.364.0",
31
+ "@cosmicdrift/kumiko-repo-manifest": "0.365.1",
32
32
  "ts-morph": "^28.0.0"
33
33
  },
34
34
  "publishConfig": {
@@ -1,11 +1,13 @@
1
1
  import { basename } from "node:path";
2
2
  import { findLocalRepo, localRepoDirName } from "./roots";
3
3
 
4
- // A worktree's folder name proves nothing (`kumiko-*` siblings share prefixes), so it is judged by its canonical checkout's directory name.
4
+ // A worktree's folder name proves nothing (`kumiko-*` siblings share prefixes), so a path that
5
+ // resolves to a repo is judged by its canonical checkout's directory name; the folder name only
6
+ // counts for paths that are not a repo (yet).
5
7
  function pushRootMatchesCliScope(pushRepoRoot: string, cliScope: string): boolean {
6
- if (basename(pushRepoRoot) === cliScope) return true;
7
8
  const local = findLocalRepo(pushRepoRoot);
8
- return local !== undefined && (local.name === cliScope || localRepoDirName(local) === cliScope);
9
+ if (local === undefined) return basename(pushRepoRoot) === cliScope;
10
+ return local.name === cliScope || localRepoDirName(local) === cliScope;
9
11
  }
10
12
 
11
13
  // 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.
@@ -27,7 +27,13 @@ export type SecurityBaselineLoad =
27
27
  readonly findings: SecurityBaseline["findings"];
28
28
  readonly hardFail: readonly string[];
29
29
  }
30
- | { readonly kind: "invalid"; readonly file: string; readonly reason: string };
30
+ | {
31
+ readonly kind: "invalid";
32
+ readonly file: string;
33
+ readonly reason: string;
34
+ /** Prose shown after the reason when the reason alone is only a code. */
35
+ readonly detail?: string;
36
+ };
31
37
 
32
38
  // Compared only against the baseline file's `repo` field — a scoped npm
33
39
  // package name (`@x/y`) is no longer a path segment, so it needs no
@@ -127,7 +133,12 @@ export function loadSecurityBaseline(repo: string, repoDir: string): SecurityBas
127
133
  }
128
134
  const parsed = parseSecurityBaseline(raw);
129
135
  if (!parsed) {
130
- return { kind: "invalid", file, reason: "unexpected_baseline_shape" };
136
+ return {
137
+ kind: "invalid",
138
+ file,
139
+ reason: "unexpected_baseline_shape",
140
+ detail: "expected format/repo/generated/total/findings and a valid hardFail",
141
+ };
131
142
  }
132
143
  if (parsed.repo !== repo) {
133
144
  return {
@@ -202,7 +213,7 @@ function invalidBaselineViolation(
202
213
  return {
203
214
  file: baseline.file,
204
215
  line: 1,
205
- message: `Security baseline unreadable or invalid: ${baseline.reason}. Fix the file; a broken baseline must not release any findings.`,
216
+ message: `Security baseline unreadable or invalid: ${baseline.reason}${baseline.detail === undefined ? "" : ` (${baseline.detail})`}. Fix the file; a broken baseline must not release any findings.`,
206
217
  };
207
218
  }
208
219
 
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "public-exports-changelog --base=<ref> checks drops against the baseline of the merge-base and matches exported names as whole identifiers",
6
+ "detail": "Check mode with --base=<ref> reads .kumiko-public-exports-baseline.json from the merge-base and sends every drop against it through the changelog/changeset ratchet, so a PR can no longer hide a removal by editing or deleting the baseline. An unknown ref is an error; a base without a baseline is a bootstrap. A changelog entry or open changeset covers a drop only when every dropped name appears in it as a whole identifier (getUserById no longer covers getUser). An unparsable changes.json is reported on stderr with its path. check-wt.sh, the worktree pre-push branch and the legacy check step pass the base."
7
+ },
8
+ {
9
+ "version": "0.365.0",
10
+ "type": "fix",
11
+ "title": "guard-test-timeouts ignores identifier test bodies and timeout-free options; push-root no longer trusts a lookalike folder name",
12
+ "detail": "An identifier passed as a test argument only counts as a timeout when its type is a number or has a timeout property, so Playwright's test(title, details, body) and options like { retry: 2 } pass. A push root that resolves to a git repo is judged by its canonical checkout name, not by its folder name. An unexpected security baseline shape keeps the reason unexpected_baseline_shape and adds a readable detail."
13
+ },
2
14
  {
3
15
  "version": "0.364.0",
4
16
  "type": "fix",
package/src/cli.ts CHANGED
@@ -36,7 +36,7 @@ const SUBCOMMAND_FLAGS: Record<Subcommand, readonly string[]> = {
36
36
  ui: UI_GUARD_FLAGS,
37
37
  checks: REPO_CHECK_FLAGS,
38
38
  "comment-lang": [...COMMENT_LANG_FLAGS, "--base=<ref>"],
39
- "public-exports-changelog": PUBLIC_EXPORTS_CHANGELOG_FLAGS,
39
+ "public-exports-changelog": [...PUBLIC_EXPORTS_CHANGELOG_FLAGS, "--base=<ref>"],
40
40
  licenses: DEPENDENCY_CHECK_FLAGS,
41
41
  security: DEPENDENCY_CHECK_FLAGS,
42
42
  outdated: DEPENDENCY_CHECK_FLAGS,
@@ -74,7 +74,7 @@ function exportedCallables(sf: SourceFile): Export[] {
74
74
  return out;
75
75
  }
76
76
 
77
- // NodeNext-Importe schreiben ".js"/".jsx", obwohl die Quelle ".ts"/".tsx" ist
77
+ // NodeNext imports write ".js"/".jsx" although the source is ".ts"/".tsx"
78
78
  // (and likewise .mjs/.cjs for .mts/.cts) — the extension must therefore be
79
79
  // stripped for all four pair variants when comparing module paths, otherwise
80
80
  // the link breaks.
@@ -121,7 +121,6 @@ const ALLOWLIST: readonly AllowEntry[] = [
121
121
  { repo: "kumiko-framework", pattern: /^packages\/create-kumiko-app\/src\/manifest\.ts$/ },
122
122
 
123
123
  // Sample apps: codegen/screenshot-mount helper, no request input.
124
- { repo: "kumiko-framework", pattern: /^samples\/apps\/use-all-bundled\/schema\/generate\.ts$/ },
125
124
  {
126
125
  repo: "kumiko-framework",
127
126
  pattern: /^samples\/apps\/showcase\/src\/app\/mount-public-screenshots\.ts$/,
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
3
  import { join, resolve } from "node:path";
3
4
  import { pathToFileURL } from "node:url";
@@ -19,6 +20,12 @@ import { frameworkTsConfigPath, resolveRepoRoots } from "./_lib/roots";
19
20
  // write is refused.
20
21
  // Without an old baseline, `--write-baseline` is a bootstrap freeze.
21
22
  //
23
+ // The committed baseline is a file the PR itself can edit (or delete and
24
+ // bootstrap-freeze), so check mode with `--base=<ref>` additionally reads the
25
+ // baseline from the merge-base with <ref> and runs every drop against THAT
26
+ // baseline through the same ratchet gate as `--write-baseline`. Only when the
27
+ // base has no baseline either is there nothing to protect.
28
+ //
22
29
  // Scope: only the two packages with a changes.json home. packages/framework
23
30
  // has ONE changelog (src/changes.json); packages/bundled-features has one PER
24
31
  // FEATURE (src/<feature>/changes.json, feature = first export-key segment).
@@ -48,6 +55,7 @@ const OUT_OF_SCOPE_PACKAGES = [
48
55
  "renderer",
49
56
  ] as const;
50
57
 
58
+ const BASE_FLAG_PREFIX = "--base=";
51
59
  const BASELINE_FILE = ".kumiko-public-exports-baseline.json";
52
60
  const REFREEZE_COMMAND = "bun packages/guards/src/cli.ts public-exports-changelog --write-baseline";
53
61
  const BASELINE_FORMAT_VERSION = 1;
@@ -204,6 +212,19 @@ export function compareVersionsLocal(a: string, b: string): number {
204
212
  return 0;
205
213
  }
206
214
 
215
+ function escapeRegExp(value: string): string {
216
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
217
+ }
218
+
219
+ // Identifier boundaries, not substrings: `getUserById` must not cover a dropped `getUser`.
220
+ export function mentionsExportName(text: string, name: string): boolean {
221
+ return new RegExp(`(?<![A-Za-z0-9_$])${escapeRegExp(name)}(?![A-Za-z0-9_$])`).test(text);
222
+ }
223
+
224
+ function everyNameMentioned(texts: readonly string[], names: readonly string[]): boolean {
225
+ return names.every((name) => texts.some((text) => mentionsExportName(text, name)));
226
+ }
227
+
207
228
  export function isCoveredByChangelog(
208
229
  entries: readonly ChangelogEntryShape[],
209
230
  asOfVersion: string,
@@ -211,15 +232,16 @@ export function isCoveredByChangelog(
211
232
  ): boolean {
212
233
  // >= not >: package.json only bumps at release, so the entry `kumiko changes
213
234
  // add` writes in the removing PR carries the SAME version as asOfVersion.
214
- // With exportNames, the entry must also name at least one dropped export, so
215
- // a single breaking entry cannot blanket-cover unrelated drops.
216
- return entries.some((e) => {
217
- if (e.type !== "breaking") return false;
218
- if (compareVersionsLocal(e.version, asOfVersion) < 0) return false;
219
- if (exportNames.length === 0) return true;
220
- const haystack = `${e.title ?? ""} ${e.detail ?? ""}`;
221
- return exportNames.some((name) => haystack.includes(name));
222
- });
235
+ // With exportNames, every dropped export must be named by some qualifying
236
+ // entry, so a single breaking entry cannot blanket-cover unrelated drops.
237
+ const qualifying = entries.filter(
238
+ (e) => e.type === "breaking" && compareVersionsLocal(e.version, asOfVersion) >= 0,
239
+ );
240
+ if (qualifying.length === 0) return false;
241
+ return everyNameMentioned(
242
+ qualifying.map((e) => `${e.title ?? ""} ${e.detail ?? ""}`),
243
+ exportNames,
244
+ );
223
245
  }
224
246
 
225
247
  function toChangelogEntry(e: unknown): ChangelogEntryShape[] {
@@ -241,7 +263,10 @@ function readChangelogEntries(absChangelogPath: string): ChangelogEntryShape[] {
241
263
  let raw: unknown;
242
264
  try {
243
265
  raw = JSON.parse(readFileSync(absChangelogPath, "utf-8"));
244
- } catch {
266
+ } catch (e) {
267
+ console.error(
268
+ ` ${absChangelogPath}: changelog unreadable (${e instanceof Error ? e.message : String(e)}); its entries cannot cover a dropped export until the JSON is fixed.`,
269
+ );
245
270
  return [];
246
271
  }
247
272
  return Array.isArray(raw) ? raw.flatMap(toChangelogEntry) : [];
@@ -395,6 +420,9 @@ export function evaluatePublicExportsChangelog(args: {
395
420
  readonly mode: EvaluateMode;
396
421
  /** Tests pass an explicit path; production resolves `<framework root>/.kumiko-public-exports-baseline.json`. */
397
422
  readonly baselinePath?: string;
423
+ /** Baseline as committed on the PR's merge-base; its drops go through the ratchet gate. */
424
+ readonly baseBaseline?: BaselinePayload;
425
+ readonly pendingBreakingChanges?: PendingBreakingChanges;
398
426
  }): EvaluateResult {
399
427
  logOutOfScopeNotice();
400
428
  const scans = scanAllPackages(args.cwd, args.project);
@@ -427,9 +455,74 @@ export function evaluatePublicExportsChangelog(args: {
427
455
  }
428
456
 
429
457
  const findings = findingsAgainstBaseline(parsed.baseline, scans);
458
+ if (args.baseBaseline) {
459
+ const alreadyReported = new Set(findings.map(findingIdentity));
460
+ const ratchet = uncoveredDrops(
461
+ args.cwd,
462
+ scans,
463
+ args.baseBaseline,
464
+ args.pendingBreakingChanges ?? new Map(),
465
+ );
466
+ findings.push(...ratchet.filter((f) => !alreadyReported.has(findingIdentity(f))));
467
+ }
430
468
  return { scans, baselineStatus: "ok", findings };
431
469
  }
432
470
 
471
+ function findingIdentity(f: UncoveredFinding): string {
472
+ return `${f.pkg}|${f.key}|${f.names.join(",")}`;
473
+ }
474
+
475
+ // ── Baseline of the merge-base ───────────────────────────────────────────
476
+
477
+ export type BaseBaselineRead =
478
+ | { readonly kind: "absent" }
479
+ | { readonly kind: "ok"; readonly baseline: BaselinePayload }
480
+ | { readonly kind: "error"; readonly detail: string };
481
+
482
+ function git(args: readonly string[], cwd: string): { ok: true; out: string } | { ok: false } {
483
+ const res = spawnSync("git", args, { cwd, encoding: "utf-8" });
484
+ return res.status === 0 ? { ok: true, out: res.stdout } : { ok: false };
485
+ }
486
+
487
+ export function readBaseBaseline(cwd: string, baseRef: string): BaseBaselineRead {
488
+ if (baseRef === "" || baseRef.startsWith("-")) {
489
+ return {
490
+ kind: "error",
491
+ detail: `--base must be a ref name such as origin/main (got "${baseRef}"); an empty value or a leading "-" would be read as a git option.`,
492
+ };
493
+ }
494
+ const mergeBase = git(["merge-base", baseRef, "HEAD"], cwd);
495
+ if (!mergeBase.ok) {
496
+ return {
497
+ kind: "error",
498
+ detail: `git merge-base ${baseRef} HEAD failed — is ${baseRef} known locally? On shallow checkouts fetch the full history or the ref.`,
499
+ };
500
+ }
501
+ const commit = mergeBase.out.trim();
502
+ const listed = git(["ls-tree", "--name-only", commit, "--", BASELINE_FILE], cwd);
503
+ if (!listed.ok) return { kind: "error", detail: `git ls-tree ${commit} failed.` };
504
+ if (listed.out.trim() === "") return { kind: "absent" };
505
+ const shown = git(["show", `${commit}:./${BASELINE_FILE}`], cwd);
506
+ if (!shown.ok) return { kind: "error", detail: `git show ${commit}:${BASELINE_FILE} failed.` };
507
+ let raw: unknown;
508
+ try {
509
+ raw = JSON.parse(shown.out);
510
+ } catch (e) {
511
+ console.error(
512
+ ` Baseline on ${baseRef} unreadable (${e instanceof Error ? e.message : String(e)}); nothing to ratchet against.`,
513
+ );
514
+ return { kind: "absent" };
515
+ }
516
+ const parsed = parseBaseline(raw);
517
+ if (parsed.kind !== "ok") {
518
+ console.error(
519
+ ` Baseline on ${baseRef} is ${parsed.kind} (${parsed.detail}); nothing to ratchet against.`,
520
+ );
521
+ return { kind: "absent" };
522
+ }
523
+ return { kind: "ok", baseline: parsed.baseline };
524
+ }
525
+
433
526
  // ── Open changesets ───────────────────────────────────────────────────────
434
527
 
435
528
  type ChangesetParser = (markdown: string, source: string) => readonly unknown[];
@@ -502,15 +595,15 @@ export async function loadPendingBreakingChanges(
502
595
  return pendingBreakingChangesIn(join(frameworkRootAbsPath, ".changeset"), parse);
503
596
  }
504
597
 
505
- // A feature-wide breaking block must not cover unrelated drops: the block has
506
- // to name at least one of the dropped exports.
598
+ // A feature-wide breaking block must not cover unrelated drops: every dropped
599
+ // export has to be named by some block of the feature.
507
600
  export function isCoveredByPendingChangeset(
508
601
  pending: PendingBreakingChanges,
509
602
  feature: string,
510
603
  droppedNames: readonly string[],
511
604
  ): boolean {
512
605
  const texts = pending.get(feature) ?? [];
513
- return texts.some((text) => droppedNames.some((name) => text.includes(name)));
606
+ return texts.length > 0 && everyNameMentioned(texts, droppedNames);
514
607
  }
515
608
 
516
609
  // ── Write baseline ────────────────────────────────────────────────────────
@@ -653,13 +746,40 @@ function reportViolations(violations: readonly GuardViolation[]): void {
653
746
  ]);
654
747
  }
655
748
 
749
+ // undefined = the ratchet inputs could not be read; the diagnosis is printed already.
750
+ async function baseRatchetFor(
751
+ scanCwd: string,
752
+ mode: EvaluateMode,
753
+ baseRef: string | undefined,
754
+ ): Promise<
755
+ { baseBaseline?: BaselinePayload; pendingBreakingChanges?: PendingBreakingChanges } | undefined
756
+ > {
757
+ if (baseRef === undefined || mode !== "check") return {};
758
+ const base = readBaseBaseline(scanCwd, baseRef);
759
+ if (base.kind === "error") {
760
+ console.error(` ${base.detail}`);
761
+ return undefined;
762
+ }
763
+ if (base.kind === "absent") return {};
764
+ try {
765
+ const pendingBreakingChanges = await loadPendingBreakingChanges(scanCwd);
766
+ return { baseBaseline: base.baseline, pendingBreakingChanges };
767
+ } catch (e) {
768
+ console.error(
769
+ ` Cannot read open changesets: ${e instanceof Error ? e.message : String(e)}. Fix the changeset file named above.`,
770
+ );
771
+ return undefined;
772
+ }
773
+ }
774
+
656
775
  export async function runPublicExportsChangelogCli(
657
776
  argv: readonly string[],
658
777
  cwd: string = process.cwd(),
659
778
  ): Promise<number> {
779
+ const baseArg = argv.find((arg) => arg.startsWith(BASE_FLAG_PREFIX));
660
780
  const flagsError = cliFlagsError(
661
781
  "public-exports-changelog",
662
- argv,
782
+ argv.filter((arg) => !arg.startsWith(BASE_FLAG_PREFIX)),
663
783
  PUBLIC_EXPORTS_CHANGELOG_FLAGS,
664
784
  );
665
785
  if (flagsError !== undefined) {
@@ -696,7 +816,9 @@ export async function runPublicExportsChangelogCli(
696
816
  }
697
817
 
698
818
  const mode: EvaluateMode = argv.includes("--no-baseline") ? "no-baseline" : "check";
699
- const result = evaluatePublicExportsChangelog({ cwd: scanCwd, project, mode });
819
+ const ratchet = await baseRatchetFor(scanCwd, mode, baseArg?.slice(BASE_FLAG_PREFIX.length));
820
+ if (ratchet === undefined) return 1;
821
+ const result = evaluatePublicExportsChangelog({ cwd: scanCwd, project, mode, ...ratchet });
700
822
  report(result);
701
823
  const baselineBroken =
702
824
  result.baselineStatus === "format-drift" || result.baselineStatus === "missing";
@@ -177,9 +177,13 @@ function declarationBaseCallee(callee: Node): Node {
177
177
  return Node.isCallExpression(callee) ? declarationBaseCallee(callee.getExpression()) : callee;
178
178
  }
179
179
 
180
+ // An identifier can be the Playwright test body (`test(title, details, body)`) or an options
181
+ // variable like `{ retry: 2 }`, so it only counts when its type is a number or carries `timeout`.
180
182
  function isTimeoutArgument(arg: Node): boolean {
181
183
  if (Node.isObjectLiteralExpression(arg)) return hasTimeoutProperty(arg);
182
- return !Node.isArrowFunction(arg) && !Node.isFunctionExpression(arg);
184
+ if (Node.isArrowFunction(arg) || Node.isFunctionExpression(arg)) return false;
185
+ const type = arg.getType();
186
+ return type.isNumber() || type.isNumberLiteral() || type.getProperty("timeout") !== undefined;
183
187
  }
184
188
 
185
189
  function declarationTimeoutReason(
package/src/pre-push.sh CHANGED
@@ -195,6 +195,7 @@ if [ "$(git rev-parse --path-format=absolute --git-dir)" != "$GIT_COMMON_DIR" ]
195
195
  run_guard_bin guards kumiko-guards guards
196
196
  run_guard_bin checks kumiko-guards checks
197
197
  run_guard_bin comment-lang kumiko-guards comment-lang --touched --base=origin/main
198
+ run_guard_bin public-exports-changelog kumiko-guards public-exports-changelog --base=origin/main
198
199
  if [ -n "$FAILED" ]; then
199
200
  echo "[pre-push] worktree check failed:$FAILED" >&2
200
201
  exit 1