@orkestrel/scaffold 0.0.67 → 0.0.69
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/dist/bin/main.js +67 -44
- package/dist/bin/main.js.map +1 -1
- package/dist/host/agents/templates/brief.md +9 -0
- package/dist/host/claude/agents/orkestrel.md +4 -4
- package/dist/host/claude/rules/names.md +15 -0
- package/dist/host/claude/rules/tests.md +33 -4
- package/dist/host/claude/rules/workspace.md +14 -2
- package/dist/host/dotfiles/prettierignore +3 -0
- package/dist/host/guides/README.md +65 -0
- package/dist/host/guides/abort.md +169 -0
- package/dist/host/guides/agent.md +1567 -0
- package/dist/host/guides/brief.md +1266 -0
- package/dist/host/guides/browser.md +2200 -0
- package/dist/host/guides/budget.md +196 -0
- package/dist/host/guides/codec.md +519 -0
- package/dist/host/guides/console.md +785 -0
- package/dist/host/guides/contract.md +1193 -0
- package/dist/host/guides/csv.md +541 -0
- package/dist/host/guides/database.md +2518 -0
- package/dist/host/guides/emitter.md +233 -0
- package/dist/host/guides/form.md +1791 -0
- package/dist/host/guides/html.md +717 -0
- package/dist/host/guides/indexeddb.md +505 -0
- package/dist/host/guides/interpret.md +1029 -0
- package/dist/host/guides/lsp.md +515 -0
- package/dist/host/guides/markdown.md +964 -0
- package/dist/host/guides/mcp.md +5554 -0
- package/dist/host/guides/middleware.md +927 -0
- package/dist/host/guides/msg.md +440 -0
- package/dist/host/guides/ndjson.md +120 -0
- package/dist/host/guides/ollama.md +380 -0
- package/dist/host/guides/pool.md +280 -0
- package/dist/host/guides/probe.md +1210 -0
- package/dist/host/guides/process.md +1620 -0
- package/dist/host/guides/program.md +1110 -0
- package/dist/host/guides/qualifier.md +854 -0
- package/dist/host/guides/queue.md +370 -0
- package/dist/host/guides/rater.md +330 -0
- package/dist/host/guides/reason.md +1122 -0
- package/dist/host/guides/relation.md +373 -0
- package/dist/host/guides/router.md +753 -0
- package/dist/host/guides/scaffold.md +192 -31
- package/dist/host/guides/sea.md +383 -0
- package/dist/host/guides/server.md +752 -0
- package/dist/host/guides/sqlite.md +330 -0
- package/dist/host/guides/sse.md +187 -0
- package/dist/host/guides/supervisor.md +4890 -0
- package/dist/host/guides/table.md +1556 -0
- package/dist/host/guides/template.md +280 -0
- package/dist/host/guides/terminal.md +1145 -0
- package/dist/host/guides/test.md +2969 -0
- package/dist/host/guides/timeout.md +252 -0
- package/dist/host/guides/tool.md +507 -0
- package/dist/host/guides/toolbox.md +1038 -0
- package/dist/host/guides/websocket.md +282 -0
- package/dist/host/guides/worker.md +615 -0
- package/dist/host/guides/workflow.md +1507 -0
- package/dist/host/guides/workspace.md +595 -0
- package/dist/host/manifest.json +1218 -10
- package/dist/host/tests/policy.test.ts +279 -2
- package/dist/host/tests/setupPolicy.ts +445 -6
- package/dist/src/core/index.cjs +38 -16
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +33 -9
- package/dist/src/core/index.d.ts +33 -9
- package/dist/src/core/index.js +37 -17
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +1750 -1567
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +106 -24
- package/dist/src/server/index.d.ts +106 -24
- package/dist/src/server/index.js +1751 -1570
- package/dist/src/server/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,1210 @@
|
|
|
1
|
+
# Probe
|
|
2
|
+
|
|
3
|
+
> The claim prover for the `@orkestrel` line: an instrument that runs a claim's case and its
|
|
4
|
+
> negative control through the workspace's own TypeScript, Oxlint, and Vitest, and returns a
|
|
5
|
+
> `Verdict` carrying every issue — and a `receipt` when the case ran clean and the control broke
|
|
6
|
+
> where it said it would.
|
|
7
|
+
|
|
8
|
+
A `Claim`, a `Verdict`, and a `receipt` carry the package. A `Claim` is the question: a case, a
|
|
9
|
+
control that must break, and the TypeScript project both are judged under. A `Verdict` is the
|
|
10
|
+
answer: one `Check` per stage in each phase, the case and the control. A `receipt` is the verdict's
|
|
11
|
+
one-line summary of the conditions it was reached under, and it exists only when the claim proved
|
|
12
|
+
itself.
|
|
13
|
+
|
|
14
|
+
The type stage runs the workspace's own compiler over a mirror of the tree, and the lint and runtime
|
|
15
|
+
stages hold resident Oxlint and Vitest engines. Source: [`src/core`](../src/core),
|
|
16
|
+
[`src/server`](../src/server), [`src/bin`](../src/bin). Published through `@orkestrel/probe` and
|
|
17
|
+
`@orkestrel/probe/server`.
|
|
18
|
+
|
|
19
|
+
**An agent is the caller this exists for.** Deciding whether an edit compiles by reasoning about it
|
|
20
|
+
costs more than asking, and the answer is a guess.
|
|
21
|
+
|
|
22
|
+
**Mechanism, not policy.** probe reports evidence and mints a receipt under stated conditions. It
|
|
23
|
+
holds no key, signs nothing, and compels nothing. It also **executes caller-supplied test code with
|
|
24
|
+
the privileges of the process that hosts it**, so give a probe a workspace and a caller you already
|
|
25
|
+
trust with a shell.
|
|
26
|
+
|
|
27
|
+
## Surface
|
|
28
|
+
|
|
29
|
+
### Contracts
|
|
30
|
+
|
|
31
|
+
The data shapes, from [`types.ts`](../src/core/types.ts). Every property is readonly, and an absent
|
|
32
|
+
optional field is absent rather than empty.
|
|
33
|
+
|
|
34
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. An extended interface's name comes before `plus`, with the members it adds after.
|
|
35
|
+
|
|
36
|
+
| Name | Kind | Shape | Summary |
|
|
37
|
+
| ------------------- | --------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
38
|
+
| `Stage` | type | `'type' \| 'lint' \| 'runtime'` | Names an inspection a claim passes through, derived from `PROBE_STAGES`. |
|
|
39
|
+
| `Draft` | interface | `{ path, text }` | Carries one proposed file's location and its contents. |
|
|
40
|
+
| `Case` | interface | `{ files, test }` | Carries the candidate drafts a claim asserts about and the test that exercises them. |
|
|
41
|
+
| `Control` | interface | `Case plus { stage, reason }` | Extends a case with the stage it must fail at and the reason it must fail there. |
|
|
42
|
+
| `Claim` | interface | `{ project, case, control }` | Carries everything the service needs to produce one verdict. |
|
|
43
|
+
| `Party` | type | `'claimant' \| 'workspace' \| 'instrument'` | Names who must act on an issue or probe failure. |
|
|
44
|
+
| `Issue` | interface | `{ origin, path, message, range? }` | Carries one message a stage reported, where it reported it, and whose fault it names. |
|
|
45
|
+
| `Check` | interface | `{ stage, elapsed, issues }` | Carries one stage's outcome: what it cost and what it reported. |
|
|
46
|
+
| `Toolchain` | interface | `{ typescript, oxlint, vitest }` | Names the tool versions a verdict was produced with. |
|
|
47
|
+
| `Project` | interface | `{ path, digest }` | Names the TypeScript project that judged a verdict's candidate drafts. |
|
|
48
|
+
| `Verdict` | interface | `{ id, digest, toolchain, project, reason?, case, control, elapsed, receipt? }` | Carries the full result of one claim: every stage, for both the case and its control. |
|
|
49
|
+
| `ProbeEventMap` | type | `{ arm, prove, expire, error }` | Reports what a probe observes while it serves. |
|
|
50
|
+
| `ProbeOptions` | interface | `{ on?, error?, workspace?, deadline? }` | Configures a probe. |
|
|
51
|
+
| `ProbeInterface` | interface | `{ emitter, toolchain } plus prove, destroy` | Answers a claim with type, lint, and runtime evidence in one call. |
|
|
52
|
+
| `ProbeErrorCode` | type | `'refused' \| 'missing' \| 'malformed' \| 'destroyed' \| 'deadline'` | Names the condition that ended a probe operation, derived from `PROBE_ERROR_CODES`. |
|
|
53
|
+
| `ProbeErrorContext` | interface | `{ stage?, path?, project?, name?, deadline?, value? }` | Carries the structured detail one probe failure reports beside its message. |
|
|
54
|
+
| `ProbeErrorOptions` | interface | `{ origin, code, context?, cause? }` | Configures one probe failure at construction. |
|
|
55
|
+
|
|
56
|
+
A row whose `Shape` cell names call-signature members after `plus` carries those members in
|
|
57
|
+
[`## Methods`](#methods); the data members before `plus` stay here.
|
|
58
|
+
|
|
59
|
+
### Constants
|
|
60
|
+
|
|
61
|
+
From [`constants.ts`](../src/core/constants.ts). Each is frozen.
|
|
62
|
+
|
|
63
|
+
A `Shape` cell holds the constant's declared type.
|
|
64
|
+
|
|
65
|
+
| Name | Kind | Shape | Summary |
|
|
66
|
+
| ---------------------- | ----- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
67
|
+
| `PROBE_STAGES` | const | `readonly Stage[]` | Lists the stages a claim passes through, in the order a verdict reports them: `['type', 'lint', 'runtime']`. |
|
|
68
|
+
| `PROBE_PARTIES` | const | `readonly Party[]` | Lists the parties that can own action on an issue or probe failure: `['claimant', 'workspace', 'instrument']`. |
|
|
69
|
+
| `RECEIPT_PREFIX` | const | `string` | Names the leading token every receipt carries, `'probe'`. |
|
|
70
|
+
| `RECEIPT_SEPARATOR` | const | `string` | Names the character joining a receipt's tokens, `':'`. |
|
|
71
|
+
| `PROBE_ERROR_CODES` | const | `readonly ProbeErrorCode[]` | Lists the conditions that can end a probe operation: `['refused', 'missing', 'malformed', 'destroyed', 'deadline']`. |
|
|
72
|
+
| `PROBE_DEADLINE` | const | `number` | Names the default inspection deadline a `Probe` applies when its construction omits one, 30,000 ms. |
|
|
73
|
+
| `LINT_DEADLINE` | const | `number` | Names the 2,000 ms bound the lint stage holds over the lifecycle exchanges the protocol leaves to the server: the `initialize` reply warming waits for and the `shutdown` reply ending waits for. |
|
|
74
|
+
| `PROBE_KEYS` | const | `number` | Names the total enumerable key bound `ProbeServer` applies to inbound metadata and to produced tool content alike, 4096. |
|
|
75
|
+
| `PROBE_SPECIFICATIONS` | const | `number` | Names the specification lifetime the runtime stage replaces its resident Vitest service at, 64 specifications. |
|
|
76
|
+
| `RUNTIME_PLUGIN` | const | `string` | Names the Vite plugin the runtime stage installs into a target workspace's Vitest configuration, `'orkestrel-runtime-overlay'`. |
|
|
77
|
+
| `TYPE_MIRROR` | const | `string` | Names the workspace-relative directory the type stage keeps its workspace mirror under, `'tmp/type'`. |
|
|
78
|
+
|
|
79
|
+
### Errors
|
|
80
|
+
|
|
81
|
+
The failure type every served claim reports through, and its guard, from
|
|
82
|
+
[`errors.ts`](../src/core/errors.ts).
|
|
83
|
+
|
|
84
|
+
| Name | Kind | Signature | Summary |
|
|
85
|
+
| ---------------------- | -------- | --------------------------------------------------- | ----------------------------------------------------------------------------- |
|
|
86
|
+
| `ProbeError` | class | `new (message: string, options: ProbeErrorOptions)` | Reports one probe failure under stable ownership and condition axes. |
|
|
87
|
+
| `isProbeError` | function | `(value: unknown) => value is ProbeError` | Checks whether an unknown value is a `ProbeError`. |
|
|
88
|
+
| `createDestroyedError` | function | `(subject: string) => ProbeError` | Creates the failure raised when an instrument is used after it was torn down. |
|
|
89
|
+
|
|
90
|
+
### Shapes
|
|
91
|
+
|
|
92
|
+
The blueprints behind both the published tool schema and the guard applied to an arriving call,
|
|
93
|
+
from [`shapers.ts`](../src/core/shapers.ts). `CLAIM_SHAPE` compiles to the `prove` tool's JSON
|
|
94
|
+
Schema. The schema is the wire contract's shape and `isClaim` is the admission rule, and the rule is
|
|
95
|
+
narrower on `Draft.path`: see
|
|
96
|
+
[The advertised schema is wider than the admission rule](#registering-the-server).
|
|
97
|
+
|
|
98
|
+
A `Shape` cell holds the constant's declared type.
|
|
99
|
+
|
|
100
|
+
| Name | Kind | Shape | Summary |
|
|
101
|
+
| --------------- | ----- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
102
|
+
| `DRAFT_SHAPE` | const | `ObjectShape<{ path, text }>` | Describes one proposed file a claim carries. |
|
|
103
|
+
| `CASE_SHAPE` | const | `ObjectShape<{ files, test }>` | Describes the drafts a claim asserts about and the test that exercises them. |
|
|
104
|
+
| `CONTROL_SHAPE` | const | `ObjectShape<{ files, test, stage, reason }>` | Describes the negative control, which is a case plus where and why it must break. |
|
|
105
|
+
| `CLAIM_SHAPE` | const | `ObjectShape<{ project, case, control }>` | Describes one claim and is the sole source of both the published tool schema and the guard applied to an arriving claim. |
|
|
106
|
+
|
|
107
|
+
### Validators
|
|
108
|
+
|
|
109
|
+
Total guards, from [`validators.ts`](../src/core/validators.ts). Each returns a boolean for any
|
|
110
|
+
input and never throws.
|
|
111
|
+
|
|
112
|
+
In a guard table a `Shape` cell holds the type the guard narrows to.
|
|
113
|
+
|
|
114
|
+
| Name | Kind | Shape | Summary |
|
|
115
|
+
| ------------- | ----- | ----------- | ----------------------------------------------------------------------------------------------- |
|
|
116
|
+
| `isStage` | const | `Stage` | Checks whether a value names a stage. |
|
|
117
|
+
| `isParty` | const | `Party` | Checks whether a value names a party an issue carries. |
|
|
118
|
+
| `isDraft` | const | `Draft` | Checks whether a value carries a proposed file's path and text. |
|
|
119
|
+
| `isCase` | const | `Case` | Checks whether a value carries a claim's candidate drafts and its test. |
|
|
120
|
+
| `isControl` | const | `Control` | Checks whether a value carries a case plus the stage and reason it must fail for. |
|
|
121
|
+
| `isClaim` | const | `Claim` | Checks whether a value carries a project, a case, and a control. |
|
|
122
|
+
| `isIssue` | const | `Issue` | Checks whether a value carries one message, its location, and the origin of the fault it names. |
|
|
123
|
+
| `isCheck` | const | `Check` | Checks whether a value carries one stage's outcome. |
|
|
124
|
+
| `isToolchain` | const | `Toolchain` | Checks whether a value names every resolved tool version. |
|
|
125
|
+
| `isProject` | const | `Project` | Checks whether a value names one resolved TypeScript project and what it contained. |
|
|
126
|
+
| `isVerdict` | const | `Verdict` | Checks whether a value carries a complete verdict. |
|
|
127
|
+
|
|
128
|
+
### Formatters and the token
|
|
129
|
+
|
|
130
|
+
Pure leaves, from [`helpers.ts`](../src/core/helpers.ts).
|
|
131
|
+
|
|
132
|
+
| Name | Kind | Signature | Summary |
|
|
133
|
+
| ---------------------- | -------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
134
|
+
| `formatIssue` | function | `(issue: Issue) => string` | Renders one tool message as a single line an agent can classify and locate. |
|
|
135
|
+
| `formatCheck` | function | `(check: Check) => string` | Renders one stage's outcome as its summary line followed by every message it reported. |
|
|
136
|
+
| `formatProof` | function | `(verdict: Verdict) => string` | Renders the closing line a rendered verdict ends with: the receipt it earned, or its absence. |
|
|
137
|
+
| `formatReceipt` | function | `(verdict: Verdict) => string` | Renders the smallest text a verdict can travel as: what it judged, and how it ended. |
|
|
138
|
+
| `formatVerdict` | function | `(verdict: Verdict) => string` | Renders a whole verdict as the text an agent reads. |
|
|
139
|
+
| `computeReceipt` | function | `(verdict: Verdict, stage: Stage) => string \| undefined` | Computes the proof token a verdict carries, or returns nothing when the claim was not proven. |
|
|
140
|
+
| `formatSpecification` | function | `(text: string, revision: string) => string` | Renders one generated specification: the caller's own test text, then the marker naming the revision that wrote it. |
|
|
141
|
+
| `matchesSpecification` | function | `(text: string, revision: string) => boolean` | Checks whether one file's text is the generated specification written for one revision. |
|
|
142
|
+
|
|
143
|
+
### Server contracts
|
|
144
|
+
|
|
145
|
+
From [`types.ts`](../src/server/types.ts).
|
|
146
|
+
|
|
147
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. An extended interface's name comes before `plus`, with the members it adds after.
|
|
148
|
+
|
|
149
|
+
| Name | Kind | Shape | Summary |
|
|
150
|
+
| ---------------------- | --------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
|
151
|
+
| `Inspection` | interface | `{ subject, claim }` | Carries one queued inspection: the case a stage reads and the claim it belongs to. |
|
|
152
|
+
| `InspectionOptions` | interface | `{ signal }` | Carries the bound a caller holds over one stage inspection. |
|
|
153
|
+
| `OverlayInterface` | interface | `{ revision, paths } plus set, text, covers, clear` | Holds the candidate drafts one inspection substitutes for the files a tool would read from disk. |
|
|
154
|
+
| `StageInterface` | interface | `{ stage, progress } plus inspect, destroy` | Inspects one case with the workspace's own tool. |
|
|
155
|
+
| `TypeStageInterface` | interface | `StageInterface plus inspect, resolve` | Inspects TypeScript source against a caller-named project and reports what that project is. |
|
|
156
|
+
| `LintStageInterface` | interface | `StageInterface plus inspect` | Inspects one case under a bound the caller supplies. |
|
|
157
|
+
| `WorkspaceManifest` | interface | `{ path, contents }` | Carries one parsed package manifest and the path it came from. |
|
|
158
|
+
| `Diagnostic` | interface | `{ path?, range?, message }` | Carries one diagnostic line a compiler run reported, in this package's own coordinates. |
|
|
159
|
+
| `ProjectConfig` | interface | `{ compilerOptions, files?, include? }` | Carries what one TypeScript project resolved to, as the compiler itself printed it. |
|
|
160
|
+
| `Execution` | interface | `{ status?, stdout, stderr }` | Carries what one spawned workspace command reported when it closed. |
|
|
161
|
+
| `ProbeServerInterface` | interface | `{} plus start, destroy` | Serves one probe over this process's Model Context Protocol stdio transport. |
|
|
162
|
+
| `ListenerCapture` | type | `ReadonlyMap<string, readonly Function[]>` | Holds the listeners one emitter carried for a set of events at the moment it was captured. |
|
|
163
|
+
|
|
164
|
+
`StageInterface.progress` is the seam a foreign coordinator reads to decide whose budget an expiry
|
|
165
|
+
belongs to, and this is the proof behind it.
|
|
166
|
+
[`RuntimeStage.test.ts`](../tests/src/server/stages/RuntimeStage.test.ts) proves the gauge boundary
|
|
167
|
+
deterministically: it holds one inspection at the results cache with a FIFO, reads `progress`
|
|
168
|
+
elevated while the caller's run is in flight, and reads it level with its pre-inspection value while
|
|
169
|
+
the stage evicts.
|
|
170
|
+
[`LintStage.test.ts`](../tests/src/server/stages/LintStage.test.ts) reads the same boundary at the
|
|
171
|
+
other stage that owns cleanup: it fills the pipe the stage writes to, so the `didClose` the stage
|
|
172
|
+
owes its own language server is still waiting for room while the gauge reads level.
|
|
173
|
+
Claimant-side expiry is proven end to end through `Probe`, which rejects a claim
|
|
174
|
+
that outran the budget with `origin: 'claimant'`, `code: 'deadline'`, and the expired stage in
|
|
175
|
+
`context`. The composed instrument-side expiry — a real expiry during stage-owned work, attributed
|
|
176
|
+
through `Probe` — has no executed proof, and the gauge is the seam a proof of it reads.
|
|
177
|
+
|
|
178
|
+
### The engine
|
|
179
|
+
|
|
180
|
+
The classes, each exported from its own file, and the contract each one implements:
|
|
181
|
+
[`Probe`](../src/server/Probe.ts) implements `ProbeInterface`,
|
|
182
|
+
[`ProbeServer`](../src/server/ProbeServer.ts) implements `ProbeServerInterface`,
|
|
183
|
+
[`TypeStage`](../src/server/stages/TypeStage.ts) implements `TypeStageInterface`,
|
|
184
|
+
[`LintStage`](../src/server/stages/LintStage.ts) implements `LintStageInterface`,
|
|
185
|
+
[`RuntimeStage`](../src/server/stages/RuntimeStage.ts) implements `StageInterface`, and
|
|
186
|
+
[`Overlay`](../src/server/Overlay.ts) implements `OverlayInterface`.
|
|
187
|
+
|
|
188
|
+
| Name | Kind | Summary |
|
|
189
|
+
| -------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
190
|
+
| `Probe` | class | Answers claims through its type, lint, and runtime stages. |
|
|
191
|
+
| `ProbeServer` | class | Implements `ProbeServerInterface` over a `PassThrough` stream this server owns, binding the published `prove` tool and the dual-era dispatcher to this process's Model Context Protocol stdio transport. |
|
|
192
|
+
| `TypeStage` | class | Inspects TypeScript source by running the target workspace's own compiler over a mirror of it. |
|
|
193
|
+
| `LintStage` | class | Inspects virtual documents through one resident Oxlint language server. |
|
|
194
|
+
| `RuntimeStage` | class | Inspects tests through one resident Vitest service from the target workspace. |
|
|
195
|
+
| `Overlay` | class | Implements `OverlayInterface` over a private map from normalized absolute path to candidate text, minting at construction the `revision` a resident tool caches its answers against. |
|
|
196
|
+
|
|
197
|
+
Each stage takes one optional `workspace` argument and defaults to the working directory. A stage
|
|
198
|
+
serves one inspection at a time and admits none itself, so drive stages through `Probe` unless you
|
|
199
|
+
are building your own coordinator.
|
|
200
|
+
|
|
201
|
+
`new Overlay()` takes no arguments, so a coordinator of your own can mint one. Mint it per
|
|
202
|
+
inspection and release it when that inspection ends. An overlay shared across inspections keeps the
|
|
203
|
+
identity a resident tool caches its answers against, so the second inspection reads the first one's
|
|
204
|
+
answer as a fresh one.
|
|
205
|
+
|
|
206
|
+
`RuntimeStage` is the stage that holds one. A lookup key matches a recorded path exactly, after both
|
|
207
|
+
sides pass through `normalizePath`, and case is never folded. On a host that resolves two spellings
|
|
208
|
+
of one file name to one file, Vite serves a covered path under whichever spelling it met first, and
|
|
209
|
+
the stage reports that as the `workspace` issue `The workspace configuration served this module
|
|
210
|
+
before the runtime overlay` rather than leaving it answered silently. `TypeStage` holds no overlay:
|
|
211
|
+
it writes each draft into its mirror as a real file, so the host's own file-name comparison decides
|
|
212
|
+
what a draft shadows.
|
|
213
|
+
|
|
214
|
+
### Server helpers
|
|
215
|
+
|
|
216
|
+
Pure leaves and workspace readers, from [`helpers.ts`](../src/server/helpers.ts).
|
|
217
|
+
|
|
218
|
+
| Name | Kind | Signature | Summary |
|
|
219
|
+
| -------------------------- | -------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
220
|
+
| `normalizePath` | function | `(path: string) => string` | Rewrites one path into the forward-slash spelling this package compares and reports paths in. |
|
|
221
|
+
| `readFaultCode` | function | `(error: unknown) => string \| undefined` | Reads the condition code a native fault carries. |
|
|
222
|
+
| `escapesRoot` | function | `(root: string, target: string) => boolean` | Reports whether one path resolves outside the root it is read against. |
|
|
223
|
+
| `resolveWorkspaceFile` | function | `(workspace: string, target: string, mutate?: boolean) => string` | Resolves a path inside a target workspace and rejects traversal outside it. |
|
|
224
|
+
| `overwriteFile` | function | `(file: string, text: string) => void` | Overwrites a file that already exists, through a descriptor that refuses a symbolic link at the final component. |
|
|
225
|
+
| `isRefusedName` | function | `(file: string, error: unknown) => boolean` | Reports whether a fault means the host refuses the name a caller supplied for a file to create. |
|
|
226
|
+
| `relativeWorkspaceFile` | function | `(workspace: string, file: string) => string` | Projects an absolute tool path into the workspace-relative form issues expose. |
|
|
227
|
+
| `relativeWorkspaceMessage` | function | `(workspace: string, message: string) => string` | Projects the paths one tool named in a message into the forms this package's issues expose. |
|
|
228
|
+
| `scanDiagnostics` | function | `(text: string) => readonly Diagnostic[]` | Scans the plain-text output of one compiler run into the diagnostics it reported. |
|
|
229
|
+
| `resolveWorkspaceModule` | function | `(workspace: string, specifier: string) => string` | Resolves one installed module from the target workspace. |
|
|
230
|
+
| `loadWorkspaceVitest` | function | `(workspace: string) => typeof import('vitest/node')` | Loads the installed `vitest/node` module from a target workspace. |
|
|
231
|
+
| `readWorkspaceManifest` | function | `(workspace: string, name: string) => WorkspaceManifest` | Reads one installed package manifest from the target workspace. |
|
|
232
|
+
| `resolveWorkspaceBinary` | function | `(workspace: string, name: string, command?: string) => string` | Resolves a package's portable JavaScript binary from the target workspace. |
|
|
233
|
+
| `inferTypeProject` | function | `(path: string) => string` | Selects the scoped TypeScript project for one candidate draft path. |
|
|
234
|
+
| `inferTestProject` | function | `(path: string) => string \| undefined` | Selects the Vitest project whose environment matches one test path. |
|
|
235
|
+
| `inferDocumentLanguage` | function | `(path: string) => string` | Selects the Language Server Protocol language identifier for a source path. |
|
|
236
|
+
| `buildRevisionPath` | function | `(workspace: string, path: string, revision: string) => string` | Builds the fresh sibling path a revision's file is written at, preserving the test's resolution directory. |
|
|
237
|
+
| `matchesWorkspaceModule` | function | `(path: string) => boolean` | Reports whether a path is a workspace module Vitest can cache. |
|
|
238
|
+
| `matchesLiveProcess` | function | `(id: number) => boolean` | Reports whether the host that wrote one file is still running. |
|
|
239
|
+
| `collectWorkspaceFiles` | function | `(workspace: string) => readonly string[]` | Collects every regular file a target workspace holds, skipping the trees no inspection reads. |
|
|
240
|
+
| `filterUniqueIssues` | function | `(issues: readonly Issue[]) => readonly Issue[]` | Filters one issue list to the distinct issues it carries, in the order they arrived. |
|
|
241
|
+
| `describeUnknown` | function | `(value: unknown) => string` | Normalizes a caught or foreign error into readable text. |
|
|
242
|
+
| `guardStage` | function | `<T>(stage: Stage, operation: Promise<T>) => Promise<T>` | Guards one stage operation with the stage failure contract. |
|
|
243
|
+
| `findRefusedPaths` | function | `(value: unknown) => readonly string[]` | Names every draft member of a claim-shaped value whose `path` this package's guard refuses. |
|
|
244
|
+
| `normalizeValue` | function | `(workspace: string, value: unknown) => unknown` | Rewrites every workspace-contained absolute path in a value to its workspace-relative form and sorts every record's keys. |
|
|
245
|
+
| `computeDigest` | function | `(workspace: string, value: unknown) => string` | Computes the canonical digest of one value as it stands in a target workspace. |
|
|
246
|
+
| `captureListeners` | function | `(emitter: EventEmitter, events: readonly string[]) => ListenerCapture` | Records the listeners one emitter carries for a set of events. |
|
|
247
|
+
| `releaseListeners` | function | `(emitter: EventEmitter, capture: ListenerCapture) => void` | Removes every listener one emitter gained for the captured events since its capture. |
|
|
248
|
+
|
|
249
|
+
### Server parsers
|
|
250
|
+
|
|
251
|
+
Coercers over the text a tool wrote, from [`parsers.ts`](../src/server/parsers.ts). Each returns
|
|
252
|
+
`undefined` for text it cannot read rather than throwing.
|
|
253
|
+
|
|
254
|
+
| Name | Kind | Signature | Summary |
|
|
255
|
+
| -------------------- | -------- | ---------------------------------------------- | --------------------------------------------------------------------------- |
|
|
256
|
+
| `parseProjectConfig` | function | `(text: string) => ProjectConfig \| undefined` | Parses the configuration one compiler run printed for a TypeScript project. |
|
|
257
|
+
| `parseRevisionOwner` | function | `(revision: string) => number \| undefined` | Parses the process id one revision identity names. |
|
|
258
|
+
|
|
259
|
+
## Methods
|
|
260
|
+
|
|
261
|
+
The public call-signature members of each behavioral interface, one table per interface.
|
|
262
|
+
|
|
263
|
+
#### `ProbeInterface`
|
|
264
|
+
|
|
265
|
+
| Method | Returns | Summary |
|
|
266
|
+
| --------- | ------------------ | --------------------------------------------------------------------------- |
|
|
267
|
+
| `prove` | `Promise<Verdict>` | Answers one claim with every stage's evidence. |
|
|
268
|
+
| `destroy` | `Promise<void>` | Tears down every stage and releases the processes and the mirror they hold. |
|
|
269
|
+
|
|
270
|
+
#### `StageInterface`
|
|
271
|
+
|
|
272
|
+
| Method | Returns | Summary |
|
|
273
|
+
| --------- | ---------------- | ---------------------------------------------------------------------- |
|
|
274
|
+
| `inspect` | `Promise<Check>` | Inspects one case. |
|
|
275
|
+
| `destroy` | `Promise<void>` | Tears down the resident tool or the mirror and releases its resources. |
|
|
276
|
+
|
|
277
|
+
#### `RuntimeStage`
|
|
278
|
+
|
|
279
|
+
| Method | Returns | Summary |
|
|
280
|
+
| --------- | ---------------- | ---------------------------------------------------------------------- |
|
|
281
|
+
| `inspect` | `Promise<Check>` | Inspects one case. |
|
|
282
|
+
| `destroy` | `Promise<void>` | Tears down the resident tool or the mirror and releases its resources. |
|
|
283
|
+
|
|
284
|
+
#### `TypeStageInterface`
|
|
285
|
+
|
|
286
|
+
| Method | Returns | Summary |
|
|
287
|
+
| --------- | ------------------ | ----------------------------------------------------------------------------- |
|
|
288
|
+
| `inspect` | `Promise<Check>` | Inspects one case, against a caller-named project where the caller names one. |
|
|
289
|
+
| `resolve` | `Promise<Project>` | Resolves one project to the path and digest the stage applies for it. |
|
|
290
|
+
| `destroy` | `Promise<void>` | Tears down the resident tool or the mirror and releases its resources. |
|
|
291
|
+
|
|
292
|
+
#### `LintStageInterface`
|
|
293
|
+
|
|
294
|
+
| Method | Returns | Summary |
|
|
295
|
+
| --------- | ---------------- | ---------------------------------------------------------------------- |
|
|
296
|
+
| `inspect` | `Promise<Check>` | Inspects one case, under the bound the caller supplies. |
|
|
297
|
+
| `destroy` | `Promise<void>` | Tears down the resident tool or the mirror and releases its resources. |
|
|
298
|
+
|
|
299
|
+
#### `OverlayInterface`
|
|
300
|
+
|
|
301
|
+
| Method | Returns | Summary |
|
|
302
|
+
| -------- | --------------------- | ------------------------------------------------------------------------ |
|
|
303
|
+
| `set` | `void` | Records one candidate's text against the absolute path it stands in for. |
|
|
304
|
+
| `text` | `string \| undefined` | Reads the candidate text recorded for one absolute path. |
|
|
305
|
+
| `covers` | `boolean` | Checks whether a candidate sits beneath one directory. |
|
|
306
|
+
| `clear` | `void` | Releases every candidate. |
|
|
307
|
+
|
|
308
|
+
#### `ProbeServerInterface`
|
|
309
|
+
|
|
310
|
+
| Method | Returns | Summary |
|
|
311
|
+
| --------- | --------------- | ------------------------------------------------------------------------- |
|
|
312
|
+
| `start` | `void` | Serves the probe over this process's standard input and output. |
|
|
313
|
+
| `destroy` | `Promise<void>` | Releases the transport, the process listeners, and the probe behind them. |
|
|
314
|
+
|
|
315
|
+
## What a probe proves
|
|
316
|
+
|
|
317
|
+
Measure a performance claim first through a guarded bench block beside the probe test, run by the
|
|
318
|
+
`test:bench` script; a settled magnitude then proves through `prove` as an ordinary runtime claim.
|
|
319
|
+
|
|
320
|
+
A `Claim` is a question with a falsifier attached. Its `case` is the edit you believe is correct.
|
|
321
|
+
Its `control` is the same edit deliberately broken, plus the `stage` you say the breakage lands at
|
|
322
|
+
and the `reason` in your own words. Both are judged under the TypeScript project the claim's
|
|
323
|
+
`project` member names.
|
|
324
|
+
|
|
325
|
+
Every verdict returned by `prove` carries that explanation unchanged as `Verdict.reason`. The
|
|
326
|
+
member reports why the claimant chose the control. No receipt condition reads it, and it still
|
|
327
|
+
reaches the token: the reason is part of the control, so it enters `verdict.digest`, and the digest
|
|
328
|
+
is a field of the token. Two claims that differ only in the reason's prose are two claims, and they
|
|
329
|
+
digest differently.
|
|
330
|
+
|
|
331
|
+
`verdict.digest` covers these things and nothing else: the case bytes, the control bytes including
|
|
332
|
+
the reason, and the workspace those bytes were read against. The workspace enters because probe
|
|
333
|
+
rewrites every absolute string in a claim relative to the workspace before hashing, which is what
|
|
334
|
+
keeps one commit checked out at two paths reading as one claim. A claim carrying no absolute string
|
|
335
|
+
therefore digests the same in every workspace; a claim that carries one digests per workspace, so
|
|
336
|
+
compare two such tokens only when both were minted against the same tree.
|
|
337
|
+
|
|
338
|
+
`prove` runs every stage over the case, then every stage over the control, and returns one `Check`
|
|
339
|
+
per stage for each. A stage that cannot start throws rather than returning an empty check, so no
|
|
340
|
+
verdict ever reports a stage that did not run.
|
|
341
|
+
|
|
342
|
+
The receipt is minted on these conditions together:
|
|
343
|
+
|
|
344
|
+
- both phases report one check per stage; and
|
|
345
|
+
- the case produced no issue at any stage; and
|
|
346
|
+
- the control produced an `origin: 'claimant'` issue at the stage it declared, and neither phase
|
|
347
|
+
produced an `origin: 'instrument'` issue; and
|
|
348
|
+
- every other control stage produced no `origin: 'claimant'` issue.
|
|
349
|
+
|
|
350
|
+
A control that also breaks somewhere else has falsified the instrument rather than the claim, so no
|
|
351
|
+
receipt is minted for it. An `origin: 'instrument'` issue in either phase says the inspection did
|
|
352
|
+
not complete, so nothing was learned about the code and no receipt is minted either. An
|
|
353
|
+
`origin: 'workspace'` issue in the control decides neither break condition: it names the target
|
|
354
|
+
tree rather than the candidate. A case carrying any issue did not run clean and earns no receipt.
|
|
355
|
+
The check-per-stage condition binds both phases, because the clean-elsewhere condition reads the
|
|
356
|
+
control entries a verdict carries: a control that omits a stage would otherwise read as a stage
|
|
357
|
+
that stayed clean. `prove` records every stage for both phases, so that condition refuses only a
|
|
358
|
+
verdict you assembled by hand and passed to `computeReceipt` yourself.
|
|
359
|
+
|
|
360
|
+
`Issue.origin` names the party that must act, and the receipt conditions read that value rather
|
|
361
|
+
than the message beside it. **A `claimant` issue is a tool's diagnostic about a candidate draft,
|
|
362
|
+
and nothing else. Every other claimant fault is a throw.** That invariant is what lets one union
|
|
363
|
+
serve both an issue and a failure: without it, a caller's own mistake — a test path no project
|
|
364
|
+
collects, a `Claim.project` path that escapes the workspace — would arrive as a `claimant`
|
|
365
|
+
issue and satisfy the condition that a test which never ran must never satisfy. A `workspace` issue
|
|
366
|
+
carries the target tree's own defect, such as a symbolic link in a mutation path, a mutation path
|
|
367
|
+
whose existing components cannot be inspected, a specification directory the target tree blocks
|
|
368
|
+
probe from creating, a Vitest project the root configuration declares as a path string, into which
|
|
369
|
+
the runtime stage can install no overlay, or a covered module the workspace's own configuration
|
|
370
|
+
served before the runtime overlay. An `instrument` issue carries this package's own
|
|
371
|
+
message about an inspection that did not complete — a specification it could not write, after its
|
|
372
|
+
directory exists, for a reason the target tree does not own, a module that ran no test, or a covered
|
|
373
|
+
module probe's own loader received and did not resolve.
|
|
374
|
+
`formatIssue` renders the value first as `[claimant]`, `[workspace]`, or `[instrument]`, so the
|
|
375
|
+
ownership survives `formatVerdict`. A clean runtime check means every collected test passed, not
|
|
376
|
+
that the module reported itself passed.
|
|
377
|
+
|
|
378
|
+
**A diagnostic about a project belongs to the workspace.** The compiler reports a configuration
|
|
379
|
+
fault — a JSON syntax error, an option it cannot apply, a `types` entry or an extended project it
|
|
380
|
+
cannot resolve — against a `.json` file, or against no file at all. Either shape raises
|
|
381
|
+
`origin: 'workspace'`, `code: 'malformed'`, naming the project in `context`, and the inspection
|
|
382
|
+
reports nothing further, unless the named `.json` file is one the claim itself drafted: a claim that
|
|
383
|
+
proposes its own `.json` file owns a diagnostic about it like any other draft. The target tree holds
|
|
384
|
+
the only file that closes an undrafted configuration fault, so charging a claimant for it would
|
|
385
|
+
refuse every receipt that tree could earn until someone repaired a configuration nobody else owns.
|
|
386
|
+
The diagnostic's own path is what separates the two, not the compiler's exit code: the supported
|
|
387
|
+
majors disagree on that code, and a run reporting a candidate's type error and a run reporting a
|
|
388
|
+
malformed project exit alike.
|
|
389
|
+
|
|
390
|
+
A compiler that ends without diagnostics and without a successful exit raises an instrument
|
|
391
|
+
fault. Its message preserves the host's reported termination: a numeric exit code when present,
|
|
392
|
+
or a signal ending when no code was reported. A child sending itself `SIGTERM` can be reported as
|
|
393
|
+
a numeric exit on Windows, so the diagnostic does not infer a POSIX signal from the requested action.
|
|
394
|
+
|
|
395
|
+
**Every other diagnostic the run reported is a claimant issue**, whichever file it names. A draft
|
|
396
|
+
replaces the file it names, so a consumer of that path is judged against the draft's text and its
|
|
397
|
+
diagnostic is the draft's doing. The consequence is worth stating plainly: a workspace whose own
|
|
398
|
+
`check` script is red reports those diagnostics against every claim, and no claim earns a receipt
|
|
399
|
+
until the tree is green. That is the same reading the gate gives.
|
|
400
|
+
|
|
401
|
+
**Every message a stage reports is rendered in the workspace's own terms.** The host's directory
|
|
402
|
+
layout is removed from each path a message names, in whichever spelling the tool wrote — the
|
|
403
|
+
absolute path, the backslash spelling a Windows tool writes, and the `file:` URL a runtime names a
|
|
404
|
+
module by. A spelling of the root counts only where a path begins, so a directory whose own name
|
|
405
|
+
ends in the root's text keeps its whole path. The runtime stage removes one further name: the
|
|
406
|
+
generated sibling it ran, rewritten by exact basename to the declared test's own name.
|
|
407
|
+
|
|
408
|
+
## Failures
|
|
409
|
+
|
|
410
|
+
Every failure probe raises while serving a claim is a `ProbeError`. Narrow a caught value with
|
|
411
|
+
`isProbeError`, then read two independent members: `origin` is the party that must act and `code` is
|
|
412
|
+
the condition that ended the operation. Read `message` to print it and `context` for the detail
|
|
413
|
+
behind it.
|
|
414
|
+
|
|
415
|
+
`origin` carries the same values `Issue.origin` carries, and it answers the question a caller has
|
|
416
|
+
to answer first — is this my fault, the target tree's, or the tool's — from a value rather than from
|
|
417
|
+
the message. One branch routes every failure this package raises:
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
import { isProbeError } from '@orkestrel/probe'
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
await probe.prove(claim)
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if (!isProbeError(error)) throw error
|
|
426
|
+
if (error.origin === 'claimant') console.log('repair the claim', error.code, error.message)
|
|
427
|
+
if (error.origin === 'workspace') console.log('repair the workspace', error.code, error.message)
|
|
428
|
+
if (error.origin === 'instrument') console.log('report a probe defect', error.code, error.message)
|
|
429
|
+
if (error.code === 'deadline') console.log(error.context?.stage, error.context?.deadline)
|
|
430
|
+
}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
`code` names the repair rather than the party: `refused` changes the value a guard rejected before
|
|
434
|
+
work started, `missing` creates or installs the named thing, `malformed` repairs a value that exists
|
|
435
|
+
and does not match the contract it is read against, `destroyed` builds a replacement because
|
|
436
|
+
teardown is permanent, and `deadline` changes the budget or the work it bounds. Neither axis is
|
|
437
|
+
derivable from the other. These are the pairs this package raises:
|
|
438
|
+
|
|
439
|
+
| Party | Code | Raised when |
|
|
440
|
+
| ------------ | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
441
|
+
| `claimant` | `refused` | An input is rejected: a path escaping the workspace, a caller-supplied name the host refuses to inspect or create — an overlong component and one carrying a NUL byte are the shapes this package meets — a claim the tool guard rejects, a control repeating the case's candidate drafts and test byte for byte, a candidate naming no scoped project, a caller-named project whose diagnostic names no file, or a lint inspection its caller supplied no bound for. |
|
|
442
|
+
| `claimant` | `missing` | The declared test path names no configured Vitest project, or names one the root configuration does not define. |
|
|
443
|
+
| `claimant` | `destroyed` | A probe, a server, or a stage is used after its `destroy`. |
|
|
444
|
+
| `claimant` | `deadline` | `ProbeOptions.deadline` expired while the stage was performing claimant-owned work, so the claim outran the budget. That stage was replaced before the next inspection began. The lint stage raises the same pair on its own when the bound its caller supplied stops a diagnostics wait, and a coordinator that armed that bound replaces the pair with its own refusal. |
|
|
445
|
+
| `workspace` | `refused` | A mutation path crosses a symbolic link in the target tree. |
|
|
446
|
+
| `workspace` | `missing` | The target tree does not install a tool probe resolves from it, or publishes no binary under that tool's name. |
|
|
447
|
+
| `workspace` | `malformed` | The target tree publishes something probe cannot read: an unparsable manifest, a `bin` entry that is not a path, a TypeScript project its own compiler refuses, an unsupported tool version, a mutation path whose existing components cannot be inspected, or a directory it blocks probe from creating for the boot workbench. |
|
|
448
|
+
| `instrument` | `malformed` | probe's own tooling could not serve: a boot control that did not report red, a language server frame it could not parse, a schema or verdict of its own it could not validate. |
|
|
449
|
+
| `instrument` | `deadline` | A stage was not performing claimant-owned work when its budget expired, or a language server did not answer its teardown exchange within the stage's own bound. |
|
|
450
|
+
|
|
451
|
+
A TypeScript project whose JSON the compiler cannot parse reaches you as that `workspace` and
|
|
452
|
+
`malformed` pair, carrying the compiler's own diagnostic, the stage, and the project you named in
|
|
453
|
+
`context`, rather than as the compiler's internal assertion.
|
|
454
|
+
|
|
455
|
+
An `instrument` failure carries the same meaning it carries on `Issue.origin`: the inspection did
|
|
456
|
+
not complete, so nothing was learned about the code. Do not read it as evidence about a candidate.
|
|
457
|
+
|
|
458
|
+
**The pure leaves are total over a validated claim and not over every value.** `computeDigest`,
|
|
459
|
+
`normalizeValue`, and the other exported leaves are written for the values a `Claim` the guards
|
|
460
|
+
admit can carry, so a caller that hands one a value `isClaim` would refuse — a cyclic record, a
|
|
461
|
+
`BigInt` — gets the host's own `RangeError` or `TypeError` unchanged rather than a `ProbeError`.
|
|
462
|
+
Measured on 2026-08-20: `computeDigest(workspace, cyclic)` raises
|
|
463
|
+
`RangeError: Maximum call stack size exceeded`, and `computeDigest(workspace, { n: 1n })` raises
|
|
464
|
+
`TypeError: Do not know how to serialize a BigInt`. Validate with `isClaim` before you reach past
|
|
465
|
+
`prove` into a leaf.
|
|
466
|
+
|
|
467
|
+
`isProbeError` reads a global brand rather than the constructor, so it admits a failure raised by a
|
|
468
|
+
second copy of this package — a duplicate installation, or the ESM and CommonJS builds loaded
|
|
469
|
+
together — where `instanceof` refuses a failure the other copy raised.
|
|
470
|
+
|
|
471
|
+
## Prerequisites
|
|
472
|
+
|
|
473
|
+
probe borrows the target workspace's own toolchain and configuration, so a workspace missing any of
|
|
474
|
+
these returns a failure the caller cannot diagnose from the verdict alone. Check them before you
|
|
475
|
+
make a claim. A direct `Probe` runs its boot controls at construction. `ProbeServer` leaves discovery
|
|
476
|
+
independent of the workspace toolchain and runs those controls when an admitted `prove` call
|
|
477
|
+
constructs the real probe.
|
|
478
|
+
|
|
479
|
+
- **A Vitest project whose name the test's path infers.** A test under `tmp/probe/` names the
|
|
480
|
+
`probe` project, and a test under `tests/src/<environment>/` names `src:<environment>`. Any other
|
|
481
|
+
path infers no project, and the runtime stage throws `origin: 'claimant'`, `code: 'missing'`, and
|
|
482
|
+
the declared path in `context` rather than reporting an issue:
|
|
483
|
+
`The runtime stage found no configured Vitest project matching the test path`. A path that infers
|
|
484
|
+
a name the root configuration does not define is refused the same way, with
|
|
485
|
+
`The runtime stage found no configured Vitest project named <name>`. A test that never ran is not
|
|
486
|
+
evidence about the candidate, which is why this is a throw.
|
|
487
|
+
- **That project is composed in the root configuration, not declared as a path string.** The
|
|
488
|
+
runtime stage installs its own overlay plugin into each project's configuration so the candidate
|
|
489
|
+
drafts resolve from memory. A project the root configuration names by path carries no such
|
|
490
|
+
plugin, so the stage installs no overlay and runs no test. The check reports an
|
|
491
|
+
`origin: 'workspace'` issue, because the party that must act is the workspace owner editing
|
|
492
|
+
`vite.config.ts`:
|
|
493
|
+
`The runtime stage cannot instrument the string-declared Vitest project <name> because its configuration carries no runtime overlay plugin`.
|
|
494
|
+
The project's `include` pattern is not the mechanism: the stage builds an explicit specification
|
|
495
|
+
for the file it wrote, so a project whose glob matches nothing still serves a claim.
|
|
496
|
+
- **The directory the declared test path names can be created.** The runtime stage writes a real
|
|
497
|
+
file beside the declared test and creates that file's directory first, recursively, so a claim
|
|
498
|
+
naming a directory the workspace does not hold still runs. A fresh clone holds no `tmp/probe/`,
|
|
499
|
+
because `tmp` is ignored by version control, and a claim declaring a test there creates it. A
|
|
500
|
+
directory the host refuses to create — a file already occupies the path, or its parent denies
|
|
501
|
+
writing — reports an `origin: 'workspace'` issue:
|
|
502
|
+
`The runtime stage could not write the generated specification (<reason>)`, where `<reason>` is
|
|
503
|
+
the host's own `mkdir` failure.
|
|
504
|
+
- **A root `tsconfig.json` that resolves at least one input.** The type stage checks the claim's
|
|
505
|
+
test file against the root project, because a test needs the Vitest and Node globals the scoped
|
|
506
|
+
projects remove.
|
|
507
|
+
- **Every project the warm builds must resolve.** The type stage's warm builds incremental state
|
|
508
|
+
for the root `tsconfig.json` and each `configs/src/tsconfig.<name>.json` and
|
|
509
|
+
`configs/app/tsconfig.<name>.json` present, because every inspection awaits that warm. A project
|
|
510
|
+
the compiler refuses raises `origin: 'workspace'`, `code: 'malformed'` naming the project, for
|
|
511
|
+
every claim whichever project it names.
|
|
512
|
+
- **The workspace's `typescript`, `oxlint`, and `vitest` are the same resolved files probe
|
|
513
|
+
resolves.** probe reads each of them from the target workspace's `package.json`, never from its
|
|
514
|
+
own dependencies, and reports the resolved versions on `Verdict.toolchain`. A verdict predicts
|
|
515
|
+
the gate only while probe and the gate read one installed copy of each tool.
|
|
516
|
+
|
|
517
|
+
Declare `@orkestrel/probe` as a development dependency of the workspace it inspects. Its tools are
|
|
518
|
+
optional peers, resolved from that workspace when a direct probe is constructed or a server admits
|
|
519
|
+
a `prove` call.
|
|
520
|
+
|
|
521
|
+
## Registering the server
|
|
522
|
+
|
|
523
|
+
The package installs a `probe` binary and publishes the `prove` Model Context Protocol tool over a
|
|
524
|
+
stdio transport. Register the resolved JavaScript entry and run it with the harness's own Node:
|
|
525
|
+
|
|
526
|
+
```json
|
|
527
|
+
{
|
|
528
|
+
"mcpServers": {
|
|
529
|
+
"probe": {
|
|
530
|
+
"command": "node",
|
|
531
|
+
"args": ["node_modules/@orkestrel/probe/dist/bin/main.js"],
|
|
532
|
+
"cwd": "/srv/checkout"
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
Register that entry rather than a global install, an `npx` invocation, or the `node_modules/.bin`
|
|
539
|
+
shim. The shim is a shell script on POSIX hosts and a batch file on Windows, and spawning the
|
|
540
|
+
JavaScript entry with the current executable is the form that survives both.
|
|
541
|
+
|
|
542
|
+
Workspace prerequisite failures from an admitted `prove` call return as an `isError: true` tool
|
|
543
|
+
result. The server keeps serving discovery and later calls, and a later admitted call retries failed
|
|
544
|
+
construction or workspace arming. A failure thrown before the entry creates and starts its server is
|
|
545
|
+
caught, written to stderr in the form `[origin] code: message`, and exits with status 1. Probe
|
|
546
|
+
construction happens only after server startup, so no public input is known to reach that pre-start
|
|
547
|
+
catch and its runtime behavior remains unproved.
|
|
548
|
+
|
|
549
|
+
These facts decide whether a hand-written client works, and each fails silently when it is wrong:
|
|
550
|
+
|
|
551
|
+
- **The transport is newline-delimited JSON.** One JSON-RPC message per line, terminated by `\n`. A
|
|
552
|
+
client that frames requests with `Content-Length` headers — the way a Language Server Protocol
|
|
553
|
+
client does — gets no reply and no error. That framing is correct for this package's _lint stage_,
|
|
554
|
+
which speaks the Language Server Protocol to Oxlint, and wrong for its _server_. Both live in this
|
|
555
|
+
one package, which is how the mistake gets made.
|
|
556
|
+
- **A current-revision request carries reserved `_meta` keys.**
|
|
557
|
+
`io.modelcontextprotocol/protocolVersion` and `io.modelcontextprotocol/clientCapabilities` are
|
|
558
|
+
both required; `io.modelcontextprotocol/clientInfo` is optional and must be a valid identity when
|
|
559
|
+
present. A request carrying the version alone is refused with
|
|
560
|
+
`-32602 Invalid params: malformed modern request metadata`, which reads like a server defect and
|
|
561
|
+
is not. A version the server does not implement is refused differently, with
|
|
562
|
+
`-32022 Unsupported protocol version` and a `data.supported` list; measured on 2026-08-20 that
|
|
563
|
+
list is `2026-07-28`, `2025-11-25`, and `2025-06-18`.
|
|
564
|
+
|
|
565
|
+
The server answers the handshake era and the current revision together, so a client that sends
|
|
566
|
+
`initialize` without `_meta` is served too. `ProbeServer` snapshots the supplied `ProbeOptions` and
|
|
567
|
+
resolves the default or relative workspace when the server is constructed. It creates and caches a
|
|
568
|
+
real probe only after a structurally valid, contained `prove` call is admitted. Concurrent admitted
|
|
569
|
+
calls share its held construction and the probe it produces. `start()` seizes this process's standard
|
|
570
|
+
input and output: a host that starts the server has given the process to it. `destroy()` gives the
|
|
571
|
+
process back and tears down the probe when a call created it. Teardown entered through a construction
|
|
572
|
+
callback waits for that admitted construction and releases its probe. Destruction before an admitted
|
|
573
|
+
call does not arm the workspace.
|
|
574
|
+
|
|
575
|
+
A handshake-era `tools/call` may carry `_meta.progressToken`, including `0` or an empty string.
|
|
576
|
+
The token does not change the verdict or its receipt. Probe emits no progress reports of its own;
|
|
577
|
+
the call still completes with its result when the claim finishes.
|
|
578
|
+
|
|
579
|
+
**A successful `tools/call` answers with the `Verdict` record and its rendered text together.** The
|
|
580
|
+
result carries the record in `structuredContent` and a single `content` entry of `type: 'text'`
|
|
581
|
+
whose text is what `formatVerdict` rendered: the identity, claim, toolchain, project, and reason
|
|
582
|
+
lines, then every case and control stage, then the closing line. That closing line is where the
|
|
583
|
+
receipt lives in the text block, spelled `receipt <token>` when the claim proved itself and
|
|
584
|
+
`no receipt` when it did not, so a client reading the text block reads the outcome from its last
|
|
585
|
+
line rather than inferring it from the rest. A client reading the record reads `verdict.receipt`
|
|
586
|
+
instead: it carries the token itself, and it is absent when the claim was not proven.
|
|
587
|
+
`structuredContent` is the record `prove` returns in this process, unchanged, so a client reads
|
|
588
|
+
`verdict.id`, `verdict.digest`, `verdict.receipt`, and the per-stage `elapsed` values as data
|
|
589
|
+
rather than off the prose. The `@orkestrel/mcp` client's outcome carries that record alone and
|
|
590
|
+
drops the content blocks, so a caller of that client who wants the rendered form calls
|
|
591
|
+
`formatVerdict` from `@orkestrel/probe` on the record, or reads the text block off the raw wire.
|
|
592
|
+
|
|
593
|
+
**The text block carries `formatVerdict`'s prose rather than the record's serialized JSON, which
|
|
594
|
+
departs from the specification's recommendation.** The tools specification recommends that a tool
|
|
595
|
+
returning structured content also return the serialized JSON in a text content block. The receipt's
|
|
596
|
+
closing line has to stay quotable verbatim, so the text block keeps the rendered form and the record
|
|
597
|
+
travels beside it in `structuredContent`. A client that wants the serialized JSON serializes
|
|
598
|
+
`structuredContent` itself.
|
|
599
|
+
|
|
600
|
+
**The server publishes a key bound above the `@orkestrel/mcp` default, because a verdict's breadth
|
|
601
|
+
is the claimant's.** That package bounds a produced tool-call result by its total enumerable key
|
|
602
|
+
count as well as by its bytes, and applies one bound to inbound metadata and to produced tool
|
|
603
|
+
content alike; its default leaf is sized for metadata. Measured on 2026-08-27 against
|
|
604
|
+
`@orkestrel/mcp` 0.0.25, over the `Verdict` shape this page documents, a verdict costs 38 keys with
|
|
605
|
+
no issues and 11 more for each issue a stage reports, so under the default a verdict whose control
|
|
606
|
+
refuses one declaration travels and the next
|
|
607
|
+
one does not — and it fails by replacing the whole answer with `-32603 Server execution returned an
|
|
608
|
+
invalid tool result` rather than by dropping the record alone. A whole result carrying the record
|
|
609
|
+
beside its rendering costs 44 keys plus 11 for each issue, so the published bound of 4096 keys
|
|
610
|
+
carries a record reporting up to 368 issues. That bound also widens the inbound `_meta` key bound,
|
|
611
|
+
deliberately: bytes and depth still bind there, the 16 KiB metadata byte limit is unchanged, and
|
|
612
|
+
the process on the other end of a stdio transport is the harness that spawned this one.
|
|
613
|
+
|
|
614
|
+
**The reply falls back rather than failing, and the receipt answers at every size.** The server
|
|
615
|
+
admits each answer against those bounds before returning it and takes the widest one they admit.
|
|
616
|
+
Past 368 issues the record is refused and the result carries the rendered text alone. Past the
|
|
617
|
+
4 MiB content bound the rendered text is refused too, and the result carries what `formatReceipt`
|
|
618
|
+
renders instead: the identity and claim lines, the reason when present, and the same closing
|
|
619
|
+
receipt line. A client therefore reads the outcome off the last line of the text block whichever
|
|
620
|
+
answer arrived.
|
|
621
|
+
|
|
622
|
+
**The `@orkestrel/mcp` stdio client drives claims through this entry.** It spawns the shipped
|
|
623
|
+
`dist/bin/main.js`, negotiates the era itself, lists `prove`, and hands back the record described
|
|
624
|
+
earlier. [`main.test.ts`](../tests/src/bin/main.test.ts) runs that round trip against the built entry
|
|
625
|
+
on the current revision and through the legacy projection. A real Codex 0.153.4 app-server in the
|
|
626
|
+
Scaffold workspace also started this entry and discovered `prove` in 575.8829 ms under its 30-second
|
|
627
|
+
deadline. That reading stopped at discovery: Codex did not call `prove`, and the entry was not read
|
|
628
|
+
from an installed tarball. Treat Codex claim execution and installed-package use as untested. The
|
|
629
|
+
transport facts stated earlier were established against this repository's own hand-written line
|
|
630
|
+
client, and the driven MCP client meets them too.
|
|
631
|
+
|
|
632
|
+
**The advertised schema is wider than the admission rule, at `Draft.path`.** The `prove` tool
|
|
633
|
+
publishes `compileSchema(CLAIM_SHAPE)` and admits a call with `isClaim`, and the two agree on every
|
|
634
|
+
member but `Draft.path`: the schema constrains it to a non-empty string, while the guard also
|
|
635
|
+
refuses an absolute path and one that traverses out of the workspace. No JSON Schema keyword
|
|
636
|
+
expresses that rule, so a claim naming `../../etc/hosts` satisfies the advertised parameters and is
|
|
637
|
+
refused. The refusal names the members it read — `The prove tool refuses case.files.0.path: a draft
|
|
638
|
+
path must stay inside the workspace, which the advertised schema does not constrain` — so a client
|
|
639
|
+
that satisfied the schema is told which path to change rather than that its claim was invalid.
|
|
640
|
+
|
|
641
|
+
## The claim that earns a receipt
|
|
642
|
+
|
|
643
|
+
This claim earns a receipt in this workspace. Run it verbatim.
|
|
644
|
+
|
|
645
|
+
```ts
|
|
646
|
+
import type { Claim } from '@orkestrel/probe'
|
|
647
|
+
import { Probe } from '@orkestrel/probe/server'
|
|
648
|
+
|
|
649
|
+
const claim: Claim = {
|
|
650
|
+
project: 'configs/src/tsconfig.core.json',
|
|
651
|
+
case: {
|
|
652
|
+
files: [
|
|
653
|
+
{
|
|
654
|
+
path: 'src/core/factories.ts',
|
|
655
|
+
text: "export function createGreeting(): string {\n\treturn 'hi'\n}\n",
|
|
656
|
+
},
|
|
657
|
+
],
|
|
658
|
+
test: {
|
|
659
|
+
path: 'tmp/probe/greeting.test.ts',
|
|
660
|
+
text: "import { expect, test } from 'vitest'\nimport { createGreeting } from '../../src/core/factories.js'\ntest('greets', () => expect(createGreeting()).toBe('hi'))\n",
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
control: {
|
|
664
|
+
files: [
|
|
665
|
+
{
|
|
666
|
+
path: 'src/core/factories.ts',
|
|
667
|
+
text: "export function createGreeting(): number {\n\treturn 'hi'\n}\n",
|
|
668
|
+
},
|
|
669
|
+
],
|
|
670
|
+
test: {
|
|
671
|
+
path: 'tmp/probe/greeting.test.ts',
|
|
672
|
+
text: "import { expect, test } from 'vitest'\nimport { createGreeting } from '../../src/core/factories.js'\ntest('greets', () => expect(createGreeting()).toBe('hi'))\n",
|
|
673
|
+
},
|
|
674
|
+
stage: 'type',
|
|
675
|
+
reason: 'a string returned as a number must not compile',
|
|
676
|
+
},
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const probe = new Probe({ workspace: process.cwd() })
|
|
680
|
+
const verdict = await probe.prove(claim)
|
|
681
|
+
verdict.digest // 'fcb88a2dee987b8673c1fc7107979470'
|
|
682
|
+
verdict.receipt // 'probe:fcb88a2dee987b8673c1fc7107979470:type:typescript@6.0.3:oxlint@1.83.0:vitest@4.1.11:configs/src/tsconfig.core.json@434f59254d58cf2683d453a26bd0d837'
|
|
683
|
+
await probe.destroy()
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
To inspect only the runtime stage, pass the case from the preceding claim directly to `RuntimeStage`.
|
|
687
|
+
This runs no type or lint stage and does not issue a `Probe` receipt.
|
|
688
|
+
|
|
689
|
+
```ts
|
|
690
|
+
import { RuntimeStage } from '@orkestrel/probe/server'
|
|
691
|
+
|
|
692
|
+
const runtime = new RuntimeStage(process.cwd())
|
|
693
|
+
try {
|
|
694
|
+
const check = await runtime.inspect(claim.case)
|
|
695
|
+
check.stage // 'runtime'
|
|
696
|
+
check.issues // []
|
|
697
|
+
} finally {
|
|
698
|
+
await runtime.destroy()
|
|
699
|
+
}
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
The full Probe example depends on these details:
|
|
703
|
+
|
|
704
|
+
- **The candidate file lives under `src/`.** It is checked against `configs/src/tsconfig.core.json`,
|
|
705
|
+
the same scoped project the workspace's own `check:src:core` script runs.
|
|
706
|
+
- **The candidate satisfies the target workspace's own lint rules.** The case runs through the lint
|
|
707
|
+
stage as well as the type stage, so a candidate the workspace's Oxlint configuration refuses earns
|
|
708
|
+
no receipt however cleanly it compiles. This repository's policy plugin admits a module function
|
|
709
|
+
only in a function-kind file, which is why the candidate is `src/core/factories.ts` rather than a
|
|
710
|
+
name of its own. Read the target's rules before you choose a candidate path.
|
|
711
|
+
- **The control differs from the case.** `prove` compares the control against the case byte for
|
|
712
|
+
byte — every candidate draft, paired by position, and the test — and refuses a control that
|
|
713
|
+
repeats all of them, with `origin: 'claimant'` and `code: 'refused'`, before any stage inspects
|
|
714
|
+
the claim. A control byte-identical to its case cannot break, so it never produces the
|
|
715
|
+
`origin: 'claimant'` issue a receipt requires, and the only receipt it could earn is one
|
|
716
|
+
nondeterminism minted for a falsification that never happened. The comparison reads the bytes, so
|
|
717
|
+
varying the control's `stage` or its `reason` alone does not admit it.
|
|
718
|
+
- **The test imports the candidate through a relative specifier.** The runtime stage serves the
|
|
719
|
+
candidate's text at the path the claim declared, so `../../src/core/factories.js` resolves to the
|
|
720
|
+
supplied text rather than to a file on disk.
|
|
721
|
+
- **The test imports `test` and `expect` from `vitest`, and asserts.** A bare `test(...)` fails at
|
|
722
|
+
runtime with `test is not defined`, and at a version-controlled path a body that asserts nothing
|
|
723
|
+
adds the lint issue `Test has no assertions`. Both are charged to the claim.
|
|
724
|
+
|
|
725
|
+
This claim carries no absolute string, so `verdict.digest` is the same in any workspace that runs
|
|
726
|
+
it. Change the control's `reason` and the digest changes with it, because the reason is part of the
|
|
727
|
+
control the digest covers. The tool versions and the project digest in the receipt are this
|
|
728
|
+
workspace's, and `tests/guides.test.ts` re-runs this claim and asserts the token this page carries.
|
|
729
|
+
|
|
730
|
+
## Reading a receipt
|
|
731
|
+
|
|
732
|
+
A receipt is a `RECEIPT_SEPARATOR`-separated token with this grammar:
|
|
733
|
+
|
|
734
|
+
```text
|
|
735
|
+
<prefix>:<digest>:<stage>:typescript@<version>:oxlint@<version>:vitest@<version>:<project>@<options>
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
- `<prefix>` is the value of `RECEIPT_PREFIX`, so a token found away from its verdict names itself.
|
|
739
|
+
- `<digest>` is the claim digest: the case and the control the verdict answered, read against this
|
|
740
|
+
workspace.
|
|
741
|
+
- `<stage>` is the stage the control declared and broke at.
|
|
742
|
+
- A tool field per resolved tool follows, spelled `<name>@<version>`, in the order `typescript`,
|
|
743
|
+
`oxlint`, `vitest`.
|
|
744
|
+
- `<project>@<options>` closes the token: the workspace-relative TypeScript project that judged the
|
|
745
|
+
candidates, and the digest of the compiler options it resolved to.
|
|
746
|
+
|
|
747
|
+
**Parse the project field as the remainder, not as another split.** A workspace-relative project
|
|
748
|
+
path may contain `:` and `@`. Split on `RECEIPT_SEPARATOR`, read the prefix, the digest, the stage,
|
|
749
|
+
and the tool fields as one field each, rejoin everything after the `vitest` field with that
|
|
750
|
+
separator, and read `<options>` as everything after that remainder's closing `@`. That rule stays
|
|
751
|
+
total for a project path containing either character.
|
|
752
|
+
|
|
753
|
+
The call's identity is deliberately absent from the token. It carries no integrity, and it is the
|
|
754
|
+
only value that would stop two honest runs of one claim from producing one comparable string.
|
|
755
|
+
|
|
756
|
+
**Verify a receipt by recomputation, or by re-running the claim.** Recompute it, holding the claim
|
|
757
|
+
and the workspace, by reading the digests the verdict carries. Or re-run `prove` over the same claim
|
|
758
|
+
and compare the two strings byte for byte: two runs of one claim in one workspace produce one token.
|
|
759
|
+
|
|
760
|
+
**probe holds no key.** The token is a function of public inputs, so anyone can type a well-formed
|
|
761
|
+
receipt. It is a statement of the conditions a verdict was reached under, not an authenticator, and
|
|
762
|
+
it is worth exactly what the reader's own recomputation is worth.
|
|
763
|
+
|
|
764
|
+
**probe executes caller-supplied test code with the host's privileges.** The runtime stage writes
|
|
765
|
+
the claim's test to a real file in the target workspace and runs it through the workspace's Vitest.
|
|
766
|
+
That code can read and write the checkout, open sockets, and reach the network. A receipt says
|
|
767
|
+
nothing about what the test did while earning it. Each specification runs in its own Vitest worker,
|
|
768
|
+
so one claim's module state, globals, and environment do not reach the next; nothing about that
|
|
769
|
+
worker contains a filesystem write, a loopback bind, or an outbound request.
|
|
770
|
+
|
|
771
|
+
## What a receipt does not vouch for
|
|
772
|
+
|
|
773
|
+
`Claim.project` is the one configuration input the caller chooses, which is why the receipt records
|
|
774
|
+
its resolved path and the digest of its compiler options. A receipt minted under a permissive
|
|
775
|
+
project names that project, so a reader comparing it against the gate's own project refuses it on
|
|
776
|
+
sight.
|
|
777
|
+
|
|
778
|
+
These configurations remain outside the token, and no receipt vouches for them:
|
|
779
|
+
|
|
780
|
+
- `.oxlintrc.json`, which the lint stage reads;
|
|
781
|
+
- `vite.config.ts`, which the runtime stage reads;
|
|
782
|
+
- the root `tsconfig.json`, against which the claim's test file is checked.
|
|
783
|
+
|
|
784
|
+
These are the same files the workspace's own `lint:check`, `test`, and `check` scripts read. A
|
|
785
|
+
caller that weakens one has defeated the gate itself, and no receipt vouches for a workspace against
|
|
786
|
+
itself.
|
|
787
|
+
|
|
788
|
+
The project digest is the digest of the `compilerOptions` member of `tsc --showConfig -p <project>`,
|
|
789
|
+
read against the mirrored copy of the project with the mirror as the current directory, so the
|
|
790
|
+
digest and the check read one set of files. `computeDigest` canonicalizes the record: a relative
|
|
791
|
+
path the project declares that escapes the workspace root resolves against the mirror's own
|
|
792
|
+
ancestors, and the mirror carries nothing such a path names. It still **moves with the TypeScript
|
|
793
|
+
version**, because a compiler decides which options it resolves and how it spells them. That is
|
|
794
|
+
contained rather than surprising: the token already names `typescript@<version>`, so any policy
|
|
795
|
+
pinning a digest already pins the version.
|
|
796
|
+
|
|
797
|
+
Further limits belong beside those:
|
|
798
|
+
|
|
799
|
+
- **A control need not be a mutation of its case, and probe applies no relatedness rule.** `Control`
|
|
800
|
+
carries its own `files` and `test`, so a caller can pair a clean case with unrelated broken code
|
|
801
|
+
and satisfy every receipt condition. Any approximation of relatedness strict enough to catch that
|
|
802
|
+
pairing also refuses controls this package deliberately admits, so none is applied. `prove`
|
|
803
|
+
refuses only a control repeating the whole case byte for byte, and that refusal answers
|
|
804
|
+
nondeterminism rather than relatedness. **Judging a control against its case is the reader's
|
|
805
|
+
obligation.** The claim digest binds the case and the control together, so the pairing a
|
|
806
|
+
token was minted over is there to be read.
|
|
807
|
+
- **The overlay is the only thing that serves a candidate's bytes to the runtime stage.** A Vite
|
|
808
|
+
filesystem module cache that answered a covered path from disk would run the file the workspace
|
|
809
|
+
holds rather than the candidate the claim supplied. Measured on 2026-08-24, the string
|
|
810
|
+
`fsModuleCache` appears nowhere in the installed `vite@8.2.2` tree, so there is no such option to
|
|
811
|
+
set and none to defeat. The standing guard is the runtime stage's serve detection rather than a
|
|
812
|
+
version pin: a covered module reachable from the generated specification that the overlay never
|
|
813
|
+
served reports an issue, whatever served it instead. See
|
|
814
|
+
[What the runtime overlay serves](#what-the-runtime-overlay-serves).
|
|
815
|
+
- **Write and delete containment does not bound reads.** TypeScript and Oxlint can inspect files
|
|
816
|
+
outside the workspace through a symlinked candidate path, and a contained `Claim.project` can
|
|
817
|
+
reach outside through `extends`, `files`, `include`, or project references. A receipt does not
|
|
818
|
+
vouch that those reads stayed inside the workspace.
|
|
819
|
+
|
|
820
|
+
That split is deliberate rather than accidental, and § What containment reaches states it as a rule
|
|
821
|
+
rather than as a limit on the token.
|
|
822
|
+
|
|
823
|
+
## What containment reaches
|
|
824
|
+
|
|
825
|
+
Containment is not one rule, and the difference between its rules decides what a hostile claim can
|
|
826
|
+
do.
|
|
827
|
+
|
|
828
|
+
**Every path a claim carries is contained lexically.** `isDraft` refuses an absolute `Draft.path`
|
|
829
|
+
and one that traverses out of the workspace, and `resolveWorkspaceFile` refuses the same shapes
|
|
830
|
+
again when the stage resolves it. `Claim.project` passes the same rule.
|
|
831
|
+
|
|
832
|
+
**A write or a delete is contained physically as well.** Before probe creates a directory, writes a
|
|
833
|
+
generated specification, writes a boot dependency, or unlinks one, it walks the path's existing
|
|
834
|
+
components and refuses a symbolic link at any of them with
|
|
835
|
+
`Path crosses a symbolic link: <path>`; it then refuses any component whose resolved path leaves
|
|
836
|
+
the workspace. The symbolic-link refusal is `origin: 'workspace'`, `code: 'refused'`, because the
|
|
837
|
+
link belongs to the target tree. A native fault while inspecting an existing component is
|
|
838
|
+
`origin: 'workspace'`, `code: 'malformed'`, and retains that fault on `cause`.
|
|
839
|
+
|
|
840
|
+
The walk and the write that follows it are separate calls, so a concurrent process can move a
|
|
841
|
+
component between them. What that reaches is not uniform, and the difference is worth stating
|
|
842
|
+
exactly. **Exclusive creation and final-component removal are closed.** probe creates every file
|
|
843
|
+
it puts in a target with the `wx` flag, which fails rather than following a symbolic link or
|
|
844
|
+
overwriting a file that appeared after the walk, and an unlink names the final component itself
|
|
845
|
+
rather than what it points at. **An overwrite refuses symbolic-link and gone-file swaps.** A boot
|
|
846
|
+
dependency is overwritten through a descriptor opened `O_WRONLY | O_NOFOLLOW` and truncated
|
|
847
|
+
through that descriptor — a Windows host refuses the numeric `O_TRUNC` without `O_CREAT` as
|
|
848
|
+
`EINVAL`, and the descriptor reaches the file the open bound — which fails on a symbolic link
|
|
849
|
+
standing at that component and on a target that has gone since the walk saw it — while
|
|
850
|
+
**hard-link aliasing remains open**: a regular file swapped for a hard link to a
|
|
851
|
+
same-filesystem file outside the workspace passes that flag set, because `O_NOFOLLOW` refuses
|
|
852
|
+
symbolic links, not hard-linked inodes. Where a host's Node build defines no `O_NOFOLLOW`, that
|
|
853
|
+
flag contributes nothing to the flag set and an overwrite there follows a link the walk did not
|
|
854
|
+
see. **A directory component is open.** A directory swapped for a symbolic link after the walk
|
|
855
|
+
redirects the create, and closing that needs a traversal pinned to file descriptors: Node exposes
|
|
856
|
+
`O_NOFOLLOW` and no descriptor-relative call to apply it through, so this package cannot walk and
|
|
857
|
+
write through one set of descriptors. Read physical containment as covering the claim inputs and
|
|
858
|
+
the target tree as the walk inspected it, plus what the closed set holds at the moment probe
|
|
859
|
+
writes or unlinks a final component.
|
|
860
|
+
|
|
861
|
+
**The type stage writes only into its own mirror.** Every candidate draft it reads is written under
|
|
862
|
+
`TYPE_MIRROR`, at the mirrored spelling of the path the draft declared, so a draft never reaches the
|
|
863
|
+
path it names in the target tree and a draft naming a path the workspace does not hold leaves
|
|
864
|
+
nothing behind. The mirror is refreshed from the workspace before each inspection and released with
|
|
865
|
+
the stage, and the mirror is this stage's own tree: where the workspace holds a file at a path a
|
|
866
|
+
draft declares a directory under, the mirrored copy of that file is removed so the draft can be
|
|
867
|
+
written, and the next refresh restores it.
|
|
868
|
+
|
|
869
|
+
**A read is contained lexically only, and that is the reach to plan for.** The mirror carries
|
|
870
|
+
regular files inside the workspace only: a symbolic link is not carried, so a file reached only
|
|
871
|
+
through one is absent from the mirror and the compiler reports what its absence causes, as a
|
|
872
|
+
claimant issue like any other. A candidate path beneath an in-workspace symbolic link resolves to a
|
|
873
|
+
file outside the workspace, and Oxlint, which reads the workspace directly rather than a mirror of
|
|
874
|
+
it, inspects it there. A `Claim.project` that reaches outside the workspace through `extends`,
|
|
875
|
+
`files`, `include`, or project references reaches nothing there: the type stage reads a project
|
|
876
|
+
against the mirror, so an escaping relative path resolves against the mirror's own ancestors, and
|
|
877
|
+
the mirror carries nothing such a path names.
|
|
878
|
+
|
|
879
|
+
Measured on 2026-08-20: with `link` a symbolic link to a directory outside the workspace,
|
|
880
|
+
`isDraft({ path: 'link/secret.ts', text })` returns `true`,
|
|
881
|
+
`resolveWorkspaceFile(workspace, 'link/secret.ts')` returns the contained spelling whose real path
|
|
882
|
+
is outside the workspace, and the mutating form of the same call throws a `ProbeError` carrying
|
|
883
|
+
`origin: 'workspace'`, `code: 'refused'`, and
|
|
884
|
+
`Path crosses a symbolic link: link/secret.ts`. A contained `tsconfig.json` whose `extends` names an
|
|
885
|
+
absolute path outside the workspace resolved to a different options digest from the same project
|
|
886
|
+
without it, so the outside file was read.
|
|
887
|
+
|
|
888
|
+
Read this as the reason the front of this guide gives a probe a workspace and a caller you already
|
|
889
|
+
trust with a shell, rather than as a hole to work around. A caller that can supply a claim can
|
|
890
|
+
already supply test code the runtime stage runs.
|
|
891
|
+
|
|
892
|
+
## What the lint stage does not see
|
|
893
|
+
|
|
894
|
+
**A path the workspace's version-control ignore excludes is a path the lint stage reports nothing
|
|
895
|
+
for.** Oxlint's language server honours `.gitignore`, and it does so for text supplied from memory
|
|
896
|
+
exactly as it does for a file on disk. The stage reports a clean check, not a skipped one.
|
|
897
|
+
|
|
898
|
+
This reaches the flagship claim stated earlier: its test lives at `tmp/probe/greeting.test.ts`, and
|
|
899
|
+
`tmp` is ignored in this workspace, so the lint stage inspects the candidate `src/core/factories.ts`
|
|
900
|
+
and reports nothing about the test. Measured on 2026-08-20: the same three-line text carrying an
|
|
901
|
+
unused binding and a `debugger` statement returns 0 issues at `tmp/probe/lint-ignored.test.ts` and 2
|
|
902
|
+
issues at `tests/src/core/lint-tracked.test.ts`.
|
|
903
|
+
|
|
904
|
+
`.gitignore` alone causes this: `tmp` appears there and in no other ignore file this workspace
|
|
905
|
+
carries. Put every candidate draft you want linted at a path version control tracks.
|
|
906
|
+
|
|
907
|
+
## How the lint stage speaks the protocol
|
|
908
|
+
|
|
909
|
+
The lint stage owns the workspace, the candidate's identity, and the projection from a diagnostic to
|
|
910
|
+
an `Issue`. `@orkestrel/lsp` owns everything between them, and the hookup is fixed:
|
|
911
|
+
|
|
912
|
+
- **The transport is `createStdioClientTransport` from `@orkestrel/lsp/server`.** Its command vector
|
|
913
|
+
is the current executable, the entry `resolveWorkspaceBinary` resolves for `oxlint` in the target
|
|
914
|
+
workspace, and `--lsp`; its directory is that workspace. The child is therefore the workspace's
|
|
915
|
+
own installed Oxlint entry rather than a `node_modules/.bin` shim, for the reason the
|
|
916
|
+
**Prerequisites** section earlier gives.
|
|
917
|
+
- **The client is `createLSPClient` from `@orkestrel/lsp`,** over that transport, with the
|
|
918
|
+
workspace's `file://` URL as its `workspace` option and a 2 s `timeout` option. That option bounds
|
|
919
|
+
the `initialize` and `shutdown` exchanges and the destroy-time settlement, and it does not reach
|
|
920
|
+
the diagnostics an inspection waits for. The transport's `grace` option is 1 s, half that
|
|
921
|
+
deadline, so a child that ignores its ending is signalled and released inside the client's own
|
|
922
|
+
wait for the close.
|
|
923
|
+
- **Each candidate reaches the server through the `open` method.** The stage supplies the URL the
|
|
924
|
+
declared path names, the language identifier `inferDocumentLanguage` selects for that path, the
|
|
925
|
+
candidate's text, and the signal its caller supplied, then closes the document. Nothing is written
|
|
926
|
+
to disk. That signal is what bounds the diagnostics wait, so the stage mints no bound of its own
|
|
927
|
+
for it: a second bound would race the caller's, and which one answered would depend on scheduling.
|
|
928
|
+
`Probe` passes the deadline it already armed for the inspection, so one budget covers the wait and
|
|
929
|
+
reports the overrun.
|
|
930
|
+
- **The published span reaches `Issue.range` unconverted.** The client advertises UTF-16 positions
|
|
931
|
+
and the protocol numbers lines and characters from zero, which is the coordinate basis
|
|
932
|
+
`Issue.range` stores, so this stage copies each coordinate rather than adjusting one. The type
|
|
933
|
+
stage lowers the compiler's one-based line and one-based UTF-16 column by one each, and the
|
|
934
|
+
runtime stage lowers a Vitest frame's line by one because that frame numbers from one.
|
|
935
|
+
`formatIssue` is the only place the one-based line a reader opens is derived.
|
|
936
|
+
|
|
937
|
+
Each limit that split produces is the client's decision rather than this package's:
|
|
938
|
+
|
|
939
|
+
- **The client declares the capabilities and selects the diagnostics path.** It advertises UTF-16
|
|
940
|
+
positions, document synchronization, published diagnostics, and pulled diagnostics, then reads the
|
|
941
|
+
server's `initialize` result: a server declaring `diagnosticProvider` is pulled, and every other
|
|
942
|
+
server is awaited for its published notification. The stage declares nothing and selects nothing,
|
|
943
|
+
so a change in that selection reaches this stage through the package rather than through a switch
|
|
944
|
+
here. Measured on 2026-08-26, Oxlint's language server reports version 1.80.0, declares
|
|
945
|
+
`textDocumentSync` with `openClose`, and declares no `diagnosticProvider`, so its diagnostics
|
|
946
|
+
arrive on the published path.
|
|
947
|
+
- **A server that declares no `openClose` synchronization admits no candidate at all.** The client
|
|
948
|
+
refuses the open before any text reaches that server, and the inspection reports the refusal as a
|
|
949
|
+
stage fault.
|
|
950
|
+
- **A published diagnostic the client refuses reaches no `Issue`.** The client validates every
|
|
951
|
+
diagnostic in a notification and drops the whole notification when one fails, so that inspection
|
|
952
|
+
waits out the caller's bound and reports the stop rather than returning a partial answer.
|
|
953
|
+
|
|
954
|
+
## What the runtime overlay serves
|
|
955
|
+
|
|
956
|
+
The runtime stage does not execute the test the claim declared. It writes that text to a fresh
|
|
957
|
+
sibling file and runs the sibling, and it installs the claim's `Case.files`, and only those, in the
|
|
958
|
+
overlay a Vite plugin reads. The type stage differs: it records the declared test at its declared
|
|
959
|
+
path alongside every candidate draft, and checks the text there. So the file one stage checks and
|
|
960
|
+
the file the other executes are not the same file, and a test that reads its own location sees the
|
|
961
|
+
generated sibling — which is why the runtime stage rewrites that name out of every message it
|
|
962
|
+
reports.
|
|
963
|
+
|
|
964
|
+
**A query is stripped for the lookup and kept for the transform.** The overlay is keyed by path, and
|
|
965
|
+
a Vite id carries its transform selectors after the first `?`. Resolution cuts the id at that
|
|
966
|
+
character, looks the path up in the overlay, and hands the suffix back on the id it returns; loading
|
|
967
|
+
cuts the same way and serves the candidate's text. So `../../src/value.ts?v=123` imports the
|
|
968
|
+
candidate's module and `../../src/value.ts?raw` imports the same candidate's text as a default
|
|
969
|
+
export, and every selector the importer wrote reaches whichever plugin owns it.
|
|
970
|
+
|
|
971
|
+
**A bare specifier is Vite's to resolve.** The overlay's resolver declines a specifier that is
|
|
972
|
+
neither relative nor absolute rather than guessing where the workspace would place it, so Vite
|
|
973
|
+
resolves it under the workspace's own configuration. The overlay's loader still runs first on the id
|
|
974
|
+
that resolution produced, so a bare import landing on a covered path reads the candidate's bytes.
|
|
975
|
+
This package holds no second copy of that resolver.
|
|
976
|
+
|
|
977
|
+
**A covered module served by anything else is reported rather than passed over.** After the run, the
|
|
978
|
+
stage takes each covered path its own loader never served and asks whether the generated
|
|
979
|
+
specification's module graph reaches that path through importers. One that is reachable was served
|
|
980
|
+
by something other than the overlay, and the party follows how far the id travelled. A loader of
|
|
981
|
+
probe's that never received the id means the workspace's own configuration answered first, reported
|
|
982
|
+
as `The workspace configuration served this module before the runtime overlay` with
|
|
983
|
+
`origin: 'workspace'`. A loader of probe's that received the id and did not match it is this
|
|
984
|
+
package's own resolution missing, reported as `The runtime overlay did not resolve this module` with
|
|
985
|
+
`origin: 'instrument'`. Reachability is what bounds the reading to this run: a resident runner keeps
|
|
986
|
+
module nodes from earlier inspections, and membership alone would report a candidate this claim
|
|
987
|
+
never imported.
|
|
988
|
+
|
|
989
|
+
## Lifecycle
|
|
990
|
+
|
|
991
|
+
A probe has no `start`. Warming begins at construction and `prove` awaits it, because the harness
|
|
992
|
+
owns the process: a restart is a new process rather than a second lifecycle, and a second client is
|
|
993
|
+
a second process with its own stages. `ProbeServer` defers that real probe construction until an
|
|
994
|
+
admitted call. `ProbeServer.start` is the transport's verb rather
|
|
995
|
+
than the probe's — it decides which process reads the stdio, not when the stages warm.
|
|
996
|
+
|
|
997
|
+
- **Arming.** Construction runs boot controls that mutate an imported dependency and refuse
|
|
998
|
+
service unless the type and runtime stages report the change. The `arm` event fires after those
|
|
999
|
+
controls have reported red and the boot's own files are gone. An attempt that rejects fires
|
|
1000
|
+
`error` instead, carrying the arming refusal as the attempt raises it, so a host waiting on `arm`
|
|
1001
|
+
reads the refusal rather than an event that never arrives. The attempt is still retained for
|
|
1002
|
+
retry, so each attempt surfaces its own `error` and no `prove` reports one refusal twice. The
|
|
1003
|
+
controls run under `tmp/probe/` against the root `tsconfig.json`, which is why the Vitest project,
|
|
1004
|
+
its composition in the root configuration, and a `tmp/probe/` the host lets it create gate the
|
|
1005
|
+
boot rather than a claim.
|
|
1006
|
+
- **Freshness.** Every `prove` revalidates before it answers. The runtime stage re-reads each
|
|
1007
|
+
workspace module and invalidates the ones whose contents moved; the type stage refreshes its
|
|
1008
|
+
mirror of the workspace by content digest, copying a file whose contents moved and removing the
|
|
1009
|
+
copy of a file the workspace deleted. A warm service that skipped this would return a confident
|
|
1010
|
+
wrong answer about freshly edited source.
|
|
1011
|
+
- **Configuration is read once per stage, not per claim.** Freshness covers source, and it does not
|
|
1012
|
+
cover the configuration a stage's own tool was built around. The type stage reads
|
|
1013
|
+
`tsc --showConfig` once per project and keys that reading by resolved project path, so a
|
|
1014
|
+
`tsconfig.json` edited after that reading does not change the compiler options the stage applies
|
|
1015
|
+
or the project digest it reports. A project that declares its own `include` still re-expands on
|
|
1016
|
+
every run, because the scratch project each run reads extends the target's own project file; a
|
|
1017
|
+
project that declares neither `include` nor `files` keeps the selection that reading printed.
|
|
1018
|
+
Oxlint's language server and the resident Vitest hold their own configuration the same way. So a
|
|
1019
|
+
receipt is read against the configuration the stage was built around. Destroy the probe and build
|
|
1020
|
+
another after you edit `tsconfig.json`, `.oxlintrc.json`, or `vite.config.ts`.
|
|
1021
|
+
- **A failed warm is not permanent.** The runtime stage holds its resident Vitest in a slot it
|
|
1022
|
+
clears when that warm rejects, so the fault reaches the caller as the target tree's own —
|
|
1023
|
+
`origin: 'workspace'`, `code: 'malformed'`, naming `vite.config.ts` in `context` — rather than
|
|
1024
|
+
being masked by an aging resident runner. The next `inspect` finds the slot empty and warms fresh,
|
|
1025
|
+
reading the configuration again, so a workspace repaired after the failed call serves the call
|
|
1026
|
+
that follows it.
|
|
1027
|
+
One call never loops through a second warm of its own, and no failure leaves the stage permanently
|
|
1028
|
+
refusing. This is a recovery path rather than a reload: a warm that succeeded is kept, so the
|
|
1029
|
+
preceding entry's rule about editing `vite.config.ts` stands.
|
|
1030
|
+
- **Admission.** One queue per stage admits inspections in arrival order, one at a time. The
|
|
1031
|
+
`deadline` covers active work rather than queue wait. Caller-named project resolution shares that
|
|
1032
|
+
order with type inspections, so a resolve never runs partway through one inspection's own
|
|
1033
|
+
candidate checks.
|
|
1034
|
+
- **Expiry.** `ProbeOptions.deadline` is the coordinator's budget for one active stage inspection,
|
|
1035
|
+
and it lives outside the worker because a Vitest `testTimeout` cannot fire while a synchronous
|
|
1036
|
+
loop blocks that worker. An expiry at any stage abandons that stage, replaces it before the next
|
|
1037
|
+
queued inspection begins, and emits `expire` with the claim that expired. A failed boot is
|
|
1038
|
+
replaced the same way: the next claim runs the controls again rather than inheriting a refusal.
|
|
1039
|
+
- **The budget covers the warm.** Every stage's inspection awaits that stage's warm, and the type
|
|
1040
|
+
stage's warm builds each declared project's incremental state before the first inspection answers.
|
|
1041
|
+
So a `deadline` under that warm expires arming rather than any claim, and the probe never arms.
|
|
1042
|
+
Size `deadline` above the § Cost reading for the target tree, and leave room for a contended host.
|
|
1043
|
+
No stage holds the host's loop: the compiler, the language server, and the test runner each work
|
|
1044
|
+
in a child process or a worker, so the deadline fires on time and terminates the work it bounds.
|
|
1045
|
+
- **Revisions.** Each runtime inspection writes its specification at a fresh path and never reuses
|
|
1046
|
+
one, because a resident runner asked to re-run a path it has already seen reports a false pass.
|
|
1047
|
+
One inspection in every 64 also replaces the resident runner, and that inspection costs more than
|
|
1048
|
+
the other 63 — budget `deadline` against that one rather than the common one. That fresh path
|
|
1049
|
+
never reaches a caller: a test that reads its own filename, through `import.meta.url` or through a
|
|
1050
|
+
frame in a failure it raised, reports the path the claim declared, because the stage rewrites the
|
|
1051
|
+
exact basename it generated back to the declared test's basename in every message it reports.
|
|
1052
|
+
- **Teardown.** `destroy()` releases every resident process and is idempotent. It releases the
|
|
1053
|
+
emitter last, and releases it on a teardown that failed too, so a listener registered through
|
|
1054
|
+
`ProbeOptions.on` or through `probe.emitter` receives nothing after teardown settles and
|
|
1055
|
+
`probe.emitter.destroyed` reads true. A refusal a later `prove` raises still reaches the caller
|
|
1056
|
+
that asked for it, and reaches no listener. `ProbeServer.destroy`
|
|
1057
|
+
adds the process itself: it removes the listeners `start` attached — the `data`, `close`, and
|
|
1058
|
+
`error` forwarders on standard input, and the `SIGINT` and `SIGTERM` handlers on the process —
|
|
1059
|
+
and pauses the stream unless `start` found it already flowing, so the
|
|
1060
|
+
event loop drains and the process exits 0 with no explicit exit call. A stream nobody has read yet
|
|
1061
|
+
is neither flowing nor paused, and this server is what sets it flowing, so it is paused. A host
|
|
1062
|
+
already reading its own standard input keeps reading it after the server it embedded is destroyed.
|
|
1063
|
+
- **The server removes only what it added.** Every listener `ProbeServer` attaches is held as a
|
|
1064
|
+
field and removed by reference, so a listener a host registers while the server is serving is
|
|
1065
|
+
still attached and still fires afterwards. Nothing is chosen by being absent from a capture,
|
|
1066
|
+
because a capture cannot tell a listener the server added from one the host added later. The
|
|
1067
|
+
transport is what makes that reachable: it reads a stream the server owns rather than this
|
|
1068
|
+
process's standard input, so its own listeners never land on `process.stdin` and the only
|
|
1069
|
+
listeners the server puts there are the `data`, `close`, and `error` forwarders into that stream.
|
|
1070
|
+
The release-time
|
|
1071
|
+
reader count is load-bearing for the same reason — a host that starts reading standard input
|
|
1072
|
+
while the server is serving keeps its reader and keeps the flow, even though `start` found the
|
|
1073
|
+
stream stopped and would otherwise pause it.
|
|
1074
|
+
- **Stage teardown is bounded, and each stage is bounded by something different.** Every stage
|
|
1075
|
+
abandons the inspections it holds rather than waiting behind one, and what it then waits for
|
|
1076
|
+
differs per stage. The lint stage holds a bound of its own and sets it on the `@orkestrel/lsp`
|
|
1077
|
+
client it drives: 2 s for each lifecycle exchange the Language Server Protocol leaves to the
|
|
1078
|
+
server — the `initialize` reply that warming waits for and the `shutdown` reply that ending waits
|
|
1079
|
+
for. It does not reach the diagnostics an inspection waits for, which the caller's own signal
|
|
1080
|
+
bounds instead, so a tight teardown bound no longer preempts a claim's budget. The transport's
|
|
1081
|
+
cooperative window is half that 2 s, so a server that answers `shutdown` and then ignores `exit`
|
|
1082
|
+
is signalled and released inside the client's own wait for the close, rather than deadlocking
|
|
1083
|
+
`destroy()`. A server that accepts the connection and answers nothing is released the same way.
|
|
1084
|
+
The type stage holds no bound and needs none for its own tools: it terminates the compiler it
|
|
1085
|
+
spawned — by process tree on Windows, where no cooperative signal reaches a child — waits for the
|
|
1086
|
+
warm it started, and then deletes its mirror. The runtime stage holds no bound either, so a
|
|
1087
|
+
`vitest.close()` that never settles is bounded by the coordinator instead: `Probe.destroy` races
|
|
1088
|
+
each stage's teardown against `ProbeOptions.deadline` and proceeds when the budget expires. What
|
|
1089
|
+
an abandoned tool still holds it holds until this process ends, so that bound buys the signal path
|
|
1090
|
+
rather than the resource — `destroy()` settles for a caller that set a budget it can wait for,
|
|
1091
|
+
instead of hanging behind a stage that will not close.
|
|
1092
|
+
- **Termination.** `ProbeServer.start` answers `SIGINT` and `SIGTERM` by destroying the server, and
|
|
1093
|
+
they are the whole set: no evidence names a harness that ends a stdio child any other way, and
|
|
1094
|
+
a configurable set would be a supported way to spell the leak this closes. Another signal arriving
|
|
1095
|
+
during a teardown already running reaches the default disposition and ends the process at once,
|
|
1096
|
+
because teardown releases its handlers before the probe. Measured on 2026-08-20 on the host § Cost
|
|
1097
|
+
names, signal to child exit is 2.2 s to 2.3 s during boot and 50 ms to 59 ms against an armed
|
|
1098
|
+
probe, over 3 runs each. The boot-time figure is the long one because teardown awaits the boot in
|
|
1099
|
+
flight; budget a harness's grace window against it rather than against the warm case.
|
|
1100
|
+
- **The listener race.** Every `createVitest` call installs `SIGINT` and `SIGTERM` handlers that end
|
|
1101
|
+
this process about a millisecond after the signal, which is three orders of magnitude inside the
|
|
1102
|
+
teardown the preceding **Termination** entry measures. The runtime stage removes the handlers its
|
|
1103
|
+
own warm installed, as the call
|
|
1104
|
+
returns and before anything is awaited, so no window exists for a signal to arrive in. Without
|
|
1105
|
+
that, a graceful teardown reads as fixed, passes a manual test, and still leaves its files in the
|
|
1106
|
+
consumer's tree.
|
|
1107
|
+
- **What a killed host leaves.** A host killed without `destroy` — `SIGKILL`, a power loss, a
|
|
1108
|
+
harness that never signals — can leave a generated specification or a boot dependency behind.
|
|
1109
|
+
Every file this package writes into a target carries `probe-<pid>-<uuid>` between its stem and its
|
|
1110
|
+
extension, and the runtime stage deletes such a file at its next warm when the process id leads a
|
|
1111
|
+
process that is gone **and** the file is one this package can attribute. Attribution is what stops
|
|
1112
|
+
the sweep reaching your tree: a generated specification carries your own test text, so probe
|
|
1113
|
+
closes the file with the marker `// @orkestrel/probe generated specification <pid>-<uuid>`, and
|
|
1114
|
+
the sweep requires that marker to name the same revision the file name does. The boot
|
|
1115
|
+
dependencies carry the same marker, so nothing is attributed by its path and nothing under
|
|
1116
|
+
`tmp/probe/` is deleted for sitting there. A file of yours that happens to carry the same name
|
|
1117
|
+
shape is left where it is, wherever it sits, and so is a live neighbour's specification.
|
|
1118
|
+
- **What the type stage leaves.** Its mirror is one directory under `TYPE_MIRROR`, named for the
|
|
1119
|
+
writing host's process id and a fresh UUID, carrying that same marker at `.probe/mirror.txt`.
|
|
1120
|
+
`destroy()` deletes it, and the next stage constructed against that workspace sweeps a mirror
|
|
1121
|
+
whose process id names a host that is gone and whose marker names its own directory. A directory
|
|
1122
|
+
failing any of those reads stays where it is. Nothing the stage writes reaches a path outside
|
|
1123
|
+
`tmp/`, so a target's version-controlled tree never carries a draft.
|
|
1124
|
+
|
|
1125
|
+
## Cost
|
|
1126
|
+
|
|
1127
|
+
The following measurements decide whether a harness's timeout is right. They were taken on
|
|
1128
|
+
2026-09-06, over this repository as the target workspace, on
|
|
1129
|
+
Linux 6.18.44 x64 with 4 processors, Node 22.22.2, TypeScript 6.0.3, Oxlint 1.81.0, and Vitest
|
|
1130
|
+
4.1.11, with other work running beside them. Read them as the shape of the cost on comparable
|
|
1131
|
+
hardware rather than as a figure another host reproduces.
|
|
1132
|
+
|
|
1133
|
+
| What | Measured |
|
|
1134
|
+
| ------------------------------------------------------------------------------ | ---------------- |
|
|
1135
|
+
| Boot: spawning `dist/bin/main.js` to the answered `initialize` | 497 ms to 561 ms |
|
|
1136
|
+
| Admitted valid claim: spawning `dist/bin/main.js` to the answered `tools/call` | 16.2 s to 16.8 s |
|
|
1137
|
+
| Warm `prove` over the flagship claim, client round trip | 4.2 s to 5.7 s |
|
|
1138
|
+
| Type stage: construction to the `arm` event over this repository | 12.2 s |
|
|
1139
|
+
| Type stage: the declared projects warmed together, cold | 4.6 s |
|
|
1140
|
+
| Type stage: the declared projects warmed together again | 2.5 s |
|
|
1141
|
+
| Type stage: warm inspection, the root project and selected scoped project | 2.0 s |
|
|
1142
|
+
| Type stage: `tsc --showConfig` for a project | 90 ms to 115 ms |
|
|
1143
|
+
| Type stage: warm inspection over the fixture target workspace | 0.9 s |
|
|
1144
|
+
|
|
1145
|
+
The type stage runs the workspace's own `tsc` per selected project, so a target's own `check` script
|
|
1146
|
+
is the shape of its cost. Warming builds each declared project's incremental state before the first
|
|
1147
|
+
inspection answers, and every inspection awaits that warm, so `ProbeOptions.deadline` must clear it:
|
|
1148
|
+
over this repository a budget under about 8 s expires arming rather than a claim. The default
|
|
1149
|
+
`PROBE_DEADLINE` of 30,000 ms clears it with room for a contended host.
|
|
1150
|
+
|
|
1151
|
+
The measured admitted valid claim is dominated by deferred arming, which runs its real controls
|
|
1152
|
+
through the stage sequence before the call answers. The call also carries `prove`, so it lands about
|
|
1153
|
+
a warm call after the `arm` event. A client whose timeout is tighter than arming reports a hang that
|
|
1154
|
+
is a wait. Handshake and discovery requests require no workspace toolchain; only an admitted
|
|
1155
|
+
`tools/call` waits on arming.
|
|
1156
|
+
|
|
1157
|
+
`prove` runs the case through every stage and then the control through every stage, in sequence, so
|
|
1158
|
+
one call pays the runtime stage's floor twice. One runtime inspection in every 64 also replaces
|
|
1159
|
+
the resident Vitest runner and costs more than the other 63, so budget a client timeout against that
|
|
1160
|
+
inspection rather than the common one.
|
|
1161
|
+
|
|
1162
|
+
## Tests
|
|
1163
|
+
|
|
1164
|
+
- [`guides.test.ts`](../tests/guides.test.ts) — this guide's parity directions, and the equality
|
|
1165
|
+
gate: every `Summary` cell against its declaration's description paragraph, the titled
|
|
1166
|
+
`The claim that earns a receipt` fence against the `@example` block of that title (pinned so the
|
|
1167
|
+
titled pair cannot be retired silently), and the README pitch against this guide's tagline. It
|
|
1168
|
+
also runs the flagship fences and asserts what they claim: the claim literal shared with the
|
|
1169
|
+
`Claim` contract, the constants at the values they publish, the failure table against the tuples
|
|
1170
|
+
the package declares, what `verdict.digest` covers, and the flagship claim run for its receipt.
|
|
1171
|
+
- [`helpers.test.ts`](../tests/src/core/helpers.test.ts) — the formatters, the receipt token's
|
|
1172
|
+
conditions, and the generated specification's marker.
|
|
1173
|
+
- [`validators.test.ts`](../tests/src/core/validators.test.ts) — every guard against hostile shapes,
|
|
1174
|
+
including the draft path the advertised schema admits and the guard refuses.
|
|
1175
|
+
- [`errors.test.ts`](../tests/src/core/errors.test.ts) — the failure guard against lookalikes and a
|
|
1176
|
+
duplicate copy of the package, and every failure path a test can drive without a resident tool,
|
|
1177
|
+
driven for real and read for the ownership and the condition it raised.
|
|
1178
|
+
- [`Probe.test.ts`](../tests/src/server/Probe.test.ts) — arming, admission, deadline expiry and
|
|
1179
|
+
stage replacement, and the receipt decision end to end.
|
|
1180
|
+
- [`helpers.test.ts`](../tests/src/server/helpers.test.ts) — the server leaves, including every
|
|
1181
|
+
documented example on this page's helper table and the members a refused claim names.
|
|
1182
|
+
- [`TypeStage.test.ts`](../tests/src/server/stages/TypeStage.test.ts),
|
|
1183
|
+
[`LintStage.test.ts`](../tests/src/server/stages/LintStage.test.ts), and
|
|
1184
|
+
[`RuntimeStage.test.ts`](../tests/src/server/stages/RuntimeStage.test.ts) — the stages against
|
|
1185
|
+
their real tools.
|
|
1186
|
+
- [`ProbeServer.test.ts`](../tests/src/server/ProbeServer.test.ts) — what `start` seizes and what
|
|
1187
|
+
`destroy` gives back, standard input's flow included, what a host attaches while the server is
|
|
1188
|
+
serving, and the key bound the installed package applies to a record-bearing result.
|
|
1189
|
+
- [`Overlay.test.ts`](../tests/src/server/Overlay.test.ts) — the candidate set's identity,
|
|
1190
|
+
containment, and release.
|
|
1191
|
+
- [`parsers.test.ts`](../tests/src/server/parsers.test.ts) — the printed project configuration and
|
|
1192
|
+
the revision identity a sweep reads, against the shapes each refuses.
|
|
1193
|
+
- [`main.test.ts`](../tests/src/bin/main.test.ts) — the shipped entry driven by this repository's
|
|
1194
|
+
own line client and by the `@orkestrel/mcp` stdio client, the record and the rendered text its
|
|
1195
|
+
reply carries on the legacy and the modern era, and the signals delivered to it during boot and
|
|
1196
|
+
in service.
|
|
1197
|
+
- [`distribution.test.ts`](../tests/distribution.test.ts) — the packed package installed outside the
|
|
1198
|
+
repository and driven through its public exports.
|
|
1199
|
+
|
|
1200
|
+
## See also
|
|
1201
|
+
|
|
1202
|
+
- [`README.md`](README.md) — the guides index.
|
|
1203
|
+
- [`mcp.md`](mcp.md) — the dependency mirror for `@orkestrel/mcp`, whose server and stdio transport
|
|
1204
|
+
carry the `prove` tool.
|
|
1205
|
+
- [`lsp.md`](lsp.md) — the dependency mirror for `@orkestrel/lsp`, whose client and stdio client
|
|
1206
|
+
transport carry the lint stage's conversation with the Oxlint language server.
|
|
1207
|
+
- [`tool.md`](tool.md) — the dependency mirror for `@orkestrel/tool`, whose registry holds it.
|
|
1208
|
+
- [`contract.md`](contract.md) — the dependency mirror for `@orkestrel/contract`, whose shapes
|
|
1209
|
+
compile both the published tool schema and the guards.
|
|
1210
|
+
- [`AGENTS.md`](../AGENTS.md) — the repository's coding and documentation contract.
|