@ogygie/core 0.19.0 → 0.20.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 +8 -1
- package/src/model.test.ts +61 -0
- package/src/model.ts +123 -0
- package/src/proposal.test.ts +315 -0
- package/src/proposal.ts +525 -0
- package/src/repo-adapter-github.test.ts +179 -0
- package/src/repo-adapter-github.ts +225 -0
- package/src/repo-adapter.ts +11 -0
- package/src/spec.schema.test.ts +39 -1
- package/src/spec.schema.ts +63 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Proves the GitHub API implementation of `RepoAdapter` (D51) against
|
|
3
|
+
* a fake `fetch`: one snapshot per adapter (the ref is resolved once), the
|
|
4
|
+
* listing comes from one recursive tree, dot-directories and node_modules stay
|
|
5
|
+
* out of it, the traversal guard refuses before any request, and every
|
|
6
|
+
* failure mode a plausible answer could hide — a truncated tree, a directory
|
|
7
|
+
* asked as a file, a large file served by the blob endpoint — is said loudly.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import { OgygieFailure } from "./error.schema.js";
|
|
11
|
+
import { createGithubApiRepoAdapter, normalizeRepoPath } from "./repo-adapter-github.js";
|
|
12
|
+
|
|
13
|
+
const BASE = "https://api.github.com/repos/acme/site";
|
|
14
|
+
|
|
15
|
+
function fakeFetch(routes: Record<string, { status: number; body?: unknown }>) {
|
|
16
|
+
const calls: { url: string; headers: Record<string, string> }[] = [];
|
|
17
|
+
const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
18
|
+
const url = String(input);
|
|
19
|
+
calls.push({ url, headers: init?.headers as Record<string, string> });
|
|
20
|
+
const route = routes[url.replace(BASE, "")];
|
|
21
|
+
if (route === undefined) return new Response("not found", { status: 404 });
|
|
22
|
+
return new Response(route.body === undefined ? null : JSON.stringify(route.body), { status: route.status });
|
|
23
|
+
}) as typeof fetch;
|
|
24
|
+
return { fetchImpl, calls };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const b64 = (text: string): string => Buffer.from(text, "utf8").toString("base64");
|
|
28
|
+
|
|
29
|
+
const SNAPSHOT = {
|
|
30
|
+
"/commits/main": { status: 200, body: { sha: "c0ffee" } },
|
|
31
|
+
"/git/trees/c0ffee?recursive=1": {
|
|
32
|
+
status: 200,
|
|
33
|
+
body: {
|
|
34
|
+
truncated: false,
|
|
35
|
+
tree: [
|
|
36
|
+
{ path: "AGENTS.md", type: "blob", size: 6 },
|
|
37
|
+
{ path: "specs", type: "tree" },
|
|
38
|
+
{ path: "specs/notes.spec.md", type: "blob", size: 12 },
|
|
39
|
+
{ path: "specs/orders.spec.md", type: "blob", size: 30 },
|
|
40
|
+
{ path: ".github/workflows/ci.yml", type: "blob", size: 9 },
|
|
41
|
+
{ path: "node_modules/left-pad/index.js", type: "blob", size: 20 },
|
|
42
|
+
{ path: "apps/api/src/features/orders/schemas.ts", type: "blob", size: 18 },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
"/contents/AGENTS.md?ref=c0ffee": {
|
|
47
|
+
status: 200,
|
|
48
|
+
body: { type: "file", encoding: "base64", content: b64("règles"), sha: "blob1" },
|
|
49
|
+
},
|
|
50
|
+
"/contents/specs?ref=c0ffee": { status: 200, body: [{ name: "notes.spec.md" }] },
|
|
51
|
+
"/contents/big.bin?ref=c0ffee": {
|
|
52
|
+
status: 200,
|
|
53
|
+
body: { type: "file", encoding: "none", content: "", sha: "blobbig" },
|
|
54
|
+
},
|
|
55
|
+
"/git/blobs/blobbig": { status: 200, body: { content: b64("gros fichier"), encoding: "base64" } },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function adapterOn(routes: Record<string, { status: number; body?: unknown }> = SNAPSHOT) {
|
|
59
|
+
const { fetchImpl, calls } = fakeFetch(routes);
|
|
60
|
+
const adapter = createGithubApiRepoAdapter({ owner: "acme", repo: "site", ref: "main", fetchImpl, token: "tkn" });
|
|
61
|
+
return { adapter, calls };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function codeOf(action: () => Promise<unknown>): Promise<string> {
|
|
65
|
+
try {
|
|
66
|
+
await action();
|
|
67
|
+
return "<no refusal>";
|
|
68
|
+
} catch (thrown) {
|
|
69
|
+
return thrown instanceof OgygieFailure ? thrown.error.code : "<not an OgygieFailure>";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("repo-adapter-github — la garde textuelle, avant toute requête", () => {
|
|
74
|
+
it.each(["/etc/passwd", "C:\\secrets", "../outside", "specs/../../x", "a/./../.."])(
|
|
75
|
+
"%s → PATH_OUTSIDE_REPO",
|
|
76
|
+
async (path) => {
|
|
77
|
+
const { adapter, calls } = adapterOn();
|
|
78
|
+
expect(await codeOf(() => adapter.readFile(path))).toBe("PATH_OUTSIDE_REPO");
|
|
79
|
+
expect(await codeOf(() => adapter.listDirectory(path))).toBe("PATH_OUTSIDE_REPO");
|
|
80
|
+
expect(await codeOf(() => adapter.exists(path))).toBe("PATH_OUTSIDE_REPO");
|
|
81
|
+
expect(calls).toEqual([]);
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
it("normalise `./specs/` et `specs` sur le même chemin", () => {
|
|
86
|
+
expect(normalizeRepoPath("./specs/")).toBe("specs");
|
|
87
|
+
expect(normalizeRepoPath("specs")).toBe("specs");
|
|
88
|
+
expect(normalizeRepoPath("")).toBe("");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("repo-adapter-github — un adaptateur, un instantané", () => {
|
|
93
|
+
it("ÉTANT DONNÉ une branche QUAND on lit le ref ALORS c'est le SHA du commit, résolu UNE fois", async () => {
|
|
94
|
+
const { adapter, calls } = adapterOn();
|
|
95
|
+
expect(await adapter.currentRef()).toBe("c0ffee");
|
|
96
|
+
expect(await adapter.currentRef()).toBe("c0ffee");
|
|
97
|
+
await adapter.listDirectory("specs");
|
|
98
|
+
expect(calls.filter((call) => call.url.endsWith("/commits/main"))).toHaveLength(1);
|
|
99
|
+
expect(calls[0]?.headers["authorization"]).toBe("Bearer tkn");
|
|
100
|
+
expect(calls[0]?.headers["user-agent"]).toBeDefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("lit un fichier à ce SHA, décodé du base64", async () => {
|
|
104
|
+
const { adapter, calls } = adapterOn();
|
|
105
|
+
expect(await adapter.readFile("AGENTS.md")).toBe("règles");
|
|
106
|
+
expect(calls.some((call) => call.url.endsWith("/contents/AGENTS.md?ref=c0ffee"))).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("un fichier trop gros pour l'endpoint contents est lu par son blob", async () => {
|
|
110
|
+
const { adapter } = adapterOn();
|
|
111
|
+
expect(await adapter.readFile("big.bin")).toBe("gros fichier");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("un chemin absent, ou un dossier demandé comme fichier → REPO_FILE_NOT_FOUND", async () => {
|
|
115
|
+
const { adapter } = adapterOn();
|
|
116
|
+
expect(await codeOf(() => adapter.readFile("nope.md"))).toBe("REPO_FILE_NOT_FOUND");
|
|
117
|
+
expect(await codeOf(() => adapter.readFile("specs"))).toBe("REPO_FILE_NOT_FOUND");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("liste RÉCURSIVEMENT depuis UN arbre, triée, relative au dossier, sans dot-dirs ni node_modules", async () => {
|
|
121
|
+
const { adapter, calls } = adapterOn();
|
|
122
|
+
expect(await adapter.listDirectory("specs")).toEqual([
|
|
123
|
+
{ path: "notes.spec.md", sizeBytes: 12 },
|
|
124
|
+
{ path: "orders.spec.md", sizeBytes: 30 },
|
|
125
|
+
]);
|
|
126
|
+
expect((await adapter.listDirectory("")).map((entry) => entry.path)).toEqual([
|
|
127
|
+
"AGENTS.md",
|
|
128
|
+
"apps/api/src/features/orders/schemas.ts",
|
|
129
|
+
"specs/notes.spec.md",
|
|
130
|
+
"specs/orders.spec.md",
|
|
131
|
+
]);
|
|
132
|
+
expect(await adapter.listDirectory("missing")).toEqual([]);
|
|
133
|
+
expect(calls.filter((call) => call.url.includes("/git/trees/"))).toHaveLength(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("exists répond vrai pour un fichier ET pour un dossier de l'arbre", async () => {
|
|
137
|
+
const { adapter } = adapterOn();
|
|
138
|
+
expect(await adapter.exists("specs/orders.spec.md")).toBe(true);
|
|
139
|
+
expect(await adapter.exists("specs")).toBe(true);
|
|
140
|
+
expect(await adapter.exists("apps/api")).toBe(true);
|
|
141
|
+
expect(await adapter.exists("spec")).toBe(false);
|
|
142
|
+
expect(await adapter.exists("")).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("repo-adapter-github — ce qu'une réponse plausible pourrait cacher", () => {
|
|
147
|
+
it("un ref inconnu → GITHUB_UNAVAILABLE qui nomme le ref, jamais null", async () => {
|
|
148
|
+
const { adapter } = adapterOn({});
|
|
149
|
+
expect(await codeOf(() => adapter.currentRef())).toBe("GITHUB_UNAVAILABLE");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("un arbre tronqué → REPO_TREE_TRUNCATED, jamais une liste partielle", async () => {
|
|
153
|
+
const { adapter } = adapterOn({
|
|
154
|
+
"/commits/main": { status: 200, body: { sha: "c0ffee" } },
|
|
155
|
+
"/git/trees/c0ffee?recursive=1": { status: 200, body: { truncated: true, tree: [] } },
|
|
156
|
+
});
|
|
157
|
+
expect(await codeOf(() => adapter.listDirectory(""))).toBe("REPO_TREE_TRUNCATED");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("401 → GITHUB_UNAVAILABLE pointant le jeton ; panne réseau → GITHUB_UNAVAILABLE aussi", async () => {
|
|
161
|
+
const { adapter } = adapterOn({ "/commits/main": { status: 401 } });
|
|
162
|
+
try {
|
|
163
|
+
await adapter.currentRef();
|
|
164
|
+
expect.fail("expected a refusal");
|
|
165
|
+
} catch (thrown) {
|
|
166
|
+
expect(thrown).toBeInstanceOf(OgygieFailure);
|
|
167
|
+
expect((thrown as OgygieFailure).error.suggestedFix).toContain("token");
|
|
168
|
+
}
|
|
169
|
+
const down = createGithubApiRepoAdapter({
|
|
170
|
+
owner: "acme",
|
|
171
|
+
repo: "site",
|
|
172
|
+
ref: "main",
|
|
173
|
+
fetchImpl: (async () => {
|
|
174
|
+
throw new Error("ECONNRESET");
|
|
175
|
+
}) as typeof fetch,
|
|
176
|
+
});
|
|
177
|
+
expect(await codeOf(() => down.exists("AGENTS.md"))).toBe("GITHUB_UNAVAILABLE");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent READ-ONLY access to an Ogygie repo through the GitHub REST API, at
|
|
3
|
+
* one ref, without a clone (DECISIONS.md D51) — the third implementation of
|
|
4
|
+
* `RepoAdapter` (D10), next to the filesystem and the in-memory ones.
|
|
5
|
+
*
|
|
6
|
+
* Why it exists: the hosted studio (spec ogygie-hosted-mode) reads the default
|
|
7
|
+
* branch AND every `spec/<id>` branch of a client repo, and must do so without
|
|
8
|
+
* a clone on its host — the host runs no git, no disk of the repo, nothing a
|
|
9
|
+
* client's code could reach. The MCP server deferred this adapter for the same
|
|
10
|
+
* reason (spec ogygie-mcp, `GITHUB_API_ADAPTER`).
|
|
11
|
+
*
|
|
12
|
+
* One adapter = one SNAPSHOT: the ref is resolved to a commit SHA on first
|
|
13
|
+
* use and every later call reads at that SHA, so a listing and the file read
|
|
14
|
+
* after it never come from two different commits. A consumer that wants the
|
|
15
|
+
* new head builds a new adapter — the same contract as the filesystem one,
|
|
16
|
+
* which reads "the repo as it sits on this disk, at this instant".
|
|
17
|
+
*
|
|
18
|
+
* No dependency, on purpose: `fetch` is INJECTED (AGENTS.md principle 5), so
|
|
19
|
+
* the tests hand in a fake and a purity gate finds neither `node:fs` nor
|
|
20
|
+
* `node:child_process` here. The textual traversal guard is the same rule as
|
|
21
|
+
* the filesystem adapter's: an absolute path or a `..` segment never reaches
|
|
22
|
+
* the network.
|
|
23
|
+
*/
|
|
24
|
+
import { throwOgygieError } from "./error.schema.js";
|
|
25
|
+
import type { RepoAdapter, RepoFileEntry } from "./repo-adapter.js";
|
|
26
|
+
|
|
27
|
+
export interface GithubApiRepoAdapterOptions {
|
|
28
|
+
owner: string;
|
|
29
|
+
repo: string;
|
|
30
|
+
/** Branch name, tag or commit SHA — resolved once to a commit SHA. */
|
|
31
|
+
ref: string;
|
|
32
|
+
fetchImpl: typeof fetch;
|
|
33
|
+
/** Absent on a public repo read anonymously (rate-limited by GitHub). */
|
|
34
|
+
token?: string;
|
|
35
|
+
apiBase?: string;
|
|
36
|
+
/** Sent as `user-agent`, which the GitHub API requires on every call. */
|
|
37
|
+
userAgent?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What the trees endpoint answers, reduced to what a listing needs. */
|
|
41
|
+
interface TreeEntry {
|
|
42
|
+
path: string;
|
|
43
|
+
type: "blob" | "tree" | "commit";
|
|
44
|
+
size?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Directories a listing never shows — the filesystem adapter's rule, kept. */
|
|
48
|
+
const NEVER_LISTED = new Set(["node_modules"]);
|
|
49
|
+
|
|
50
|
+
function isSkippedSegment(segment: string): boolean {
|
|
51
|
+
return segment.startsWith(".") || NEVER_LISTED.has(segment);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function refusePathOutsideRepo(relativePath: string, cause: string): never {
|
|
55
|
+
return throwOgygieError(
|
|
56
|
+
{
|
|
57
|
+
code: "PATH_OUTSIDE_REPO",
|
|
58
|
+
message: "The requested path resolves outside the repo root.",
|
|
59
|
+
file: relativePath,
|
|
60
|
+
cause,
|
|
61
|
+
suggestedFix:
|
|
62
|
+
"Address a path INSIDE the repo, written relative to its root and without `..`.",
|
|
63
|
+
},
|
|
64
|
+
400,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Textual guard, the only one possible without a disk: no absolute path, no
|
|
70
|
+
* `..` segment. `.` segments and empty segments are dropped so `./specs/` and
|
|
71
|
+
* `specs` name the same directory, as they do on a filesystem.
|
|
72
|
+
*/
|
|
73
|
+
export function normalizeRepoPath(relativePath: string): string {
|
|
74
|
+
if (relativePath.startsWith("/") || /^[A-Za-z]:[\\/]/.test(relativePath)) {
|
|
75
|
+
refusePathOutsideRepo(relativePath, `"${relativePath}" is an absolute path`);
|
|
76
|
+
}
|
|
77
|
+
const segments = relativePath.split(/[\\/]+/).filter((segment) => segment !== "" && segment !== ".");
|
|
78
|
+
if (segments.includes("..")) {
|
|
79
|
+
refusePathOutsideRepo(relativePath, `"${relativePath}" climbs out of the repo with ".."`);
|
|
80
|
+
}
|
|
81
|
+
return segments.join("/");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function createGithubApiRepoAdapter(options: GithubApiRepoAdapterOptions): RepoAdapter {
|
|
85
|
+
const apiBase = options.apiBase ?? "https://api.github.com";
|
|
86
|
+
const repoUrl = `${apiBase}/repos/${options.owner}/${options.repo}`;
|
|
87
|
+
const where = `${options.owner}/${options.repo}@${options.ref}`;
|
|
88
|
+
|
|
89
|
+
async function request<T>(url: string, allowNotFound: boolean): Promise<T | null> {
|
|
90
|
+
let response: Response;
|
|
91
|
+
try {
|
|
92
|
+
response = await options.fetchImpl(url, {
|
|
93
|
+
method: "GET",
|
|
94
|
+
headers: {
|
|
95
|
+
accept: "application/vnd.github+json",
|
|
96
|
+
"x-github-api-version": "2022-11-28",
|
|
97
|
+
"user-agent": options.userAgent ?? "ogygie-core",
|
|
98
|
+
...(options.token === undefined ? {} : { authorization: `Bearer ${options.token}` }),
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
} catch (thrown) {
|
|
102
|
+
return throwOgygieError({
|
|
103
|
+
code: "GITHUB_UNAVAILABLE",
|
|
104
|
+
message: `GitHub could not be reached to read ${where}.`,
|
|
105
|
+
file: null,
|
|
106
|
+
cause: thrown instanceof Error ? thrown.message : String(thrown),
|
|
107
|
+
suggestedFix: "Retry once the network is back — a read has no side effect to undo.",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (response.status === 404 && allowNotFound) return null;
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
return throwOgygieError({
|
|
113
|
+
code: "GITHUB_UNAVAILABLE",
|
|
114
|
+
message: `GitHub answered ${response.status} for GET ${url.replace(apiBase, "")}.`,
|
|
115
|
+
file: null,
|
|
116
|
+
cause: `status=${response.status}`,
|
|
117
|
+
suggestedFix:
|
|
118
|
+
response.status === 401 || response.status === 403
|
|
119
|
+
? "Check the GitHub token: it must grant contents (read) on this repo."
|
|
120
|
+
: "Retry later — a read has no side effect to undo.",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return (await response.json()) as T;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The snapshot: resolved once, shared by every call of this adapter.
|
|
127
|
+
let commitShaPromise: Promise<string> | null = null;
|
|
128
|
+
let treePromise: Promise<TreeEntry[]> | null = null;
|
|
129
|
+
|
|
130
|
+
function commitSha(): Promise<string> {
|
|
131
|
+
commitShaPromise ??= (async () => {
|
|
132
|
+
const commit = await request<{ sha: string }>(
|
|
133
|
+
`${repoUrl}/commits/${encodeURIComponent(options.ref)}`,
|
|
134
|
+
true,
|
|
135
|
+
);
|
|
136
|
+
if (commit === null) {
|
|
137
|
+
return throwOgygieError({
|
|
138
|
+
code: "GITHUB_UNAVAILABLE",
|
|
139
|
+
message: `Ref "${options.ref}" does not exist on ${options.owner}/${options.repo}.`,
|
|
140
|
+
file: null,
|
|
141
|
+
cause: "the commits endpoint answered 404 for that ref",
|
|
142
|
+
suggestedFix: "Name an existing branch, tag or commit SHA of that repo.",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return commit.sha;
|
|
146
|
+
})();
|
|
147
|
+
return commitShaPromise;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function tree(): Promise<TreeEntry[]> {
|
|
151
|
+
treePromise ??= (async () => {
|
|
152
|
+
const sha = await commitSha();
|
|
153
|
+
const answer = await request<{ tree: TreeEntry[]; truncated: boolean }>(
|
|
154
|
+
`${repoUrl}/git/trees/${sha}?recursive=1`,
|
|
155
|
+
false,
|
|
156
|
+
);
|
|
157
|
+
if (answer === null) return [];
|
|
158
|
+
if (answer.truncated) {
|
|
159
|
+
// A partial listing would say "absent" about files that exist — the
|
|
160
|
+
// silent lie this framework refuses. Said loudly instead.
|
|
161
|
+
return throwOgygieError({
|
|
162
|
+
code: "REPO_TREE_TRUNCATED",
|
|
163
|
+
message: `The tree of ${where} is too large for one GitHub answer.`,
|
|
164
|
+
file: null,
|
|
165
|
+
cause: "the trees endpoint set truncated=true",
|
|
166
|
+
suggestedFix: "Read that repo from a clone (createFilesystemRepoAdapter) rather than through the API.",
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return answer.tree.filter(
|
|
170
|
+
(entry) =>
|
|
171
|
+
entry.type === "blob" && !entry.path.split("/").slice(0, -1).some(isSkippedSegment),
|
|
172
|
+
);
|
|
173
|
+
})();
|
|
174
|
+
return treePromise;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
async readFile(relativePath: string): Promise<string> {
|
|
179
|
+
const path = normalizeRepoPath(relativePath);
|
|
180
|
+
const sha = await commitSha();
|
|
181
|
+
const file = await request<
|
|
182
|
+
{ content?: string; encoding?: string; sha: string; type?: string } | unknown[]
|
|
183
|
+
>(`${repoUrl}/contents/${path}?ref=${sha}`, true);
|
|
184
|
+
if (file === null || Array.isArray(file) || file.type !== "file") {
|
|
185
|
+
return throwOgygieError({
|
|
186
|
+
code: "REPO_FILE_NOT_FOUND",
|
|
187
|
+
message: "The requested file cannot be read from the repo.",
|
|
188
|
+
file: relativePath,
|
|
189
|
+
cause:
|
|
190
|
+
file === null
|
|
191
|
+
? `no file at "${path}" in ${where}`
|
|
192
|
+
: `"${path}" is not a file in ${where}`,
|
|
193
|
+
suggestedFix:
|
|
194
|
+
"Check the path against what the map declares — the manifest is the only chart of a repo.",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (file.encoding === "base64" && file.content !== undefined) {
|
|
198
|
+
return Buffer.from(file.content, "base64").toString("utf8");
|
|
199
|
+
}
|
|
200
|
+
// Above 1 MB the contents endpoint ships no content: the blob does.
|
|
201
|
+
const blob = await request<{ content: string }>(`${repoUrl}/git/blobs/${file.sha}`, false);
|
|
202
|
+
return Buffer.from(blob?.content ?? "", "base64").toString("utf8");
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
async listDirectory(relativePath: string): Promise<RepoFileEntry[]> {
|
|
206
|
+
const path = normalizeRepoPath(relativePath);
|
|
207
|
+
const prefix = path === "" ? "" : `${path}/`;
|
|
208
|
+
return (await tree())
|
|
209
|
+
.filter((entry) => entry.path.startsWith(prefix))
|
|
210
|
+
.map((entry) => ({ path: entry.path.slice(prefix.length), sizeBytes: entry.size ?? 0 }))
|
|
211
|
+
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
async exists(relativePath: string): Promise<boolean> {
|
|
215
|
+
const path = normalizeRepoPath(relativePath);
|
|
216
|
+
if (path === "") return true;
|
|
217
|
+
const prefix = `${path}/`;
|
|
218
|
+
return (await tree()).some((entry) => entry.path === path || entry.path.startsWith(prefix));
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
async currentRef(): Promise<string | null> {
|
|
222
|
+
return commitSha();
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
package/src/repo-adapter.ts
CHANGED
|
@@ -305,3 +305,14 @@ export function createInMemoryRepoAdapter(
|
|
|
305
305
|
},
|
|
306
306
|
};
|
|
307
307
|
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The third implementation, over the GitHub REST API without a clone (D51):
|
|
311
|
+
* declared in its own module so a consumer that must prove it never touches a
|
|
312
|
+
* disk (the hosted studio) can import it without dragging `node:fs` along.
|
|
313
|
+
*/
|
|
314
|
+
export {
|
|
315
|
+
createGithubApiRepoAdapter,
|
|
316
|
+
normalizeRepoPath,
|
|
317
|
+
type GithubApiRepoAdapterOptions,
|
|
318
|
+
} from "./repo-adapter-github.js";
|
package/src/spec.schema.test.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* rejection of a non-kebab-case blockedBy target.
|
|
8
8
|
*/
|
|
9
9
|
import { describe, expect, it } from "vitest";
|
|
10
|
-
import { SpecFrontmatterSchema, parseSpecFrontmatter } from "./spec.schema.js";
|
|
10
|
+
import { SpecFrontmatterSchema, compositionOfSpec, parseSpecDocument, parseSpecFrontmatter } from "./spec.schema.js";
|
|
11
11
|
|
|
12
12
|
const NOMINAL_FRONTMATTER = `
|
|
13
13
|
id: order-flow
|
|
@@ -141,3 +141,41 @@ status: validated
|
|
|
141
141
|
).toBe(true);
|
|
142
142
|
});
|
|
143
143
|
});
|
|
144
|
+
|
|
145
|
+
describe("parseSpecDocument — la spec entière, frontmatter jugé et corps toujours rendu (D51)", () => {
|
|
146
|
+
const VALID = "---\nid: orders\nstatus: draft\ndepends_on: []\npriority: P1\n---\n\n# @intent\nCommander.\n";
|
|
147
|
+
|
|
148
|
+
it("ÉTANT DONNÉ un frontmatter valide ALORS il est parsé ET validé, le corps commence après le bloc", () => {
|
|
149
|
+
const parsed = parseSpecDocument(VALID);
|
|
150
|
+
expect(parsed.problems).toEqual([]);
|
|
151
|
+
expect(parsed.frontmatter?.id).toBe("orders");
|
|
152
|
+
expect(parsed.frontmatter?.refs).toEqual([]); // the schema's default applies
|
|
153
|
+
expect(parsed.body).toBe("\n\n# @intent\nCommander.\n");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("ÉTANT DONNÉ un fichier sans bloc --- ALORS frontmatter null, un problème nommé, et le fichier ENTIER en corps", () => {
|
|
157
|
+
const parsed = parseSpecDocument("# @intent\nSans frontmatter.\n");
|
|
158
|
+
expect(parsed.frontmatter).toBeNull();
|
|
159
|
+
expect(parsed.problems).toHaveLength(1);
|
|
160
|
+
expect(parsed.body).toBe("# @intent\nSans frontmatter.\n");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("MUTATION — un frontmatter au format illisible, ou au schéma violé, rend ses problèmes et jamais un frontmatter partiel", () => {
|
|
164
|
+
const badFormat = parseSpecDocument(VALID.replace("depends_on: []", "depends_on: ["));
|
|
165
|
+
expect(badFormat.frontmatter).toBeNull();
|
|
166
|
+
expect(badFormat.problems.length).toBeGreaterThan(0);
|
|
167
|
+
const badSchema = parseSpecDocument(VALID.replace("priority: P1", "priority: P9"));
|
|
168
|
+
expect(badSchema.frontmatter).toBeNull();
|
|
169
|
+
expect(badSchema.problems.some((problem) => problem.startsWith("priority"))).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("compositionOfSpec — la composition déclarée sous # Design (D53)", () => {
|
|
174
|
+
it("lit `Composition : <nom>` dans la section Design seulement, en kebab-case, sinon null", () => {
|
|
175
|
+
const spec = "# @intent\nx\n\n# Design\n\n- Tokens du starter.\n- Composition : `liste-d-abord` — la liste puis le formulaire.\n\n# Non-objectifs\nComposition : ailleurs\n";
|
|
176
|
+
expect(compositionOfSpec(spec)).toBe("liste-d-abord");
|
|
177
|
+
expect(compositionOfSpec("# @intent\nComposition : hors-section\n")).toBeNull();
|
|
178
|
+
expect(compositionOfSpec("# Design\ncomposition: Formulaire\n")).toBeNull();
|
|
179
|
+
expect(compositionOfSpec("# Design\nRien.\n")).toBeNull();
|
|
180
|
+
});
|
|
181
|
+
});
|
package/src/spec.schema.ts
CHANGED
|
@@ -182,3 +182,66 @@ export function parseSpecFrontmatter(text: string): ParsedSpecFrontmatter {
|
|
|
182
182
|
}
|
|
183
183
|
return { value, problems };
|
|
184
184
|
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------- whole document
|
|
187
|
+
/**
|
|
188
|
+
* A spec file read as a whole: the frontmatter PARSED and VALIDATED, or the
|
|
189
|
+
* problems that prevent it, and the body in every case. A broken spec comes
|
|
190
|
+
* back WITH its body: a client asked to repair one needs to see it, and a bare
|
|
191
|
+
* refusal would leave it blind (spec ogygie-mcp B3). Shared by the MCP server
|
|
192
|
+
* and the proposal module (D51), so "what a readable spec is" is said once.
|
|
193
|
+
*/
|
|
194
|
+
export interface ParsedSpecDocument {
|
|
195
|
+
frontmatter: SpecFrontmatter | null;
|
|
196
|
+
/** Format or schema problems; non-empty ⇔ `frontmatter` is null. */
|
|
197
|
+
problems: string[];
|
|
198
|
+
/** Everything after the frontmatter block — the whole file when there is none. */
|
|
199
|
+
body: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const FRONTMATTER_BLOCK = /^---\n([\s\S]*?)\n---/;
|
|
203
|
+
|
|
204
|
+
export function parseSpecDocument(content: string): ParsedSpecDocument {
|
|
205
|
+
const match = FRONTMATTER_BLOCK.exec(content);
|
|
206
|
+
if (match === null || match[1] === undefined) {
|
|
207
|
+
return {
|
|
208
|
+
frontmatter: null,
|
|
209
|
+
problems: ["file does not start with a --- ... --- frontmatter block"],
|
|
210
|
+
body: content,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const body = content.slice(match[0].length);
|
|
214
|
+
const parsed = parseSpecFrontmatter(match[1]);
|
|
215
|
+
if (parsed.problems.length > 0) {
|
|
216
|
+
return { frontmatter: null, problems: parsed.problems, body };
|
|
217
|
+
}
|
|
218
|
+
const checked = SpecFrontmatterSchema.safeParse(parsed.value);
|
|
219
|
+
if (!checked.success) {
|
|
220
|
+
return {
|
|
221
|
+
frontmatter: null,
|
|
222
|
+
problems: checked.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`),
|
|
223
|
+
body,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return { frontmatter: checked.data, problems: [], body };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ------------------------------------------------------------- # Design (D53)
|
|
230
|
+
export const DESIGN_SECTION_TITLE = "Design";
|
|
231
|
+
const COMPOSITION_LINE = /^\s*(?:[-*]\s*)?[Cc]omposition\s*:\s*`?([a-z][a-z0-9]*(?:-[a-z0-9]+)*)`?(?![A-Za-z0-9])/mu;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The composition a spec declares in its `# Design` section (spec
|
|
235
|
+
* ogygie-hosted-mode B10, D53): a line `Composition : <kebab-name>`, the name
|
|
236
|
+
* the generator renders the screen's previews under and the studio lets the
|
|
237
|
+
* client choose between. Null when the spec declares none — then the screen
|
|
238
|
+
* has a single composition and its previews sit at the root of its folder.
|
|
239
|
+
*/
|
|
240
|
+
export function compositionOfSpec(content: string): string | null {
|
|
241
|
+
const heading = new RegExp(`^# ${DESIGN_SECTION_TITLE}\\s*$`, "mu").exec(content);
|
|
242
|
+
if (heading === null) return null;
|
|
243
|
+
const rest = content.slice(heading.index + heading[0].length);
|
|
244
|
+
const next = /^# /mu.exec(rest);
|
|
245
|
+
const section = next === null ? rest : rest.slice(0, next.index);
|
|
246
|
+
return COMPOSITION_LINE.exec(section)?.[1] ?? null;
|
|
247
|
+
}
|