@zerotal/arch 1.7.0 → 1.7.2
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/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 +46 -0
- package/docs/commands.md +21 -12
- package/docs/flow/components.md +23 -0
- package/docs/flow/decorators.md +17 -5
- package/docs/flow/events.md +51 -17
- package/docs/flow/index.md +8 -1
- package/docs/flow/layouts.md +16 -0
- package/docs/flow/performance.md +27 -1
- package/docs/flow/references.md +15 -0
- package/docs/flow/testing.md +32 -0
- package/docs/getting-started.md +25 -0
- package/docs/i18n.md +248 -145
- package/docs/notifications.md +1 -1
- package/docs/queue.md +11 -3
- package/docs/routing.md +78 -7
- package/docs/seeding.md +7 -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
|
@@ -488,9 +488,12 @@ Typed names flow through the helpers built on `route()` too — `redirect().to()
|
|
|
488
488
|
|
|
489
489
|
### route() in the browser
|
|
490
490
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
491
|
+
`route()` works on the server with no setup: the application installs the table
|
|
492
|
+
during boot, from the routes it just registered. That covers every URL your
|
|
493
|
+
server renders — a `view` build's `href` attributes and form actions included.
|
|
494
|
+
|
|
495
|
+
A browser bundle is a different process with no router to read, so there it needs
|
|
496
|
+
the table handed to it once, at your entry point:
|
|
494
497
|
|
|
495
498
|
```typescript
|
|
496
499
|
// resources/js/app.js
|
|
@@ -503,8 +506,6 @@ defineRoutes(ROUTES);
|
|
|
503
506
|
From there the call is the one you already know:
|
|
504
507
|
|
|
505
508
|
```typescript
|
|
506
|
-
import { route } from "zerotal/routes";
|
|
507
|
-
|
|
508
509
|
route("posts.show", { slug }); // → '/posts/hello'
|
|
509
510
|
route("posts.index", {}, { page: 2 }); // → '/posts?page=2'
|
|
510
511
|
```
|
|
@@ -515,10 +516,35 @@ server and the same call made in a component cannot disagree about encoding —
|
|
|
515
516
|
because `types/routes.generated.ts` augments the one registry, a name that
|
|
516
517
|
type-checks in a controller type-checks in a component.
|
|
517
518
|
|
|
519
|
+
### route() needs no import
|
|
520
|
+
|
|
521
|
+
`defineRoutes()` also puts `route()` on `globalThis`, so a page, a component or a
|
|
522
|
+
controller calls it with nothing at the top of the file:
|
|
523
|
+
|
|
524
|
+
```tsx
|
|
525
|
+
// no import line
|
|
526
|
+
<a href={route("posts.show", { slug })}>{post.title}</a>
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
It is installed from `defineRoutes()` because that is the one function both
|
|
530
|
+
processes already call — the server during boot, a browser entry beside its
|
|
531
|
+
generated `ROUTES` — which means neither has to remember a second setup step. The
|
|
532
|
+
table is installed before the global, so `route()` never exists in a state where
|
|
533
|
+
calling it reports a missing table.
|
|
534
|
+
|
|
535
|
+
The global is typed by an ambient declaration in `@zerotal/core/routes`, as the
|
|
536
|
+
same `RouteBuilder` the named export is: an unknown route name or a missing
|
|
537
|
+
`:param` still fails the build.
|
|
538
|
+
|
|
539
|
+
> **Note** — `route` remains a named export. Importing it explicitly keeps
|
|
540
|
+
> working, and is worth doing in a library that cannot assume an application has
|
|
541
|
+
> booted.
|
|
542
|
+
|
|
518
543
|
`defineRoutes()` takes the generated `ROUTES` object or any `RouteTable`
|
|
519
544
|
(a name → pattern map). Calling it again replaces the table, which is what makes
|
|
520
|
-
hot reload work
|
|
521
|
-
|
|
545
|
+
hot reload work — and what lets a browser entry install its own copy without
|
|
546
|
+
disturbing the server's. `resetRoutes()` clears it again, for tests that assert
|
|
547
|
+
on the unconfigured error.
|
|
522
548
|
|
|
523
549
|
If your app renders through SSR, call `defineRoutes()` in the SSR entry too — the
|
|
524
550
|
page components run in both processes.
|
|
@@ -543,6 +569,51 @@ helper to Alpine expressions as `$route`:
|
|
|
543
569
|
|
|
544
570
|
Nothing to install, and the names are the same ones the server rendered with.
|
|
545
571
|
|
|
572
|
+
### Submitting to a route with action()
|
|
573
|
+
|
|
574
|
+
`route()` gives you a URL. A form needs two things — where to send the request
|
|
575
|
+
and how — and a URL alone leaves the second one to be typed out beside it:
|
|
576
|
+
|
|
577
|
+
```typescript
|
|
578
|
+
// The URL is generated; the verb is a guess that happens to be right today.
|
|
579
|
+
form.post(route("posts.comments.store", { post: id }));
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
`bun zt route:types` also writes a `METHODS` table, so the verb can come from
|
|
583
|
+
the same place the URL does. `action()` returns both:
|
|
584
|
+
|
|
585
|
+
```typescript
|
|
586
|
+
// resources/js/app.js
|
|
587
|
+
import { defineRouteMethods, defineRoutes } from "zerotal/routes";
|
|
588
|
+
import { METHODS, ROUTES } from "../../types/routes.generated";
|
|
589
|
+
|
|
590
|
+
defineRoutes(ROUTES);
|
|
591
|
+
defineRouteMethods(METHODS);
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
```typescript
|
|
595
|
+
import { action } from "zerotal/routes";
|
|
596
|
+
|
|
597
|
+
const endpoint = action("posts.comments.store", { post: id });
|
|
598
|
+
// → { url: '/posts/42/comments', method: 'POST' }
|
|
599
|
+
|
|
600
|
+
form.submit(endpoint.method.toLowerCase(), endpoint.url);
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
`action()` takes the same names and params as `route()` and reports the same
|
|
604
|
+
compile errors, so switching a call over costs nothing. What it buys is that a
|
|
605
|
+
route which changes verb changes every submission with it — the failure it
|
|
606
|
+
prevents is a 405 on submit, which looks nothing like its cause when the URL in
|
|
607
|
+
front of you is plainly correct.
|
|
608
|
+
|
|
609
|
+
Two tables rather than one map of `{ url, method }` objects, for two reasons: it
|
|
610
|
+
leaves `ROUTES` alone, so a generated file from an earlier version still works;
|
|
611
|
+
and a bundle that only renders links never pulls the verbs in.
|
|
612
|
+
|
|
613
|
+
`defineRouteMethods()` is optional. Without it `action()` still resolves the URL
|
|
614
|
+
and reports `GET`, which is the right answer for a link-only bundle and a better
|
|
615
|
+
one than throwing.
|
|
616
|
+
|
|
546
617
|
## File-based routing
|
|
547
618
|
|
|
548
619
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/arch",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.2",
|
|
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.2"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^5.8.0",
|
|
42
|
-
"@zerotal/orm": "1.7.
|
|
42
|
+
"@zerotal/orm": "1.7.2"
|
|
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.");
|
package/src/tools/logs.ts
CHANGED
|
@@ -32,8 +32,37 @@ export interface LogEntry {
|
|
|
32
32
|
channel?: string;
|
|
33
33
|
scope?: string;
|
|
34
34
|
context?: unknown;
|
|
35
|
+
/**
|
|
36
|
+
* Every other field on the line — `error`, `stack`, `requestId`, and whatever
|
|
37
|
+
* a package writes.
|
|
38
|
+
*
|
|
39
|
+
* Carried wholesale rather than enumerated. Naming the six fields this tool
|
|
40
|
+
* knew about meant an error entry arrived as its bare message: the framework
|
|
41
|
+
* logs the exception class in `error` and the trace in `stack`, and
|
|
42
|
+
* `last_error` — whose entire job is saying *why* something failed — reported
|
|
43
|
+
* "Unhandled error" and dropped both. A tool that lists the fields it
|
|
44
|
+
* understands will always lag the logger; passing the rest through cannot.
|
|
45
|
+
*/
|
|
46
|
+
fields: Record<string, unknown>;
|
|
35
47
|
}
|
|
36
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Fields identical on every line from a process, so worth nothing in a report
|
|
51
|
+
* and not free — this output goes into a context window.
|
|
52
|
+
*/
|
|
53
|
+
const AMBIENT = new Set([
|
|
54
|
+
"level",
|
|
55
|
+
"message",
|
|
56
|
+
"timestamp",
|
|
57
|
+
"channel",
|
|
58
|
+
"scope",
|
|
59
|
+
"context",
|
|
60
|
+
"app",
|
|
61
|
+
"env",
|
|
62
|
+
"hostname",
|
|
63
|
+
"pid",
|
|
64
|
+
]);
|
|
65
|
+
|
|
37
66
|
// ── Reading ───────────────────────────────────────────────────────────────────
|
|
38
67
|
|
|
39
68
|
/** Day-files newest first, `YYYY-MM-DD.log`. */
|
|
@@ -71,6 +100,12 @@ export async function readTail(path: string): Promise<LogEntry[]> {
|
|
|
71
100
|
if (typeof parsed !== "object" || parsed === null) continue;
|
|
72
101
|
const record = parsed as Record<string, unknown>;
|
|
73
102
|
if (typeof record["message"] !== "string") continue;
|
|
103
|
+
|
|
104
|
+
const fields: Record<string, unknown> = {};
|
|
105
|
+
for (const [key, value] of Object.entries(record)) {
|
|
106
|
+
if (!AMBIENT.has(key) && value !== undefined) fields[key] = value;
|
|
107
|
+
}
|
|
108
|
+
|
|
74
109
|
entries.push({
|
|
75
110
|
level: typeof record["level"] === "string" ? record["level"] : "info",
|
|
76
111
|
message: record["message"],
|
|
@@ -78,6 +113,7 @@ export async function readTail(path: string): Promise<LogEntry[]> {
|
|
|
78
113
|
...(typeof record["channel"] === "string" ? { channel: record["channel"] } : {}),
|
|
79
114
|
...(typeof record["scope"] === "string" ? { scope: record["scope"] } : {}),
|
|
80
115
|
...(record["context"] !== undefined ? { context: record["context"] } : {}),
|
|
116
|
+
fields,
|
|
81
117
|
});
|
|
82
118
|
} catch {
|
|
83
119
|
/* a truncated or hand-edited line is skipped, not fatal */
|
|
@@ -129,11 +165,37 @@ function clampLimit(raw: unknown): number {
|
|
|
129
165
|
return Math.min(MAX_LIMIT, Math.max(1, Math.floor(raw)));
|
|
130
166
|
}
|
|
131
167
|
|
|
132
|
-
|
|
168
|
+
/**
|
|
169
|
+
* Render one entry.
|
|
170
|
+
*
|
|
171
|
+
* `stack` is included only when asked for. `last_error` returns a single entry
|
|
172
|
+
* and the trace is the answer; `logs` can return two hundred, and a trace on
|
|
173
|
+
* each would bury the sequence the caller asked to see under its own detail.
|
|
174
|
+
*/
|
|
175
|
+
function renderEntry(entry: LogEntry, options: { stack?: boolean } = {}): string {
|
|
133
176
|
const scope = entry.scope ? ` [${entry.scope}]` : "";
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
177
|
+
const lines = [`${entry.timestamp} ${entry.level.toUpperCase()}${scope} ${entry.message}`];
|
|
178
|
+
|
|
179
|
+
const { error, stack, requestId, ...rest } = entry.fields as {
|
|
180
|
+
error?: unknown;
|
|
181
|
+
stack?: unknown;
|
|
182
|
+
requestId?: unknown;
|
|
183
|
+
} & Record<string, unknown>;
|
|
184
|
+
|
|
185
|
+
// The exception first: for an error entry it is the thing being reported, and
|
|
186
|
+
// `message` is often only the generic "Unhandled error" wrapping it.
|
|
187
|
+
if (typeof error === "string" && error !== entry.message) lines.push(` error: ${error}`);
|
|
188
|
+
if (typeof requestId === "string") lines.push(` request: ${requestId}`);
|
|
189
|
+
if (entry.context !== undefined) lines.push(` ${JSON.stringify(entry.context)}`);
|
|
190
|
+
|
|
191
|
+
const extra = Object.entries(rest).filter(([, value]) => value !== undefined);
|
|
192
|
+
if (extra.length > 0) lines.push(` ${JSON.stringify(Object.fromEntries(extra))}`);
|
|
193
|
+
|
|
194
|
+
if (options.stack && typeof stack === "string") {
|
|
195
|
+
lines.push(...stack.split("\n").map((line) => ` ${line.trim()}`));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return lines.join("\n");
|
|
137
199
|
}
|
|
138
200
|
|
|
139
201
|
const noTrail = (dir: string): string =>
|
|
@@ -202,7 +264,7 @@ export function logsTool(ctx: ToolContext): ArchTool {
|
|
|
202
264
|
}
|
|
203
265
|
|
|
204
266
|
return {
|
|
205
|
-
text: entries.map(renderEntry).join("\n"),
|
|
267
|
+
text: entries.map((entry) => renderEntry(entry)).join("\n"),
|
|
206
268
|
data: { total: entries.length, entries },
|
|
207
269
|
};
|
|
208
270
|
},
|
|
@@ -244,7 +306,8 @@ export function lastErrorTool(ctx: ToolContext): ArchTool {
|
|
|
244
306
|
};
|
|
245
307
|
}
|
|
246
308
|
|
|
247
|
-
|
|
309
|
+
// The trace is the answer here, so it is included.
|
|
310
|
+
return { text: renderEntry(entry, { stack: true }), data: { found: true, entry } };
|
|
248
311
|
},
|
|
249
312
|
};
|
|
250
313
|
}
|
|
@@ -259,7 +322,14 @@ function entrySchema(): Record<string, unknown> {
|
|
|
259
322
|
channel: { type: "string" },
|
|
260
323
|
scope: { type: "string" },
|
|
261
324
|
context: {},
|
|
325
|
+
fields: {
|
|
326
|
+
type: "object",
|
|
327
|
+
description:
|
|
328
|
+
"Everything else the logger recorded on this line — `error` and `stack` on an " +
|
|
329
|
+
"exception, `requestId` to correlate it with a request, and whatever a package adds.",
|
|
330
|
+
additionalProperties: true,
|
|
331
|
+
},
|
|
262
332
|
},
|
|
263
|
-
required: ["level", "message", "timestamp"],
|
|
333
|
+
required: ["level", "message", "timestamp", "fields"],
|
|
264
334
|
};
|
|
265
335
|
}
|