@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/CHANGELOG.md +57 -0
- package/docs/about.md +4 -4
- package/docs/broadcasting/channels.md +1 -1
- package/docs/broadcasting/client.md +17 -4
- package/docs/broadcasting/events.md +2 -2
- package/docs/broadcasting/index.md +1 -1
- package/docs/changelog.md +106 -4
- package/docs/commands.md +21 -12
- package/docs/contributing.md +2 -2
- package/docs/errors.md +22 -0
- package/docs/flow/components.md +27 -8
- package/docs/flow/decorators.md +32 -16
- package/docs/flow/events.md +53 -19
- package/docs/flow/index.md +18 -7
- package/docs/flow/layouts.md +16 -0
- package/docs/flow/lifecycle.md +31 -16
- package/docs/flow/models.md +284 -0
- package/docs/flow/performance.md +27 -1
- package/docs/flow/references.md +16 -1
- package/docs/flow/routing.md +14 -2
- package/docs/flow/testing.md +32 -0
- package/docs/getting-started.md +26 -1
- package/docs/i18n.md +247 -144
- package/docs/notifications.md +1 -1
- package/docs/queue.md +11 -3
- package/docs/routing.md +89 -7
- package/docs/seeding.md +7 -0
- package/docs/support-policy.md +13 -5
- package/docs/upgrade.md +2 -0
- package/package.json +3 -3
- package/src/install/guidelines.ts +5 -1
- package/src/install/markers.ts +11 -2
- package/src/install/mcpConfig.ts +14 -2
- package/src/probe/topics.ts +19 -4
- package/src/tools/apiSurface.ts +115 -20
- package/src/tools/baselines.ts +33 -4
- package/src/tools/logs.ts +77 -7
- package/src/tools/searchDocs.ts +196 -29
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
|
521
|
-
|
|
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";
|
package/docs/support-policy.md
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
38
|
+
"@zerotal/core": "1.7.3"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^5.8.0",
|
|
42
|
-
"@zerotal/orm": "1.7.
|
|
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: [
|
|
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",
|
package/src/install/markers.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
/**
|
package/src/install/mcpConfig.ts
CHANGED
|
@@ -70,10 +70,22 @@ export function applyMcpConfig(
|
|
|
70
70
|
: {};
|
|
71
71
|
|
|
72
72
|
servers[name] = serverEntry();
|
|
73
|
-
const
|
|
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
|
-
|
|
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 {
|
package/src/probe/topics.ts
CHANGED
|
@@ -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
|
|
266
|
-
|
|
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
|
-
|
|
282
|
+
const found: InstalledPackage[] = [];
|
|
283
|
+
for (const dir of candidates) {
|
|
269
284
|
try {
|
|
270
|
-
const manifest = (await Bun.file(`${
|
|
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;
|
package/src/tools/apiSurface.ts
CHANGED
|
@@ -106,23 +106,46 @@ export async function findSurfaceFile(
|
|
|
106
106
|
return undefined;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
/**
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
180
|
-
|
|
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
|
+
}
|
package/src/tools/baselines.ts
CHANGED
|
@@ -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.
|
|
193
|
-
(reading)
|
|
194
|
-
|
|
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.");
|