@ultimat3/manifest 1.1.0 → 2.0.0
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/CLAUDE.md +88 -0
- package/README.md +36 -5
- package/package.json +7 -6
- package/src/build.ts +11 -2
- package/src/diff.ts +156 -5
- package/src/docs-scan.ts +285 -0
- package/src/docs-search.ts +225 -0
- package/src/emit.ts +5 -2
- package/src/index.ts +12 -0
- package/src/schema.ts +77 -11
- package/src/sources.ts +5 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# @ultimat3/manifest — boundary
|
|
2
|
+
|
|
3
|
+
Tier 4. May import tier 0–3: `core schema i18n money time cache seo entity policy http action
|
|
4
|
+
query jobs realtime`. **Never** `render mcp ai pwa ui admin testing cli`.
|
|
5
|
+
|
|
6
|
+
Route, policy, task and locale facts come from tier 4 / per-app code, so they are **injected**
|
|
7
|
+
by the CLI, not imported.
|
|
8
|
+
|
|
9
|
+
## Owns
|
|
10
|
+
|
|
11
|
+
| File | Job |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `schema.ts` | the manifest's typed shape + `MANIFEST_VERSION` |
|
|
14
|
+
| `build.ts` | `buildManifest` — pure, deterministic, stably sorted |
|
|
15
|
+
| `sources.ts` | wires `describe*` from entity/action/query/jobs into `ManifestSources` |
|
|
16
|
+
| `diff.ts` | `diffManifest` — breaking / additive / internal |
|
|
17
|
+
| `verify.ts` | `verifyContract` — the major-bump gate |
|
|
18
|
+
| `emit.ts` | canonical serialisation, write, `--json`, drift check |
|
|
19
|
+
| `agents-md.ts` | read-only AGENTS.md existence + size check |
|
|
20
|
+
| `docs-scan.ts` | read-only: an installed package tree → `DocEntry[]`. Never writes |
|
|
21
|
+
| `docs-search.ts` | pure ranking of `DocEntry[]` against a question. No I/O, no clock |
|
|
22
|
+
|
|
23
|
+
## Invariants
|
|
24
|
+
|
|
25
|
+
- **No nondeterminism.** No timestamp, git sha, hostname, counter, or unsorted iteration.
|
|
26
|
+
`buildManifest` is pure — it must never read a registry, a clock, or the filesystem.
|
|
27
|
+
- Top-level key order in the file is fixed by `KEY_ORDER` in `emit.ts`.
|
|
28
|
+
- `buildId` = sha256 of the canonical body. Verifiable from the file alone.
|
|
29
|
+
- Job `steps` keep declared order. Everything else sorts.
|
|
30
|
+
- `permissions` is derived, never a second declared list — and derived from each operation's own
|
|
31
|
+
`permissions`, **never from `policy`**. `policy` is a DISPLAY label: a composite renders as
|
|
32
|
+
`and(post:publish, org:administer)`, which is not a permission and matches no grant, so deriving
|
|
33
|
+
from it published one fictional entry per composite rule and dropped every real one.
|
|
34
|
+
- **An operation's own `permissions` and `rateLimit` are contract, and `diff.ts` classifies both.**
|
|
35
|
+
A permission gained is breaking (every caller holding the old grant set starts collecting 403s
|
|
36
|
+
and no schema in the file moved); one dropped is additive but reported, because a widening of
|
|
37
|
+
access is what a reviewer most needs to see. A rate limit tightened — in burst OR in refill rate,
|
|
38
|
+
compared cross-multiplied so no rounding can invent a change — is breaking, and so is
|
|
39
|
+
introducing one where there was none; loosening or removing is additive.
|
|
40
|
+
- **An absent `permissions` is NOT an empty one.** Unlike `mcp.expose` there is no value to fold
|
|
41
|
+
absence into: `[]` asserts "this operation requires nothing", so reading an absent field that
|
|
42
|
+
way would call every permission of every operation newly required the first time an app diffs
|
|
43
|
+
against a manifest written before the field existed. Absence is no evidence — the comparison is
|
|
44
|
+
skipped. Same for a `rateLimit` neither half of which `toBucket` would accept.
|
|
45
|
+
- **`isManifest` checks every top-level key, never five and a cast.** `diffManifest` reads
|
|
46
|
+
`before.queries`, `before.jobs`, `before.permissions` and `before.locales` with no guard, so a
|
|
47
|
+
section a truncated or hand-trimmed file happens not to carry was a bare `TypeError` two calls
|
|
48
|
+
from the gate that exists to explain. The FACTS inside a section stay unwalked — a manifest
|
|
49
|
+
written before a field existed is still readable, which is `MANIFEST_VERSION`'s rule.
|
|
50
|
+
- **`--json` is awaited.** `emitManifest({ stdout: true })` writes through `await Bun.write(
|
|
51
|
+
Bun.stdout, …)`: a write to a pipe is asynchronous and `process.exit()` discards the queue, and
|
|
52
|
+
this is the largest payload the CLI prints. Same bug `scripts/stdout-truncation.test.ts` pins.
|
|
53
|
+
- **One package's tree costs that package.** `scanInstalledDocs` guards each `scanPackageDocs`,
|
|
54
|
+
and a `package.json` that will not parse is "not a package", not a `SyntaxError` thrown through
|
|
55
|
+
the `Promise.all` for every other package to inherit. `node_modules` is not curated and is not
|
|
56
|
+
stable while an install is running.
|
|
57
|
+
- `agents-md.ts` **never writes**. Generated prose lowers agent task success; facts go in
|
|
58
|
+
`x.manifest.json` and conventions stay human-authored.
|
|
59
|
+
- `docs-scan.ts` emits **no artifact**. The published tarball is the source, so the docs are
|
|
60
|
+
already installed; a generated `docs.json` would be a second copy of them, and the second copy
|
|
61
|
+
is what drifts. There is no drift check for local docs because there is nothing that can drift.
|
|
62
|
+
- `docs-scan.ts` indexes the **file header comment**, not JSDoc: `As of 2026-08`, 99.8% of source
|
|
63
|
+
files carry a header, 42.3% of public exports carry JSDoc, and `job()` is in the missing 57.7%.
|
|
64
|
+
- A guide topic is unique within its package — repeated headings take a document-order suffix.
|
|
65
|
+
- Neither docs module may import a registry or a clock. `docs-search.ts` is pure; `docs-scan.ts`
|
|
66
|
+
reads files and nothing else.
|
|
67
|
+
- **A new manifest field ⇒ a `diff.ts` rule for it.** Always — a field nothing classifies is a
|
|
68
|
+
fact the gate cannot see, which is the whole reason the field was added.
|
|
69
|
+
- **`MANIFEST_VERSION` bumps only when a reader built for the old version would be WRONG** — a
|
|
70
|
+
field removed, retyped, or given a new meaning — never for one that is merely added. Two costs
|
|
71
|
+
make the reflex expensive: `isCompatible` is an equality check, so a bump rejects every
|
|
72
|
+
`x.manifest.json` in existence at once; and `diff.ts` classifies a `manifestVersion` change as
|
|
73
|
+
**breaking**, so it also demands a major release of every APP that regenerates. Charging every
|
|
74
|
+
app a major for a field their readers never had to look at is a fix line that is not true.
|
|
75
|
+
`build.test.ts`'s `shape compatibility` case is the assertion — if it fails, the bump is earned.
|
|
76
|
+
- `diff.ts` reads `mcp.expose` through `isMcpExposed` from `@ultimat3/core`, on **both** sides.
|
|
77
|
+
`before` is a file parsed off disk, so an older or hand-trimmed manifest can carry an absent or
|
|
78
|
+
non-boolean value that `!==` would classify from; and the fact `sources.ts` publishes has to be
|
|
79
|
+
the answer `toMcpTools` gives, or the gate demands a major bump for a tool that never existed.
|
|
80
|
+
|
|
81
|
+
## Commands
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
bun test packages/manifest
|
|
85
|
+
bun run --filter @ultimat3/manifest typecheck
|
|
86
|
+
x manifest # build + emit
|
|
87
|
+
x verify # drift + contract gate, and the AGENTS.md check under the `manifest` step
|
|
88
|
+
```
|
package/README.md
CHANGED
|
@@ -18,17 +18,22 @@ verifyContract({ before: committed, after: manifest });
|
|
|
18
18
|
|---|---|
|
|
19
19
|
| `routes` | url, render mode, offline strategy, hydrate, revalidate tags, budget |
|
|
20
20
|
| `entities` | table, columns (type, nullability, PK, FK), named invariants |
|
|
21
|
-
| `actions` | input + output schema, policy, cache invalidations, MCP exposure, `mutator` when it is one |
|
|
22
|
-
| `queries` | input schema, policy, live, cache tags |
|
|
21
|
+
| `actions` | input + output schema, policy label, **required permissions**, cache invalidations, declared rate limit, MCP exposure, `mutator` when it is one |
|
|
22
|
+
| `queries` | input schema, policy label, **required permissions**, live, cache tags |
|
|
23
23
|
| `jobs` | input schema, queue, retry policy, step names |
|
|
24
24
|
| `tasks` | cron, tz, jobs enqueued |
|
|
25
25
|
| `policies` | permission, where enforced |
|
|
26
|
-
| `permissions` | **derived** from policies +
|
|
26
|
+
| `permissions` | **derived** from policies + each operation's own list, never declared twice |
|
|
27
27
|
| `locales`, `errorCodes` | catalogs a tool can enumerate |
|
|
28
28
|
|
|
29
29
|
Plus `manifestVersion` (shape version, so a reader can check compatibility), `app`, and
|
|
30
30
|
`buildId`.
|
|
31
31
|
|
|
32
|
+
An operation's `policy` is its **display label** — a composite renders as
|
|
33
|
+
`and(post:publish, org:administer)`, which is not a permission and matches no grant. Match a grant
|
|
34
|
+
against `permissions`; matching on `policy` reports every non-trivially-guarded operation as
|
|
35
|
+
enforcing nothing.
|
|
36
|
+
|
|
32
37
|
## Determinism
|
|
33
38
|
|
|
34
39
|
The file is committed and reviewed, so two builds of the same tree must produce identical
|
|
@@ -53,8 +58,8 @@ whole mechanism.
|
|
|
53
58
|
|
|
54
59
|
| Class | Examples |
|
|
55
60
|
|---|---|
|
|
56
|
-
| **breaking** | action/query/route/job/entity removed; input or output schema changed; policy changed; MCP exposure withdrawn; column removed, retyped, or made NOT NULL; live query became non-live |
|
|
57
|
-
| **additive** | primitive added; nullable column added; MCP exposure granted; locale added |
|
|
61
|
+
| **breaking** | action/query/route/job/entity removed; input or output schema changed; policy changed; **an operation gained a required permission**; **a rate limit was tightened or introduced**; MCP exposure withdrawn; column removed, retyped, or made NOT NULL; live query became non-live |
|
|
62
|
+
| **additive** | primitive added; nullable column added; a required permission dropped; a rate limit loosened or removed; MCP exposure granted; locale added |
|
|
58
63
|
| **internal** | cache tags changed; render mode changed; job steps reordered; `buildId` |
|
|
59
64
|
|
|
60
65
|
`verifyContract()` is the gate: a breaking change fails unless the app's **major** version
|
|
@@ -87,6 +92,32 @@ past 200 lines, or claims to be generated. Those warnings ride in the step's `ou
|
|
|
87
92
|
generated half — and unlike the drift check it applies everywhere, including a repo that has
|
|
88
93
|
never run `x manifest`. Enforced, not documented: both codes below can actually fail a build.
|
|
89
94
|
|
|
95
|
+
## Local docs: read, never emitted
|
|
96
|
+
|
|
97
|
+
`scanPackageDocs()` / `scanInstalledDocs()` read an installed package tree and return `DocEntry`
|
|
98
|
+
values; `searchDocs()` ranks them against a question. This is what `x docs "how does job() retry"`
|
|
99
|
+
answers from — offline, from `node_modules`, with no filename known in advance.
|
|
100
|
+
|
|
101
|
+
There is **no generated `docs.json`**, on purpose. The published artifact *is* the source
|
|
102
|
+
(`PUBLISHING.md`): `files` ships `src/**`, `README.md` and `CLAUDE.md`, and Bun runs the
|
|
103
|
+
TypeScript directly. Every doc is therefore already inside the tarball — what was missing was
|
|
104
|
+
retrieval, not payload. A per-package `docs.json` would be a **second copy** of bytes the install
|
|
105
|
+
already has, and the second copy is the one that goes stale. Reading the installed source cannot
|
|
106
|
+
disagree with the installed version, because it *is* the installed version.
|
|
107
|
+
|
|
108
|
+
| Entry | Source | Topic |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `module` | the file header comment on a module `src/index.ts` re-exports, plus its public symbols | `jobs.retry` |
|
|
111
|
+
| `guide` | a `##` section of `README.md` or `CLAUDE.md`, quoted verbatim | `money.README#why-no-floats` |
|
|
112
|
+
|
|
113
|
+
The **file header** is the doc unit, not JSDoc. Measured across this repo, `As of 2026-08`: 99.8%
|
|
114
|
+
of source files carry a 1–4 line header (2,510 of 2,514), while only 42.3% of public exports have
|
|
115
|
+
JSDoc directly above the declaration (1,868 of 4,414) — `job()` itself has none. Ranking a question
|
|
116
|
+
against 42% coverage would have missed the framework's most-used export.
|
|
117
|
+
|
|
118
|
+
Same split as `AGENTS.md` above: this module **derives** facts and **quotes** human prose. It
|
|
119
|
+
never writes, and it never synthesises a sentence.
|
|
120
|
+
|
|
90
121
|
## Errors
|
|
91
122
|
|
|
92
123
|
| Code | Meaning | Fix |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/manifest",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "x.manifest.json: deterministic generated facts, contract diff, AGENTS.md budget",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"CLAUDE.md",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
|
@@ -30,10 +31,10 @@
|
|
|
30
31
|
"test": "bun test"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@ultimat3/action": "
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/entity": "
|
|
36
|
-
"@ultimat3/jobs": "
|
|
37
|
-
"@ultimat3/query": "
|
|
34
|
+
"@ultimat3/action": "2.0.0",
|
|
35
|
+
"@ultimat3/core": "2.0.0",
|
|
36
|
+
"@ultimat3/entity": "2.0.0",
|
|
37
|
+
"@ultimat3/jobs": "2.0.0",
|
|
38
|
+
"@ultimat3/query": "2.0.0"
|
|
38
39
|
}
|
|
39
40
|
}
|
package/src/build.ts
CHANGED
|
@@ -60,10 +60,15 @@ export function buildManifest(sources: ManifestSources): Manifest {
|
|
|
60
60
|
|
|
61
61
|
// Derived, never declared twice: the permission list IS the set of policy permissions
|
|
62
62
|
// plus anything an action asserts. Two lists that must agree eventually disagree.
|
|
63
|
+
//
|
|
64
|
+
// Read from `permissions`, never from `policy`. `policy` is a policy's LABEL — for a composite
|
|
65
|
+
// it renders `and(post:publish, org:administer)`, which is not a permission and matches no
|
|
66
|
+
// grant, so deriving from it published one fictional entry per composite rule and dropped the
|
|
67
|
+
// real ones. Every non-trivial rule in a real app is a composite.
|
|
63
68
|
const permissions = unique([
|
|
64
69
|
...policies.map((p) => p.permission),
|
|
65
|
-
...actions.
|
|
66
|
-
...queries.
|
|
70
|
+
...actions.flatMap((a) => a.permissions),
|
|
71
|
+
...queries.flatMap((q) => q.permissions),
|
|
67
72
|
]);
|
|
68
73
|
|
|
69
74
|
const body = {
|
|
@@ -128,13 +133,17 @@ const normalizeEntity = (entity: EntityFact): EntityFact => ({
|
|
|
128
133
|
invariants: [...entity.invariants].sort(),
|
|
129
134
|
});
|
|
130
135
|
|
|
136
|
+
// `permissions` is sorted here as well as by its producer: `ManifestSources` is a public input,
|
|
137
|
+
// so a caller assembling facts by hand must not be able to make two builds of one program differ.
|
|
131
138
|
const normalizeAction = (action: ActionFact): ActionFact => ({
|
|
132
139
|
...action,
|
|
140
|
+
permissions: [...action.permissions].sort(),
|
|
133
141
|
cacheInvalidates: [...action.cacheInvalidates].sort(),
|
|
134
142
|
});
|
|
135
143
|
|
|
136
144
|
const normalizeQuery = (query: QueryFact): QueryFact => ({
|
|
137
145
|
...query,
|
|
146
|
+
permissions: [...query.permissions].sort(),
|
|
138
147
|
cacheTags: [...query.cacheTags].sort(),
|
|
139
148
|
});
|
|
140
149
|
|
package/src/diff.ts
CHANGED
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Three classes, and the classification is the whole value:
|
|
4
4
|
// breaking — an existing consumer stops working (a removal, a tightened input, a changed
|
|
5
|
-
// output, a new or changed policy
|
|
5
|
+
// output, a new or changed policy, a newly REQUIRED permission, a tightened rate
|
|
6
|
+
// limit — anything that already shipped and now refuses a caller it served)
|
|
6
7
|
// additive — a new capability; nothing that worked stops working
|
|
7
8
|
// internal — visible in the file but not in the contract (a description, a cache tag, the
|
|
8
9
|
// buildId itself)
|
|
9
10
|
// `x verify` fails on a breaking change without a major version bump. Additive and internal
|
|
10
11
|
// changes never fail, which is what makes the gate credible enough to leave on.
|
|
11
12
|
|
|
13
|
+
import { isMcpExposed } from '@ultimat3/core';
|
|
12
14
|
import { canonical } from './build';
|
|
13
|
-
import type { ActionFact, JobFact, Manifest, QueryFact, RouteFact } from './schema';
|
|
15
|
+
import type { ActionFact, JobFact, Manifest, QueryFact, RateLimitFact, RouteFact } from './schema';
|
|
14
16
|
|
|
15
17
|
export type ChangeKind = 'breaking' | 'additive' | 'internal';
|
|
16
18
|
|
|
@@ -91,14 +93,22 @@ function diffActions(
|
|
|
91
93
|
detail: `policy ${action.policy ?? 'none'} -> ${next.policy ?? 'none'}`,
|
|
92
94
|
});
|
|
93
95
|
}
|
|
94
|
-
|
|
96
|
+
// Through `isMcpExposed`, not the raw field: `before` is a file parsed from disk, so an
|
|
97
|
+
// older or hand-trimmed manifest can carry an absent, `null` or non-boolean `expose` that
|
|
98
|
+
// `!==` would read as a change and classify from. One predicate, the same one the tool
|
|
99
|
+
// projection asks, is what makes this verdict match what the surface actually serves.
|
|
100
|
+
const exposed = isMcpExposed(action.mcp);
|
|
101
|
+
const nextExposed = isMcpExposed(next.mcp);
|
|
102
|
+
if (exposed !== nextExposed) {
|
|
95
103
|
// Widening the surface is additive; withdrawing a tool an agent depends on is not.
|
|
96
104
|
changes.push({
|
|
97
|
-
kind:
|
|
105
|
+
kind: nextExposed ? 'additive' : 'breaking',
|
|
98
106
|
path: `${path}.mcp.expose`,
|
|
99
|
-
detail: `mcp exposure ${String(
|
|
107
|
+
detail: `mcp exposure ${String(exposed)} -> ${String(nextExposed)}`,
|
|
100
108
|
});
|
|
101
109
|
}
|
|
110
|
+
changes.push(...diffPermissions(path, action, next));
|
|
111
|
+
changes.push(...diffRateLimit(path, action, next));
|
|
102
112
|
if (canonical(action.cacheInvalidates) !== canonical(next.cacheInvalidates)) {
|
|
103
113
|
changes.push({
|
|
104
114
|
kind: 'internal',
|
|
@@ -140,6 +150,7 @@ function diffQueries(
|
|
|
140
150
|
detail: `policy ${query.policy ?? 'none'} -> ${next.policy ?? 'none'}`,
|
|
141
151
|
});
|
|
142
152
|
}
|
|
153
|
+
changes.push(...diffPermissions(path, query, next));
|
|
143
154
|
if (query.live !== next.live) {
|
|
144
155
|
// Losing live-ness breaks subscribers; gaining it breaks nobody.
|
|
145
156
|
changes.push({
|
|
@@ -157,6 +168,146 @@ function diffQueries(
|
|
|
157
168
|
return changes;
|
|
158
169
|
}
|
|
159
170
|
|
|
171
|
+
/**
|
|
172
|
+
* The permissions an operation REQUIRES, and the direction each move points.
|
|
173
|
+
*
|
|
174
|
+
* Gaining one is breaking: every caller holding yesterday's grant set is refused by an operation
|
|
175
|
+
* that served them, and the failure arrives at runtime as a 403 with nothing in the build that
|
|
176
|
+
* said so. Losing one is additive — nothing that worked stops working — but it is still reported,
|
|
177
|
+
* because a grant quietly dropped from an operation is a widening of access a reviewer has to see.
|
|
178
|
+
*
|
|
179
|
+
* Matched on `permissions`, never `policy`: `policy` is a display label, and a composite's label
|
|
180
|
+
* (`and(post:publish, org:administer)`) equals no permission, so a rule reading it would call
|
|
181
|
+
* every non-trivially-guarded operation unchanged while both of its real grants moved.
|
|
182
|
+
*/
|
|
183
|
+
function diffPermissions(
|
|
184
|
+
path: string,
|
|
185
|
+
before: ActionFact | QueryFact,
|
|
186
|
+
after: ActionFact | QueryFact,
|
|
187
|
+
): readonly ManifestChange[] {
|
|
188
|
+
const declared = readPermissions(before);
|
|
189
|
+
const next = readPermissions(after);
|
|
190
|
+
// Absence is no evidence, on either side. Unlike `mcp.expose` there is no value to fold it
|
|
191
|
+
// into: `[]` asserts "this operation requires nothing", so reading an absent field as `[]`
|
|
192
|
+
// would report every permission of every operation as newly required the first time an app
|
|
193
|
+
// diffs against a manifest written before the field existed — a wall of false breakings for
|
|
194
|
+
// an upgrade that changed no authorization at all.
|
|
195
|
+
if (declared === undefined || next === undefined) return [];
|
|
196
|
+
|
|
197
|
+
const changes: ManifestChange[] = [];
|
|
198
|
+
const declaredSet = new Set(declared);
|
|
199
|
+
const nextSet = new Set(next);
|
|
200
|
+
for (const permission of next) {
|
|
201
|
+
if (!declaredSet.has(permission)) {
|
|
202
|
+
changes.push({
|
|
203
|
+
kind: 'breaking',
|
|
204
|
+
path: `${path}.permissions.${permission}`,
|
|
205
|
+
detail: 'now required; callers granted the old set are refused',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (const permission of declared) {
|
|
210
|
+
if (!nextSet.has(permission)) {
|
|
211
|
+
changes.push({
|
|
212
|
+
kind: 'additive',
|
|
213
|
+
path: `${path}.permissions.${permission}`,
|
|
214
|
+
detail: 'no longer required; access widened',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return changes;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The list as the FILE carries it, or `undefined` when it carries nothing this can compare. */
|
|
222
|
+
function readPermissions(fact: ActionFact | QueryFact): readonly string[] | undefined {
|
|
223
|
+
const value: unknown = fact.permissions;
|
|
224
|
+
if (!Array.isArray(value)) return undefined;
|
|
225
|
+
return value.every((entry) => typeof entry === 'string')
|
|
226
|
+
? (value as readonly string[])
|
|
227
|
+
: undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** No declaration at all, a declaration, or one this reader cannot make sense of. */
|
|
231
|
+
type RateLimitReading = RateLimitFact | 'none' | 'unreadable';
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A tightened limit refuses a caller the old pair served, which is the definition of breaking —
|
|
235
|
+
* and it is the one contract change that leaves every schema in the manifest untouched, so
|
|
236
|
+
* nothing else here can see it. Introducing a limit where there was none is the same event at
|
|
237
|
+
* its extreme: a client that was never throttled now can be.
|
|
238
|
+
*/
|
|
239
|
+
function diffRateLimit(
|
|
240
|
+
path: string,
|
|
241
|
+
before: ActionFact,
|
|
242
|
+
after: ActionFact,
|
|
243
|
+
): readonly ManifestChange[] {
|
|
244
|
+
const declared = readRateLimit(before);
|
|
245
|
+
const next = readRateLimit(after);
|
|
246
|
+
if (declared === 'unreadable' || next === 'unreadable') return [];
|
|
247
|
+
const at = `${path}.rateLimit`;
|
|
248
|
+
|
|
249
|
+
if (declared === 'none') {
|
|
250
|
+
if (next === 'none') return [];
|
|
251
|
+
return [
|
|
252
|
+
{
|
|
253
|
+
kind: 'breaking',
|
|
254
|
+
path: at,
|
|
255
|
+
detail: `rate limit introduced (${render(next)}); an unthrottled caller can now be refused`,
|
|
256
|
+
},
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
if (next === 'none') {
|
|
260
|
+
return [{ kind: 'additive', path: at, detail: `rate limit removed (was ${render(declared)})` }];
|
|
261
|
+
}
|
|
262
|
+
if (tighter(declared, next)) {
|
|
263
|
+
return [
|
|
264
|
+
{
|
|
265
|
+
kind: 'breaking',
|
|
266
|
+
path: at,
|
|
267
|
+
detail: `rate limit tightened ${render(declared)} -> ${render(next)}; callers at the old rate are refused`,
|
|
268
|
+
},
|
|
269
|
+
];
|
|
270
|
+
}
|
|
271
|
+
if (tighter(next, declared)) {
|
|
272
|
+
return [
|
|
273
|
+
{
|
|
274
|
+
kind: 'additive',
|
|
275
|
+
path: at,
|
|
276
|
+
detail: `rate limit loosened ${render(declared)} -> ${render(next)}`,
|
|
277
|
+
},
|
|
278
|
+
];
|
|
279
|
+
}
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Both halves, because either one alone refuses somebody: `limit` is the burst a caller may
|
|
285
|
+
* spend at once and `limit / windowMs` is the rate it refills at, so a larger burst on a slower
|
|
286
|
+
* refill still turns away a client the old pair served. Cross-multiplied rather than divided —
|
|
287
|
+
* both windows are positive, and an exact integer comparison cannot invent a change out of a
|
|
288
|
+
* rounding difference in a file that is diffed on every build.
|
|
289
|
+
*/
|
|
290
|
+
const tighter = (from: RateLimitFact, to: RateLimitFact): boolean =>
|
|
291
|
+
to.limit < from.limit || to.limit * from.windowMs < from.limit * to.windowMs;
|
|
292
|
+
|
|
293
|
+
const render = (limit: RateLimitFact): string => `${limit.limit}/${limit.windowMs}ms`;
|
|
294
|
+
|
|
295
|
+
function readRateLimit(fact: ActionFact): RateLimitReading {
|
|
296
|
+
const value: unknown = fact.rateLimit;
|
|
297
|
+
if (value === undefined || value === null) return 'none';
|
|
298
|
+
if (typeof value !== 'object') return 'unreadable';
|
|
299
|
+
const record = value as Record<string, unknown>;
|
|
300
|
+
const limit = record['limit'];
|
|
301
|
+
const windowMs = record['windowMs'];
|
|
302
|
+
// The same two conditions `toBucket` enforces at mount: a non-positive window is an infinite
|
|
303
|
+
// refill and a sub-token limit closes the endpoint, so neither describes a limit to compare.
|
|
304
|
+
if (!positive(limit) || !positive(windowMs)) return 'unreadable';
|
|
305
|
+
return { limit, windowMs };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const positive = (value: unknown): value is number =>
|
|
309
|
+
typeof value === 'number' && Number.isFinite(value) && value > 0;
|
|
310
|
+
|
|
160
311
|
function diffRoutes(
|
|
161
312
|
before: readonly RouteFact[],
|
|
162
313
|
after: readonly RouteFact[],
|
package/src/docs-scan.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// Doc entries read out of an installed package tree, at query time.
|
|
2
|
+
//
|
|
3
|
+
// WHY NOTHING IS GENERATED HERE
|
|
4
|
+
// -----------------------------
|
|
5
|
+
// The published artifact IS the source (PUBLISHING.md): `files` ships `src/**` and `README.md`,
|
|
6
|
+
// and Bun runs the TypeScript directly. So every doc an agent needs is already inside
|
|
7
|
+
// `node_modules` — what was missing is retrieval, not payload. A generated `docs.json` per package
|
|
8
|
+
// would be a SECOND copy of bytes the tarball already carries, and a second copy is the thing that
|
|
9
|
+
// drifts. Reading the installed source cannot disagree with the installed version, because it is
|
|
10
|
+
// the installed version. Same split `agents-md.ts` states: facts are derived, prose is
|
|
11
|
+
// human-authored — this module derives, and quotes human prose verbatim. It never writes.
|
|
12
|
+
|
|
13
|
+
// `node:` by necessity, not habit. Bun exposes no native directory listing: `Bun.Glob` walks a
|
|
14
|
+
// pattern and yields matches, while this needs the immediate children of the scope directory —
|
|
15
|
+
// including the ones that turn out not to be packages. And Bun ships no path module at all, so
|
|
16
|
+
// `basename`/`join` are the only way to build a path without hand-rolling separator handling.
|
|
17
|
+
import { readdir } from 'node:fs/promises';
|
|
18
|
+
import { basename, join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
export type DocEntryKind = 'module' | 'guide';
|
|
21
|
+
|
|
22
|
+
export interface DocEntry {
|
|
23
|
+
/** `jobs.retry`, `jobs.README#queues` — stable, derived, and unique within a package. */
|
|
24
|
+
readonly topic: string;
|
|
25
|
+
readonly package: string;
|
|
26
|
+
/**
|
|
27
|
+
* The installed version this entry was read out of. Carried on every entry because a doc an
|
|
28
|
+
* agent cannot date is a doc it cannot distrust — and this is the only staleness signal that
|
|
29
|
+
* matters once the text itself is the installed source.
|
|
30
|
+
*/
|
|
31
|
+
readonly version: string;
|
|
32
|
+
readonly kind: DocEntryKind;
|
|
33
|
+
/** First line of the module header, or the heading text. Empty when the module has none. */
|
|
34
|
+
readonly title: string;
|
|
35
|
+
/** The header comment or the section body, verbatim, capped. */
|
|
36
|
+
readonly text: string;
|
|
37
|
+
/** Public export names this module contributes to the package's API. */
|
|
38
|
+
readonly symbols: readonly string[];
|
|
39
|
+
/** Package-relative, so a match names a file an agent can open. */
|
|
40
|
+
readonly source: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Long enough for a 1–4 line header and a short section, short enough to print several. */
|
|
44
|
+
const MAX_TEXT = 1_200;
|
|
45
|
+
|
|
46
|
+
/** Only `README.md` and `CLAUDE.md`: the two files every package is already gated on carrying. */
|
|
47
|
+
const GUIDE_FILES = ['README.md', 'CLAUDE.md'] as const;
|
|
48
|
+
|
|
49
|
+
const clamp = (text: string): string =>
|
|
50
|
+
text.length <= MAX_TEXT ? text : `${text.slice(0, MAX_TEXT).trimEnd()}…`;
|
|
51
|
+
|
|
52
|
+
const read = async (path: string): Promise<string | undefined> => {
|
|
53
|
+
const file = Bun.file(path);
|
|
54
|
+
return (await file.exists()) ? file.text() : undefined;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Local re-exports only. `export { t } from '@ultimat3/schema'` is another package's symbol: an
|
|
59
|
+
* entry for it would point at a file this package does not ship, and two packages would claim one
|
|
60
|
+
* topic. The public name is the one after `as` — that is what an importer can actually write.
|
|
61
|
+
*/
|
|
62
|
+
const RE_EXPORT = /export\s+(?:type\s+)?\{([^}]*)\}\s+from\s+'(\.[^']+)'/g;
|
|
63
|
+
|
|
64
|
+
export function parseReExports(indexSource: string): ReadonlyMap<string, readonly string[]> {
|
|
65
|
+
const byModule = new Map<string, string[]>();
|
|
66
|
+
for (const match of indexSource.matchAll(RE_EXPORT)) {
|
|
67
|
+
const module = (match[2] ?? '').replace(/^\.\//, '');
|
|
68
|
+
const names = (match[1] ?? '')
|
|
69
|
+
.split(',')
|
|
70
|
+
.map(
|
|
71
|
+
(raw) =>
|
|
72
|
+
raw
|
|
73
|
+
.trim()
|
|
74
|
+
.split(/\s+as\s+/)
|
|
75
|
+
.at(-1)
|
|
76
|
+
?.trim() ?? '',
|
|
77
|
+
)
|
|
78
|
+
.filter((name) => name !== '');
|
|
79
|
+
if (names.length === 0) continue;
|
|
80
|
+
const existing = byModule.get(module);
|
|
81
|
+
if (existing === undefined) byModule.set(module, names);
|
|
82
|
+
else existing.push(...names);
|
|
83
|
+
}
|
|
84
|
+
return byModule;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The 1–4 line header every source file carries (99.8% of them, measured) is the most reliable
|
|
89
|
+
* doc unit in this codebase — far more so than JSDoc, which sits on 42% of public exports and not
|
|
90
|
+
* on `job()` itself. Both comment styles, because both are in use.
|
|
91
|
+
*/
|
|
92
|
+
export function headerComment(source: string): string {
|
|
93
|
+
const lines = source.split('\n');
|
|
94
|
+
const first = lines[0]?.trimStart() ?? '';
|
|
95
|
+
const out: string[] = [];
|
|
96
|
+
if (first.startsWith('//')) {
|
|
97
|
+
for (const line of lines) {
|
|
98
|
+
const trimmed = line.trimStart();
|
|
99
|
+
if (!trimmed.startsWith('//')) break;
|
|
100
|
+
out.push(trimmed.replace(/^\/\/\s?/, ''));
|
|
101
|
+
}
|
|
102
|
+
} else if (first.startsWith('/*')) {
|
|
103
|
+
for (const line of lines) {
|
|
104
|
+
const trimmed = line.trim();
|
|
105
|
+
out.push(
|
|
106
|
+
trimmed
|
|
107
|
+
.replace(/^\/\*+\s?/, '')
|
|
108
|
+
.replace(/\s?\*+\/$/, '')
|
|
109
|
+
.replace(/^\*\s?/, ''),
|
|
110
|
+
);
|
|
111
|
+
if (trimmed.endsWith('*/')) break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// A rule of dashes under a heading is a separator in prose, never a sentence.
|
|
115
|
+
return clamp(
|
|
116
|
+
out
|
|
117
|
+
.join('\n')
|
|
118
|
+
.replace(/^-{3,}$/gm, '')
|
|
119
|
+
.trim(),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const slug = (heading: string): string =>
|
|
124
|
+
heading
|
|
125
|
+
.toLowerCase()
|
|
126
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
127
|
+
.replace(/^-|-$/g, '');
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `##`-and-deeper sections of a human-authored guide, quoted verbatim. This is where a conceptual
|
|
131
|
+
* question ("why is money never a float") finds an answer that no symbol name carries.
|
|
132
|
+
*/
|
|
133
|
+
export function parseGuideSections(
|
|
134
|
+
markdown: string,
|
|
135
|
+
): readonly { readonly heading: string; readonly body: string }[] {
|
|
136
|
+
const sections: { heading: string; body: string[] }[] = [];
|
|
137
|
+
for (const line of markdown.split('\n')) {
|
|
138
|
+
const heading = /^#{2,}\s+(.+?)\s*$/.exec(line);
|
|
139
|
+
if (heading !== null) sections.push({ heading: heading[1] ?? '', body: [] });
|
|
140
|
+
else sections.at(-1)?.body.push(line);
|
|
141
|
+
}
|
|
142
|
+
return sections.map((section) => ({
|
|
143
|
+
heading: section.heading,
|
|
144
|
+
body: clamp(section.body.join('\n').trim()),
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** `@ultimat3/jobs` → `jobs`. An unscoped package (`create-ultimate`) keeps its whole name. */
|
|
149
|
+
export const shortName = (packageName: string): string => packageName.split('/').at(-1) ?? '';
|
|
150
|
+
|
|
151
|
+
async function guideEntries(
|
|
152
|
+
dir: string,
|
|
153
|
+
name: string,
|
|
154
|
+
version: string,
|
|
155
|
+
): Promise<readonly DocEntry[]> {
|
|
156
|
+
const entries: DocEntry[] = [];
|
|
157
|
+
// Two `##` sections can carry the same text, and their slugs would then collide into one topic
|
|
158
|
+
// id — silently, which is the failure mode the search's own coverage floor exists to prevent,
|
|
159
|
+
// and a direct contradiction of `DocEntry.topic`'s promise to be unique within a package. No
|
|
160
|
+
// shipped guide collides today; an app's own package is one heading away from it. The suffix
|
|
161
|
+
// follows document order, so the id is still derived and still reproducible.
|
|
162
|
+
const used = new Map<string, number>();
|
|
163
|
+
for (const file of GUIDE_FILES) {
|
|
164
|
+
const markdown = await read(join(dir, file));
|
|
165
|
+
if (markdown === undefined) continue;
|
|
166
|
+
const stem = basename(file, '.md');
|
|
167
|
+
for (const section of parseGuideSections(markdown)) {
|
|
168
|
+
const base = `${shortName(name)}.${stem}#${slug(section.heading)}`;
|
|
169
|
+
const seen = (used.get(base) ?? 0) + 1;
|
|
170
|
+
used.set(base, seen);
|
|
171
|
+
entries.push({
|
|
172
|
+
topic: seen === 1 ? base : `${base}-${seen}`,
|
|
173
|
+
package: name,
|
|
174
|
+
version,
|
|
175
|
+
kind: 'guide',
|
|
176
|
+
title: section.heading,
|
|
177
|
+
text: section.body,
|
|
178
|
+
symbols: [],
|
|
179
|
+
source: file,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return entries;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* One package tree → its doc entries. A tree with no `package.json` is not a package (npm leaves
|
|
188
|
+
* `.bin` and cache directories inside a scope), and a module `index.ts` names but the tarball does
|
|
189
|
+
* not ship is skipped rather than reported: `files` excludes test helpers on purpose, and an entry
|
|
190
|
+
* pointing at a file the install does not have is exactly the lie this module must not tell.
|
|
191
|
+
*/
|
|
192
|
+
export async function scanPackageDocs(dir: string): Promise<readonly DocEntry[]> {
|
|
193
|
+
const manifest = await read(join(dir, 'package.json'));
|
|
194
|
+
if (manifest === undefined) return [];
|
|
195
|
+
// A truncated `package.json` is what an interrupted install leaves behind, and it is not a
|
|
196
|
+
// package either — same answer as a directory with none, rather than a `SyntaxError` thrown
|
|
197
|
+
// through `scanInstalledDocs`'s `Promise.all` for every other package to inherit.
|
|
198
|
+
const parsed: unknown = parseJson(manifest);
|
|
199
|
+
if (parsed === undefined) return [];
|
|
200
|
+
// `unknown` + a field-by-field read, never a cast: this JSON is whatever is on disk.
|
|
201
|
+
const field = (key: string): string | undefined => {
|
|
202
|
+
if (typeof parsed !== 'object' || parsed === null) return undefined;
|
|
203
|
+
const value: unknown = Reflect.get(parsed, key);
|
|
204
|
+
return typeof value === 'string' ? value : undefined;
|
|
205
|
+
};
|
|
206
|
+
const name = field('name');
|
|
207
|
+
if (name === undefined) return [];
|
|
208
|
+
const version = field('version') ?? '';
|
|
209
|
+
|
|
210
|
+
const index = await read(join(dir, 'src/index.ts'));
|
|
211
|
+
const modules: ReadonlyMap<string, readonly string[]> =
|
|
212
|
+
index === undefined ? new Map<string, readonly string[]>() : parseReExports(index);
|
|
213
|
+
const scanned = await Promise.all(
|
|
214
|
+
[...modules].map(async ([module, symbols]) => {
|
|
215
|
+
// `.tsx` before giving up: @ultimat3/ui ships ~40 components as .tsx, and resolving only
|
|
216
|
+
// `.ts` indexed none of them — a shipped command silently blind to a whole package.
|
|
217
|
+
const candidates = [`src/${module}.ts`, `src/${module}.tsx`] as const;
|
|
218
|
+
let source: string | undefined;
|
|
219
|
+
let text: string | undefined;
|
|
220
|
+
for (const candidate of candidates) {
|
|
221
|
+
text = await read(join(dir, candidate));
|
|
222
|
+
if (text !== undefined) {
|
|
223
|
+
source = candidate;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (text === undefined || source === undefined) return undefined;
|
|
228
|
+
const header = headerComment(text);
|
|
229
|
+
return {
|
|
230
|
+
topic: `${shortName(name)}.${module}`,
|
|
231
|
+
package: name,
|
|
232
|
+
version,
|
|
233
|
+
kind: 'module',
|
|
234
|
+
title: header.split('\n')[0] ?? '',
|
|
235
|
+
text: header,
|
|
236
|
+
symbols: [...new Set(symbols)].sort(),
|
|
237
|
+
source,
|
|
238
|
+
} satisfies DocEntry;
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
// Modules sort by topic; guides keep the order they were written in. Sorting a guide's sections
|
|
242
|
+
// alphabetically would reorder an argument its author sequenced on purpose, and prose read out
|
|
243
|
+
// of order is prose an agent has to reassemble. Both halves are deterministic, which is what
|
|
244
|
+
// "two scans of one tree agree" actually requires.
|
|
245
|
+
const moduleEntries = scanned
|
|
246
|
+
.filter((entry) => entry !== undefined)
|
|
247
|
+
.sort((a, b) => a.topic.localeCompare(b.topic));
|
|
248
|
+
return [...moduleEntries, ...(await guideEntries(dir, name, version))];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Every package under a resolved `@ultimat3` scope directory. Deliberately a directory listing
|
|
253
|
+
* rather than a hardcoded package list: an app installs the subset it uses, this repo has all 29,
|
|
254
|
+
* and a list would answer for packages that are not there.
|
|
255
|
+
*/
|
|
256
|
+
export async function scanInstalledDocs(scopeDir: string): Promise<readonly DocEntry[]> {
|
|
257
|
+
let names: readonly string[];
|
|
258
|
+
try {
|
|
259
|
+
names = await readdir(scopeDir);
|
|
260
|
+
} catch {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
const perPackage = await Promise.all(
|
|
264
|
+
[...names].sort().map(async (name) => {
|
|
265
|
+
try {
|
|
266
|
+
return await scanPackageDocs(join(scopeDir, name));
|
|
267
|
+
} catch {
|
|
268
|
+
// `node_modules` is not a curated tree and it is not stable while an install is running:
|
|
269
|
+
// a file can vanish between `exists()` and `text()`, and a directory can be half-written.
|
|
270
|
+
// One package's tree is worth exactly that package's entries — never the whole answer.
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
return perPackage.flat();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** `undefined` rather than a throw. The bytes are whatever is on disk, not something we wrote. */
|
|
279
|
+
function parseJson(text: string): unknown {
|
|
280
|
+
try {
|
|
281
|
+
return JSON.parse(text) as unknown;
|
|
282
|
+
} catch {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Ranking doc entries against a natural-language question. Pure — no I/O, no clock — so the
|
|
2
|
+
// order it produces is a function of its input alone, and a scan plus a search is reproducible.
|
|
3
|
+
|
|
4
|
+
import type { DocEntry } from './docs-scan';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Words that carry no signal in a question about an API. Deliberately tiny: every word removed
|
|
8
|
+
* here is a word an agent cannot search for, and `get`, `set`, `run` and `new` are all real
|
|
9
|
+
* symbol names in this framework.
|
|
10
|
+
*/
|
|
11
|
+
const STOPWORDS: ReadonlySet<string> = new Set([
|
|
12
|
+
'a',
|
|
13
|
+
'an',
|
|
14
|
+
'and',
|
|
15
|
+
'are',
|
|
16
|
+
'as',
|
|
17
|
+
'at',
|
|
18
|
+
'be',
|
|
19
|
+
'but',
|
|
20
|
+
'by',
|
|
21
|
+
'can',
|
|
22
|
+
'do',
|
|
23
|
+
'does',
|
|
24
|
+
'for',
|
|
25
|
+
'from',
|
|
26
|
+
'how',
|
|
27
|
+
'i',
|
|
28
|
+
'if',
|
|
29
|
+
'in',
|
|
30
|
+
'is',
|
|
31
|
+
'it',
|
|
32
|
+
'its',
|
|
33
|
+
'me',
|
|
34
|
+
'my',
|
|
35
|
+
'no',
|
|
36
|
+
'not',
|
|
37
|
+
'of',
|
|
38
|
+
'on',
|
|
39
|
+
'or',
|
|
40
|
+
'the',
|
|
41
|
+
'to',
|
|
42
|
+
'use',
|
|
43
|
+
'using',
|
|
44
|
+
'was',
|
|
45
|
+
'what',
|
|
46
|
+
'when',
|
|
47
|
+
'where',
|
|
48
|
+
'which',
|
|
49
|
+
'why',
|
|
50
|
+
'with',
|
|
51
|
+
'you',
|
|
52
|
+
'your',
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Split on non-alphanumerics, then split camelCase — so `nextRetry` answers a query for `retry`
|
|
57
|
+
* and `retry` answers one for `nextRetry`. The joined original is kept alongside its parts so an
|
|
58
|
+
* exact symbol name can still score as an exact hit.
|
|
59
|
+
*/
|
|
60
|
+
export function tokenize(query: string): readonly string[] {
|
|
61
|
+
const out: string[] = [];
|
|
62
|
+
const push = (token: string): void => {
|
|
63
|
+
if (token.length < 2 || STOPWORDS.has(token) || out.includes(token)) return;
|
|
64
|
+
out.push(token);
|
|
65
|
+
};
|
|
66
|
+
// Case is load-bearing until after the split: lowercasing first erases the boundary in
|
|
67
|
+
// `nextRetry`, which is the one this exists to find.
|
|
68
|
+
for (const word of query.split(/[^a-zA-Z0-9]+/)) {
|
|
69
|
+
if (word === '') continue;
|
|
70
|
+
const parts = word.split(/(?<=[a-z0-9])(?=[A-Z])/);
|
|
71
|
+
for (const part of parts) push(part.toLowerCase());
|
|
72
|
+
if (parts.length > 1) push(word.toLowerCase());
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A question about rationale wants the prose a human wrote, not the declaration site: "why is
|
|
79
|
+
* money never a float" is answered by the README section that argues it, while the module that
|
|
80
|
+
* exports `Money` merely contains the word. `how` is deliberately absent — "how do I call job()"
|
|
81
|
+
* is a question about an API, and the symbol is the better answer.
|
|
82
|
+
*/
|
|
83
|
+
const RATIONALE = /^\s*(why|should|when should)\b/i;
|
|
84
|
+
|
|
85
|
+
export interface DocHit {
|
|
86
|
+
readonly entry: DocEntry;
|
|
87
|
+
readonly score: number;
|
|
88
|
+
/** The symbols and words that earned the hit — why this answer, not just which. */
|
|
89
|
+
readonly matched: readonly string[];
|
|
90
|
+
/** How many of the query's tokens this entry accounted for. See `COVERAGE`. */
|
|
91
|
+
readonly covered: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The share of a question's words an entry must account for to be offered as an answer.
|
|
96
|
+
*
|
|
97
|
+
* Without this, "kubernetes ingress annotation rewrite-target" returned `cli.cmd-build` — one
|
|
98
|
+
* token of five, matched on `--target` — presented in the same shape as a real answer. An agent
|
|
99
|
+
* cannot see that difference, and a confident irrelevant answer is worse than no answer, because
|
|
100
|
+
* it stops the search. Half the words is the line between "about this" and "mentions this".
|
|
101
|
+
*/
|
|
102
|
+
const COVERAGE = 0.5;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Weights, highest first: an exact public symbol name is the strongest possible signal that this
|
|
106
|
+
* is the file the question is about; prose is the weakest, because every package's README says
|
|
107
|
+
* "cache" somewhere. Prose is capped so a long document cannot out-score the declaration itself.
|
|
108
|
+
*/
|
|
109
|
+
const EXACT_SYMBOL = 12;
|
|
110
|
+
const PARTIAL_SYMBOL = 4;
|
|
111
|
+
const TOPIC = 7;
|
|
112
|
+
const PACKAGE = 3;
|
|
113
|
+
const TITLE = 3;
|
|
114
|
+
const TEXT = 1;
|
|
115
|
+
const MAX_TEXT_SCORE = 6;
|
|
116
|
+
/** Applied once, to a guide entry, when the question asks for rationale rather than an API. */
|
|
117
|
+
const RATIONALE_BONUS = 5;
|
|
118
|
+
|
|
119
|
+
function scoreEntry(
|
|
120
|
+
entry: DocEntry,
|
|
121
|
+
tokens: readonly string[],
|
|
122
|
+
rationale: boolean,
|
|
123
|
+
): DocHit | undefined {
|
|
124
|
+
const symbolsLower = entry.symbols.map((symbol) => symbol.toLowerCase());
|
|
125
|
+
const topicTokens = tokenize(entry.topic);
|
|
126
|
+
const packageToken = entry.package.split('/').at(-1)?.toLowerCase() ?? '';
|
|
127
|
+
const titleLower = entry.title.toLowerCase();
|
|
128
|
+
const textLower = entry.text.toLowerCase();
|
|
129
|
+
const matched: string[] = [];
|
|
130
|
+
let score = 0;
|
|
131
|
+
let textScore = 0;
|
|
132
|
+
|
|
133
|
+
let covered = 0;
|
|
134
|
+
for (const token of tokens) {
|
|
135
|
+
const before = score + textScore;
|
|
136
|
+
// Symbol, topic and package name are three views of ONE fact — "this thing is called that".
|
|
137
|
+
// Summing them let a token that is a package name, its module name and its exported type
|
|
138
|
+
// (`money`) out-score three genuinely independent hits, so the strongest view wins alone.
|
|
139
|
+
const exact = symbolsLower.indexOf(token);
|
|
140
|
+
const partial = exact >= 0 ? -1 : symbolsLower.findIndex((symbol) => symbol.includes(token));
|
|
141
|
+
const symbolScore = exact >= 0 ? EXACT_SYMBOL : partial >= 0 ? PARTIAL_SYMBOL : 0;
|
|
142
|
+
const nameScore = Math.max(
|
|
143
|
+
symbolScore,
|
|
144
|
+
topicTokens.includes(token) ? TOPIC : 0,
|
|
145
|
+
packageToken === token ? PACKAGE : 0,
|
|
146
|
+
);
|
|
147
|
+
score += nameScore;
|
|
148
|
+
const named = entry.symbols[exact >= 0 ? exact : partial];
|
|
149
|
+
if (symbolScore > 0 && named !== undefined) matched.push(named);
|
|
150
|
+
else if (nameScore > 0 && !matched.includes(token)) matched.push(token);
|
|
151
|
+
|
|
152
|
+
// Prose is independent evidence of the same token, and is counted on top — capped, so a long
|
|
153
|
+
// document cannot out-argue the declaration it describes.
|
|
154
|
+
if (titleLower.includes(token)) {
|
|
155
|
+
score += TITLE;
|
|
156
|
+
if (!matched.includes(token)) matched.push(token);
|
|
157
|
+
} else if (textLower.includes(token)) {
|
|
158
|
+
textScore += TEXT;
|
|
159
|
+
if (!matched.includes(token)) matched.push(token);
|
|
160
|
+
}
|
|
161
|
+
// Counted per query token, not per hit: a token that matched a symbol AND the prose is still
|
|
162
|
+
// one of the question's words accounted for.
|
|
163
|
+
if (score + textScore > before) covered += 1;
|
|
164
|
+
}
|
|
165
|
+
score += Math.min(textScore, MAX_TEXT_SCORE);
|
|
166
|
+
if (score === 0) return undefined;
|
|
167
|
+
return {
|
|
168
|
+
entry,
|
|
169
|
+
score: score + (rationale && entry.kind === 'guide' ? RATIONALE_BONUS : 0),
|
|
170
|
+
matched,
|
|
171
|
+
covered,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Ranked answers to a question, best first. A query that earns no points anywhere returns an
|
|
177
|
+
* empty list rather than the corpus: a search that always answers teaches an agent to trust an
|
|
178
|
+
* answer that was never about its question.
|
|
179
|
+
*
|
|
180
|
+
* Ties break on topic, never on scan order, so the corpus can be assembled in any order and the
|
|
181
|
+
* ranking still reproduces.
|
|
182
|
+
*/
|
|
183
|
+
export function searchDocs(
|
|
184
|
+
entries: readonly DocEntry[],
|
|
185
|
+
query: string,
|
|
186
|
+
limit = 8,
|
|
187
|
+
): readonly DocHit[] {
|
|
188
|
+
const tokens = tokenize(query);
|
|
189
|
+
if (tokens.length === 0) return [];
|
|
190
|
+
const rationale = RATIONALE.test(query);
|
|
191
|
+
const required = Math.ceil(tokens.length * COVERAGE);
|
|
192
|
+
const hits: DocHit[] = [];
|
|
193
|
+
for (const entry of entries) {
|
|
194
|
+
const hit = scoreEntry(entry, tokens, rationale);
|
|
195
|
+
if (hit !== undefined && hit.covered >= required) hits.push(hit);
|
|
196
|
+
}
|
|
197
|
+
hits.sort((a, b) => b.score - a.score || a.entry.topic.localeCompare(b.entry.topic));
|
|
198
|
+
return hits.slice(0, limit);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Nearest topics for a question that matched nothing — the same "here is what does exist" move
|
|
203
|
+
* `x errors explain` makes for an unregistered code, so a miss still ends in something runnable.
|
|
204
|
+
*/
|
|
205
|
+
export function nearestTopics(
|
|
206
|
+
entries: readonly DocEntry[],
|
|
207
|
+
query: string,
|
|
208
|
+
limit = 5,
|
|
209
|
+
): readonly string[] {
|
|
210
|
+
const tokens = tokenize(query);
|
|
211
|
+
const scored: { topic: string; score: number }[] = [];
|
|
212
|
+
for (const entry of entries) {
|
|
213
|
+
const topic = entry.topic.toLowerCase();
|
|
214
|
+
let score = 0;
|
|
215
|
+
for (const token of tokens) {
|
|
216
|
+
if (topic.includes(token)) score += 2;
|
|
217
|
+
// A prefix, so `retries` still reaches `retry` and `caching` reaches `cache`. Four
|
|
218
|
+
// characters, because three matches almost everything.
|
|
219
|
+
else if (token.length >= 4 && topic.includes(token.slice(0, 4))) score += 1;
|
|
220
|
+
}
|
|
221
|
+
if (score > 0) scored.push({ topic: entry.topic, score });
|
|
222
|
+
}
|
|
223
|
+
scored.sort((a, b) => b.score - a.score || a.topic.localeCompare(b.topic));
|
|
224
|
+
return [...new Set(scored.map((item) => item.topic))].slice(0, limit);
|
|
225
|
+
}
|
package/src/emit.ts
CHANGED
|
@@ -56,8 +56,11 @@ export async function emitManifest(input: EmitInput): Promise<EmitResult> {
|
|
|
56
56
|
const text = manifestJson(input.manifest);
|
|
57
57
|
|
|
58
58
|
if (input.stdout === true) {
|
|
59
|
-
// stdout is the wire in `--json` mode; nothing else may be written to it
|
|
60
|
-
|
|
59
|
+
// stdout is the wire in `--json` mode; nothing else may be written to it — and the write is
|
|
60
|
+
// AWAITED, because a write to a pipe is asynchronous and `process.exit()` throws away whatever
|
|
61
|
+
// is still queued. Unawaited, the largest payload the CLI prints was the one that lost bytes,
|
|
62
|
+
// exactly as `scripts/stdout-truncation.test.ts` documents for the same bug elsewhere.
|
|
63
|
+
await Bun.write(Bun.stdout, text);
|
|
61
64
|
return { path, bytes: text.length, buildId: input.manifest.buildId, changed: false };
|
|
62
65
|
}
|
|
63
66
|
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,17 @@ export type { ManifestSources } from './build';
|
|
|
12
12
|
export { buildManifest, canonical, contentHash } from './build';
|
|
13
13
|
export type { ChangeKind, ManifestChange, ManifestDiff } from './diff';
|
|
14
14
|
export { diffManifest, formatDiff } from './diff';
|
|
15
|
+
export type { DocEntry, DocEntryKind } from './docs-scan';
|
|
16
|
+
export {
|
|
17
|
+
headerComment,
|
|
18
|
+
parseGuideSections,
|
|
19
|
+
parseReExports,
|
|
20
|
+
scanInstalledDocs,
|
|
21
|
+
scanPackageDocs,
|
|
22
|
+
shortName,
|
|
23
|
+
} from './docs-scan';
|
|
24
|
+
export type { DocHit } from './docs-search';
|
|
25
|
+
export { nearestTopics, searchDocs, tokenize } from './docs-search';
|
|
15
26
|
export type { EmitInput, EmitResult } from './emit';
|
|
16
27
|
export {
|
|
17
28
|
assertNoDrift,
|
|
@@ -42,6 +53,7 @@ export type {
|
|
|
42
53
|
OfflineStrategy,
|
|
43
54
|
PolicyFact,
|
|
44
55
|
QueryFact,
|
|
56
|
+
RateLimitFact,
|
|
45
57
|
RenderMode,
|
|
46
58
|
RouteFact,
|
|
47
59
|
TaskFact,
|
package/src/schema.ts
CHANGED
|
@@ -5,7 +5,16 @@
|
|
|
5
5
|
// Every collection is `readonly` and every field is a plain JSON value: the manifest must
|
|
6
6
|
// round-trip through `JSON.stringify` without loss, because that is how it is stored.
|
|
7
7
|
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Bumped when a reader built for the previous version would be WRONG, not merely incomplete:
|
|
10
|
+
* a field removed, retyped, or given a new meaning.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately NOT bumped for a field that is only added. `isCompatible` is an equality check,
|
|
13
|
+
* so a bump rejects every `x.manifest.json` in existence at once — and `diffManifest` classifies
|
|
14
|
+
* a `manifestVersion` change as **breaking**, so a bump also demands a major version bump of
|
|
15
|
+
* every APP that regenerates its manifest against the new framework. Charging every app a major
|
|
16
|
+
* release for a field their readers never had to look at is a fix line that is not true.
|
|
17
|
+
*/
|
|
9
18
|
export const MANIFEST_VERSION = 1;
|
|
10
19
|
|
|
11
20
|
export type JsonValue =
|
|
@@ -47,13 +56,32 @@ export interface EntityFact {
|
|
|
47
56
|
readonly invariants: readonly string[];
|
|
48
57
|
}
|
|
49
58
|
|
|
59
|
+
/** A declared bucket as the author wrote it — `toBucket`'s input, never its converted output. */
|
|
60
|
+
export interface RateLimitFact {
|
|
61
|
+
readonly limit: number;
|
|
62
|
+
readonly windowMs: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
50
65
|
export interface ActionFact {
|
|
51
66
|
readonly name: string;
|
|
52
67
|
readonly input: JsonValue;
|
|
53
68
|
readonly output: JsonValue;
|
|
54
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* The policy's DISPLAY label — `post:publish` for a bare `can()`, but
|
|
71
|
+
* `and(post:publish, org:administer)` for a composite. Read `permissions` to ask which grants a
|
|
72
|
+
* policy actually asserts; matching on this string reports every composite as enforcing nothing.
|
|
73
|
+
*/
|
|
55
74
|
readonly policy: string | null;
|
|
75
|
+
/** Every permission the policy asserts, flattened through the combinators, deduped and sorted. */
|
|
76
|
+
readonly permissions: readonly string[];
|
|
56
77
|
readonly cacheInvalidates: readonly string[];
|
|
78
|
+
/**
|
|
79
|
+
* The declared rate limit; absent when the action declares none. A contract, not a tuning
|
|
80
|
+
* knob: a client written against 1000/minute is broken by 5/minute as surely as by a narrowed
|
|
81
|
+
* input, and the OpenAPI document already publishes the same pair as `x-ultimate.rateLimit` —
|
|
82
|
+
* the manifest is the copy the gate reads, so without it the tightening passes clean.
|
|
83
|
+
*/
|
|
84
|
+
readonly rateLimit?: RateLimitFact;
|
|
57
85
|
readonly mcp: { readonly expose: boolean; readonly description?: string };
|
|
58
86
|
readonly mutator?: boolean;
|
|
59
87
|
}
|
|
@@ -62,7 +90,10 @@ export interface QueryFact {
|
|
|
62
90
|
readonly name: string;
|
|
63
91
|
/** Optional: `QueryDescriptor` is schema-erased, so a live query may not expose one. */
|
|
64
92
|
readonly input?: JsonValue;
|
|
93
|
+
/** The policy's DISPLAY label — see `ActionFact.policy`, and read `permissions` to match on. */
|
|
65
94
|
readonly policy: string | null;
|
|
95
|
+
/** Every permission the policy asserts, flattened through the combinators, deduped and sorted. */
|
|
96
|
+
readonly permissions: readonly string[];
|
|
66
97
|
readonly live: boolean;
|
|
67
98
|
readonly cacheTags: readonly string[];
|
|
68
99
|
}
|
|
@@ -116,20 +147,55 @@ export interface Manifest {
|
|
|
116
147
|
readonly errorCodes: readonly ErrorCodeFact[];
|
|
117
148
|
}
|
|
118
149
|
|
|
119
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* Whether a reader built for `MANIFEST_VERSION` can consume `manifest`.
|
|
152
|
+
*
|
|
153
|
+
* READABILITY, not completeness. An older file may simply lack a field this build publishes;
|
|
154
|
+
* that is a reader's `?? []`, not an incompatibility. See `MANIFEST_VERSION` for when the answer
|
|
155
|
+
* is allowed to become `false`.
|
|
156
|
+
*/
|
|
120
157
|
export function isCompatible(manifest: { manifestVersion: number }): boolean {
|
|
121
158
|
return manifest.manifestVersion === MANIFEST_VERSION;
|
|
122
159
|
}
|
|
123
160
|
|
|
124
|
-
/**
|
|
161
|
+
/** Every top-level section the type declares as an array. Checked, never assumed. */
|
|
162
|
+
const ARRAY_SECTIONS: readonly (keyof Manifest)[] = [
|
|
163
|
+
'routes',
|
|
164
|
+
'entities',
|
|
165
|
+
'actions',
|
|
166
|
+
'queries',
|
|
167
|
+
'jobs',
|
|
168
|
+
'tasks',
|
|
169
|
+
'policies',
|
|
170
|
+
'permissions',
|
|
171
|
+
'locales',
|
|
172
|
+
'errorCodes',
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Structural check for a value read off disk, before it is trusted as a `Manifest`.
|
|
177
|
+
*
|
|
178
|
+
* EVERY top-level key, because the cast covers all of them: this checked five and cast the rest,
|
|
179
|
+
* and `diffManifest` then read `before.queries`, `before.jobs`, `before.permissions` and
|
|
180
|
+
* `before.locales` with no guard — so a section a hand-trimmed or truncated file happened not to
|
|
181
|
+
* carry surfaced as a bare `TypeError` out of the contract gate, two calls from the file that
|
|
182
|
+
* caused it. Rejecting here makes it `X_MANIFEST_DRIFT`, which names the file and the command.
|
|
183
|
+
*
|
|
184
|
+
* The individual FACTS inside a section are deliberately not walked: a manifest written before a
|
|
185
|
+
* field existed is still readable, which is the compatibility rule `MANIFEST_VERSION` owns and
|
|
186
|
+
* `build.test.ts`'s `shape compatibility` case pins.
|
|
187
|
+
*/
|
|
125
188
|
export function isManifest(value: unknown): value is Manifest {
|
|
126
189
|
if (typeof value !== 'object' || value === null) return false;
|
|
127
190
|
const m = value as Record<string, unknown>;
|
|
128
|
-
return
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
191
|
+
if (typeof m['manifestVersion'] !== 'number' || typeof m['buildId'] !== 'string') return false;
|
|
192
|
+
for (const section of ARRAY_SECTIONS) if (!Array.isArray(m[section])) return false;
|
|
193
|
+
return isAppIdentity(m['app']);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** `app` drives the semver gate, so a missing or non-string version is not a manifest. */
|
|
197
|
+
function isAppIdentity(value: unknown): boolean {
|
|
198
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
199
|
+
const app = value as Record<string, unknown>;
|
|
200
|
+
return typeof app['name'] === 'string' && typeof app['version'] === 'string';
|
|
135
201
|
}
|
package/src/sources.ts
CHANGED
|
@@ -62,7 +62,11 @@ export function frameworkSources(input: FrameworkSourcesInput): ManifestSources
|
|
|
62
62
|
input: asJson(action.input),
|
|
63
63
|
output: asJson(action.output),
|
|
64
64
|
policy: action.capability,
|
|
65
|
+
permissions: action.permissions,
|
|
65
66
|
cacheInvalidates: action.invalidates,
|
|
67
|
+
// Written only when declared, exactly like `mcp.description`: absence already reads as
|
|
68
|
+
// "no limit", and the descriptor's `null` is not a JSON fact worth a line per action.
|
|
69
|
+
...(action.rateLimit === null ? {} : { rateLimit: action.rateLimit }),
|
|
66
70
|
mcp: {
|
|
67
71
|
expose: action.mcp.expose,
|
|
68
72
|
...(action.mcp.description === null ? {} : { description: action.mcp.description }),
|
|
@@ -75,6 +79,7 @@ export function frameworkSources(input: FrameworkSourcesInput): ManifestSources
|
|
|
75
79
|
queries: describeQueries().map((query) => ({
|
|
76
80
|
name: query.name,
|
|
77
81
|
policy: query.capability,
|
|
82
|
+
permissions: query.permissions,
|
|
78
83
|
live: query.live,
|
|
79
84
|
cacheTags: query.tags,
|
|
80
85
|
})),
|