@zerotal/arch 1.7.0 → 1.7.3

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/docs/routing.md CHANGED
@@ -486,11 +486,25 @@ the checked signature above decorative.
486
486
  Typed names flow through the helpers built on `route()` too — `redirect().to()`,
487
487
  `Url.route()`, `Uri.route()`, and Flow's `redirectRoute()`.
488
488
 
489
+ The types that checking is built from are exported from `zerotal/routes`, for
490
+ when you write a helper that forwards to `route()` rather than calling it
491
+ directly:
492
+
493
+ | Type | What it holds |
494
+ | ------------------ | ----------------------------------------------------------------------------------------------------------------- |
495
+ | `RouteTarget` | The name a checked helper accepts: `RouteName` once the registry is generated, plain `string` before it. |
496
+ | `RouteArgs<N>` | Everything `route()` takes after the name — params required only when the pattern has one, query always optional. |
497
+ | `RouteParamValues` | The loose param bag the unchecked overload accepts. |
498
+ | `RouteQuery` | Query values. `null` and `undefined` entries drop out, and an array repeats the key. |
499
+
489
500
  ### route() in the browser
490
501
 
491
- The server's `route()` reads the live router, which only exists in the server
492
- process. In a browser bundle, import it from `zerotal/routes` instead and
493
- hand it the generated table once, at your entry point:
502
+ `route()` works on the server with no setup: the application installs the table
503
+ during boot, from the routes it just registered. That covers every URL your
504
+ server renders a `view` build's `href` attributes and form actions included.
505
+
506
+ A browser bundle is a different process with no router to read, so there it needs
507
+ the table handed to it once, at your entry point:
494
508
 
495
509
  ```typescript
496
510
  // resources/js/app.js
@@ -503,8 +517,6 @@ defineRoutes(ROUTES);
503
517
  From there the call is the one you already know:
504
518
 
505
519
  ```typescript
506
- import { route } from "zerotal/routes";
507
-
508
520
  route("posts.show", { slug }); // → '/posts/hello'
509
521
  route("posts.index", {}, { page: 2 }); // → '/posts?page=2'
510
522
  ```
@@ -515,10 +527,35 @@ server and the same call made in a component cannot disagree about encoding —
515
527
  because `types/routes.generated.ts` augments the one registry, a name that
516
528
  type-checks in a controller type-checks in a component.
517
529
 
530
+ ### route() needs no import
531
+
532
+ `defineRoutes()` also puts `route()` on `globalThis`, so a page, a component or a
533
+ controller calls it with nothing at the top of the file:
534
+
535
+ ```tsx
536
+ // no import line
537
+ <a href={route("posts.show", { slug })}>{post.title}</a>
538
+ ```
539
+
540
+ It is installed from `defineRoutes()` because that is the one function both
541
+ processes already call — the server during boot, a browser entry beside its
542
+ generated `ROUTES` — which means neither has to remember a second setup step. The
543
+ table is installed before the global, so `route()` never exists in a state where
544
+ calling it reports a missing table.
545
+
546
+ The global is typed by an ambient declaration in `@zerotal/core/routes`, as the
547
+ same `RouteBuilder` the named export is: an unknown route name or a missing
548
+ `:param` still fails the build.
549
+
550
+ > **Note** — `route` remains a named export. Importing it explicitly keeps
551
+ > working, and is worth doing in a library that cannot assume an application has
552
+ > booted.
553
+
518
554
  `defineRoutes()` takes the generated `ROUTES` object or any `RouteTable`
519
555
  (a name → pattern map). Calling it again replaces the table, which is what makes
520
- hot reload work. `resetRoutes()` clears it again, for tests that assert on the
521
- unconfigured error.
556
+ hot reload work and what lets a browser entry install its own copy without
557
+ disturbing the server's. `resetRoutes()` clears it again, for tests that assert
558
+ on the unconfigured error.
522
559
 
523
560
  If your app renders through SSR, call `defineRoutes()` in the SSR entry too — the
524
561
  page components run in both processes.
@@ -543,6 +580,51 @@ helper to Alpine expressions as `$route`:
543
580
 
544
581
  Nothing to install, and the names are the same ones the server rendered with.
545
582
 
583
+ ### Submitting to a route with action()
584
+
585
+ `route()` gives you a URL. A form needs two things — where to send the request
586
+ and how — and a URL alone leaves the second one to be typed out beside it:
587
+
588
+ ```typescript
589
+ // The URL is generated; the verb is a guess that happens to be right today.
590
+ form.post(route("posts.comments.store", { post: id }));
591
+ ```
592
+
593
+ `bun zt route:types` also writes a `METHODS` table, so the verb can come from
594
+ the same place the URL does. `action()` returns both:
595
+
596
+ ```typescript
597
+ // resources/js/app.js
598
+ import { defineRouteMethods, defineRoutes } from "zerotal/routes";
599
+ import { METHODS, ROUTES } from "../../types/routes.generated";
600
+
601
+ defineRoutes(ROUTES);
602
+ defineRouteMethods(METHODS);
603
+ ```
604
+
605
+ ```typescript
606
+ import { action } from "zerotal/routes";
607
+
608
+ const endpoint = action("posts.comments.store", { post: id });
609
+ // → { url: '/posts/42/comments', method: 'POST' }
610
+
611
+ form.submit(endpoint.method.toLowerCase(), endpoint.url);
612
+ ```
613
+
614
+ `action()` takes the same names and params as `route()` and reports the same
615
+ compile errors, so switching a call over costs nothing. What it buys is that a
616
+ route which changes verb changes every submission with it — the failure it
617
+ prevents is a 405 on submit, which looks nothing like its cause when the URL in
618
+ front of you is plainly correct.
619
+
620
+ Two tables rather than one map of `{ url, method }` objects, for two reasons: it
621
+ leaves `ROUTES` alone, so a generated file from an earlier version still works;
622
+ and a bundle that only renders links never pulls the verbs in.
623
+
624
+ `defineRouteMethods()` is optional. Without it `action()` still resolves the URL
625
+ and reports `GET`, which is the right answer for a link-only bundle and a better
626
+ one than throwing.
627
+
546
628
  ## File-based routing
547
629
 
548
630
  Map a directory tree to routes: each file under the routes directory becomes an
package/docs/seeding.md CHANGED
@@ -76,6 +76,13 @@ Create a `DatabaseSeeder` that coordinates all other seeders. Use `this.call()`
76
76
  to run child seeders — they execute in order inside a single transaction, so if
77
77
  any fails, every change rolls back atomically.
78
78
 
79
+ `bun zt db:seed` wraps the whole run in a transaction too, so a seeder that does
80
+ its work inline rather than delegating to `call()` is just as atomic. Nesting is
81
+ fine: `call()` inside the outer transaction becomes a savepoint, and an inner
82
+ failure still rolls back independently. Where there is no database connection
83
+ bound at all — a seeder that writes fixtures to disk, say — the run is left
84
+ alone rather than failing for want of a transaction it never needed.
85
+
79
86
  ```ts
80
87
  // database/seeders/DatabaseSeeder.ts
81
88
  import { Seeder, DB } from "@zerotal/orm";
@@ -49,11 +49,11 @@ course?", so this is it:
49
49
 
50
50
  ## Databases
51
51
 
52
- | Database | Status |
53
- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
54
- | SQLite | Supported. The default; the full test suite runs against it on every merge. |
55
- | PostgreSQL | Supported, hardening. The ORM suite runs against a real Postgres in CI; remaining dialect gaps are being driven to zero before the job blocks merges. |
56
- | MySQL | Experimental. The ORM ships a MySQL dialect and the scaffolder can configure it, but no CI suite runs against a real MySQL server yet — treat it as unverified until it joins the tested matrix. |
52
+ | Database | Status |
53
+ | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
54
+ | SQLite | Supported. The default; the full test suite runs against it on every merge. |
55
+ | PostgreSQL | Supported. A smoke suite runs against a real PostgreSQL 16 on every merge schema DDL, identity columns, CRUD, type round-trips, row locks and transaction rollback — and the job blocks a merge when it fails. The bulk of the ORM suite still runs on SQLite, so coverage of the Postgres path is narrower than of the default one. |
56
+ | MySQL | Experimental. The ORM ships a MySQL dialect and the scaffolder can configure it, but no CI suite runs against a real MySQL server yet — treat it as unverified until it joins the tested matrix. |
57
57
 
58
58
  Redis-backed drivers (cache, session, queue, broadcasting) build on
59
59
  `Bun.RedisClient` and are tested against the protocol surface it provides.
@@ -67,6 +67,14 @@ dependency order, from CI. Never mix versions across packages.
67
67
  - **Semantic versioning:** patch for fixes, minor for compatible features, major
68
68
  for breaking changes. The [Upgrade Guide](/docs/upgrade) describes the upgrade
69
69
  procedure; the [Release Notes](/docs/changelog) list what changed.
70
+ - **One exception, while the 1.x line is young:** a breaking change may land in a
71
+ minor or a patch when leaving it in place would cost more than the migration
72
+ does. It is called out in the release notes as **BREAKING**, with the reason and
73
+ the migration steps, and it is never silent. Two have shipped so far — the
74
+ `ComponentWith` / `BaseModelWith` removal in 1.3.0 and Flow's `socket:` listener
75
+ prefix in 1.7.2. This carve-out is a consequence of the project's age, not a
76
+ standing policy; it will be withdrawn, with a version named here, once adoption
77
+ makes the cost of a break real.
70
78
  - **Provenance:** packages are published with npm provenance, so you can verify
71
79
  a tarball was built by this repository's release workflow rather than someone's
72
80
  laptop.
package/docs/upgrade.md CHANGED
@@ -18,6 +18,8 @@ version line:
18
18
  - **Major** (`X.y.z`) — breaking changes; read the version's section in the
19
19
  [Release Notes](/docs/changelog) before upgrading.
20
20
 
21
+ > **Warning** — while the 1.x line is young, a breaking change may also land in a minor or a patch. It is always labelled **BREAKING** in the [Release Notes](/docs/changelog) with migration steps, and two have shipped so far (1.3.0 and 1.7.2). Read the notes for every version you cross, not only the majors. See [Releases and versioning](/docs/support-policy#releases-and-versioning) for when this carve-out ends.
22
+
21
23
  > **Warning** — always upgrade the `@zerotal/*` packages together. Mixing versions across core, ORM, and feature packages leads to type and runtime mismatches.
22
24
 
23
25
  ## Upgrade steps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/arch",
3
- "version": "1.7.0",
3
+ "version": "1.7.3",
4
4
  "license": "MIT",
5
5
  "maturity": "beta",
6
6
  "private": false,
@@ -35,11 +35,11 @@
35
35
  "typecheck": "tsc --noEmit"
36
36
  },
37
37
  "dependencies": {
38
- "@zerotal/core": "1.7.0"
38
+ "@zerotal/core": "1.7.3"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/orm": "1.7.0"
42
+ "@zerotal/orm": "1.7.3"
43
43
  },
44
44
  "description": "The Zerotal agent surface — an MCP server that hands coding agents the framework's machine-readable truth: exact API signatures, live routes and schema, version-matched docs, and `zt doctor`.",
45
45
  "keywords": [
@@ -117,7 +117,11 @@ const PACKAGE_BLOCKS: PackageBlock[] = [
117
117
  },
118
118
  {
119
119
  pkg: "@zerotal/i18n",
120
- lines: ["**Translations.** `@zerotal/i18n`; message files live under `resources/lang/`."],
120
+ lines: [
121
+ "**Translations.** `@zerotal/i18n`; message files live under `resources/lang/`. " +
122
+ 'Translate with `__("English sentence")` — the source string is the key, so there ' +
123
+ "is no catalog for the source language and no key to invent.",
124
+ ],
121
125
  },
122
126
  {
123
127
  pkg: "@zerotal/tenancy",
@@ -25,9 +25,18 @@ export type BlockOutcome =
25
25
  /** The markers are damaged; nothing was written. */
26
26
  | { status: "conflict"; reason: string };
27
27
 
28
- /** Wrap generated content in its markers. */
28
+ /**
29
+ * Wrap generated content in its markers.
30
+ *
31
+ * The blank lines either side are not cosmetic. In Markdown, text on the line
32
+ * immediately after an HTML comment is parsed as part of that raw-HTML block, so
33
+ * `@AGENTS.md` pressed against the opening marker stops being a paragraph — and
34
+ * every formatter, including the `prettier --check` a Zerotal project already
35
+ * runs, inserts them. A generator whose output fails the project's own format
36
+ * gate is a generator nobody can run twice.
37
+ */
29
38
  export function fence(content: string): string {
30
- return `${BLOCK_START}\n${content.trim()}\n${BLOCK_END}`;
39
+ return `${BLOCK_START}\n\n${content.trim()}\n\n${BLOCK_END}`;
31
40
  }
32
41
 
33
42
  /**
@@ -70,10 +70,22 @@ export function applyMcpConfig(
70
70
  : {};
71
71
 
72
72
  servers[name] = serverEntry();
73
- const text = JSON.stringify({ ...document, [target.key]: servers }, null, 2) + "\n";
73
+ const next = { ...document, [target.key]: servers };
74
+ const text = JSON.stringify(next, null, 2) + "\n";
74
75
 
75
76
  if (isNew) return { status: "created", text };
76
- return text === existing ? { status: "unchanged", text } : { status: "updated", text };
77
+
78
+ // Compared as data, not as text. `JSON.stringify(…, 2)` expands a one-element
79
+ // array across three lines where a formatter collapses it, and a project's
80
+ // formatter settings are its own business — so a textual comparison made
81
+ // `arch:update` rewrite a file it had no change to make to, and the next
82
+ // `prettier --write` put it back. That ping-pong shows up as a dirty working
83
+ // tree after running two commands that both claim to be no-ops.
84
+ //
85
+ // Semantic equality ends it: when the config already says what it should, the
86
+ // file is returned exactly as it was found, in whatever shape its owner keeps it.
87
+ if (Bun.deepEquals(document, next)) return { status: "unchanged", text: existing };
88
+ return { status: "updated", text };
77
89
  }
78
90
 
79
91
  function describe(error: unknown): string {
@@ -262,12 +262,27 @@ function readConfig(app: Application, key: string): unknown {
262
262
  * heard of.
263
263
  */
264
264
  export async function installedPackages(root = process.cwd()): Promise<InstalledPackage[]> {
265
- const found: InstalledPackage[] = [];
266
- const glob = new Bun.Glob("node_modules/{zerotal,@zerotal/*}/package.json");
265
+ const { readdir } = await import("node:fs/promises");
266
+
267
+ // Listed with `readdir`, not matched with a glob. In a workspace — this
268
+ // monorepo, `bun link`, any app developed against a checkout — every
269
+ // `node_modules/@zerotal/*` is a symlink to the package directory, and glob
270
+ // traversal does not descend into one even with `followSymlinks`. It returned
271
+ // zero packages for `apps/docs`, which has seventeen. `readdir` lists the link
272
+ // itself and `Bun.file` follows it, so both layouts read the same.
273
+ const candidates = [`${root}/node_modules/zerotal`];
274
+ try {
275
+ for (const name of await readdir(`${root}/node_modules/@zerotal`)) {
276
+ candidates.push(`${root}/node_modules/@zerotal/${name}`);
277
+ }
278
+ } catch {
279
+ /* no scoped packages installed here */
280
+ }
267
281
 
268
- for await (const file of glob.scan({ cwd: root, onlyFiles: true })) {
282
+ const found: InstalledPackage[] = [];
283
+ for (const dir of candidates) {
269
284
  try {
270
- const manifest = (await Bun.file(`${root}/${file}`).json()) as Record<string, unknown>;
285
+ const manifest = (await Bun.file(`${dir}/package.json`).json()) as Record<string, unknown>;
271
286
  const name = manifest["name"];
272
287
  const version = manifest["version"];
273
288
  if (typeof name !== "string" || typeof version !== "string") continue;
@@ -106,23 +106,46 @@ export async function findSurfaceFile(
106
106
  return undefined;
107
107
  }
108
108
 
109
- /** Every package whose snapshot this project can serve, for the "did you mean" list. */
109
+ /**
110
+ * Every package whose snapshot this project can serve, for the "did you mean" list.
111
+ *
112
+ * `node_modules` is listed rather than globbed: in a workspace each
113
+ * `@zerotal/*` entry is a symlink to the package directory, and glob traversal
114
+ * does not descend into one — so the suggestion list came back empty in exactly
115
+ * the layout a framework contributor works in. `packages/` is a real tree and is
116
+ * globbed as before.
117
+ */
110
118
  async function availablePackages(root: string): Promise<string[]> {
119
+ const { readdir } = await import("node:fs/promises");
111
120
  const names = new Set<string>();
112
- for (const pattern of [
113
- "node_modules/{zerotal,@zerotal/*}/api-surface.md",
114
- "packages/*/api-surface.md",
115
- ]) {
116
- try {
117
- for await (const file of new Bun.Glob(pattern).scan({ cwd: root, onlyFiles: true })) {
118
- const parts = file.split(/[\\/]/);
119
- const dir = parts[parts.length - 2] ?? "";
120
- names.add(dir === "zerotal" ? "zerotal" : `@zerotal/${dir}`);
121
- }
122
- } catch {
123
- /* a pattern that matches nothing contributes nothing */
121
+
122
+ const linked = [`${root}/node_modules/zerotal`];
123
+ try {
124
+ for (const name of await readdir(`${root}/node_modules/@zerotal`)) {
125
+ linked.push(`${root}/node_modules/@zerotal/${name}`);
124
126
  }
127
+ } catch {
128
+ /* nothing installed under the scope */
129
+ }
130
+ for (const dir of linked) {
131
+ if (!(await Bun.file(`${dir}/api-surface.md`).exists())) continue;
132
+ const base = dir.split(/[\\/]/).pop() ?? "";
133
+ names.add(base === "zerotal" ? "zerotal" : `@zerotal/${base}`);
125
134
  }
135
+
136
+ try {
137
+ for await (const file of new Bun.Glob("packages/*/api-surface.md").scan({
138
+ cwd: root,
139
+ onlyFiles: true,
140
+ })) {
141
+ const parts = file.split(/[\\/]/);
142
+ const dir = parts[parts.length - 2] ?? "";
143
+ names.add(dir === "zerotal" ? "zerotal" : `@zerotal/${dir}`);
144
+ }
145
+ } catch {
146
+ /* a pattern that matches nothing contributes nothing */
147
+ }
148
+
126
149
  return [...names].sort();
127
150
  }
128
151
 
@@ -134,13 +157,16 @@ export function apiSurfaceTool(ctx: ToolContext): ArchTool {
134
157
  "The exact public API of a Zerotal package: every export with its full TypeScript " +
135
158
  "signature, including class members and static properties. This is the mechanical " +
136
159
  "record CI diffs on every change, read from the version installed in this project — " +
137
- "prefer it over recalling an API from memory. Pass `symbol` to narrow to one export.",
160
+ "prefer it over recalling an API from memory. Pass `symbol` alone to find an export " +
161
+ "when you do not know which package owns it; add `package` to narrow.",
138
162
  inputSchema: {
139
163
  type: "object",
140
164
  properties: {
141
165
  package: {
142
166
  type: "string",
143
- description: 'Package name, with or without the scope — "core" or "@zerotal/core".',
167
+ description:
168
+ 'Package name, with or without the scope — "core" or "@zerotal/core". ' +
169
+ "Omit to search every installed package.",
144
170
  },
145
171
  symbol: {
146
172
  type: "string",
@@ -148,7 +174,6 @@ export function apiSurfaceTool(ctx: ToolContext): ArchTool {
148
174
  "Only return exports whose name contains this, case-insensitively. Omit for all.",
149
175
  },
150
176
  },
151
- required: ["package"],
152
177
  additionalProperties: false,
153
178
  },
154
179
  outputSchema: {
@@ -175,9 +200,23 @@ export function apiSurfaceTool(ctx: ToolContext): ArchTool {
175
200
  },
176
201
 
177
202
  async run(args): Promise<ToolOutcome> {
178
- const requested = typeof args["package"] === "string" ? args["package"] : "";
179
- if (requested.trim().length === 0) {
180
- return { text: "`package` is required.", failed: true };
203
+ const requested = typeof args["package"] === "string" ? args["package"].trim() : "";
204
+ const filter = typeof args["symbol"] === "string" ? args["symbol"].toLowerCase() : undefined;
205
+
206
+ // No package named: search them all for the symbol.
207
+ //
208
+ // Requiring one assumed the caller already knew where an export lived,
209
+ // which is the opposite of the situation that sends someone here — you
210
+ // reach for this tool *because* you are unsure, and being made to guess a
211
+ // package first is how you end up guessing the signature instead.
212
+ if (requested.length === 0) {
213
+ if (filter === undefined || filter.length === 0) {
214
+ return {
215
+ text: "Pass `symbol` to search every package, or `package` to list one.",
216
+ failed: true,
217
+ };
218
+ }
219
+ return searchEverywhere(ctx.root, args["symbol"] as string, filter);
181
220
  }
182
221
 
183
222
  const found = await findSurfaceFile(ctx.root, requested);
@@ -192,7 +231,6 @@ export function apiSurfaceTool(ctx: ToolContext): ArchTool {
192
231
  }
193
232
 
194
233
  const all = parseSurface(await Bun.file(found.path).text());
195
- const filter = typeof args["symbol"] === "string" ? args["symbol"].toLowerCase() : undefined;
196
234
  const entries =
197
235
  filter === undefined || filter.length === 0
198
236
  ? all
@@ -239,3 +277,60 @@ function render(pkg: string, entries: SurfaceEntry[], total: number): string {
239
277
 
240
278
  return `${header}\n\n${sections.join("\n\n")}`;
241
279
  }
280
+
281
+ /**
282
+ * Find a symbol without being told which package it lives in.
283
+ *
284
+ * The narrow form answers "what is the signature of X in package Y". This one
285
+ * answers "where is X, and what is its signature" — the question you actually
286
+ * have when an API you half-remember turns out not to exist. Matches are
287
+ * grouped by package so an ambiguous name shows every owner rather than the
288
+ * first.
289
+ */
290
+ async function searchEverywhere(
291
+ root: string,
292
+ symbol: string,
293
+ filter: string,
294
+ ): Promise<ToolOutcome> {
295
+ const packages = await availablePackages(root);
296
+ const hits: { package: string; entries: SurfaceEntry[] }[] = [];
297
+
298
+ for (const name of packages) {
299
+ const found = await findSurfaceFile(root, name);
300
+ if (!found) continue;
301
+
302
+ // Signature as well as name. An export's name is the class — `Auth` — while
303
+ // the thing someone looks up is usually a member of it: `userOrNull`,
304
+ // `salutation`, `assertInvalid`. Matching names alone answers "is there an
305
+ // export called X", which is rarely the question; the question is "where is
306
+ // this method and what does it take".
307
+ const matched = parseSurface(await Bun.file(found.path).text()).filter(
308
+ (entry) =>
309
+ entry.name.toLowerCase().includes(filter) || entry.signature.toLowerCase().includes(filter),
310
+ );
311
+ if (matched.length > 0) hits.push({ package: found.scoped, entries: matched });
312
+ }
313
+
314
+ if (hits.length === 0) {
315
+ return {
316
+ text:
317
+ `No export matching "${symbol}" in any installed package.\n\n` +
318
+ `Searched: ${packages.join(", ")}`,
319
+ data: { package: "", total: 0, matched: 0, entries: [] },
320
+ failed: true,
321
+ };
322
+ }
323
+
324
+ const total = hits.reduce((n, hit) => n + hit.entries.length, 0);
325
+ const text = hits.map((hit) => render(hit.package, hit.entries, hit.entries.length)).join("\n\n");
326
+
327
+ return {
328
+ text: `${total} export(s) matching "${symbol}" across ${hits.length} package(s).\n\n${text}`,
329
+ data: {
330
+ package: hits.map((hit) => hit.package).join(", "),
331
+ total,
332
+ matched: total,
333
+ entries: hits.flatMap((hit) => hit.entries),
334
+ },
335
+ };
336
+ }
@@ -58,6 +58,15 @@ export interface BaselineReading {
58
58
  total: number;
59
59
  /** Per-package or per-file detail, when the baseline records it. */
60
60
  breakdown?: Record<string, number>;
61
+ /**
62
+ * What the number does not cover, when the baseline exempts something.
63
+ *
64
+ * The cast baseline ratchets per file but exempts designated boundary
65
+ * modules, so its ceiling is smaller than the count `cast:check` prints — and
66
+ * a reader who saw only the ceiling would take the command's larger number for
67
+ * a regression it is not.
68
+ */
69
+ note?: string;
61
70
  updatedAt?: string;
62
71
  }
63
72
 
@@ -95,6 +104,24 @@ function readBreakdown(document: Record<string, unknown>): Record<string, number
95
104
  return Object.keys(out).length > 0 ? out : undefined;
96
105
  }
97
106
 
107
+ /**
108
+ * What a baseline's ceiling leaves out.
109
+ *
110
+ * The cast baseline lists `boundaries` — files exempt from the per-file ratchet
111
+ * — so its recorded ceiling is smaller than the count `cast:check` reports.
112
+ * Without saying so, a reader comparing the two numbers sees a regression that
113
+ * is not there.
114
+ */
115
+ function exemptionNote(document: Record<string, unknown>): string | undefined {
116
+ const boundaries = document["boundaries"];
117
+ if (!Array.isArray(boundaries) || boundaries.length === 0) return undefined;
118
+ const count = boundaries.length;
119
+ return (
120
+ `Excludes ${count} boundary module${count === 1 ? "" : "s"} exempt from the ratchet, so ` +
121
+ `the command reports a larger total: ${boundaries.join(", ")}.`
122
+ );
123
+ }
124
+
98
125
  async function readBaselines(root: string): Promise<BaselineReading[]> {
99
126
  const readings: BaselineReading[] = [];
100
127
 
@@ -107,12 +134,14 @@ async function readBaselines(root: string): Promise<BaselineReading[]> {
107
134
  if (total === undefined) continue;
108
135
  const breakdown = readBreakdown(document);
109
136
  const updatedAt = document["updatedAt"];
137
+ const note = exemptionNote(document);
110
138
  readings.push({
111
139
  name: ratchet.name,
112
140
  file: ratchet.file,
113
141
  command: ratchet.command,
114
142
  total,
115
143
  ...(breakdown !== undefined ? { breakdown } : {}),
144
+ ...(note !== undefined ? { note } : {}),
116
145
  ...(typeof updatedAt === "string" ? { updatedAt } : {}),
117
146
  });
118
147
  } catch {
@@ -189,10 +218,10 @@ export function baselinesTool(ctx: ToolContext): ArchTool {
189
218
  const sections: string[] = [];
190
219
 
191
220
  if (baselines.length > 0) {
192
- const rows = baselines.map(
193
- (reading) =>
194
- ` ${reading.name.padEnd(18)}${String(reading.total).padStart(6)} ${reading.command}`,
195
- );
221
+ const rows = baselines.flatMap((reading) => {
222
+ const row = ` ${reading.name.padEnd(18)}${String(reading.total).padStart(6)} ${reading.command}`;
223
+ return reading.note ? [row, ` note: ${reading.note}`] : [row];
224
+ });
196
225
  sections.push(`Ratchets — these numbers may go down, never up:\n\n${rows.join("\n")}`);
197
226
  } else {
198
227
  sections.push("This project records no baselines.");