toolroll 0.9.34 → 0.9.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/README.md +7 -0
- package/dist/assignment.d.ts +1 -0
- package/dist/assignment.js +2 -2
- package/dist/browser-workspace.d.ts +3 -3
- package/dist/builder.js +4 -7
- package/dist/chat-acceptance.d.ts +1 -1
- package/dist/contracts/contract.d.ts +33 -5
- package/dist/contracts/contract.js +143 -16
- package/dist/contracts/flow-propose.d.ts +325 -0
- package/dist/contracts/flow-propose.js +41 -0
- package/dist/contracts/flow.d.ts +19400 -0
- package/dist/contracts/flow.js +359 -0
- package/dist/contracts/handoff.d.ts +91 -0
- package/dist/contracts/handoff.js +82 -0
- package/dist/contracts/proof.d.ts +136 -0
- package/dist/contracts/proof.js +110 -0
- package/dist/contracts/verification-receipt.d.ts +148 -0
- package/dist/contracts/verification-receipt.js +87 -0
- package/dist/demo.js +28 -11
- package/dist/desktop-update.d.ts +2 -2
- package/dist/evidence.d.ts +3 -40
- package/dist/evidence.js +5 -1
- package/dist/flow-gallery.d.ts +3 -2
- package/dist/flow-share.d.ts +10 -32
- package/dist/flow-share.js +65 -83
- package/dist/flow-starters.d.ts +2 -1
- package/dist/flow-triggers.d.ts +15 -72
- package/dist/flow-triggers.js +94 -83
- package/dist/flows.d.ts +37 -218
- package/dist/flows.js +350 -385
- package/dist/mate-tools.js +14 -44
- package/dist/mcp-connect.d.ts +3 -0
- package/dist/mcp-connect.js +16 -4
- package/dist/operate.js +31 -2
- package/dist/plan.d.ts +5 -5
- package/dist/plan.js +5 -25
- package/dist/process-recovery-eligibility.d.ts +2 -2
- package/dist/process-recovery-eligibility.js +13 -8
- package/dist/proof.d.ts +15 -91
- package/dist/proof.js +90 -249
- package/dist/result-evidence-readers.d.ts +42 -0
- package/dist/result-evidence-readers.js +91 -0
- package/dist/serve.js +8 -99
- package/dist/setup-guide.d.ts +2 -2
- package/dist/surface.js +8 -1
- package/dist/task-review-brief.d.ts +75 -0
- package/dist/task-review-brief.js +179 -0
- package/dist/text-limits.d.ts +70 -0
- package/dist/text-limits.js +70 -0
- package/dist/verification-evidence.js +24 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.36 — 2026-10-06
|
|
4
|
+
|
|
5
|
+
- **Flows have one contract.** Every kind of zone, step, route, answer,
|
|
6
|
+
option, trigger and the flow file is described once
|
|
7
|
+
(`src/contracts/flow.ts`), and that description is what the lead's
|
|
8
|
+
`propose_flow` is told, what `toolroll flows create/edit/import` and the
|
|
9
|
+
console check, and what `docs/flow-file.schema.json` publishes (now
|
|
10
|
+
generated, so it includes Send to me, Person chooses and `ifReplied`, which
|
|
11
|
+
the hand-written one had missed). A refusal names the field and suggests the
|
|
12
|
+
one meant: `steps[0].routes[0]: unknown key 'to' (did you mean goesTo?)`,
|
|
13
|
+
`zones[2]: unknown key 'onFail' (did you mean ifFails?)`, instead of
|
|
14
|
+
"there's no step called ." Every saved flow, template, starter, kit and
|
|
15
|
+
gallery flow reads exactly as before: the same saved bytes and digests, and
|
|
16
|
+
the same exported files.
|
|
17
|
+
- **Tightened, on purpose.** A step, flow file zone, trigger setting or
|
|
18
|
+
`propose_flow` argument with a key its kind doesn't take is refused by name
|
|
19
|
+
rather than silently ignored: `planning` on a Holding step, `remindAfter` on
|
|
20
|
+
a Wait step, `ifFails` on a Sort step (use `ifNotSure`, which it meant) or
|
|
21
|
+
`next` on a Sort or Person chooses step. A sort's `sureAt` is 0.5–99. A flow
|
|
22
|
+
file's zones keep only the words their kind uses when exported, and a file
|
|
23
|
+
or drawing made by a newer Toolroll is refused as such. Messages for flows
|
|
24
|
+
and triggers changed from "Zone Build: …" sentences to path-named lines.
|
|
25
|
+
|
|
26
|
+
- **Builder handoffs and proofs have one contract.** The handoff a builder
|
|
27
|
+
leaves, its proof and the verification receipt are versioned schemas. Fifteen
|
|
28
|
+
real past runs replay with identical verdicts, receipt bytes and digests.
|
|
29
|
+
- **One compact review packet.** `toolroll task review <id> --brief` (or
|
|
30
|
+
`--json`) shows a build's conclusion, changed files, check result, review
|
|
31
|
+
findings by severity, screenshot paths and what to do next, in about 40
|
|
32
|
+
lines and without logs or diffs.
|
|
33
|
+
- **PostHog connects in one short step.** Its sign-in now uses PostHog's own
|
|
34
|
+
MCP permission preset instead of listing every permission, which showed a
|
|
35
|
+
75-group permission screen. Sign-in returns are now logged.
|
|
36
|
+
- **Tests can't mistake a real process for a fake one.** Fake process IDs in
|
|
37
|
+
tests sit above every platform's limit, and a guard refuses new low ones (a
|
|
38
|
+
Brave tab and a GitHub runner process had made tests fail at random).
|
|
39
|
+
|
|
40
|
+
## 0.9.35 — 2026-10-05
|
|
41
|
+
|
|
42
|
+
- **One-click sign-ins ask for every permission a service lists.** Toolroll
|
|
43
|
+
asked for only the first 20, so a service needing one later in its list
|
|
44
|
+
issued a token it then refused (PostHog needs `user:read`, its 140th).
|
|
45
|
+
Reconnect a service that failed its test after signing in.
|
|
46
|
+
|
|
3
47
|
## 0.9.34 — 2026-10-05
|
|
4
48
|
|
|
5
49
|
- **Plans have one contract.** The first step of making every handoff robust:
|
package/README.md
CHANGED
|
@@ -117,10 +117,17 @@ toolroll project use /path/to/project
|
|
|
117
117
|
toolroll project show
|
|
118
118
|
toolroll brief
|
|
119
119
|
toolroll assignment show <task>
|
|
120
|
+
toolroll task review <task> --brief
|
|
120
121
|
toolroll task complete <task>
|
|
121
122
|
toolroll task revise <task> --feedback "Keep the filter selected after reload."
|
|
122
123
|
```
|
|
123
124
|
|
|
125
|
+
`task review <task> --brief` combines the latest finished build or report's conclusion,
|
|
126
|
+
file counts, checks, review findings, screenshot paths, plan and next actions.
|
|
127
|
+
Use `--run <id>` for an earlier attempt, `--json` for the same fields, and `--all`
|
|
128
|
+
to include LOW findings. Lists stay bounded, with omitted entries counted; missing
|
|
129
|
+
fields say `not recorded`. The lead token works through `--token` or `TOOLROLL_LEAD_TOKEN`.
|
|
130
|
+
|
|
124
131
|
Completion and revisions use the local sign-in already saved by `up`. Completion
|
|
125
132
|
reports the exact run, commit and check outcome it handled. Agents use a scoped
|
|
126
133
|
credential (`project use /path/to/project --token-file /path/to/credential`) and
|
package/dist/assignment.d.ts
CHANGED
|
@@ -170,6 +170,7 @@ export declare function assignmentBrief(assignment: AssignmentSnapshot | null):
|
|
|
170
170
|
status: "not-recorded";
|
|
171
171
|
};
|
|
172
172
|
} | null;
|
|
173
|
+
export declare function assignmentChecksForRun(store: Store, root: string | undefined, runId: number, now: Date): AssignmentChecks;
|
|
173
174
|
export declare function assignmentOf(store: Store, taskId: string, now: Date, access: AssignmentAccess, root?: string): AssignmentSnapshot | null;
|
|
174
175
|
type MutationResult = {
|
|
175
176
|
ok: true;
|
package/dist/assignment.js
CHANGED
|
@@ -83,7 +83,7 @@ function ownerOf(store, rootId, repo) {
|
|
|
83
83
|
}
|
|
84
84
|
// Report the recorded check outcome, never turn a model verdict into a check.
|
|
85
85
|
// Follow-up checks on the same commit (Run checks) upgrade a pass and show a failure.
|
|
86
|
-
function
|
|
86
|
+
export function assignmentChecksForRun(store, root, runId, now) {
|
|
87
87
|
const own = buildChecks(store, root, runId);
|
|
88
88
|
const run = store.getRun(runId);
|
|
89
89
|
const followUps = followUpChecksOf(store, runId, now, root);
|
|
@@ -163,7 +163,7 @@ export function assignmentOf(store, taskId, now, access, root) {
|
|
|
163
163
|
const reports = artifacts.filter(a => a.kind === "report");
|
|
164
164
|
const unavailable = family.versions.flatMap(version => store.runsFor(version.refId).flatMap(run => store.artifactsFor(run.id).filter(artifact => root === undefined || !readVerifiedArtifact(root, artifact).ok)
|
|
165
165
|
.map(artifact => `Saved ${artifact.kind} #${artifact.id} (run ${run.id}) is unavailable or changed.`)));
|
|
166
|
-
const checks = result === null ? null :
|
|
166
|
+
const checks = result === null ? null : assignmentChecksForRun(store, root, result.id, now);
|
|
167
167
|
const finishedBuild = result?.role === "builder" && (result.outcome === "built" || result.outcome === "no-change") &&
|
|
168
168
|
result.finishedAt !== null && /^[a-f0-9]{40}$/.test(result.headRevision ?? "");
|
|
169
169
|
// Finished work returns to its lead or user. Strict terms and previous model
|
|
@@ -802,9 +802,9 @@ export type BrowserFlowStage = {
|
|
|
802
802
|
wait?: {
|
|
803
803
|
for: "reply" | "time" | "hours";
|
|
804
804
|
minutes: number;
|
|
805
|
-
from?: string;
|
|
806
|
-
to?: string;
|
|
807
|
-
timeZone?: string;
|
|
805
|
+
from?: string | undefined;
|
|
806
|
+
to?: string | undefined;
|
|
807
|
+
timeZone?: string | undefined;
|
|
808
808
|
};
|
|
809
809
|
/** A Pull request zone merges once checks pass (after a person approved), this way. */
|
|
810
810
|
merge?: "squash" | "merge" | "rebase" | undefined;
|
package/dist/builder.js
CHANGED
|
@@ -63,6 +63,7 @@ import { openLiveLog } from "./live.js";
|
|
|
63
63
|
import { CheckProgressTracker } from "./check-progress.js";
|
|
64
64
|
import { captureParkEvidence, captureTerminalDiff, captureBaseTree, evidenceRoot, handoffName, isImagePath, WORKTREE_EVIDENCE_DIR, storeHandoffArtifact, looksLikeProtocolFile, mailboxName, progressFileName, proofFileName, rubricFileName, proposalFileName, quarantineMailboxes, readMailbox, readVerifiedArtifact, scanForSecrets, redactSecretLines, storeEvidence, validateScreenshotBytes, imageDimensions, SCREENSHOT_BYTE_CAP, boundStreamHeadTail } from "./evidence.js";
|
|
65
65
|
import { PROOF_LIMITS, parseProof, serializeProof, adjudicate, artifactManifestOnly, sameDiffStatFacts } from "./proof.js";
|
|
66
|
+
import { parseHandoffArtifact } from "./contracts/handoff.js";
|
|
66
67
|
import { captureReviewContext } from "./review-context.js";
|
|
67
68
|
import { authoritySnapshotDigest, classifyRevisionAuthority, isMilestoneRegression, milestonesOf, parseExecutionPlanDocument, parsePlanRevisionProposal, parseProgressSnapshot, renderExecutionPlanDocument, PROGRESS_LIMITS, REVISION_LIMITS, } from "./plan.js";
|
|
68
69
|
import { maybeSettleRepairChain } from "./dispose.js";
|
|
@@ -1115,11 +1116,9 @@ export async function build(store, request) {
|
|
|
1115
1116
|
try {
|
|
1116
1117
|
const verified = readVerifiedArtifact(root, handoffArtifact);
|
|
1117
1118
|
if (verified.ok) {
|
|
1118
|
-
const parsed =
|
|
1119
|
-
if (parsed.branch === branch &&
|
|
1120
|
-
|
|
1121
|
-
parsed.freshness?.currentAsOf === baseRevision) {
|
|
1122
|
-
previousHandoff = `A previous attempt (${String(parsed.outcome ?? "finished")}) left the branch exactly where it now stands and concluded: ${parsed.conclusion}`;
|
|
1119
|
+
const parsed = parseHandoffArtifact(verified.content.toString("utf8"));
|
|
1120
|
+
if (parsed.ok && parsed.value.branch === branch && parsed.value.freshness.currentAsOf === baseRevision) {
|
|
1121
|
+
previousHandoff = `A previous attempt (${parsed.value.outcome}) left the branch exactly where it now stands and concluded: ${parsed.value.conclusion}`;
|
|
1123
1122
|
}
|
|
1124
1123
|
}
|
|
1125
1124
|
}
|
|
@@ -1740,7 +1739,6 @@ export async function settleProviderOutcome(captured, result) {
|
|
|
1740
1739
|
store.setRunPhase(request.runId, "capturing-evidence");
|
|
1741
1740
|
const diffEvidence = await captureTerminalDiff(store, git, worktree, pinnedBase, baseRevision, root, request.runId, clock());
|
|
1742
1741
|
storeHandoffArtifact(store, root, {
|
|
1743
|
-
schema: 1,
|
|
1744
1742
|
taskId,
|
|
1745
1743
|
runId: request.runId,
|
|
1746
1744
|
provider,
|
|
@@ -1835,7 +1833,6 @@ export async function settleProviderOutcome(captured, result) {
|
|
|
1835
1833
|
store.setRunPhase(request.runId, "capturing-evidence");
|
|
1836
1834
|
const diffEvidence = await captureTerminalDiff(store, git, worktree, pinnedBase, head, root, request.runId, clock());
|
|
1837
1835
|
storeHandoffArtifact(store, root, {
|
|
1838
|
-
schema: 1,
|
|
1839
1836
|
taskId,
|
|
1840
1837
|
runId: request.runId,
|
|
1841
1838
|
provider,
|
|
@@ -45,7 +45,7 @@ export declare function readAcceptanceEvidence(store: Store, who: VerifiedApprov
|
|
|
45
45
|
note: string | null;
|
|
46
46
|
} | null;
|
|
47
47
|
evidence: {
|
|
48
|
-
kind:
|
|
48
|
+
kind: "changed-path" | "check" | "manual-review" | "screenshot";
|
|
49
49
|
reference: string | null;
|
|
50
50
|
}[];
|
|
51
51
|
coverageGaps: (string | null)[];
|
|
@@ -29,10 +29,37 @@ export type ContractIssue = {
|
|
|
29
29
|
};
|
|
30
30
|
/** `["steps", 0, "routes", 0, "goesTo"]` as `steps[0].routes[0].goesTo`. */
|
|
31
31
|
export declare function pathOf(path: readonly PropertyKey[]): string;
|
|
32
|
-
/**
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* How a contract names a key it doesn't know: the keys a person or model often writes for one it does (`onFail` for
|
|
34
|
+
* `ifFails`, `to` for `goesTo`), tried in order against the keys allowed at that place. Without one that fits, a key
|
|
35
|
+
* spelled close to an allowed one (a case or a letter or two off) is suggested.
|
|
36
|
+
*/
|
|
37
|
+
export type ContractOptions = {
|
|
38
|
+
aliases?: Readonly<Record<string, readonly string[]>>;
|
|
39
|
+
};
|
|
40
|
+
type Where = {
|
|
41
|
+
schema?: z.ZodType;
|
|
42
|
+
input?: unknown;
|
|
43
|
+
options?: ContractOptions;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* A Zod error as path-named issues, in the error's own order. Given the schema and input it came from, an unknown key
|
|
47
|
+
* also says which allowed key it most likely meant (`unknown key 'onFail' (did you mean ifFails?)`).
|
|
48
|
+
*/
|
|
49
|
+
export declare function contractIssues(error: z.ZodError, where?: Where): ContractIssue[];
|
|
34
50
|
/** A Zod error as path-named lines: `steps[0].routes[0].goesTo: required`, `routes[0]: unknown key 'to'`. */
|
|
35
|
-
export declare function contractError(error: z.ZodError): string[];
|
|
51
|
+
export declare function contractError(error: z.ZodError, where?: Where): string[];
|
|
52
|
+
/** A refusal as a reader reports it: a stable reason code, and the path-named line as the message. */
|
|
53
|
+
export type ContractProblem = {
|
|
54
|
+
reason: string;
|
|
55
|
+
message: string;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* A contract issue as a problem with a reason code built from its path — `missing-goal`, `criteria[0].how-too-long`,
|
|
59
|
+
* `checks-too-many`, `payload-unknown-key`, `bad-version`, `newer-version` — so callers that branch on a reason, and
|
|
60
|
+
* durable outcomes that recorded one, read the same as before the contract.
|
|
61
|
+
*/
|
|
62
|
+
export declare function contractProblemOf(issue: ContractIssue): ContractProblem;
|
|
36
63
|
export type ContractResult<T> = {
|
|
37
64
|
ok: true;
|
|
38
65
|
value: T;
|
|
@@ -41,7 +68,7 @@ export type ContractResult<T> = {
|
|
|
41
68
|
issues: ContractIssue[];
|
|
42
69
|
};
|
|
43
70
|
/** Parse with the input reported, so a missing field reads `required` rather than a wrong type. */
|
|
44
|
-
export declare function parseContract<T>(schema: z.ZodType<T>, input: unknown): ContractResult<T>;
|
|
71
|
+
export declare function parseContract<T>(schema: z.ZodType<T>, input: unknown, options?: ContractOptions): ContractResult<T>;
|
|
45
72
|
/**
|
|
46
73
|
* Read a versioned payload. The current version parses as itself; an older known version (0 for a payload saved
|
|
47
74
|
* before it carried `version`) is upgraded by its adapter and then parsed by the same schema; a newer version is
|
|
@@ -51,9 +78,10 @@ export declare function readVersioned<T>(schema: z.ZodType<T> & {
|
|
|
51
78
|
shape: {
|
|
52
79
|
version: z.ZodLiteral<number>;
|
|
53
80
|
};
|
|
54
|
-
}, input: unknown, upgrades?: Readonly<Record<number, (payload: Record<string, unknown>) => unknown
|
|
81
|
+
}, input: unknown, upgrades?: Readonly<Record<number, (payload: Record<string, unknown>) => unknown>>, options?: ContractOptions): ContractResult<T>;
|
|
55
82
|
/**
|
|
56
83
|
* The JSON Schema a model gets for `schema` (Claude's `--json-schema`). Throws when the schema uses refine,
|
|
57
84
|
* superRefine, transform or preprocess anywhere: what a model is told must be everything the schema checks.
|
|
58
85
|
*/
|
|
59
86
|
export declare function toModelSchema(schema: z.ZodType): Record<string, unknown>;
|
|
87
|
+
export {};
|
|
@@ -41,55 +41,182 @@ function got(input) {
|
|
|
41
41
|
return "an array";
|
|
42
42
|
return typeof input === "object" ? "an object" : `a ${typeof input}`;
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
45
|
+
/** The keys an object schema allows at `path` in `input`, following arrays, wrappers and (by its discriminator) a union. */
|
|
46
|
+
function keysAt(schema, input, path) {
|
|
47
|
+
let node = schema, value = input;
|
|
48
|
+
for (let at = 0; node !== undefined;) {
|
|
49
|
+
const def = node._zod.def;
|
|
50
|
+
if (def.type === "optional" || def.type === "nullable" || def.type === "readonly") {
|
|
51
|
+
node = def["innerType"];
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (def.type === "lazy") {
|
|
55
|
+
node = def["getter"]();
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (def.type === "union") {
|
|
59
|
+
const options = def["options"];
|
|
60
|
+
const by = def["discriminator"];
|
|
61
|
+
const wanted = by !== undefined && isRecord(value) ? value[by] : undefined;
|
|
62
|
+
node = options.find(option => {
|
|
63
|
+
const shape = option._zod.def["shape"];
|
|
64
|
+
if (shape === undefined)
|
|
65
|
+
return false;
|
|
66
|
+
if (by === undefined)
|
|
67
|
+
return true;
|
|
68
|
+
const literal = shape[by]?._zod.def;
|
|
69
|
+
return literal !== undefined && literal["values"]?.includes(wanted) === true;
|
|
70
|
+
});
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (at === path.length)
|
|
74
|
+
return def.type === "object" ? Object.keys(def["shape"]) : null;
|
|
75
|
+
const part = path[at++];
|
|
76
|
+
if (def.type === "array" && typeof part === "number") {
|
|
77
|
+
node = def["element"];
|
|
78
|
+
value = Array.isArray(value) ? value[part] : undefined;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (def.type === "object" && typeof part === "string") {
|
|
82
|
+
const shape = def["shape"];
|
|
83
|
+
node = shape[part] ?? def["catchall"];
|
|
84
|
+
value = isRecord(value) ? value[part] : undefined;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
/** Edit distance, for "did you mean" (short strings only). */
|
|
92
|
+
function distance(a, b) {
|
|
93
|
+
const row = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
94
|
+
for (let i = 1; i <= a.length; i++) {
|
|
95
|
+
let last = row[0];
|
|
96
|
+
row[0] = i;
|
|
97
|
+
for (let j = 1; j <= b.length; j++) {
|
|
98
|
+
const was = row[j];
|
|
99
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, last + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
100
|
+
last = was;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return row[b.length];
|
|
104
|
+
}
|
|
105
|
+
/** The allowed key an unknown one most likely meant, or null. */
|
|
106
|
+
function suggestion(key, known, options) {
|
|
107
|
+
if (known === null || known.length === 0)
|
|
108
|
+
return null;
|
|
109
|
+
const alias = options.aliases?.[key]?.find(one => known.includes(one));
|
|
110
|
+
if (alias !== undefined)
|
|
111
|
+
return alias;
|
|
112
|
+
const close = known
|
|
113
|
+
.map(one => ({ one, far: one.toLowerCase() === key.toLowerCase() ? 0 : distance(one.toLowerCase(), key.toLowerCase()) }))
|
|
114
|
+
.filter(({ one, far }) => far <= (Math.min(one.length, key.length) >= 6 ? 2 : 1))
|
|
115
|
+
.sort((a, b) => a.far - b.far);
|
|
116
|
+
return close[0]?.one ?? null;
|
|
117
|
+
}
|
|
118
|
+
function issuesOf(issue, where = {}) {
|
|
45
119
|
const at = pathOf(issue.path);
|
|
46
120
|
const one = (kind, what) => ({ path: at, kind, line: `${at}: ${what}` });
|
|
47
121
|
switch (issue.code) {
|
|
48
|
-
case "unrecognized_keys":
|
|
49
|
-
|
|
122
|
+
case "unrecognized_keys": {
|
|
123
|
+
const known = where.schema === undefined ? null : keysAt(where.schema, where.input, issue.path);
|
|
124
|
+
return issue.keys.map(key => {
|
|
125
|
+
const meant = suggestion(key, known, where.options ?? {});
|
|
126
|
+
return { path: at, kind: "unknown-key", line: `${at}: unknown key '${key}'${meant === null ? "" : ` (did you mean ${meant}?)`}` };
|
|
127
|
+
});
|
|
128
|
+
}
|
|
50
129
|
case "invalid_type": {
|
|
51
130
|
const missing = "input" in issue ? issue.input === undefined : / received undefined$/.test(issue.message);
|
|
52
|
-
|
|
131
|
+
// A null where a value belongs is a value left out.
|
|
132
|
+
if (missing || ("input" in issue && issue.input === null && issue.expected !== "null"))
|
|
53
133
|
return [one("required", "required")];
|
|
54
|
-
|
|
134
|
+
const expected = issue.expected === "array" ? "an array" : issue.expected === "object" ? "an object" : issue.expected === "null" ? "null" : `a ${issue.expected}`;
|
|
135
|
+
return [one("wrong-type", `must be ${expected}${"input" in issue ? ` (got ${got(issue.input)})` : ""}`)];
|
|
55
136
|
}
|
|
56
137
|
case "too_big":
|
|
57
138
|
if (issue.origin === "array" || issue.origin === "set")
|
|
58
139
|
return [one("too-many", `at most ${String(issue.maximum)} items`)];
|
|
140
|
+
if (issue.origin === "number" || issue.origin === "int" || issue.origin === "bigint")
|
|
141
|
+
return [one("bad-value", `at most ${String(issue.maximum)}`)];
|
|
59
142
|
return [one("too-long", issue.origin === "string" && issue.message.startsWith("over ") ? issue.message : `at most ${String(issue.maximum)}${issue.origin === "string" ? " characters" : ""}`)];
|
|
60
143
|
case "too_small":
|
|
61
144
|
if (issue.origin === "string" && Number(issue.minimum) === 1)
|
|
62
145
|
return [one("empty", "must not be empty")];
|
|
63
146
|
if (issue.origin === "array" || issue.origin === "set")
|
|
64
147
|
return [one("too-few", `at least ${String(issue.minimum)} item${Number(issue.minimum) === 1 ? "" : "s"}`)];
|
|
148
|
+
if (issue.origin === "number" || issue.origin === "int" || issue.origin === "bigint")
|
|
149
|
+
return [one("bad-value", `at least ${String(issue.minimum)}`)];
|
|
65
150
|
return [one("too-few", `at least ${String(issue.minimum)}`)];
|
|
66
151
|
case "invalid_value":
|
|
67
152
|
return [one("bad-value", `must be ${issue.values.length === 1 ? JSON.stringify(issue.values[0]) : `one of ${issue.values.map(value => JSON.stringify(value)).join(", ")}`}`)];
|
|
68
|
-
case "invalid_union":
|
|
153
|
+
case "invalid_union": {
|
|
154
|
+
// A discriminated union names its choices (`steps[0].kind: must be one of "inbox", "task", ...`).
|
|
155
|
+
const by = issue.discriminator, options = issue.options;
|
|
156
|
+
if (typeof by === "string" && Array.isArray(options)) {
|
|
157
|
+
const given = "input" in issue && isRecord(issue.input) ? issue.input[by] : undefined;
|
|
158
|
+
const path = at === "payload" || issue.path.at(-1) === by ? at : `${at}.${by}`;
|
|
159
|
+
const line = given === undefined || given === null ? "required" : `must be one of ${options.map(value => JSON.stringify(value)).join(", ")}`;
|
|
160
|
+
return [{ path, kind: given === undefined || given === null ? "required" : "bad-value", line: `${path}: ${line}` }];
|
|
161
|
+
}
|
|
69
162
|
return [one("invalid", "does not match any allowed shape")];
|
|
163
|
+
}
|
|
164
|
+
case "invalid_format":
|
|
165
|
+
// A pattern's own words say what it wants (`must be a short id: lowercase letters, numbers and dashes`).
|
|
166
|
+
return [one("bad-value", issue.message.startsWith("Invalid") ? `must match ${issue.format === "regex" ? issue.pattern ?? "its pattern" : issue.format}` : issue.message)];
|
|
70
167
|
default:
|
|
71
168
|
return [one("invalid", issue.message)];
|
|
72
169
|
}
|
|
73
170
|
}
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
171
|
+
/**
|
|
172
|
+
* A Zod error as path-named issues, in the error's own order. Given the schema and input it came from, an unknown key
|
|
173
|
+
* also says which allowed key it most likely meant (`unknown key 'onFail' (did you mean ifFails?)`).
|
|
174
|
+
*/
|
|
175
|
+
export function contractIssues(error, where = {}) {
|
|
176
|
+
return error.issues.flatMap(issue => issuesOf(issue, where));
|
|
77
177
|
}
|
|
78
178
|
/** A Zod error as path-named lines: `steps[0].routes[0].goesTo: required`, `routes[0]: unknown key 'to'`. */
|
|
79
|
-
export function contractError(error) {
|
|
80
|
-
return contractIssues(error).map(issue => issue.line);
|
|
179
|
+
export function contractError(error, where = {}) {
|
|
180
|
+
return contractIssues(error, where).map(issue => issue.line);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* A contract issue as a problem with a reason code built from its path — `missing-goal`, `criteria[0].how-too-long`,
|
|
184
|
+
* `checks-too-many`, `payload-unknown-key`, `bad-version`, `newer-version` — so callers that branch on a reason, and
|
|
185
|
+
* durable outcomes that recorded one, read the same as before the contract.
|
|
186
|
+
*/
|
|
187
|
+
export function contractProblemOf(issue) {
|
|
188
|
+
const at = issue.path;
|
|
189
|
+
const reason = (() => {
|
|
190
|
+
switch (issue.kind) {
|
|
191
|
+
case "required":
|
|
192
|
+
case "empty":
|
|
193
|
+
case "too-few":
|
|
194
|
+
return `missing-${at}`;
|
|
195
|
+
case "too-long":
|
|
196
|
+
return `${at}-too-long`;
|
|
197
|
+
case "too-many":
|
|
198
|
+
return `${at}-too-many`;
|
|
199
|
+
case "unknown-key":
|
|
200
|
+
return `${at}-unknown-key`;
|
|
201
|
+
case "newer-version":
|
|
202
|
+
return "newer-version";
|
|
203
|
+
default:
|
|
204
|
+
return `bad-${at}`;
|
|
205
|
+
}
|
|
206
|
+
})();
|
|
207
|
+
return { reason, message: issue.line };
|
|
81
208
|
}
|
|
82
209
|
/** Parse with the input reported, so a missing field reads `required` rather than a wrong type. */
|
|
83
|
-
export function parseContract(schema, input) {
|
|
210
|
+
export function parseContract(schema, input, options = {}) {
|
|
84
211
|
const parsed = schema.safeParse(input, { reportInput: true });
|
|
85
|
-
return parsed.success ? { ok: true, value: parsed.data } : { ok: false, issues: contractIssues(parsed.error) };
|
|
212
|
+
return parsed.success ? { ok: true, value: parsed.data } : { ok: false, issues: contractIssues(parsed.error, { schema, input, options }) };
|
|
86
213
|
}
|
|
87
214
|
/**
|
|
88
215
|
* Read a versioned payload. The current version parses as itself; an older known version (0 for a payload saved
|
|
89
216
|
* before it carried `version`) is upgraded by its adapter and then parsed by the same schema; a newer version is
|
|
90
217
|
* refused plainly; anything else is an unknown version.
|
|
91
218
|
*/
|
|
92
|
-
export function readVersioned(schema, input, upgrades = {}) {
|
|
219
|
+
export function readVersioned(schema, input, upgrades = {}, options = {}) {
|
|
93
220
|
const current = schema.shape.version.value;
|
|
94
221
|
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
95
222
|
return { ok: false, issues: [{ path: "payload", kind: "wrong-type", line: `payload: must be an object (got ${got(input)})` }] };
|
|
@@ -97,7 +224,7 @@ export function readVersioned(schema, input, upgrades = {}) {
|
|
|
97
224
|
const body = input;
|
|
98
225
|
const version = Object.prototype.hasOwnProperty.call(body, "version") ? body["version"] : 0;
|
|
99
226
|
if (version === current)
|
|
100
|
-
return parseContract(schema, body);
|
|
227
|
+
return parseContract(schema, body, options);
|
|
101
228
|
if (typeof version === "number" && Number.isInteger(version) && version > current) {
|
|
102
229
|
return { ok: false, issues: [{ path: "version", kind: "newer-version", line: `version: made by a newer Toolroll (version ${version}; this one reads up to ${current})` }] };
|
|
103
230
|
}
|
|
@@ -105,7 +232,7 @@ export function readVersioned(schema, input, upgrades = {}) {
|
|
|
105
232
|
if (upgrade === undefined) {
|
|
106
233
|
return { ok: false, issues: [{ path: "version", kind: "bad-value", line: `version: unknown version ${JSON.stringify(version)} (this Toolroll reads ${current}${Object.keys(upgrades).length === 0 ? "" : ` and ${Object.keys(upgrades).join(", ")}`})` }] };
|
|
107
234
|
}
|
|
108
|
-
return parseContract(schema, upgrade(body));
|
|
235
|
+
return parseContract(schema, upgrade(body), options);
|
|
109
236
|
}
|
|
110
237
|
/** The Zod node kinds a model-facing schema may use: each says exactly what its JSON Schema says. */
|
|
111
238
|
const MODEL_FACING_TYPES = new Set(["string", "number", "int", "boolean", "null", "literal", "enum", "array", "object", "optional", "nullable", "union", "tuple", "record", "lazy", "readonly", "unknown", "any"]);
|