@openparachute/vault 0.7.9-rc.2 → 0.7.9-rc.4

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.
Files changed (38) hide show
  1. package/core/src/core.test.ts +546 -0
  2. package/core/src/cursor.ts +1 -0
  3. package/core/src/link-count.test.ts +29 -0
  4. package/core/src/links.ts +43 -0
  5. package/core/src/mcp-manifest.ts +10 -6
  6. package/core/src/mcp.ts +129 -44
  7. package/core/src/notes.ts +51 -2
  8. package/core/src/schema-v28-unresolved-wikilinks.test.ts +103 -0
  9. package/core/src/schema.ts +28 -1
  10. package/core/src/store.ts +180 -57
  11. package/core/src/txn.test.ts +33 -1
  12. package/core/src/txn.ts +80 -7
  13. package/core/src/types.ts +11 -0
  14. package/core/src/vault-projection.ts +8 -1
  15. package/core/src/wikilinks.ts +873 -44
  16. package/package.json +2 -2
  17. package/src/aggregate-routes.test.ts +76 -0
  18. package/src/config.ts +8 -2
  19. package/src/mcp-http.test.ts +51 -1
  20. package/src/mcp-tools.ts +106 -5
  21. package/src/mirror-routes.test.ts +47 -0
  22. package/src/release-plan.test.ts +90 -1
  23. package/src/routes.ts +280 -66
  24. package/src/routing.test.ts +67 -0
  25. package/src/tag-scope-query-tag.test.ts +374 -0
  26. package/src/tag-scope.ts +116 -0
  27. package/src/test-support/vault-714-find-path.json +58 -0
  28. package/src/test-support/vault-714-graph.json +27 -0
  29. package/src/test-support/vault-714-has_links.json +96 -0
  30. package/src/test-support/vault-714-include_broken_links.json +118 -0
  31. package/src/test-support/vault-714-include_link_count.json +348 -0
  32. package/src/test-support/vault-714-include_links.json +86 -0
  33. package/src/test-support/vault-714-near.json +78 -0
  34. package/src/test-support/vault-714-unresolved-wikilinks.json +6 -0
  35. package/src/vault.test.ts +928 -1
  36. package/src/write-warnings-scope.test.ts +344 -0
  37. package/src/ws-server.ts +16 -3
  38. package/src/ws-subscribe.test.ts +76 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.9-rc.2",
3
+ "version": "0.7.9-rc.4",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  "start": "bun src/server.ts",
20
20
  "cli": "bun src/cli.ts",
21
21
  "test": "bun test ./src/",
22
- "test:core": "cd core && node --experimental-vm-modules node_modules/vitest/dist/cli.js run",
22
+ "test:core": "bun test ./core/src/",
23
23
  "typecheck": "tsc --noEmit",
24
24
  "build:spa": "cd web/ui && bun install --frozen-lockfile && bun run build",
25
25
  "prepack": "bun run build:spa"
@@ -217,6 +217,82 @@ describe("REST GET /notes — aggregate: count without group_by (vault#626)", ()
217
217
  });
218
218
  });
219
219
 
220
+ describe("REST GET /notes — the filtered total's filter surface IS the list's (vault#626)", () => {
221
+ /**
222
+ * vault#626 item 1 promises the ungrouped count runs over "the same filter
223
+ * surface as the list query". Today that holds by construction — both
224
+ * `queryNotes` and `aggregateNotes` build their WHERE from the shared
225
+ * `buildFilterConditions` (core/src/notes.ts) — but "by construction" is
226
+ * exactly the kind of fact a later refactor breaks silently: a filter
227
+ * handled OUTSIDE the shared builder would still narrow the list while the
228
+ * count kept reporting the unfiltered total, and every existing test here
229
+ * (which only exercises `tag`) would stay green.
230
+ *
231
+ * So: for each filter, assert `count == list.length` — and assert the
232
+ * filter actually NARROWED (`0 < n < TOTAL`), because a param that both
233
+ * paths ignore would satisfy the equality vacuously.
234
+ */
235
+ const TOTAL = 5;
236
+
237
+ async function seed(): Promise<void> {
238
+ await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
239
+ // 1 + 2 are linked to each other (the [[wikilink]] resolves by path).
240
+ await store.createNote("alpha [[Projects/beta]]", {
241
+ path: "Projects/alpha",
242
+ tags: ["task", "work"],
243
+ metadata: { status: "open" },
244
+ });
245
+ await store.createNote("beta", { path: "Projects/beta", tags: ["task"], metadata: { status: "done" } });
246
+ await store.createNote("gamma", { path: "Archive/gamma", tags: ["work"] });
247
+ await store.createNote("delta", { path: "Archive/delta", tags: ["work"] });
248
+ await store.createNote("untagged epsilon", { path: "Archive/epsilon" });
249
+ }
250
+
251
+ async function listLength(qs: string): Promise<number> {
252
+ const res = await get(`?${qs}&limit=500`);
253
+ expect(res.status).toBe(200);
254
+ return ((await res.json()) as any[]).length;
255
+ }
256
+
257
+ async function count(qs: string): Promise<number> {
258
+ const res = await get(`?${qs}&aggregate[op]=count`);
259
+ expect(res.status).toBe(200);
260
+ const body: any = await res.json();
261
+ expect(body).toHaveLength(1);
262
+ expect(body[0].group).toBeNull();
263
+ return body[0].value;
264
+ }
265
+
266
+ const CASES: [name: string, qs: string][] = [
267
+ ["tag", "tag=task"],
268
+ ["tag + tag_match=all", "tag=task&tag=work&tag_match=all"],
269
+ ["exclude_tag", "exclude_tag=work"],
270
+ ["has_tags=false", "has_tags=false"],
271
+ ["has_links=true", "has_links=true"],
272
+ ["path_prefix", "path_prefix=Projects/"],
273
+ ["exclude_path_prefix", "exclude_path_prefix=Projects/"],
274
+ ["meta[field][op]", "meta[status][eq]=open"],
275
+ ];
276
+
277
+ it("the unfiltered total matches the unfiltered list", async () => {
278
+ await seed();
279
+ expect(await count("")).toBe(TOTAL);
280
+ expect(await listLength("")).toBe(TOTAL);
281
+ });
282
+
283
+ for (const [name, qs] of CASES) {
284
+ it(`${name}: count == list length, and the filter really narrowed`, async () => {
285
+ await seed();
286
+ const n = await listLength(qs);
287
+ // Non-vacuity: a param both paths silently ignored would make the
288
+ // equality below trivially true.
289
+ expect(n).toBeGreaterThan(0);
290
+ expect(n).toBeLessThan(TOTAL);
291
+ expect(await count(qs)).toBe(n);
292
+ });
293
+ }
294
+ });
295
+
220
296
  describe("REST GET /notes — aggregate: group_by \"tag\"", () => {
221
297
  it("counts by tag membership", async () => {
222
298
  await store.createNote("a", { tags: ["work", "urgent"] });
package/src/config.ts CHANGED
@@ -187,7 +187,7 @@ export interface TagSchema {
187
187
 
188
188
  export interface VaultConfig {
189
189
  name: string;
190
- description?: string;
190
+ description?: string | null;
191
191
  api_keys: StoredKey[];
192
192
  created_at: string;
193
193
  tag_schemas?: Record<string, TagSchema>;
@@ -516,7 +516,13 @@ export function defaultBackupConfig(): BackupConfig {
516
516
  function serializeVaultConfig(config: VaultConfig): string {
517
517
  const lines: string[] = [];
518
518
  lines.push(`name: ${config.name}`);
519
- if (config.description) {
519
+ // vault#669: only serialize a real string. A non-string that slipped
520
+ // past a write door used to throw here (`description.split is not a
521
+ // function`) and 500 the request; the doors now reject that shape, and
522
+ // this is the persist-time backstop so a poisoned in-memory config
523
+ // cannot crash the write path either. Falsy / non-string omits the
524
+ // field, which is how a `null` description clears.
525
+ if (typeof config.description === "string" && config.description.length > 0) {
520
526
  lines.push(`description: |`);
521
527
  for (const line of config.description.split("\n")) {
522
528
  lines.push(` ${line}`);
@@ -19,11 +19,21 @@
19
19
  * do — `McpError` is only ever constructed inside `mcp-http.ts` itself).
20
20
  */
21
21
  import { describe, test, expect } from "bun:test";
22
+ import { Database } from "bun:sqlite";
22
23
  import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
23
24
  import { handleMcp, mcpDomainError } from "./mcp-http.ts";
24
- import type { McpToolDef } from "../core/src/mcp.ts";
25
+ import { generateMcpTools, type McpToolDef } from "../core/src/mcp.ts";
26
+ import { SqliteStore } from "../core/src/store.ts";
25
27
  import type { AuthResult } from "./auth.ts";
26
28
 
29
+ interface ToolErrorResponse {
30
+ error: {
31
+ code: number;
32
+ message: string;
33
+ data: Record<string, unknown>;
34
+ };
35
+ }
36
+
27
37
  function toolsWith(execute: (params: Record<string, unknown>) => unknown): () => McpToolDef[] {
28
38
  return () => [
29
39
  {
@@ -64,6 +74,29 @@ async function callBoom(execute: (params: Record<string, unknown>) => unknown):
64
74
  return res.json();
65
75
  }
66
76
 
77
+ async function callNoteToolWithoutId(
78
+ name: "update-note" | "delete-note",
79
+ ): Promise<ToolErrorResponse> {
80
+ const db = new Database(":memory:");
81
+ try {
82
+ const store = new SqliteStore(db);
83
+ const req = new Request("http://localhost:1940/vault/v/mcp", {
84
+ method: "POST",
85
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
86
+ body: JSON.stringify({
87
+ jsonrpc: "2.0",
88
+ id: 1,
89
+ method: "tools/call",
90
+ params: { name, arguments: {} },
91
+ }),
92
+ });
93
+ const res = await handleMcp(req, () => generateMcpTools(store), "test-server", "v", fullAuth(), "");
94
+ return (await res.json()) as ToolErrorResponse;
95
+ } finally {
96
+ db.close();
97
+ }
98
+ }
99
+
67
100
  describe("MCP SDK McpError — confirms the mechanics this fix depends on", () => {
68
101
  test("the SDK bakes the JSON-RPC prefix into .message itself, not just .toString()", () => {
69
102
  const err = new McpError(ErrorCode.InvalidParams, "bad stuff happened", { error_type: "invalid_query" });
@@ -150,3 +183,20 @@ describe("handleMcp JSON-RPC error mapping — end to end (vault#555 fix 6)", ()
150
183
  expect(body.result.content[0].text).toContain("plain unstructured failure");
151
184
  });
152
185
  });
186
+
187
+ describe("note mutation tools require an id (vault#705)", () => {
188
+ for (const name of ["update-note", "delete-note"] as const) {
189
+ test(`${name} without id returns structured invalid params, never a TypeError`, async () => {
190
+ const body = await callNoteToolWithoutId(name);
191
+
192
+ expect(body.error.code).toBe(ErrorCode.InvalidParams);
193
+ expect(body.error.data).toMatchObject({
194
+ error_type: "missing_required_field",
195
+ field: "id",
196
+ hint: expect.stringContaining("path"),
197
+ });
198
+ expect(body.error.message).not.toContain("TypeError");
199
+ expect(body.error.message).not.toContain("idOrPath.match");
200
+ });
201
+ }
202
+ });
package/src/mcp-tools.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import { generateMcpTools } from "../core/src/mcp.ts";
9
9
  import type { McpToolDef, GenerateMcpToolsOpts } from "../core/src/mcp.ts";
10
10
  import { getNoteTags, getVaultMap } from "../core/src/notes.ts";
11
+ import { narrowLinkWarningsForVisibility } from "../core/src/wikilinks.ts";
11
12
  import type { Note } from "../core/src/types.ts";
12
13
  import {
13
14
  buildVaultProjection,
@@ -23,6 +24,7 @@ import {
23
24
  expandTokenTagScope,
24
25
  filterHydratedLinksByTagScope,
25
26
  noteWithinTagScope,
27
+ scopeQueryTagParam,
26
28
  scrubIndexedFieldConflictError,
27
29
  scrubNoteTagsByScope,
28
30
  scrubParentCycleError,
@@ -211,6 +213,24 @@ export function generateScopedMcpTools(
211
213
  )
212
214
  : undefined;
213
215
 
216
+ // Tag-scope guard for the ambiguous-links surface (vault#581 auth review):
217
+ // `candidate_count` is derived vault-wide, so without this a `work`-scoped
218
+ // reader learns from `candidate_count: 2` on its own `[[Dup]]` that a
219
+ // second `Dup` exists in a scope it cannot see — and `has_ambiguous_links:
220
+ // true` turns that into a standing, sweepable census of cross-scope naming
221
+ // collisions. Identical closure/shape to `nearTraversable` above (by note
222
+ // id, tags looked up sync + core-native); core narrows each row's
223
+ // candidates through it. Unscoped sessions install no predicate, so the
224
+ // persisted counts and the SQL filter answer directly, unchanged.
225
+ const ambiguityVisible = scoped
226
+ ? (noteId: string) =>
227
+ noteWithinTagScope(
228
+ { id: noteId, tags: getNoteTags(store.db, noteId) } as Note,
229
+ allowedHolder.value,
230
+ rawTags,
231
+ )
232
+ : undefined;
233
+
214
234
  // Tag-scope guard for `create-note` `if_exists` (vault#555 auth-review
215
235
  // must-fix): the core `if_exists` upsert resolves the target path VAULT-WIDE
216
236
  // and returns/updates/replaces the found note, so a scoped caller could
@@ -274,6 +294,7 @@ export function generateScopedMcpTools(
274
294
  ...(nearTraversable ? { nearTraversable } : {}),
275
295
  ...(ifExistsVisible ? { ifExistsVisible } : {}),
276
296
  ...(aggregateVisibility ? { aggregateVisibility } : {}),
297
+ ...(ambiguityVisible ? { ambiguityVisible } : {}),
277
298
  ...(writeContext ? { writeContext } : {}),
278
299
  ...(strictBypass ? { strictBypass } : {}),
279
300
  ...(onStrictBypass ? { onStrictBypass } : {}),
@@ -439,6 +460,38 @@ function applyTagScopeWrappers(
439
460
  return scrubNoteTagsByScope(n, allowedHolder?.value ?? null, rawTags);
440
461
  };
441
462
 
463
+ /**
464
+ * Re-decide a write response's `ambiguous_link` warnings against the
465
+ * caller's OWN sub-vault (vault#707's rule, write side). `candidate_count`
466
+ * is derived vault-wide, so a `work`-scoped writer saving `[[Dup]]` would
467
+ * otherwise learn from `candidate_count: 2` that a second `Dup` exists in a
468
+ * scope it cannot see — the same oracle vault#707 closed on `query-notes`,
469
+ * reachable through `create-note` / `update-note` instead. Same visibility
470
+ * model as `ambiguityVisible` / `nearTraversable` in generateScopedMcpTools
471
+ * (`noteWithinTagScope` by note id), and the same decision function in core
472
+ * (`visibleResolutionCount`), so the surfaces cannot drift.
473
+ *
474
+ * Ordering: identical to `scrubNoteForScope` above — every write wrapper
475
+ * `await getAllowed()`s before `orig(params)`, so `allowedHolder.value` is
476
+ * the resolved allowlist by the time this runs.
477
+ */
478
+ const scrubWriteNote = (n: any): any => {
479
+ const scrubbed = scrubNoteForScope(n);
480
+ if (!scrubbed || !Array.isArray(scrubbed.warnings)) return scrubbed;
481
+ const visible = (noteId: string) =>
482
+ noteWithinTagScope(
483
+ { id: noteId, tags: getNoteTags(store.db, noteId) } as Note,
484
+ allowedHolder?.value ?? null,
485
+ rawTags,
486
+ );
487
+ const warnings = narrowLinkWarningsForVisibility(store.db, scrubbed.warnings, visible);
488
+ if (warnings.length === 0) {
489
+ const { warnings: _dropped, ...rest } = scrubbed;
490
+ return rest;
491
+ }
492
+ return { ...scrubbed, warnings };
493
+ };
494
+
442
495
  /**
443
496
  * Shape dispatcher for the WRITE tools (vault#568). `create-note` and
444
497
  * `update-note` echo the stored note, so they leak exactly what the read
@@ -449,18 +502,46 @@ function applyTagScopeWrappers(
449
502
  */
450
503
  const scrubWriteResult = (result: any): any => {
451
504
  if (!result || typeof result !== "object") return result;
452
- if (Array.isArray(result)) return result.map(scrubNoteForScope);
505
+ if (Array.isArray(result)) return result.map(scrubWriteNote);
453
506
  if ("error" in result) return result;
454
507
  if (Array.isArray((result as any).notes)) {
455
- return { ...result, notes: (result as any).notes.map(scrubNoteForScope) };
508
+ return { ...result, notes: (result as any).notes.map(scrubWriteNote) };
456
509
  }
457
- if ("id" in result && "tags" in result) return scrubNoteForScope(result);
510
+ if ("id" in result && "tags" in result) return scrubWriteNote(result);
458
511
  return result;
459
512
  };
460
513
 
514
+ /**
515
+ * Neutralise out-of-scope tags NAMED IN THE QUERY (vault#675) before core
516
+ * ever runs. Without this a scoped session could ask `query-notes { tag:
517
+ * "project-manhattan" }` and read the answer off hit/miss — a co-tagged
518
+ * note is in scope via its OTHER tag, so it survived the result filter
519
+ * below and confirmed the guessed name. Rewriting the input (rather than
520
+ * emptying the output) keeps the response byte-identical to the same call
521
+ * naming a tag that doesn't exist, in every shape this tool returns —
522
+ * including the `aggregate` rollup and the `next_cursor` envelope, which
523
+ * an output-side patch could not reproduce. `exclude_tags` is rewritten on
524
+ * the same terms (core skips an exclude clause that can't match, so it
525
+ * excludes nothing — again exactly a nonexistent tag). Aliases mirror
526
+ * core's `exclude_tags ?? excludeTags ?? exclude_tag` resolution order.
527
+ */
528
+ const QUERY_TAG_PARAMS = ["tag", "exclude_tags", "excludeTags", "exclude_tag"] as const;
529
+ const scopeQueryTagParams = (params: any, allowed: Set<string> | null): any => {
530
+ if (!params || typeof params !== "object") return params;
531
+ let next = params;
532
+ for (const key of QUERY_TAG_PARAMS) {
533
+ if (params[key] === undefined) continue;
534
+ const scoped = scopeQueryTagParam(params[key], allowed, rawTags);
535
+ if (scoped === params[key]) continue;
536
+ if (next === params) next = { ...params };
537
+ next[key] = scoped;
538
+ }
539
+ return next;
540
+ };
541
+
461
542
  wrapReadTool(tools, "query-notes", async (orig, params) => {
462
543
  const allowed = await getAllowed();
463
- const result = await orig(params);
544
+ const result = await orig(scopeQueryTagParams(params, allowed));
464
545
  if (!allowed) return result;
465
546
  // `aggregate` mode returns `[{group, value}]` rollup rows — no `.tags`
466
547
  // to post-filter (and `noteWithinTagScope` would wrongly drop every
@@ -863,7 +944,27 @@ function overrideVaultInfo(
863
944
  `Forbidden: updating the vault description requires the 'vault:admin' scope (or 'vault:${vaultName}:admin'). Granted scopes: ${auth?.scopes.join(" ") || "(none)"}.`,
864
945
  );
865
946
  }
866
- config.description = params.description as string;
947
+ // vault#669: runtime type guard — the `as string` cast below was a
948
+ // compile-time claim guarding nothing, so a write/admin caller could
949
+ // persist a non-string `description` (or crash serializeVaultConfig
950
+ // with `description.split is not a function`) and poison the NEXT
951
+ // MCP `initialize` (projectionToMarkdown's `description.trim()`).
952
+ // Reject non-string/non-null here as a structured invalid-params
953
+ // error: a duck-typed `error_type` throw, which src/mcp-http.ts's
954
+ // generic branch turns into JSON-RPC -32602 INVALID_PARAMS carrying
955
+ // structured `data` — the same shape core's own validation leaves
956
+ // take, no new error family. `null` stays legal: it clears the
957
+ // description. Cross-door parity with cloud#263 (closes the bun
958
+ // half of cloud#87).
959
+ if (params.description !== null && typeof params.description !== "string") {
960
+ throw Object.assign(new Error("description must be a string or null"), {
961
+ error_type: "invalid_description",
962
+ field: "description",
963
+ got: params.description,
964
+ hint: "pass a string, or null to clear the description",
965
+ });
966
+ }
967
+ config.description = params.description as string | null;
867
968
  writeVaultConfig(config);
868
969
  }
869
970
 
@@ -440,6 +440,53 @@ describe("handleMirrorPut", () => {
440
440
  expect(manager.getConfig().safety_net_seconds).toBe(120);
441
441
  await manager.stop();
442
442
  });
443
+
444
+ test("two concurrent PUTs both 200 and leave a live watcher (safety envelope, vault#662)", async () => {
445
+ // From #661's review: the serialized sibling above soundly proves the
446
+ // only promised behavior (sequential last-writer-wins). Concurrent
447
+ // execution still has residual value — both PUTs 200, watcher stays
448
+ // live, final safety_net_seconds is one of the two requested values.
449
+ // The product makes no concurrent-winner promise, so we do not assert
450
+ // which config won.
451
+ home = tmp("mirror-put-concurrent-");
452
+ const { manager } = makeManager(home);
453
+ const put = (body: Record<string, unknown>) =>
454
+ handleMirrorPut(
455
+ new Request("http://x/admin/mirror", {
456
+ method: "PUT",
457
+ body: JSON.stringify(body),
458
+ }),
459
+ manager,
460
+ );
461
+ const [res1, res2] = await Promise.all([
462
+ put({
463
+ enabled: true,
464
+ location: "internal",
465
+ sync_mode: "events",
466
+ auto_commit: false,
467
+ safety_net_seconds: 60,
468
+ }),
469
+ put({
470
+ enabled: true,
471
+ location: "internal",
472
+ sync_mode: "events",
473
+ auto_commit: false,
474
+ safety_net_seconds: 120,
475
+ }),
476
+ ]);
477
+ expect(res1.status).toBe(200);
478
+ expect(res2.status).toBe(200);
479
+ const status = manager.getStatus();
480
+ expect(status.enabled).toBe(true);
481
+ expect(status.watch_running).toBe(true);
482
+ expect([60, 120]).toContain(manager.getConfig().safety_net_seconds);
483
+ // Concurrent start() can re-arm timers after the other PUT's stop().
484
+ // Drain once more so this test cannot leak a safety-net interval into
485
+ // the rest of the suite.
486
+ await new Promise((r) => setTimeout(r, 50));
487
+ await manager.stop();
488
+ await manager.stop();
489
+ });
443
490
  });
444
491
 
445
492
  // ---------------------------------------------------------------------------
@@ -11,8 +11,10 @@
11
11
  import { describe, expect, test } from "bun:test";
12
12
  import {
13
13
  compareVersions,
14
+ coreVersion,
14
15
  decidePublish,
15
16
  distTagFor,
17
+ matchingRcVersions,
16
18
  readRegistry,
17
19
  unpublishedDrift,
18
20
  } from "../scripts/release-plan.ts";
@@ -193,13 +195,81 @@ describe("decidePublish", () => {
193
195
  {
194
196
  versionExists: false,
195
197
  currentDistTagVersion: "0.7.8",
196
- publishedVersions: ["0.7.8"],
198
+ publishedVersions: ["0.7.8", "0.7.9-rc.1"],
197
199
  },
198
200
  { branch: "main" },
199
201
  );
200
202
  expect(d).toMatchObject({ publish: true });
201
203
  });
202
204
 
205
+ test("a stable without a matching rc is refused — vault#697", () => {
206
+ // Hub 0.7.13–0.7.16 shipped @latest with no rc of the same X.Y.Z.
207
+ // Vault's previous plan (and the inline shell it replaced) did the same.
208
+ const d = decidePublish(
209
+ "0.7.9",
210
+ {
211
+ versionExists: false,
212
+ currentDistTagVersion: "0.7.8",
213
+ publishedVersions: ["0.7.8", "0.7.8-rc.2"],
214
+ },
215
+ { branch: "main" },
216
+ );
217
+ expect(d).toMatchObject({ refuse: true });
218
+ expect("refuse" in d && d.reason).toMatch(/0\.7\.9-rc/);
219
+ expect("refuse" in d && d.reason).toMatch(/suffix-drop|Cut an rc first/i);
220
+ });
221
+
222
+ test("a stable whose only published rcs are a different X.Y.Z is still refused", () => {
223
+ const d = decidePublish(
224
+ "0.7.9",
225
+ {
226
+ versionExists: false,
227
+ currentDistTagVersion: "0.7.8",
228
+ publishedVersions: ["0.7.8", "0.7.8-rc.1"],
229
+ },
230
+ { branch: "main" },
231
+ );
232
+ expect(d).toMatchObject({ refuse: true });
233
+ });
234
+
235
+ test("a stable with a matching rc publishes from main", () => {
236
+ const d = decidePublish(
237
+ "0.7.9",
238
+ {
239
+ versionExists: false,
240
+ currentDistTagVersion: "0.7.8",
241
+ publishedVersions: ["0.7.8", "0.7.9-rc.1"],
242
+ },
243
+ { branch: "main" },
244
+ );
245
+ expect(d).toMatchObject({ publish: true });
246
+ });
247
+
248
+ test("omitted publishedVersions still refuses a stable when latest already exists", () => {
249
+ const d = decidePublish(
250
+ "0.7.9",
251
+ {
252
+ versionExists: false,
253
+ currentDistTagVersion: "0.7.8",
254
+ },
255
+ { branch: "main" },
256
+ );
257
+ expect(d).toMatchObject({ refuse: true });
258
+ });
259
+
260
+ test("an rc publishes even with no prior rc of the same core", () => {
261
+ const d = decidePublish(
262
+ "0.7.9-rc.1",
263
+ {
264
+ versionExists: false,
265
+ currentDistTagVersion: "0.7.8-rc.5",
266
+ publishedVersions: ["0.7.8", "0.7.8-rc.5"],
267
+ },
268
+ { branch: "next" },
269
+ );
270
+ expect(d).toMatchObject({ publish: true });
271
+ });
272
+
203
273
  test("a tag push of a stable does NOT override the main-only gate", () => {
204
274
  const d = decidePublish(
205
275
  "0.7.9",
@@ -281,6 +351,25 @@ describe("readRegistry", () => {
281
351
  });
282
352
  });
283
353
 
354
+ describe("matchingRcVersions", () => {
355
+ test("matches only the same X.Y.Z rc chain", () => {
356
+ expect(
357
+ matchingRcVersions("0.7.9", ["0.7.8", "0.7.8-rc.1", "0.7.9-rc.1", "0.7.9-rc.2"]),
358
+ ).toEqual(["0.7.9-rc.1", "0.7.9-rc.2"]);
359
+ });
360
+
361
+ test("a prerelease still matches siblings of its core", () => {
362
+ expect(matchingRcVersions("0.7.9-rc.3", ["0.7.9-rc.1", "0.7.8-rc.1"])).toEqual(["0.7.9-rc.1"]);
363
+ });
364
+ });
365
+
366
+ describe("coreVersion", () => {
367
+ test("strips the rc suffix and leaves a stable alone", () => {
368
+ expect(coreVersion("0.7.9-rc.1")).toBe("0.7.9");
369
+ expect(coreVersion("0.7.9")).toBe("0.7.9");
370
+ });
371
+ });
372
+
284
373
  describe("unpublishedDrift", () => {
285
374
  test("no commits → not drifted", () => {
286
375
  expect(unpublishedDrift([]).drifted).toBe(false);