balladeer 1.0.16 → 1.0.18
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/anchor-budget.d.ts +40 -0
- package/dist/anchor-budget.js +110 -0
- package/dist/anchoring.d.ts +241 -0
- package/dist/anchoring.js +766 -0
- package/dist/anchors-client.d.ts +62 -0
- package/dist/anchors-client.js +197 -0
- package/dist/anchors-schema.d.ts +85 -0
- package/dist/anchors-schema.js +246 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +24 -7
- package/dist/commands/anchor-usage.d.ts +6 -0
- package/dist/commands/anchor-usage.js +19 -0
- package/dist/commands/anchor.d.ts +127 -0
- package/dist/commands/anchor.js +303 -0
- package/dist/commands/guidance.d.ts +2 -1
- package/dist/commands/guidance.js +55 -0
- package/dist/commands/judge.d.ts +219 -12
- package/dist/commands/judge.js +911 -103
- package/dist/commands/map.d.ts +46 -2
- package/dist/commands/map.js +244 -5
- package/dist/commands/mcp.js +2 -2
- package/dist/guidance-hook.mjs +282 -95
- package/dist/guidance.d.ts +7 -0
- package/dist/guidance.js +10 -1
- package/dist/headless-agent.d.ts +19 -1
- package/dist/headless-agent.js +22 -5
- package/dist/hook-trust.d.ts +41 -9
- package/dist/hook-trust.js +98 -16
- package/dist/judge-brief.d.ts +33 -3
- package/dist/judge-brief.js +39 -2
- package/dist/judge-hook.d.ts +188 -14
- package/dist/judge-hook.js +919 -61
- package/dist/judge-said.d.ts +52 -0
- package/dist/judge-said.js +181 -0
- package/dist/promise-meaning.d.ts +25 -0
- package/dist/promise-meaning.js +24 -7
- package/dist/relay.d.ts +71 -0
- package/dist/relay.js +193 -0
- package/dist/remove-earlier.js +4 -2
- package/dist/risk/git.d.ts +3 -1
- package/dist/risk/git.js +3 -3
- package/dist/risk/graph-cache.d.ts +83 -0
- package/dist/risk/graph-cache.js +291 -0
- package/dist/risk/import-graph.d.ts +43 -0
- package/dist/risk/import-graph.js +88 -34
- package/dist/risk/index.d.ts +1 -1
- package/dist/risk/index.js +1 -1
- package/dist/risk/pipeline.d.ts +13 -0
- package/dist/risk/pipeline.js +15 -4
- package/dist/risk/score.d.ts +10 -0
- package/dist/risk/score.js +11 -2
- package/dist/scratch-worktree.d.ts +5 -1
- package/dist/scratch-worktree.js +9 -2
- package/dist/user-scope.d.ts +74 -3
- package/dist/user-scope.js +270 -9
- package/dist/wire.d.ts +19 -3
- package/dist/wire.js +2 -2
- package/package.json +7 -2
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type PromiseAnchors } from "./anchors-schema.js";
|
|
2
|
+
import type { StoredAgent } from "./store.js";
|
|
3
|
+
/**
|
|
4
|
+
* `/api/promise-anchors`, reached with this repository's agent connection the
|
|
5
|
+
* way `/api/agent-guidance` is: the stored bearer, the workspace and
|
|
6
|
+
* repository in the query, the same origin as the connection. It is not an
|
|
7
|
+
* MCP tool, so no agent's tool list grows for it.
|
|
8
|
+
*
|
|
9
|
+
* `list` reads every agreed promise of the repository with its current
|
|
10
|
+
* anchors; `record` sends one anchors document, checked against the data
|
|
11
|
+
* boundary before it leaves the laptop. Neither throws.
|
|
12
|
+
*/
|
|
13
|
+
export declare const PROMISE_ANCHORS_PATH = "/api/promise-anchors";
|
|
14
|
+
export type AnchoredPromise = Readonly<{
|
|
15
|
+
promiseId: string;
|
|
16
|
+
semanticDigest: string;
|
|
17
|
+
title: string;
|
|
18
|
+
/** The promise's current anchors, or null when it has none for its current meaning. */
|
|
19
|
+
anchors: PromiseAnchors | null;
|
|
20
|
+
}>;
|
|
21
|
+
export type AnchorsList = Readonly<{
|
|
22
|
+
ok: true;
|
|
23
|
+
promises: readonly AnchoredPromise[];
|
|
24
|
+
/** Anchors the server sent that this command could not accept, each read as none. */
|
|
25
|
+
refused: number;
|
|
26
|
+
}> | Readonly<{
|
|
27
|
+
ok: false;
|
|
28
|
+
reason: string;
|
|
29
|
+
/** The server answered that it has no such route: an older server. */
|
|
30
|
+
unsupported?: boolean;
|
|
31
|
+
}>;
|
|
32
|
+
export type AnchorsRecord = Readonly<{
|
|
33
|
+
ok: true;
|
|
34
|
+
alreadyRecorded: boolean;
|
|
35
|
+
}> | Readonly<{
|
|
36
|
+
ok: false;
|
|
37
|
+
reason: string;
|
|
38
|
+
status?: number;
|
|
39
|
+
}>;
|
|
40
|
+
export type AnchorsClient = Readonly<{
|
|
41
|
+
list(): Promise<AnchorsList>;
|
|
42
|
+
record(anchors: PromiseAnchors): Promise<AnchorsRecord>;
|
|
43
|
+
}>;
|
|
44
|
+
/** The anchors route for one stored connection. */
|
|
45
|
+
export declare function anchorsClientFor(agent: StoredAgent, options?: Readonly<{
|
|
46
|
+
fetchImpl?: typeof fetch;
|
|
47
|
+
timeoutMs?: number;
|
|
48
|
+
}>): AnchorsClient;
|
|
49
|
+
/** The anchors route for the repository a folder is in, or the reason there is none. */
|
|
50
|
+
export declare function connectionAnchorsClient(input: Readonly<{
|
|
51
|
+
environment: NodeJS.ProcessEnv;
|
|
52
|
+
controlPlane: string;
|
|
53
|
+
cwd: string;
|
|
54
|
+
repository?: string;
|
|
55
|
+
fetchImpl?: typeof fetch;
|
|
56
|
+
}>): {
|
|
57
|
+
ok: true;
|
|
58
|
+
client: AnchorsClient;
|
|
59
|
+
} | {
|
|
60
|
+
ok: false;
|
|
61
|
+
reason: string;
|
|
62
|
+
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { checkPromiseAnchors, isPromiseAnchorId } from "./anchors-schema.js";
|
|
2
|
+
import { noteServerVersion } from "./currency.js";
|
|
3
|
+
import { agentEndpoint } from "./guidance.js";
|
|
4
|
+
import { connectionAgent } from "./promise-meaning.js";
|
|
5
|
+
import { CLIENT_HEADER, CLIENT_HEADER_VALUE } from "./wire.js";
|
|
6
|
+
/**
|
|
7
|
+
* `/api/promise-anchors`, reached with this repository's agent connection the
|
|
8
|
+
* way `/api/agent-guidance` is: the stored bearer, the workspace and
|
|
9
|
+
* repository in the query, the same origin as the connection. It is not an
|
|
10
|
+
* MCP tool, so no agent's tool list grows for it.
|
|
11
|
+
*
|
|
12
|
+
* `list` reads every agreed promise of the repository with its current
|
|
13
|
+
* anchors; `record` sends one anchors document, checked against the data
|
|
14
|
+
* boundary before it leaves the laptop. Neither throws.
|
|
15
|
+
*/
|
|
16
|
+
export const PROMISE_ANCHORS_PATH = "/api/promise-anchors";
|
|
17
|
+
const MAX_LIST_BYTES = 8 * 1024 * 1024;
|
|
18
|
+
const MAX_REPLY_BYTES = 64 * 1024;
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
20
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
21
|
+
async function boundedText(response, limit) {
|
|
22
|
+
const declared = Number(response.headers.get("content-length") ?? 0);
|
|
23
|
+
if (declared > limit)
|
|
24
|
+
throw new Error("too_large");
|
|
25
|
+
const reader = response.body?.getReader();
|
|
26
|
+
if (!reader)
|
|
27
|
+
return "";
|
|
28
|
+
const parts = [];
|
|
29
|
+
let bytes = 0;
|
|
30
|
+
try {
|
|
31
|
+
for (;;) {
|
|
32
|
+
const part = await reader.read();
|
|
33
|
+
if (part.done)
|
|
34
|
+
break;
|
|
35
|
+
bytes += part.value.byteLength;
|
|
36
|
+
if (bytes > limit)
|
|
37
|
+
throw new Error("too_large");
|
|
38
|
+
parts.push(part.value);
|
|
39
|
+
}
|
|
40
|
+
return Buffer.concat(parts).toString("utf8");
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
await reader.cancel().catch(() => undefined);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function refusalReason(status, body) {
|
|
47
|
+
const message = body !== null &&
|
|
48
|
+
typeof body === "object" &&
|
|
49
|
+
typeof body.message === "string"
|
|
50
|
+
? body.message.replace(/\s+/g, " ").trim().slice(0, 200)
|
|
51
|
+
: "";
|
|
52
|
+
const said = message === "" ? "" : `: ${message}`;
|
|
53
|
+
if (status === 401 || status === 403)
|
|
54
|
+
return `Balladeer refused this repository's agent connection${said}`;
|
|
55
|
+
if (status === 404)
|
|
56
|
+
return `Balladeer has no agreed, current promise by that id in this repository${said}`;
|
|
57
|
+
if (status === 409)
|
|
58
|
+
return `the promise's meaning changed since it was read${said}`;
|
|
59
|
+
if (status === 413)
|
|
60
|
+
return "the anchors were larger than Balladeer takes";
|
|
61
|
+
if (status === 426)
|
|
62
|
+
return "this copy of the Balladeer command is too old for the server";
|
|
63
|
+
if (status === 400)
|
|
64
|
+
return `Balladeer refused the anchors${said}`;
|
|
65
|
+
return `Balladeer answered ${status}${said}`;
|
|
66
|
+
}
|
|
67
|
+
/** The anchors route for one stored connection. */
|
|
68
|
+
export function anchorsClientFor(agent, options = {}) {
|
|
69
|
+
const send = async (init, limit) => {
|
|
70
|
+
let url;
|
|
71
|
+
try {
|
|
72
|
+
url = agentEndpoint(agent, PROMISE_ANCHORS_PATH);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return {
|
|
76
|
+
error: "this repository's stored connection does not name a Balladeer server it can use",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const abort = new AbortController();
|
|
80
|
+
const timer = setTimeout(() => abort.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
81
|
+
try {
|
|
82
|
+
const response = await (options.fetchImpl ?? fetch)(url, {
|
|
83
|
+
...init,
|
|
84
|
+
redirect: "manual",
|
|
85
|
+
signal: abort.signal,
|
|
86
|
+
headers: {
|
|
87
|
+
authorization: `Bearer ${agent.token}`,
|
|
88
|
+
[CLIENT_HEADER]: CLIENT_HEADER_VALUE,
|
|
89
|
+
accept: "application/json",
|
|
90
|
+
...(init.body === undefined ? {} : { "content-type": "application/json" }),
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
noteServerVersion(response.headers);
|
|
94
|
+
const text = await boundedText(response, limit);
|
|
95
|
+
let body;
|
|
96
|
+
try {
|
|
97
|
+
body = text === "" ? undefined : JSON.parse(text);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
body = undefined;
|
|
101
|
+
}
|
|
102
|
+
return { status: response.status, body };
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
return {
|
|
106
|
+
error: abort.signal.aborted
|
|
107
|
+
? `Balladeer did not answer within ${Math.round((options.timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000)} seconds`
|
|
108
|
+
: error.message === "too_large"
|
|
109
|
+
? "Balladeer's answer was larger than this command reads"
|
|
110
|
+
: `Balladeer could not be reached at ${agent.controlPlane}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
return {
|
|
118
|
+
async list() {
|
|
119
|
+
const answer = await send({ method: "GET" }, MAX_LIST_BYTES);
|
|
120
|
+
if ("error" in answer)
|
|
121
|
+
return { ok: false, reason: answer.error };
|
|
122
|
+
if (answer.status === 404 || answer.status === 405)
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
unsupported: true,
|
|
126
|
+
reason: "this Balladeer server does not keep promise anchors yet",
|
|
127
|
+
};
|
|
128
|
+
if (answer.status !== 200)
|
|
129
|
+
return { ok: false, reason: refusalReason(answer.status, answer.body) };
|
|
130
|
+
const rows = answer.body?.promises;
|
|
131
|
+
if (!Array.isArray(rows))
|
|
132
|
+
return { ok: false, reason: "Balladeer's answer listed no promises" };
|
|
133
|
+
const promises = [];
|
|
134
|
+
let refused = 0;
|
|
135
|
+
for (const row of rows) {
|
|
136
|
+
if (row === null || typeof row !== "object")
|
|
137
|
+
continue;
|
|
138
|
+
const { promiseId, semanticDigest, title, anchors } = row;
|
|
139
|
+
if (!isPromiseAnchorId(promiseId))
|
|
140
|
+
continue;
|
|
141
|
+
if (typeof semanticDigest !== "string" || !DIGEST.test(semanticDigest))
|
|
142
|
+
continue;
|
|
143
|
+
let current = null;
|
|
144
|
+
if (anchors !== null && anchors !== undefined) {
|
|
145
|
+
const checked = checkPromiseAnchors(anchors);
|
|
146
|
+
// Anchors for another promise or an older meaning are not this promise's current ones.
|
|
147
|
+
if (checked.ok &&
|
|
148
|
+
checked.value.promiseId === promiseId &&
|
|
149
|
+
checked.value.semanticDigest === semanticDigest)
|
|
150
|
+
current = checked.value;
|
|
151
|
+
else
|
|
152
|
+
refused += 1;
|
|
153
|
+
}
|
|
154
|
+
promises.push({
|
|
155
|
+
promiseId,
|
|
156
|
+
semanticDigest,
|
|
157
|
+
title: typeof title === "string" && title.trim() !== ""
|
|
158
|
+
? title.trim().slice(0, 200)
|
|
159
|
+
: promiseId,
|
|
160
|
+
anchors: current,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return { ok: true, promises, refused };
|
|
164
|
+
},
|
|
165
|
+
async record(anchors) {
|
|
166
|
+
const checked = checkPromiseAnchors(anchors);
|
|
167
|
+
if (!checked.ok)
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
reason: `not sent, because they fail the data boundary: ${checked.problems.slice(0, 3).join("; ")}`,
|
|
171
|
+
};
|
|
172
|
+
const answer = await send({ method: "POST", body: JSON.stringify(checked.value) }, MAX_REPLY_BYTES);
|
|
173
|
+
if ("error" in answer)
|
|
174
|
+
return { ok: false, reason: answer.error };
|
|
175
|
+
if (answer.status === 200 || answer.status === 201)
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
alreadyRecorded: answer.body?.alreadyRecorded === true,
|
|
179
|
+
};
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
status: answer.status,
|
|
183
|
+
reason: refusalReason(answer.status, answer.body),
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** The anchors route for the repository a folder is in, or the reason there is none. */
|
|
189
|
+
export function connectionAnchorsClient(input) {
|
|
190
|
+
const found = connectionAgent(input);
|
|
191
|
+
if (!found.ok)
|
|
192
|
+
return found;
|
|
193
|
+
return {
|
|
194
|
+
ok: true,
|
|
195
|
+
client: anchorsClientFor(found.agent, input.fetchImpl === undefined ? {} : { fetchImpl: input.fetchImpl }),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type BehaviorMapEntryPoint, type BehaviorMapResource, type BehaviorMapSymbol } from "./behavior-map-schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* `balladeer-promise-anchors/v1`, as `docs/agentic-testing/anchors.md` spells
|
|
4
|
+
* it: where an agreed promise's behavior lives in a repository, as paths and
|
|
5
|
+
* names only.
|
|
6
|
+
*
|
|
7
|
+
* This is the command's own check of the data boundary, run before anything is
|
|
8
|
+
* sent. The server runs the same rules and refuses anything else, so a document
|
|
9
|
+
* this refuses would be refused there too; checking here means a refusal is
|
|
10
|
+
* explained on the laptop, and nothing that is not a path or a name ever leaves
|
|
11
|
+
* it. Each rule is its own named check below, so a test can show that every one
|
|
12
|
+
* of them is load-bearing.
|
|
13
|
+
*/
|
|
14
|
+
export declare const PROMISE_ANCHORS_SCHEMA_VERSION = "balladeer-promise-anchors/v1";
|
|
15
|
+
export declare const ANCHOR_BRIEF_SOURCE = "anchor-brief/v3";
|
|
16
|
+
export declare const BEHAVIOR_MAP_SOURCE = "behavior-map";
|
|
17
|
+
export declare const ANCHOR_SOURCES: readonly ["anchor-brief/v3", "behavior-map"];
|
|
18
|
+
export type AnchorSource = (typeof ANCHOR_SOURCES)[number];
|
|
19
|
+
export declare const ANCHOR_LIMITS: {
|
|
20
|
+
readonly entryPoints: 12;
|
|
21
|
+
readonly symbols: 24;
|
|
22
|
+
readonly resources: 20;
|
|
23
|
+
readonly linkedTests: 20;
|
|
24
|
+
readonly path: 400;
|
|
25
|
+
readonly name: 200;
|
|
26
|
+
};
|
|
27
|
+
export type AnchorEntryPoint = Readonly<Omit<BehaviorMapEntryPoint, "note">>;
|
|
28
|
+
export type AnchorSymbol = Readonly<Omit<BehaviorMapSymbol, "role">>;
|
|
29
|
+
export type AnchorResource = Readonly<BehaviorMapResource>;
|
|
30
|
+
export type AnchorLinkedTest = Readonly<{
|
|
31
|
+
file: string;
|
|
32
|
+
}>;
|
|
33
|
+
export type PromiseAnchors = Readonly<{
|
|
34
|
+
schemaVersion: typeof PROMISE_ANCHORS_SCHEMA_VERSION;
|
|
35
|
+
promiseId: string;
|
|
36
|
+
semanticDigest: string;
|
|
37
|
+
anchoredAt: Readonly<{
|
|
38
|
+
sha: string;
|
|
39
|
+
at: string;
|
|
40
|
+
source: AnchorSource;
|
|
41
|
+
client: "claude" | "codex" | null;
|
|
42
|
+
model: string | null;
|
|
43
|
+
}>;
|
|
44
|
+
entryPoints: readonly AnchorEntryPoint[];
|
|
45
|
+
symbols: readonly AnchorSymbol[];
|
|
46
|
+
resources: readonly AnchorResource[];
|
|
47
|
+
linkedTests: readonly AnchorLinkedTest[];
|
|
48
|
+
}>;
|
|
49
|
+
export type AnchorsCheck = Readonly<{
|
|
50
|
+
ok: true;
|
|
51
|
+
value: PromiseAnchors;
|
|
52
|
+
}> | Readonly<{
|
|
53
|
+
ok: false;
|
|
54
|
+
problems: readonly string[];
|
|
55
|
+
}>;
|
|
56
|
+
/** Every pattern rule by name, for the parity test against the server's copy. */
|
|
57
|
+
export declare const ANCHOR_RULES: {
|
|
58
|
+
readonly promiseId: RegExp;
|
|
59
|
+
readonly digest: RegExp;
|
|
60
|
+
readonly sha: RegExp;
|
|
61
|
+
readonly instant: RegExp;
|
|
62
|
+
readonly model: RegExp;
|
|
63
|
+
readonly pathCharacters: RegExp;
|
|
64
|
+
readonly pathSpacing: RegExp;
|
|
65
|
+
readonly symbolName: RegExp;
|
|
66
|
+
readonly resourceWhitespace: RegExp;
|
|
67
|
+
readonly resourceQuotes: RegExp;
|
|
68
|
+
readonly resourcePunctuation: RegExp;
|
|
69
|
+
readonly controlCharacters: RegExp;
|
|
70
|
+
};
|
|
71
|
+
/** A repository-relative path with forward slashes, inside the repository, at most 400 characters. */
|
|
72
|
+
export declare function isAnchorPath(value: unknown): value is string;
|
|
73
|
+
/** An identifier, optionally dotted, at most 200 characters. */
|
|
74
|
+
export declare function isAnchorSymbol(value: unknown): value is string;
|
|
75
|
+
/** A table, route, flag or other resource name: no whitespace, quotes or code punctuation. */
|
|
76
|
+
export declare function isAnchorResourceName(value: unknown): value is string;
|
|
77
|
+
export declare function isPromiseAnchorId(value: unknown): value is string;
|
|
78
|
+
/**
|
|
79
|
+
* A whole anchors document, checked against the contract and the data
|
|
80
|
+
* boundary. Problems name the field and the rule, never the value, so a
|
|
81
|
+
* refusal never repeats what it refused.
|
|
82
|
+
*/
|
|
83
|
+
export declare function checkPromiseAnchors(value: unknown): AnchorsCheck;
|
|
84
|
+
/** The files a promise is selected by: its entry points' and its symbols' files. */
|
|
85
|
+
export declare function anchorFiles(anchors: PromiseAnchors): string[];
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { ENTRY_POINT_KINDS, RESOURCE_ACCESS, RESOURCE_KINDS, SYMBOL_KINDS, } from "./behavior-map-schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* `balladeer-promise-anchors/v1`, as `docs/agentic-testing/anchors.md` spells
|
|
4
|
+
* it: where an agreed promise's behavior lives in a repository, as paths and
|
|
5
|
+
* names only.
|
|
6
|
+
*
|
|
7
|
+
* This is the command's own check of the data boundary, run before anything is
|
|
8
|
+
* sent. The server runs the same rules and refuses anything else, so a document
|
|
9
|
+
* this refuses would be refused there too; checking here means a refusal is
|
|
10
|
+
* explained on the laptop, and nothing that is not a path or a name ever leaves
|
|
11
|
+
* it. Each rule is its own named check below, so a test can show that every one
|
|
12
|
+
* of them is load-bearing.
|
|
13
|
+
*/
|
|
14
|
+
export const PROMISE_ANCHORS_SCHEMA_VERSION = "balladeer-promise-anchors/v1";
|
|
15
|
+
export const ANCHOR_BRIEF_SOURCE = "anchor-brief/v3";
|
|
16
|
+
export const BEHAVIOR_MAP_SOURCE = "behavior-map";
|
|
17
|
+
export const ANCHOR_SOURCES = [ANCHOR_BRIEF_SOURCE, BEHAVIOR_MAP_SOURCE];
|
|
18
|
+
export const ANCHOR_LIMITS = {
|
|
19
|
+
entryPoints: 12,
|
|
20
|
+
symbols: 24,
|
|
21
|
+
resources: 20,
|
|
22
|
+
linkedTests: 20,
|
|
23
|
+
path: 400,
|
|
24
|
+
name: 200,
|
|
25
|
+
};
|
|
26
|
+
// The rules, written exactly as the server writes them
|
|
27
|
+
// (`packages/service/src/promise-anchors.ts`); `tests/cli/anchors-parity.test.ts`
|
|
28
|
+
// holds the two copies to the same text and the same answers.
|
|
29
|
+
/** `prom_` ids as Balladeer issues them. */
|
|
30
|
+
const PROMISE_ID_RULE = /^prom_[a-z0-9]{8,64}$/;
|
|
31
|
+
const DIGEST_RULE = /^sha256:[0-9a-f]{64}$/;
|
|
32
|
+
const SHA_RULE = /^[0-9a-f]{40}$/;
|
|
33
|
+
const INSTANT_RULE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
34
|
+
const MODEL_RULE = /^[A-Za-z0-9][A-Za-z0-9._:/@+[\]-]{0,127}$/;
|
|
35
|
+
/** The characters file paths use: letters, digits, `_ - . / @ ( ) [ ] + ~ $` and spaces. */
|
|
36
|
+
const PATH_CHARACTERS_RULE = /^[A-Za-z0-9_\-./@()[\]+~$ ]+$/;
|
|
37
|
+
/** Spaces only one at a time, and only between other characters. */
|
|
38
|
+
const PATH_SPACING_RULE = /^ | {2}| $/;
|
|
39
|
+
/** An identifier, optionally dotted (`Class.method`). */
|
|
40
|
+
const SYMBOL_RULE = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/;
|
|
41
|
+
/** What a resource name may not hold: whitespace, quotes, backticks, `;`, `=`, `<`, `>`. */
|
|
42
|
+
const RESOURCE_WHITESPACE_RULE = /\s/;
|
|
43
|
+
const RESOURCE_QUOTE_RULE = /['"`]/;
|
|
44
|
+
const RESOURCE_PUNCTUATION_RULE = /[;=<>]/;
|
|
45
|
+
const RESOURCE_CONTROL_RULE = /[\u0000-\u001f\u007f]/;
|
|
46
|
+
/** Every pattern rule by name, for the parity test against the server's copy. */
|
|
47
|
+
export const ANCHOR_RULES = {
|
|
48
|
+
promiseId: PROMISE_ID_RULE,
|
|
49
|
+
digest: DIGEST_RULE,
|
|
50
|
+
sha: SHA_RULE,
|
|
51
|
+
instant: INSTANT_RULE,
|
|
52
|
+
model: MODEL_RULE,
|
|
53
|
+
pathCharacters: PATH_CHARACTERS_RULE,
|
|
54
|
+
pathSpacing: PATH_SPACING_RULE,
|
|
55
|
+
symbolName: SYMBOL_RULE,
|
|
56
|
+
resourceWhitespace: RESOURCE_WHITESPACE_RULE,
|
|
57
|
+
resourceQuotes: RESOURCE_QUOTE_RULE,
|
|
58
|
+
resourcePunctuation: RESOURCE_PUNCTUATION_RULE,
|
|
59
|
+
controlCharacters: RESOURCE_CONTROL_RULE,
|
|
60
|
+
};
|
|
61
|
+
const isText = (value) => typeof value === "string";
|
|
62
|
+
/** A repository-relative path with forward slashes, inside the repository, at most 400 characters. */
|
|
63
|
+
export function isAnchorPath(value) {
|
|
64
|
+
if (!isText(value))
|
|
65
|
+
return false;
|
|
66
|
+
if (value.length > ANCHOR_LIMITS.path)
|
|
67
|
+
return false;
|
|
68
|
+
// Also refuses the empty string.
|
|
69
|
+
if (!PATH_CHARACTERS_RULE.test(value))
|
|
70
|
+
return false;
|
|
71
|
+
if (PATH_SPACING_RULE.test(value))
|
|
72
|
+
return false;
|
|
73
|
+
// An absolute path, a doubled slash and a trailing slash each leave an empty segment.
|
|
74
|
+
if (value.split("/").some((segment) => segment === ""))
|
|
75
|
+
return false;
|
|
76
|
+
if (value.split("/").some((segment) => segment === ".."))
|
|
77
|
+
return false;
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
/** An identifier, optionally dotted, at most 200 characters. */
|
|
81
|
+
export function isAnchorSymbol(value) {
|
|
82
|
+
if (!isText(value))
|
|
83
|
+
return false;
|
|
84
|
+
if (value.length > ANCHOR_LIMITS.name)
|
|
85
|
+
return false;
|
|
86
|
+
return SYMBOL_RULE.test(value);
|
|
87
|
+
}
|
|
88
|
+
/** A table, route, flag or other resource name: no whitespace, quotes or code punctuation. */
|
|
89
|
+
export function isAnchorResourceName(value) {
|
|
90
|
+
if (!isText(value))
|
|
91
|
+
return false;
|
|
92
|
+
if (value.length < 1)
|
|
93
|
+
return false;
|
|
94
|
+
if (value.length > ANCHOR_LIMITS.name)
|
|
95
|
+
return false;
|
|
96
|
+
if (RESOURCE_WHITESPACE_RULE.test(value))
|
|
97
|
+
return false;
|
|
98
|
+
if (RESOURCE_QUOTE_RULE.test(value))
|
|
99
|
+
return false;
|
|
100
|
+
if (RESOURCE_PUNCTUATION_RULE.test(value))
|
|
101
|
+
return false;
|
|
102
|
+
if (RESOURCE_CONTROL_RULE.test(value))
|
|
103
|
+
return false;
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
export function isPromiseAnchorId(value) {
|
|
107
|
+
return isText(value) && PROMISE_ID_RULE.test(value);
|
|
108
|
+
}
|
|
109
|
+
function isInstant(value) {
|
|
110
|
+
return isText(value) && INSTANT_RULE.test(value) && !Number.isNaN(Date.parse(value));
|
|
111
|
+
}
|
|
112
|
+
function isModel(value) {
|
|
113
|
+
return isText(value) && MODEL_RULE.test(value);
|
|
114
|
+
}
|
|
115
|
+
function oneOf(values) {
|
|
116
|
+
return (value) => isText(value) && values.includes(value);
|
|
117
|
+
}
|
|
118
|
+
const isEntryKind = oneOf(ENTRY_POINT_KINDS);
|
|
119
|
+
const isSymbolKind = oneOf(SYMBOL_KINDS);
|
|
120
|
+
const isResourceKind = oneOf(RESOURCE_KINDS);
|
|
121
|
+
const isResourceAccess = oneOf(RESOURCE_ACCESS);
|
|
122
|
+
const isSource = oneOf(ANCHOR_SOURCES);
|
|
123
|
+
const isClient = oneOf(["claude", "codex"]);
|
|
124
|
+
function isRecord(value) {
|
|
125
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
126
|
+
}
|
|
127
|
+
/** Exactly these keys and no others, so no free-text field can ride along. */
|
|
128
|
+
function hasExactKeys(value, keys) {
|
|
129
|
+
const present = Object.keys(value).sort();
|
|
130
|
+
const wanted = [...keys].sort();
|
|
131
|
+
return present.length === wanted.length && present.every((key, index) => key === wanted[index]);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* A whole anchors document, checked against the contract and the data
|
|
135
|
+
* boundary. Problems name the field and the rule, never the value, so a
|
|
136
|
+
* refusal never repeats what it refused.
|
|
137
|
+
*/
|
|
138
|
+
export function checkPromiseAnchors(value) {
|
|
139
|
+
const problems = [];
|
|
140
|
+
const fail = (at, rule) => problems.push(`${at} ${rule}`);
|
|
141
|
+
if (!isRecord(value))
|
|
142
|
+
return { ok: false, problems: ["the document is not an object"] };
|
|
143
|
+
if (!hasExactKeys(value, [
|
|
144
|
+
"schemaVersion",
|
|
145
|
+
"promiseId",
|
|
146
|
+
"semanticDigest",
|
|
147
|
+
"anchoredAt",
|
|
148
|
+
"entryPoints",
|
|
149
|
+
"symbols",
|
|
150
|
+
"resources",
|
|
151
|
+
"linkedTests",
|
|
152
|
+
]))
|
|
153
|
+
fail("the document", "has fields the contract does not define, or lacks one");
|
|
154
|
+
if (value.schemaVersion !== PROMISE_ANCHORS_SCHEMA_VERSION)
|
|
155
|
+
fail("schemaVersion", `is not ${PROMISE_ANCHORS_SCHEMA_VERSION}`);
|
|
156
|
+
if (!isPromiseAnchorId(value.promiseId))
|
|
157
|
+
fail("promiseId", "is not a prom_ id");
|
|
158
|
+
if (!isText(value.semanticDigest) || !DIGEST_RULE.test(value.semanticDigest))
|
|
159
|
+
fail("semanticDigest", "is not sha256: and 64 hex characters");
|
|
160
|
+
const anchoredAt = value.anchoredAt;
|
|
161
|
+
if (!isRecord(anchoredAt))
|
|
162
|
+
fail("anchoredAt", "is not an object");
|
|
163
|
+
else {
|
|
164
|
+
if (!hasExactKeys(anchoredAt, ["sha", "at", "source", "client", "model"]))
|
|
165
|
+
fail("anchoredAt", "has fields the contract does not define, or lacks one");
|
|
166
|
+
if (!isText(anchoredAt.sha) || !SHA_RULE.test(anchoredAt.sha))
|
|
167
|
+
fail("anchoredAt.sha", "is not a 40-character commit sha");
|
|
168
|
+
if (!isInstant(anchoredAt.at))
|
|
169
|
+
fail("anchoredAt.at", "is not an ISO 8601 instant");
|
|
170
|
+
if (!isSource(anchoredAt.source))
|
|
171
|
+
fail("anchoredAt.source", "is not a known source");
|
|
172
|
+
if (anchoredAt.source === BEHAVIOR_MAP_SOURCE) {
|
|
173
|
+
if (anchoredAt.client !== null)
|
|
174
|
+
fail("anchoredAt.client", "must be null for a map");
|
|
175
|
+
if (anchoredAt.model !== null)
|
|
176
|
+
fail("anchoredAt.model", "must be null for a map");
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
if (!isClient(anchoredAt.client))
|
|
180
|
+
fail("anchoredAt.client", "is not claude or codex");
|
|
181
|
+
if (!isModel(anchoredAt.model))
|
|
182
|
+
fail("anchoredAt.model", "is not a model name");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const list = (key, keys, check) => {
|
|
186
|
+
const items = value[key];
|
|
187
|
+
if (!Array.isArray(items)) {
|
|
188
|
+
fail(key, "is not a list");
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
if (items.length > ANCHOR_LIMITS[key])
|
|
192
|
+
fail(key, `has more than ${ANCHOR_LIMITS[key]} entries`);
|
|
193
|
+
items.forEach((item, index) => {
|
|
194
|
+
const at = `${key}[${index}]`;
|
|
195
|
+
if (!isRecord(item))
|
|
196
|
+
return fail(at, "is not an object");
|
|
197
|
+
if (!hasExactKeys(item, keys))
|
|
198
|
+
fail(at, "has fields the contract does not define, or lacks one");
|
|
199
|
+
check(item, at);
|
|
200
|
+
});
|
|
201
|
+
return items.length;
|
|
202
|
+
};
|
|
203
|
+
const entryPoints = list("entryPoints", ["file", "symbol", "kind"], (item, at) => {
|
|
204
|
+
if (!isAnchorPath(item.file))
|
|
205
|
+
fail(`${at}.file`, "is not a repository path");
|
|
206
|
+
if (!isAnchorSymbol(item.symbol))
|
|
207
|
+
fail(`${at}.symbol`, "is not a symbol name");
|
|
208
|
+
if (!isEntryKind(item.kind))
|
|
209
|
+
fail(`${at}.kind`, "is not an entry point kind");
|
|
210
|
+
});
|
|
211
|
+
const symbols = list("symbols", ["file", "name", "kind"], (item, at) => {
|
|
212
|
+
if (!isAnchorPath(item.file))
|
|
213
|
+
fail(`${at}.file`, "is not a repository path");
|
|
214
|
+
if (!isAnchorSymbol(item.name))
|
|
215
|
+
fail(`${at}.name`, "is not a symbol name");
|
|
216
|
+
if (!isSymbolKind(item.kind))
|
|
217
|
+
fail(`${at}.kind`, "is not a symbol kind");
|
|
218
|
+
});
|
|
219
|
+
list("resources", ["kind", "name", "access"], (item, at) => {
|
|
220
|
+
if (!isResourceKind(item.kind))
|
|
221
|
+
fail(`${at}.kind`, "is not a resource kind");
|
|
222
|
+
if (!isAnchorResourceName(item.name))
|
|
223
|
+
fail(`${at}.name`, "is not a resource name");
|
|
224
|
+
if (!isResourceAccess(item.access))
|
|
225
|
+
fail(`${at}.access`, "is not a resource access");
|
|
226
|
+
});
|
|
227
|
+
list("linkedTests", ["file"], (item, at) => {
|
|
228
|
+
if (!isAnchorPath(item.file))
|
|
229
|
+
fail(`${at}.file`, "is not a repository path");
|
|
230
|
+
});
|
|
231
|
+
// A document that names no anchor file could never select its promise.
|
|
232
|
+
if (entryPoints + symbols === 0)
|
|
233
|
+
fail("the document", "names no entry point and no symbol");
|
|
234
|
+
return problems.length === 0
|
|
235
|
+
? { ok: true, value: value }
|
|
236
|
+
: { ok: false, problems };
|
|
237
|
+
}
|
|
238
|
+
/** The files a promise is selected by: its entry points' and its symbols' files. */
|
|
239
|
+
export function anchorFiles(anchors) {
|
|
240
|
+
return [
|
|
241
|
+
...new Set([
|
|
242
|
+
...anchors.entryPoints.map((entry) => entry.file),
|
|
243
|
+
...anchors.symbols.map((symbol) => symbol.file),
|
|
244
|
+
]),
|
|
245
|
+
].sort();
|
|
246
|
+
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ type Parsed = Readonly<{
|
|
|
33
33
|
*/
|
|
34
34
|
repositories: readonly string[];
|
|
35
35
|
hook: "codex" | "claude" | undefined;
|
|
36
|
-
event: "SessionStart" | "UserPromptSubmit" | "SubagentStart" | undefined;
|
|
36
|
+
event: "SessionStart" | "UserPromptSubmit" | "SubagentStart" | "PostToolUse" | undefined;
|
|
37
37
|
owner: string | undefined;
|
|
38
38
|
/** `check-seals --install-hook`: write the optional pre-push hook. */
|
|
39
39
|
installHook: boolean;
|