@ogygie/core 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@ogygie/core",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Ogygie framework core: manifest/spec/error schemas, generic tenancy and gate tooling. Ships TypeScript sources — Ogygie products run tsx/vitest by construction (D1).",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/julienTeam/Ogygie.git",
9
+ "directory": "packages/ogygie-core"
10
+ },
6
11
  "type": "module",
7
12
  "files": [
8
13
  "src",
@@ -26,7 +31,9 @@
26
31
  "./impact": "./src/impact.ts",
27
32
  "./manifest": "./src/manifest.schema.ts",
28
33
  "./manifest-owners": "./src/manifest-owners.ts",
34
+ "./model": "./src/model.ts",
29
35
  "./module-purity": "./src/module-purity.ts",
36
+ "./proposal": "./src/proposal.ts",
30
37
  "./repo-adapter": "./src/repo-adapter.ts",
31
38
  "./scan": "./src/scan.ts",
32
39
  "./shared-tables": "./src/shared-tables.ts",
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @intent Proves the shared model client against a fake `fetch`: the request
3
+ * carries the key, the version, the model, a cached system block, the
4
+ * messages and the effort; the text, the stop reason and the usage come back;
5
+ * a spent budget, an outage and a network failure each have their code and
6
+ * status.
7
+ */
8
+ import { describe, expect, it } from "vitest";
9
+ import { OgygieFailure } from "./error.schema.js";
10
+ import { createMessagesClient } from "./model.js";
11
+
12
+ function fakeApi(answer: { status?: number; body: unknown }) {
13
+ const calls: { url: string; headers: Record<string, string>; body: Record<string, unknown> }[] = [];
14
+ const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
15
+ calls.push({ url: String(input), headers: init?.headers as Record<string, string>, body: JSON.parse(String(init?.body)) as Record<string, unknown> });
16
+ return new Response(typeof answer.body === "string" ? answer.body : JSON.stringify(answer.body), { status: answer.status ?? 200 });
17
+ }) as typeof fetch;
18
+ return { fetchImpl, calls };
19
+ }
20
+
21
+ const OK = { content: [{ type: "text", text: "hello" }, { type: "thinking", thinking: "" }], stop_reason: "end_turn", model: "claude-opus-5-5", usage: { input_tokens: 12, output_tokens: 3 } };
22
+
23
+ describe("createMessagesClient", () => {
24
+ it("ÉTANT DONNÉ une complétion ALORS la requête porte clé, version, modèle, bloc system mis en cache, messages, effort ; le texte, le stop et l'usage reviennent", async () => {
25
+ const api = fakeApi({ body: OK });
26
+ const client = createMessagesClient({ apiKey: "sk-test", fetchImpl: api.fetchImpl });
27
+ const answer = await client.complete({ model: "claude-opus-5-5", system: "rules", messages: [{ role: "user", content: "hi" }], maxTokens: 500, effort: "low" });
28
+ expect(answer).toEqual({ text: "hello", model: "claude-opus-5-5", stopReason: "end_turn", usage: { inputTokens: 12, outputTokens: 3 } });
29
+ expect(api.calls[0]?.url).toBe("https://api.anthropic.com/v1/messages");
30
+ expect(api.calls[0]?.headers["x-api-key"]).toBe("sk-test");
31
+ expect(api.calls[0]?.headers["anthropic-version"]).toBe("2023-06-01");
32
+ expect(api.calls[0]?.body).toEqual({
33
+ model: "claude-opus-5-5",
34
+ max_tokens: 500,
35
+ system: [{ type: "text", text: "rules", cache_control: { type: "ephemeral" } }],
36
+ messages: [{ role: "user", content: "hi" }],
37
+ output_config: { effort: "low" },
38
+ });
39
+ });
40
+
41
+ it.each([
42
+ ["plafond dépensé", { status: 400, body: { error: { message: "Your credit balance is too low" } } }, "MODEL_BUDGET_EXCEEDED", 429],
43
+ ["panne", { status: 529, body: "overloaded" }, "MODEL_UNAVAILABLE", 502],
44
+ ])("%s → %s", async (_label, answer, code, status) => {
45
+ const api = fakeApi(answer);
46
+ const client = createMessagesClient({ apiKey: "k", fetchImpl: api.fetchImpl, credentialHint: "OGYGIE_MODEL_API_KEY" });
47
+ try {
48
+ await client.complete({ model: "m", system: "s", messages: [{ role: "user", content: "u" }] });
49
+ expect.fail("expected a refusal");
50
+ } catch (thrown) {
51
+ expect(thrown).toBeInstanceOf(OgygieFailure);
52
+ expect((thrown as OgygieFailure).error.code).toBe(code);
53
+ expect((thrown as OgygieFailure).httpStatus).toBe(status);
54
+ }
55
+ });
56
+
57
+ it("un échec réseau → MODEL_UNAVAILABLE", async () => {
58
+ const client = createMessagesClient({ apiKey: "k", fetchImpl: (async () => { throw new Error("ECONNRESET"); }) as typeof fetch });
59
+ await expect(client.complete({ model: "m", system: "s", messages: [{ role: "user", content: "u" }] })).rejects.toMatchObject({ error: { code: "MODEL_UNAVAILABLE", cause: "ECONNRESET" } });
60
+ });
61
+ });
package/src/model.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @intent The ONE way the framework calls a model: the Messages API over an
3
+ * injected `fetch` — no SDK, no dependency (the core's contract, D1). Two
4
+ * consumers share it — the hosted studio (brief → spec, spec ogygie-hosted-mode
5
+ * B3) and the generator (the filling of holes, D24) — and a request shape
6
+ * fixed on one side and not the other would be a lockstep bug waiting to
7
+ * happen (the motive of D10, applied to models).
8
+ *
9
+ * What it knows: the endpoint, the headers, the cached system block, the
10
+ * usage an answer carries, and the two refusals every caller must tell apart
11
+ * — the API unreachable or failing (`MODEL_UNAVAILABLE`) and the client's
12
+ * workspace out of budget (`MODEL_BUDGET_EXCEEDED`, D50). What it does NOT
13
+ * decide: what a refusal or a truncated answer means for the caller — the
14
+ * stop reason is returned, each caller names its own outcome.
15
+ */
16
+ import { throwOgygieError } from "./error.schema.js";
17
+
18
+ export interface ModelMessage {
19
+ role: "user" | "assistant";
20
+ content: string;
21
+ }
22
+
23
+ export interface ModelCompletionInput {
24
+ model: string;
25
+ /** Stable instructions and context — marked for caching on the model side. */
26
+ system: string;
27
+ messages: ModelMessage[];
28
+ maxTokens?: number;
29
+ effort?: "low" | "medium" | "high" | "xhigh" | "max";
30
+ }
31
+
32
+ export interface ModelCompletion {
33
+ text: string;
34
+ model: string;
35
+ stopReason: string | null;
36
+ usage: { inputTokens: number; outputTokens: number };
37
+ }
38
+
39
+ export interface ModelClient {
40
+ complete(input: ModelCompletionInput): Promise<ModelCompletion>;
41
+ }
42
+
43
+ export interface MessagesClientDeps {
44
+ apiKey: string;
45
+ fetchImpl: typeof fetch;
46
+ apiBase?: string;
47
+ /** Named in a refusal so the operator knows which key to check. */
48
+ credentialHint?: string;
49
+ }
50
+
51
+ /** Shape of what the Messages API answers, reduced to what is read here. */
52
+ interface MessagesAnswer {
53
+ content: { type: string; text?: string }[];
54
+ stop_reason: string | null;
55
+ model: string;
56
+ usage: { input_tokens: number; output_tokens: number };
57
+ }
58
+
59
+ export function createMessagesClient(deps: MessagesClientDeps): ModelClient {
60
+ const apiBase = deps.apiBase ?? "https://api.anthropic.com";
61
+ const credential = deps.credentialHint ?? "the model API key";
62
+ return {
63
+ async complete(input) {
64
+ let response: Response;
65
+ try {
66
+ response = await deps.fetchImpl(`${apiBase}/v1/messages`, {
67
+ method: "POST",
68
+ headers: {
69
+ "x-api-key": deps.apiKey,
70
+ "anthropic-version": "2023-06-01",
71
+ "content-type": "application/json",
72
+ },
73
+ body: JSON.stringify({
74
+ model: input.model,
75
+ max_tokens: input.maxTokens ?? 16_000,
76
+ system: [{ type: "text", text: input.system, cache_control: { type: "ephemeral" } }],
77
+ messages: input.messages,
78
+ output_config: { effort: input.effort ?? "high" },
79
+ }),
80
+ });
81
+ } catch (thrown) {
82
+ return throwOgygieError(
83
+ {
84
+ code: "MODEL_UNAVAILABLE",
85
+ message: "The model API could not be reached.",
86
+ file: null,
87
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
88
+ suggestedFix: "Retry once the network is back; nothing was written.",
89
+ },
90
+ 502,
91
+ );
92
+ }
93
+ if (!response.ok) {
94
+ const body = await response.text();
95
+ const budget = response.status === 400 && /credit|billing|spend|budget/i.test(body);
96
+ return throwOgygieError(
97
+ {
98
+ code: budget ? "MODEL_BUDGET_EXCEEDED" : "MODEL_UNAVAILABLE",
99
+ message: budget
100
+ ? "The model workspace refused the call: its spending limit is reached."
101
+ : `The model API answered ${response.status}.`,
102
+ file: null,
103
+ cause: `status=${response.status} ${body.slice(0, 300)}`,
104
+ suggestedFix: budget
105
+ ? "The monthly budget of this workspace is spent; it resets with the month, or the operator raises it."
106
+ : `Check ${credential} and retry; nothing was written.`,
107
+ },
108
+ budget ? 429 : 502,
109
+ );
110
+ }
111
+ const answer = (await response.json()) as MessagesAnswer;
112
+ return {
113
+ text: answer.content
114
+ .filter((block) => block.type === "text" && typeof block.text === "string")
115
+ .map((block) => block.text ?? "")
116
+ .join(""),
117
+ model: answer.model,
118
+ stopReason: answer.stop_reason,
119
+ usage: { inputTokens: answer.usage.input_tokens, outputTokens: answer.usage.output_tokens },
120
+ };
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,315 @@
1
+ /**
2
+ * @intent Proves the proposal module of the core (D17, D51) on an in-memory
3
+ * repo and an in-memory GitHub: the client hits the documented endpoints over
4
+ * an injected `fetch`, the perimeter is refused before any call, the branch
5
+ * and the PR body are what the caller injects, and nothing touches a disk or
6
+ * the network. The full behavioral suite of the write path (every refusal of
7
+ * spec ogygie-mcp B10) runs in the MCP package, which consumes this module.
8
+ */
9
+ import { describe, expect, it } from "vitest";
10
+ import { OgygieFailure } from "./error.schema.js";
11
+ import { createInMemoryRepoAdapter } from "./repo-adapter.js";
12
+ import {
13
+ assertProposablePath,
14
+ createGithubWriteClient,
15
+ proposeChange,
16
+ type GithubWriteClient,
17
+ type ProposalDeps,
18
+ type ProposalTarget,
19
+ } from "./proposal.js";
20
+
21
+ const GITHUB = { owner: "acme", repo: "site", defaultBranch: "main" };
22
+
23
+ // ------------------------------------------------------------- the client
24
+
25
+ function fakeFetch(routes: Record<string, { status: number; body?: unknown }>) {
26
+ const calls: { method: string; url: string; headers: Record<string, string>; body: unknown }[] = [];
27
+ const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
28
+ const url = String(input);
29
+ const method = init?.method ?? "GET";
30
+ calls.push({
31
+ method,
32
+ url,
33
+ headers: init?.headers as Record<string, string>,
34
+ body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
35
+ });
36
+ const route = routes[`${method} ${url.replace("https://api.github.com", "")}`];
37
+ if (route === undefined) return new Response("not found", { status: 404 });
38
+ return new Response(route.body === undefined ? null : JSON.stringify(route.body), { status: route.status });
39
+ }) as typeof fetch;
40
+ return { fetchImpl, calls };
41
+ }
42
+
43
+ describe("GithubWriteClient", () => {
44
+ it("lit une tête de branche, et rend null quand elle n'existe pas", async () => {
45
+ const { fetchImpl, calls } = fakeFetch({
46
+ "GET /repos/acme/site/git/ref/heads/main": { status: 200, body: { object: { sha: "abc" } } },
47
+ });
48
+ const client = createGithubWriteClient({ token: "tkn", fetchImpl }, GITHUB);
49
+ expect(await client.getBranchHead("main")).toBe("abc");
50
+ expect(await client.getBranchHead("mcp/acme/x")).toBeNull();
51
+ expect(calls[0]?.headers["authorization"]).toBe("Bearer tkn");
52
+ });
53
+
54
+ it("décode le contenu base64 d'un fichier et porte son sha ; absent → null", async () => {
55
+ const { fetchImpl } = fakeFetch({
56
+ "GET /repos/acme/site/contents/DECISIONS.md?ref=abc": {
57
+ status: 200,
58
+ body: { content: Buffer.from("# Décisions\n", "utf8").toString("base64"), sha: "blob1" },
59
+ },
60
+ });
61
+ const client = createGithubWriteClient({ token: "tkn", fetchImpl }, GITHUB);
62
+ expect(await client.readFileAtRef("DECISIONS.md", "abc")).toEqual({ content: "# Décisions\n", sha: "blob1" });
63
+ expect(await client.readFileAtRef("specs/none.spec.md", "abc")).toBeNull();
64
+ });
65
+
66
+ it("commit un fichier en base64 sur la branche, avec le sha attendu quand il y en a un", async () => {
67
+ const { fetchImpl, calls } = fakeFetch({
68
+ "PUT /repos/acme/site/contents/specs/orders.spec.md": { status: 201, body: { commit: { sha: "c1" } } },
69
+ });
70
+ const client = createGithubWriteClient({ token: "tkn", fetchImpl }, GITHUB);
71
+ const result = await client.commitFile({ branch: "mcp/x", path: "specs/orders.spec.md", content: "é", message: "m", expectedSha: "blob1" });
72
+ expect(result.commitSha).toBe("c1");
73
+ expect(calls[0]?.body).toEqual({ message: "m", content: Buffer.from("é").toString("base64"), branch: "mcp/x", sha: "blob1" });
74
+ });
75
+
76
+ it("une réponse non 2xx hors 404 toléré → GITHUB_UNAVAILABLE avec le statut ; 401 nomme la crédential du consommateur", async () => {
77
+ const { fetchImpl } = fakeFetch({ "POST /repos/acme/site/git/refs": { status: 401 } });
78
+ const client = createGithubWriteClient({ token: "tkn", fetchImpl, credentialHint: "MY_TOKEN_VAR" }, GITHUB);
79
+ try {
80
+ await client.createBranch("mcp/x", "abc");
81
+ expect.fail("expected a refusal");
82
+ } catch (thrown) {
83
+ expect(thrown).toBeInstanceOf(OgygieFailure);
84
+ const error = (thrown as OgygieFailure).error;
85
+ expect(error.code).toBe("GITHUB_UNAVAILABLE");
86
+ expect(error.cause).toBe("status=401");
87
+ expect(error.suggestedFix).toContain("MY_TOKEN_VAR");
88
+ }
89
+ });
90
+
91
+ it("un échec réseau → GITHUB_UNAVAILABLE aussi", async () => {
92
+ const client = createGithubWriteClient(
93
+ { token: "tkn", fetchImpl: (async () => { throw new Error("ECONNRESET"); }) as typeof fetch },
94
+ GITHUB,
95
+ );
96
+ await expect(client.findOpenPullRequest("mcp/x")).rejects.toMatchObject({ error: { code: "GITHUB_UNAVAILABLE", cause: "ECONNRESET" } });
97
+ });
98
+
99
+ it("trouve la PR ouverte d'une branche par head owner:branch, et en ouvre une sinon", async () => {
100
+ const { fetchImpl, calls } = fakeFetch({
101
+ "GET /repos/acme/site/pulls?state=open&head=acme%3Amcp%2Fx": { status: 200, body: [{ number: 7, html_url: "https://g/7" }] },
102
+ "POST /repos/acme/site/pulls": { status: 201, body: { number: 8, html_url: "https://g/8" } },
103
+ });
104
+ const client = createGithubWriteClient({ token: "tkn", fetchImpl, userAgent: "ogygie-test" }, GITHUB);
105
+ expect(await client.findOpenPullRequest("mcp/x")).toEqual({ number: 7, url: "https://g/7" });
106
+ expect(await client.openPullRequest({ branch: "mcp/y", base: "main", title: "t", body: "b" })).toEqual({ number: 8, url: "https://g/8" });
107
+ expect(calls[1]?.body).toEqual({ title: "t", body: "b", head: "mcp/y", base: "main" });
108
+ expect(calls[0]?.headers["user-agent"]).toBe("ogygie-test");
109
+ });
110
+ });
111
+
112
+ // ------------------------------------------------------------ the proposal
113
+
114
+ const ORDERS_SPEC = [
115
+ "---",
116
+ "id: orders",
117
+ "status: draft",
118
+ "depends_on: []",
119
+ "priority: P1",
120
+ "refs: []",
121
+ "---",
122
+ "",
123
+ "# @intent",
124
+ "Commander.",
125
+ "",
126
+ "# Comportements",
127
+ "- ÉTANT DONNÉ… QUAND… ALORS…",
128
+ "",
129
+ "# Contrats de données",
130
+ "- OrderSchema",
131
+ "",
132
+ "# États UI",
133
+ "- vide / nominal",
134
+ "",
135
+ "# Non-objectifs",
136
+ "- rien d'autre",
137
+ "",
138
+ "# Budget",
139
+ "- P95 < 100 ms",
140
+ "",
141
+ ].join("\n");
142
+
143
+ const BASE_FILES: Record<string, string> = {
144
+ "DECISIONS.md": "# DECISIONS.md\n",
145
+ "DECISIONS.product.md": "# DECISIONS.product.md\n",
146
+ "specs/orders.spec.md": ORDERS_SPEC,
147
+ };
148
+
149
+ /** An in-memory GitHub, recording every call it receives. */
150
+ function fakeGithub(files: Record<string, string>) {
151
+ let counter = 0;
152
+ const nextSha = (): string => `sha${(counter += 1)}`;
153
+ const mainSha = nextSha();
154
+ const snapshots = new Map<string, Map<string, { content: string; sha: string }>>([
155
+ [mainSha, new Map(Object.entries(files).map(([path, content]) => [path, { content, sha: nextSha() }]))],
156
+ ]);
157
+ const branches = new Map<string, string>([["main", mainSha]]);
158
+ const pulls: { number: number; url: string; branch: string; base: string; title: string; body: string }[] = [];
159
+ const calls: string[] = [];
160
+ const client: GithubWriteClient = {
161
+ async getBranchHead(branch) {
162
+ calls.push(`head ${branch}`);
163
+ return branches.get(branch) ?? null;
164
+ },
165
+ async readFileAtRef(path, ref) {
166
+ calls.push(`read ${path}@${ref}`);
167
+ return snapshots.get(ref)?.get(path) ?? null;
168
+ },
169
+ async createBranch(branch, fromSha) {
170
+ calls.push(`branch ${branch}`);
171
+ branches.set(branch, fromSha);
172
+ },
173
+ async commitFile(input) {
174
+ calls.push(`commit ${input.branch} ${input.path}`);
175
+ const head = branches.get(input.branch) ?? mainSha;
176
+ const next = new Map(snapshots.get(head));
177
+ next.set(input.path, { content: input.content, sha: nextSha() });
178
+ const commitSha = nextSha();
179
+ snapshots.set(commitSha, next);
180
+ branches.set(input.branch, commitSha);
181
+ return { commitSha };
182
+ },
183
+ async findOpenPullRequest(branch) {
184
+ calls.push(`pull? ${branch}`);
185
+ const found = pulls.find((pull) => pull.branch === branch);
186
+ return found === undefined ? null : { number: found.number, url: found.url };
187
+ },
188
+ async openPullRequest(input) {
189
+ const number = pulls.length + 1;
190
+ const pull = { number, url: `https://github.test/acme/site/pull/${number}`, ...input };
191
+ pulls.push(pull);
192
+ calls.push(`open ${input.branch}`);
193
+ return { number, url: pull.url };
194
+ },
195
+ };
196
+ return { client, calls, pulls, branches, mainSha };
197
+ }
198
+
199
+ function targetOn(files: Record<string, string>): ProposalTarget {
200
+ return { id: "acme", github: GITHUB, adapter: createInMemoryRepoAdapter(files) };
201
+ }
202
+
203
+ function depsFor(client: GithubWriteClient, overrides: Partial<ProposalDeps> = {}): ProposalDeps {
204
+ return {
205
+ clientFor: () => client,
206
+ originOf: (path) => `Proposé par le test (chemin ${path}).`,
207
+ now: () => 1_000_000,
208
+ rateLimitPerHour: 20,
209
+ recentProposals: new Map(),
210
+ ...overrides,
211
+ };
212
+ }
213
+
214
+ async function codeOf(action: () => Promise<unknown>): Promise<string> {
215
+ try {
216
+ await action();
217
+ return "<no refusal>";
218
+ } catch (thrown) {
219
+ return thrown instanceof OgygieFailure ? thrown.error.code : "<not an OgygieFailure>";
220
+ }
221
+ }
222
+
223
+ describe("proposeChange — le périmètre, avant tout", () => {
224
+ it.each(["AGENTS.md", "ogygie.manifest.json", "specs/_TEMPLATE.spec.md", "src/x.ts", "../DECISIONS.md"])(
225
+ "%s → WRITE_PATH_FORBIDDEN, sans aucun appel GitHub",
226
+ async (path) => {
227
+ const github = fakeGithub(BASE_FILES);
228
+ expect(await codeOf(() => proposeChange(targetOn(BASE_FILES), depsFor(github.client), { path, content: "x", message: "a long enough message" }))).toBe("WRITE_PATH_FORBIDDEN");
229
+ expect(github.calls).toEqual([]);
230
+ expect(() => assertProposablePath(path)).toThrow(OgygieFailure);
231
+ },
232
+ );
233
+
234
+ it("accepte les trois natures du périmètre", () => {
235
+ expect(assertProposablePath("specs/orders.spec.md")).toBe("specs/orders.spec.md");
236
+ expect(assertProposablePath("DECISIONS.md")).toBe("DECISIONS.md");
237
+ expect(assertProposablePath("DECISIONS.product.md")).toBe("DECISIONS.product.md");
238
+ });
239
+ });
240
+
241
+ describe("proposeChange — la branche et le corps de PR sont ceux que le consommateur injecte", () => {
242
+ it("ÉTANT DONNÉ aucun branchFor ALORS la branche est mcp/<id>/<slug> et le corps porte originOf (D17)", async () => {
243
+ const github = fakeGithub(BASE_FILES);
244
+ const proposal = await proposeChange(targetOn(BASE_FILES), depsFor(github.client), {
245
+ path: "specs/orders.spec.md",
246
+ content: ORDERS_SPEC.replace("status: draft", "status: validated"),
247
+ message: "spec(orders): validée",
248
+ });
249
+ expect(proposal).toMatchObject({ repo: "acme", branch: "mcp/acme/specs-orders-spec-md", baseSha: github.mainSha, created: true, pullRequestNumber: 1 });
250
+ expect(github.pulls[0]?.body).toContain("Proposé par le test (chemin specs/orders.spec.md).");
251
+ expect(github.pulls[0]?.title).toBe("spec(orders): validée");
252
+ expect(github.branches.get("main")).toBe(github.mainSha);
253
+ });
254
+
255
+ it("ÉTANT DONNÉ un branchFor ALORS la proposition atterrit sur cette branche — le chemin du mode hébergé (spec/<id>)", async () => {
256
+ const github = fakeGithub(BASE_FILES);
257
+ const deps = depsFor(github.client, { branchFor: (_target, path) => `spec/${path.replace(/^specs\//, "").replace(/\.spec\.md$/, "")}` });
258
+ const proposal = await proposeChange(targetOn(BASE_FILES), deps, {
259
+ path: "specs/orders.spec.md",
260
+ content: ORDERS_SPEC,
261
+ message: "spec(orders): amendement",
262
+ });
263
+ expect(proposal.branch).toBe("spec/orders");
264
+ expect(github.calls).toContain("branch spec/orders");
265
+ expect(github.calls).toContain("commit spec/orders specs/orders.spec.md");
266
+ });
267
+
268
+ it("le compteur de débit est tenu par id de cible, et une proposition refusée n'y entre pas", async () => {
269
+ const github = fakeGithub(BASE_FILES);
270
+ const deps = depsFor(github.client, { rateLimitPerHour: 1 });
271
+ await proposeChange(targetOn(BASE_FILES), deps, { path: "specs/orders.spec.md", content: ORDERS_SPEC, message: "spec(orders): un" });
272
+ expect(deps.recentProposals.get("acme")).toHaveLength(1);
273
+ expect(await codeOf(() => proposeChange(targetOn(BASE_FILES), deps, { path: "specs/orders.spec.md", content: ORDERS_SPEC, message: "spec(orders): deux" }))).toBe("WRITE_RATE_LIMITED");
274
+ expect(deps.recentProposals.get("acme")).toHaveLength(1);
275
+ });
276
+
277
+ it("ÉTANT DONNÉ authorOf ALORS le commit porte l'humain en author (D45) ; absent, rien n'est ajouté", async () => {
278
+ const github = fakeGithub(BASE_FILES);
279
+ const authors: unknown[] = [];
280
+ const client: GithubWriteClient = {
281
+ ...github.client,
282
+ async commitFile(input) {
283
+ authors.push(input.author);
284
+ return github.client.commitFile(input);
285
+ },
286
+ };
287
+ await proposeChange(targetOn(BASE_FILES), depsFor(client, { authorOf: () => ({ name: "Ada", email: "ada@acme.test" }) }), { path: "specs/orders.spec.md", content: ORDERS_SPEC, message: "spec(orders): par Ada" });
288
+ await proposeChange(targetOn(BASE_FILES), depsFor(client, { branchFor: () => "spec/other" }), { path: "specs/orders.spec.md", content: ORDERS_SPEC, message: "spec(orders): sans auteur" });
289
+ expect(authors).toEqual([{ name: "Ada", email: "ada@acme.test" }, undefined]);
290
+ });
291
+
292
+ it("ÉTANT DONNÉ readFrom: branch ALORS la page lue est comparée à la branche, pas à main — le second amendement passe, un amendement périmé est REMOTE_CONFLICT", async () => {
293
+ const github = fakeGithub(BASE_FILES);
294
+ const branchFor = () => "spec/orders";
295
+ const v2 = ORDERS_SPEC.replace("Commander.", "Commander v2.");
296
+ const v3 = ORDERS_SPEC.replace("Commander.", "Commander v3.");
297
+ // First amendment: the caller read main (no branch yet).
298
+ await proposeChange(targetOn(BASE_FILES), depsFor(github.client, { branchFor, readFrom: "branch" }), { path: "specs/orders.spec.md", content: v2, message: "spec(orders): v2" });
299
+ // Second: the caller read the BRANCH (v2) — passes, where defaultBranch mode would refuse.
300
+ const onBranch = { ...BASE_FILES, "specs/orders.spec.md": v2 };
301
+ const second = await proposeChange(targetOn(onBranch), depsFor(github.client, { branchFor, readFrom: "branch" }), { path: "specs/orders.spec.md", content: v3, message: "spec(orders): v3" });
302
+ expect(second.created).toBe(false);
303
+ // Stale: a caller still holding main's copy while the branch moved.
304
+ expect(await codeOf(() => proposeChange(targetOn(BASE_FILES), depsFor(github.client, { branchFor, readFrom: "branch" }), { path: "specs/orders.spec.md", content: v3, message: "spec(orders): périmé" }))).toBe("REMOTE_CONFLICT");
305
+ // The MCP semantics are untouched: reading main, a second proposal on the same path still passes.
306
+ expect(await codeOf(() => proposeChange(targetOn(BASE_FILES), depsFor(github.client, { branchFor }), { path: "specs/orders.spec.md", content: v3, message: "spec(orders): depuis main" }))).toBe("<no refusal>");
307
+ });
308
+
309
+ it("une spec invalide est refusée AVANT la lecture de la tête du fichier (SPEC_INVALID)", async () => {
310
+ const github = fakeGithub(BASE_FILES);
311
+ expect(await codeOf(() => proposeChange(targetOn(BASE_FILES), depsFor(github.client), { path: "specs/orders.spec.md", content: "pas une spec", message: "spec(orders): cassée" }))).toBe("SPEC_INVALID");
312
+ expect(github.calls.some((call) => call.startsWith("read specs/"))).toBe(false);
313
+ expect(github.calls.some((call) => call.startsWith("commit"))).toBe(false);
314
+ });
315
+ });