@ogygie/core 0.18.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.
@@ -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
+ }
@@ -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";
@@ -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
+ });
@@ -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
+ }