@ogygie/core 0.7.0 → 0.9.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogygie/core",
3
- "version": "0.7.0",
3
+ "version": "0.9.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
6
  "type": "module",
@@ -20,6 +20,7 @@
20
20
  "./web-lint": "./src/web-lint.ts",
21
21
  "./bundle-budget": "./src/bundle-budget.ts",
22
22
  "./spec": "./src/spec.schema.ts",
23
+ "./repo-adapter": "./src/repo-adapter.ts",
23
24
  "./decision-refs": "./src/decision-refs.ts",
24
25
  "./design-brief": "./src/design-brief.ts",
25
26
  "./module-purity": "./src/module-purity.ts",
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { describe, expect, it } from "vitest";
9
9
  import {
10
+ extractDecisionSection,
10
11
  findDecisionReferences,
11
12
  findDuplicateDecisionIds,
12
13
  findUnknownDecisionReferences,
@@ -112,3 +113,33 @@ describe("gate SPEC_UNKNOWN_DECISION_REFERENCE", () => {
112
113
  ]);
113
114
  });
114
115
  });
116
+
117
+ describe("extraction d'UNE décision (spec ogygie-mcp)", () => {
118
+ it("ÉTANT DONNÉ une décision déclarée QUAND on l'extrait ALORS on reçoit sa section, titre compris", () => {
119
+ expect(extractDecisionSection(DECISIONS, "D11")).toBe(
120
+ [
121
+ "## D11 — 2026-07-30 — Anglais maximal dans le code",
122
+ "- **Décision** : les identifiants sont en anglais (voir aussi D99, jamais prise).",
123
+ ].join("\n"),
124
+ );
125
+ });
126
+
127
+ it("la DERNIÈRE décision du fichier va jusqu'à la fin, sans ligne vide traînante", () => {
128
+ expect(extractDecisionSection(`${DECISIONS}\n\n`, "D26")).toBe(
129
+ [
130
+ "## D26 — 2026-08 — Nomenclature partagée : codes normalisés",
131
+ "- **Décision** : le catalogue global s'appuie sur des codes normalisés.",
132
+ ].join("\n"),
133
+ );
134
+ });
135
+
136
+ it("MUTATION : une décision seulement CITÉE dans le corps d'une autre ne s'extrait pas", () => {
137
+ // D99 is mentioned inside D11. Cutting on a citation instead of a heading
138
+ // would serve half of D11 as if it were a decision of its own.
139
+ expect(extractDecisionSection(DECISIONS, "D99")).toBeNull();
140
+ });
141
+
142
+ it("MUTATION : un préfixe ne fait pas une décision — D1 n'est pas D11", () => {
143
+ expect(extractDecisionSection(DECISIONS, "D1")).toBeNull();
144
+ });
145
+ });
@@ -69,6 +69,39 @@ export function findDuplicateDecisionIds(
69
69
  return duplicated;
70
70
  }
71
71
 
72
+ /**
73
+ * The section of ONE decision — its declaring heading and everything up to the
74
+ * next one — or null when no decisions file declares it.
75
+ *
76
+ * Serving a whole decisions file to answer a question about one decision is
77
+ * how a reader's context fills with rules nobody asked about (spec ogygie-mcp,
78
+ * `ogygie_read_decisions`). The cut is made on the DECLARING heading, the very
79
+ * one `parseKnownDecisionIds` counts, so what can be cited and what can be
80
+ * served never drift apart.
81
+ */
82
+ export function extractDecisionSection(
83
+ decisionsMarkdown: string,
84
+ decisionId: string,
85
+ ): string | null {
86
+ const lines = decisionsMarkdown.split("\n");
87
+ const trimTrailingBlanks = (section: string[]): string => {
88
+ while (section.length > 0 && (section[section.length - 1] ?? "").trim() === "") section.pop();
89
+ return section.join("\n");
90
+ };
91
+ let start = -1;
92
+ for (let index = 0; index < lines.length; index += 1) {
93
+ const heading = DECISION_HEADING.exec(lines[index] ?? "");
94
+ if (heading === null) continue;
95
+ if (start === -1) {
96
+ if (`D${heading[1]}` === decisionId) start = index;
97
+ continue;
98
+ }
99
+ // The next declaration ends the one being cut.
100
+ return trimTrailingBlanks(lines.slice(start, index));
101
+ }
102
+ return start === -1 ? null : trimTrailingBlanks(lines.slice(start));
103
+ }
104
+
72
105
  /** Every decision citation found in a text (frontmatter included), in order. */
73
106
  export function findDecisionReferences(text: string): DecisionReference[] {
74
107
  const references: DecisionReference[] = [];
@@ -77,3 +77,70 @@ describe("findSpecOwner — feature ou package, la carte confirme les deux", ()
77
77
  expect(findSpecOwner(manifestWith({}), "gadget-engine")).toBeUndefined();
78
78
  });
79
79
  });
80
+
81
+ /**
82
+ * The nature added the day a framework spec could not be confirmed. The
83
+ * mutation these tests guard is the one that was there for months: the lookup
84
+ * IGNORING a nature the map declares, and answering "no owner" instead of
85
+ * "this owner, not delivered yet".
86
+ */
87
+ const frameworkWith = (packages: unknown[]) => ({
88
+ framework: {
89
+ packages,
90
+ templates: { project: "templates/project", feature: "templates/feature" },
91
+ governedFiles: "packages/ogygie-cli/src/governed-files.ts",
92
+ },
93
+ });
94
+
95
+ const mcpPackage = {
96
+ name: "@acme/mcp",
97
+ path: "packages/acme-mcp",
98
+ intent: "Serveur du protocole, publié à part parce qu'il porte sa propre dépendance.",
99
+ spec: "specs/acme-mcp.spec.md",
100
+ status: "delivered",
101
+ };
102
+
103
+ describe("findSpecOwner — le package d'un framework confirme aussi", () => {
104
+ it("ÉTANT DONNÉ une spec décrite par un package du framework ALORS la carte le confirme", () => {
105
+ const owner = findSpecOwner(manifestWith(frameworkWith([mcpPackage])), "acme-mcp");
106
+ expect(owner).toEqual({
107
+ kind: "framework-package",
108
+ name: "@acme/mcp",
109
+ status: "delivered",
110
+ folder: "packages/acme-mcp",
111
+ });
112
+ });
113
+
114
+ it("l'appariement se fait par le champ `spec`, JAMAIS par le nom npm publié", () => {
115
+ // The mutation this kills: matching `candidate.name === specId`, which
116
+ // silently finds nothing — "@acme/mcp" is not "acme-mcp".
117
+ expect(findSpecOwner(manifestWith(frameworkWith([mcpPackage])), "@acme/mcp")).toBeUndefined();
118
+ expect(findSpecOwner(manifestWith(frameworkWith([mcpPackage])), "acme-mcp")?.name).toBe(
119
+ "@acme/mcp",
120
+ );
121
+ });
122
+
123
+ it("une spec portée par PLUSIEURS packages vaut le MOINS avancé d'entre eux", () => {
124
+ const owner = findSpecOwner(
125
+ manifestWith(
126
+ frameworkWith([
127
+ { ...mcpPackage, name: "@acme/core", path: "packages/acme-core" },
128
+ { ...mcpPackage, name: "@acme/cli", path: "packages/acme-cli", status: "in-progress" },
129
+ ]),
130
+ ),
131
+ "acme-mcp",
132
+ );
133
+ expect(owner?.status).toBe("in-progress");
134
+ expect(owner?.name).toBe("@acme/cli");
135
+ });
136
+
137
+ // The "a feature and a framework package claim the same spec" case is not
138
+ // tested here: the manifest schema refuses it upstream (see
139
+ // manifest.schema.test.ts), so it cannot reach this lookup.
140
+ it("un package du framework SANS spec ne possède rien", () => {
141
+ expect(
142
+ findSpecOwner(manifestWith(frameworkWith([{ ...mcpPackage, spec: null }])), "acme-mcp"),
143
+ ).toBeUndefined();
144
+ });
145
+
146
+ });
@@ -3,19 +3,42 @@
3
3
  * the map owns this spec, and how far along is it?
4
4
  *
5
5
  * The rule itself does not change — a spec only claims `implemented` when the
6
- * manifest confirms it. What changes is that the map now has two kinds of
7
- * entry able to confirm: a feature living under an app, and a PRODUCT PACKAGE
8
- * living outside every app (spec manifest-packages). Before that, a spec whose
9
- * code sat in `packages/` could never be marked finished, and nothing said why
10
- * — the gate answered `manifest status=absent` and suggested two fixes, both
11
- * impossible.
6
+ * manifest confirms it. What changes is WHICH entries are able to confirm.
7
+ * The map declares THREE natures, and this function knows all three:
8
+ * a feature living under an app, a PRODUCT PACKAGE living outside every app
9
+ * (spec manifest-packages), and a FRAMEWORK PACKAGE — the published packages a
10
+ * framework repo describes in its `framework` block.
11
+ *
12
+ * Each nature was added the day a spec of that nature could not be confirmed,
13
+ * and the failure mode is always the same silence: the gate answers "no entry
14
+ * of that name", suggests fixes that cannot be applied, and the spec stays
15
+ * `validated` for ever while its code ships (FAILURES.md 2026-08-25 — three
16
+ * framework specs were in that state, one of them for a delivered gate).
17
+ * Hence the standing convention: a spec verifier knows EVERY nature of owner
18
+ * the manifest declares, and a nature added to the map is added here in the
19
+ * same lot.
20
+ *
21
+ * A framework package matches by its `spec` field, not by its name: a
22
+ * published npm name (`@ogygie/mcp`) is not a spec id (`ogygie-mcp`). Several
23
+ * may point at the same spec — a framework spec often spans the core and the
24
+ * CLI — and the LEAST advanced one answers: a spec is delivered when all its
25
+ * code is, not when its first half is.
12
26
  *
13
27
  * Lives in the core, not in the gate script, so the rule travels with the
14
28
  * versioned package instead of with a copy each project keeps.
15
29
  */
16
30
  import type { OgygieManifest } from "./manifest.schema.js";
17
31
 
18
- export type SpecOwnerKind = "feature" | "package";
32
+ /** Least advanced first — the order the "all its code" rule sorts on. */
33
+ const ADVANCEMENT = ["skeleton", "in-progress", "delivered"] as const;
34
+
35
+ /** `specs/ogygie-mcp.spec.md` -> `ogygie-mcp`. The manifest declares a PATH
36
+ * (as features do), the lifecycle rule reasons on ids. */
37
+ function specIdOf(specPath: string): string {
38
+ return (specPath.split("/").pop() ?? specPath).replace(/\.spec\.md$/, "");
39
+ }
40
+
41
+ export type SpecOwnerKind = "feature" | "package" | "framework-package";
19
42
 
20
43
  export interface SpecOwner {
21
44
  kind: SpecOwnerKind;
@@ -64,9 +87,24 @@ export function findSpecOwner(
64
87
  };
65
88
  }
66
89
 
90
+ const frameworkPackages = (manifest.framework?.packages ?? []).filter(
91
+ (candidate) => candidate.spec !== null && specIdOf(candidate.spec) === specId,
92
+ );
93
+ const leastAdvanced = frameworkPackages
94
+ .slice()
95
+ .sort((a, b) => ADVANCEMENT.indexOf(a.status) - ADVANCEMENT.indexOf(b.status))[0];
96
+ if (leastAdvanced !== undefined) {
97
+ return {
98
+ kind: "framework-package",
99
+ name: leastAdvanced.name,
100
+ status: leastAdvanced.status,
101
+ folder: leastAdvanced.path,
102
+ };
103
+ }
104
+
67
105
  return undefined;
68
106
  }
69
107
 
70
108
  /** What a refusal should suggest — both natures, never one that cannot exist. */
71
109
  export const SPEC_OWNER_FIX =
72
- "Set the owning entry to delivered in the manifest — a feature under `features`, or a library under `packages` — or keep the spec in validated.";
110
+ "Set the owning entry to delivered in the manifest — a feature under `features`, a library under `packages`, or a published package under `framework.packages` (which names its spec explicitly) — or keep the spec in validated.";
@@ -220,6 +220,78 @@ describe("manifest — ogygieVersion et bloc framework (spec ogygie-standalone-r
220
220
  name: "@ogygie/core",
221
221
  path: "packages/ogygie-core",
222
222
  intent: "Schémas, erreurs et outillage génériques du framework.",
223
+ spec: null,
224
+ status: "delivered",
225
+ },
226
+ ],
227
+ templates: { project: "templates/project", feature: "templates/feature" },
228
+ governedFiles: "packages/ogygie-cli/src/governed-files.ts",
229
+ },
230
+ };
231
+ expect(OgygieManifestSchema.safeParse(manifest).success).toBe(true);
232
+ });
233
+
234
+ // A published package is an OWNER of specs like any other entry of the map.
235
+ // What follows guards the day that was discovered — a spec verifier that
236
+ // ignores a nature answers "no owner" and lets a delivered spec look unborn
237
+ // (FAILURES.md 2026-08-25).
238
+ it("MUTATION : un package du framework sans `status` est refusé (le cycle de vie ne devine pas)", () => {
239
+ const manifest = {
240
+ ...manifestWithFeature({}),
241
+ framework: {
242
+ packages: [
243
+ {
244
+ name: "@ogygie/core",
245
+ path: "packages/ogygie-core",
246
+ intent: "Schémas, erreurs et outillage génériques du framework.",
247
+ spec: null,
248
+ },
249
+ ],
250
+ templates: { project: "templates/project", feature: "templates/feature" },
251
+ governedFiles: "packages/ogygie-cli/src/governed-files.ts",
252
+ },
253
+ };
254
+ expect(OgygieManifestSchema.safeParse(manifest).success).toBe(false);
255
+ });
256
+
257
+ it("MUTATION : un package du framework qui revendique la spec d'une FEATURE est refusé", () => {
258
+ const manifest = {
259
+ ...manifestWithFeature({}),
260
+ framework: {
261
+ packages: [
262
+ {
263
+ name: "@ogygie/shell",
264
+ path: "packages/ogygie-shell",
265
+ intent: "Package revendiquant la spec déjà possédée par la feature web-shell.",
266
+ spec: "specs/web-shell.spec.md",
267
+ status: "delivered",
268
+ },
269
+ ],
270
+ templates: { project: "templates/project", feature: "templates/feature" },
271
+ governedFiles: "packages/ogygie-cli/src/governed-files.ts",
272
+ },
273
+ };
274
+ expect(OgygieManifestSchema.safeParse(manifest).success).toBe(false);
275
+ });
276
+
277
+ it("DEUX packages du framework peuvent porter la MÊME spec : une spec du framework enjambe souvent core et cli", () => {
278
+ const manifest = {
279
+ ...manifestWithFeature({}),
280
+ framework: {
281
+ packages: [
282
+ {
283
+ name: "@ogygie/core",
284
+ path: "packages/ogygie-core",
285
+ intent: "Moitié jugement de la spec, sans aucune collecte disque.",
286
+ spec: "specs/outillage.spec.md",
287
+ status: "delivered",
288
+ },
289
+ {
290
+ name: "@ogygie/cli",
291
+ path: "packages/ogygie-cli",
292
+ intent: "Moitié collecte de la même spec, appelée par la CI gouvernée.",
293
+ spec: "specs/outillage.spec.md",
294
+ status: "in-progress",
223
295
  },
224
296
  ],
225
297
  templates: { project: "templates/project", feature: "templates/feature" },
@@ -64,9 +64,33 @@ export const HttpRouteSchema = z.object({
64
64
  * `shared` is a literal `true`: a table is either shared (and says so) or
65
65
  * tenant-scoped (and stays a plain string) — no `shared: false` third state.
66
66
  */
67
+ /**
68
+ * WHICH hole in the tenancy rail, because the two do not carry the same risk
69
+ * (D8):
70
+ * - `reference` — universal knowledge loaded by an extraction script, READ
71
+ * ONLY for the API. What an ABSENT key means, which is also what
72
+ * `shared: true` historically meant — read it through `sharedKindOf()`
73
+ * rather than defaulting in the schema, so the manifest a project wrote is
74
+ * the manifest an agent reads.
75
+ * - `ownerless` — data the API itself produces, owned by nobody because it is
76
+ * born before any account. Its feature WRITES it, and since no tenant guards
77
+ * it, an unguessable secret must: its rows are read by token, never by an
78
+ * enumerable id. No gate can check that, so the feature's spec must say
79
+ * where the secret lives.
80
+ *
81
+ * A security fact belongs on the map, not in a comment — hence a declared key
82
+ * rather than a convention.
83
+ */
84
+ export const SharedTableKindSchema = z.enum(["reference", "ownerless"]);
85
+ export type SharedTableKind = z.infer<typeof SharedTableKindSchema>;
86
+
67
87
  export const DbTableDeclarationSchema = z.union([
68
88
  z.string(),
69
- z.object({ name: z.string(), shared: z.literal(true) }),
89
+ z.object({
90
+ name: z.string(),
91
+ shared: z.literal(true),
92
+ sharedKind: SharedTableKindSchema.optional(),
93
+ }),
70
94
  ]);
71
95
  export type DbTableDeclaration = z.infer<typeof DbTableDeclarationSchema>;
72
96
 
@@ -121,6 +145,20 @@ export const FrameworkManifestSchema = z.object({
121
145
  name: z.string().describe("Published npm name, e.g. @ogygie/core"),
122
146
  path: z.string().describe("Folder relative to the repo root"),
123
147
  intent: z.string().min(20),
148
+ /**
149
+ * The spec describing this package, when one does. Named EXPLICITLY
150
+ * because a published npm name (`@ogygie/mcp`) is not a spec id
151
+ * (`ogygie-mcp`) — the other two natures of entry match by name, this
152
+ * one cannot. Several packages may point at the same spec: a spec of
153
+ * the framework often spans the core and the CLI.
154
+ */
155
+ spec: z.string().nullable(),
156
+ /**
157
+ * How far along, in the SAME words as the other natures. Required, and
158
+ * deliberately without default: a lifecycle gate that guesses a status
159
+ * confirms specs nobody delivered.
160
+ */
161
+ status: z.enum(["skeleton", "in-progress", "delivered"]),
124
162
  }),
125
163
  ),
126
164
  templates: z.object({
@@ -286,6 +324,27 @@ export const OgygieManifestSchema = z.object({
286
324
  packageNames.add(packageEntry.name);
287
325
  });
288
326
 
327
+ // One spec, one owner, ACROSS the three natures. A framework package
328
+ // pointing at a spec a feature or a product package already owns would
329
+ // give the lifecycle gate two answers, and it only ever reads the first.
330
+ const specIdOf = (specPath: string): string =>
331
+ (specPath.split("/").pop() ?? specPath).replace(/\.spec\.md$/, "");
332
+ const ownedSpecIds = new Set<string>([
333
+ ...manifest.features.map((feature) => feature.name),
334
+ ...packageNames,
335
+ ]);
336
+ (manifest.framework?.packages ?? []).forEach((packageEntry, index) => {
337
+ if (packageEntry.spec === null) return;
338
+ const specId = specIdOf(packageEntry.spec);
339
+ if (ownedSpecIds.has(specId)) {
340
+ context.addIssue({
341
+ code: "custom",
342
+ path: ["framework", "packages", index, "spec"],
343
+ message: `spec "${specId}" is already owned by a feature or a product package — one spec, one owner`,
344
+ });
345
+ }
346
+ });
347
+
289
348
  // Every `package:<name>` must designate a DECLARED package, wherever it is
290
349
  // written: a dangling dependency describes a map nobody can follow.
291
350
  const checkPackageDependencies = (
@@ -0,0 +1,192 @@
1
+ /**
2
+ * @intent Proves the read-only repo access of DECISIONS.md D10, and guards the
3
+ * mutations that make such a module lie instead of fail: a traversal guard
4
+ * comparing prefixes without a separator, a symlink walking out of the repo, a
5
+ * `.git` that is a FILE (every worktree), a ref stored packed rather than
6
+ * loose. Each of those returns a plausible answer when it is wrong, which is
7
+ * why each has its test.
8
+ */
9
+ import { execFileSync } from "node:child_process";
10
+ import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
14
+ import { OgygieFailure } from "./error.schema.js";
15
+ import {
16
+ createFilesystemRepoAdapter,
17
+ createInMemoryRepoAdapter,
18
+ type RepoAdapter,
19
+ } from "./repo-adapter.js";
20
+
21
+ /** The stable code carried by a thrown OgygieFailure. */
22
+ function codeOf(thrown: unknown): string {
23
+ return thrown instanceof OgygieFailure ? thrown.error.code : "<not an OgygieFailure>";
24
+ }
25
+
26
+ async function expectRefusal(action: () => Promise<unknown>, expectedCode: string): Promise<void> {
27
+ await expect(action()).rejects.toSatisfy(
28
+ (thrown: unknown) => codeOf(thrown) === expectedCode,
29
+ `expected an OgygieError with code ${expectedCode}`,
30
+ );
31
+ }
32
+
33
+ describe("repo-adapter — lecture du disque", () => {
34
+ let rootDir: string;
35
+ let outsideDir: string;
36
+ let adapter: RepoAdapter;
37
+
38
+ beforeAll(async () => {
39
+ const base = await mkdtemp(join(tmpdir(), "ogygie-repo-"));
40
+ rootDir = join(base, "repo");
41
+ outsideDir = join(base, "repo-evil");
42
+ await mkdir(join(rootDir, "src", "features", "orders"), { recursive: true });
43
+ await mkdir(outsideDir, { recursive: true });
44
+ await writeFile(join(rootDir, "AGENTS.md"), "règles", "utf8");
45
+ await writeFile(join(rootDir, "src", "features", "orders", "schemas.ts"), "export const a = 1;\n", "utf8");
46
+ await writeFile(join(rootDir, "src", "features", "orders", "service.ts"), "// service\n", "utf8");
47
+ await writeFile(join(outsideDir, "secrets.env"), "TOKEN=leaked", "utf8");
48
+ await mkdir(join(rootDir, "node_modules", "left-pad"), { recursive: true });
49
+ await writeFile(join(rootDir, "node_modules", "left-pad", "index.js"), "module.exports = 1;", "utf8");
50
+ await symlink(join(outsideDir, "secrets.env"), join(rootDir, "escape.env"));
51
+ adapter = createFilesystemRepoAdapter(rootDir);
52
+ });
53
+
54
+ afterAll(async () => {
55
+ await rm(join(rootDir, ".."), { recursive: true, force: true });
56
+ });
57
+
58
+ it("ÉTANT DONNÉ un fichier du repo QUAND on le lit ALORS son contenu revient tel quel", async () => {
59
+ expect(await adapter.readFile("AGENTS.md")).toBe("règles");
60
+ });
61
+
62
+ it("un listing est RÉCURSIF, trié, et porte la taille de chaque fichier", async () => {
63
+ const entries = await adapter.listDirectory("src/features/orders");
64
+ expect(entries.map((entry) => entry.path)).toEqual(["schemas.ts", "service.ts"]);
65
+ expect(entries[0]?.sizeBytes).toBe(20);
66
+ });
67
+
68
+ it("un dossier ABSENT liste vide — une feature en squelette est un état légitime", async () => {
69
+ expect(await adapter.listDirectory("src/features/inexistante")).toEqual([]);
70
+ });
71
+
72
+ it("node_modules et les dossiers cachés ne sont jamais listés", async () => {
73
+ const paths = (await adapter.listDirectory(".")).map((entry) => entry.path);
74
+ expect(paths).toContain("AGENTS.md");
75
+ expect(paths.some((path) => path.startsWith("node_modules/"))).toBe(false);
76
+ });
77
+
78
+ it("MUTATION : un chemin remontant vers un repo VOISIN est refusé, rien n'est lu", async () => {
79
+ // The neighbour is named "repo-evil" next to "repo" on purpose: a guard
80
+ // comparing prefixes without a separator lets it through, since
81
+ // "/…/repo-evil" does start with "/…/repo".
82
+ await expectRefusal(() => adapter.readFile("../repo-evil/secrets.env"), "PATH_OUTSIDE_REPO");
83
+ });
84
+
85
+ it("MUTATION : un chemin ABSOLU est refusé", async () => {
86
+ await expectRefusal(() => adapter.readFile(join(outsideDir, "secrets.env")), "PATH_OUTSIDE_REPO");
87
+ });
88
+
89
+ it("MUTATION : un lien symbolique sortant est refusé, bien que son chemin soit propre", async () => {
90
+ await expectRefusal(() => adapter.readFile("escape.env"), "PATH_OUTSIDE_REPO");
91
+ });
92
+
93
+ it("un fichier absent échoue avec un code stable, jamais un contenu vide", async () => {
94
+ await expectRefusal(() => adapter.readFile("jamais-ecrit.md"), "REPO_FILE_NOT_FOUND");
95
+ });
96
+
97
+ it("exists répond sans jamais lever pour un fichier absent", async () => {
98
+ expect(await adapter.exists("AGENTS.md")).toBe(true);
99
+ expect(await adapter.exists("jamais-ecrit.md")).toBe(false);
100
+ });
101
+
102
+ it("un dossier qui n'est PAS un clone git rend un ref null — jamais un identifiant inventé", async () => {
103
+ expect(await adapter.currentRef()).toBeNull();
104
+ });
105
+ });
106
+
107
+ describe("repo-adapter — le ref git, dans les trois formes que git donne à `.git`", () => {
108
+ let base: string;
109
+
110
+ const git = (cwd: string, ...args: string[]): string =>
111
+ execFileSync("git", args, {
112
+ cwd,
113
+ encoding: "utf8",
114
+ env: {
115
+ ...process.env,
116
+ GIT_AUTHOR_NAME: "Fixture",
117
+ GIT_AUTHOR_EMAIL: "fixture@example.invalid",
118
+ GIT_COMMITTER_NAME: "Fixture",
119
+ GIT_COMMITTER_EMAIL: "fixture@example.invalid",
120
+ },
121
+ }).trim();
122
+
123
+ let clone: string;
124
+ let head: string;
125
+
126
+ beforeAll(async () => {
127
+ base = await mkdtemp(join(tmpdir(), "ogygie-git-"));
128
+ clone = join(base, "clone");
129
+ await mkdir(clone);
130
+ git(clone, "init", "--initial-branch=main");
131
+ await writeFile(join(clone, "AGENTS.md"), "règles", "utf8");
132
+ git(clone, "add", "AGENTS.md");
133
+ git(clone, "commit", "-m", "fixture");
134
+ head = git(clone, "rev-parse", "HEAD");
135
+ });
136
+
137
+ afterAll(async () => {
138
+ await rm(base, { recursive: true, force: true });
139
+ });
140
+
141
+ it("ÉTANT DONNÉ un clone ordinaire QUAND on demande le ref ALORS c'est le SHA de HEAD", async () => {
142
+ expect(await createFilesystemRepoAdapter(clone).currentRef()).toBe(head);
143
+ });
144
+
145
+ it("ÉTANT DONNÉ un WORKTREE, dont `.git` est un FICHIER, ALORS le ref est trouvé quand même", async () => {
146
+ // The form this repo itself works in. A reader that only knows the
147
+ // directory form answers null here — and null is a legitimate answer
148
+ // elsewhere, so nothing would ever protest.
149
+ const worktree = join(base, "wt");
150
+ git(clone, "worktree", "add", "-b", "essai", worktree);
151
+ const worktreeHead = git(worktree, "rev-parse", "HEAD");
152
+ expect(await createFilesystemRepoAdapter(worktree).currentRef()).toBe(worktreeHead);
153
+ });
154
+
155
+ it("ÉTANT DONNÉ des refs COMPACTÉES QUAND on demande le ref ALORS packed-refs répond", async () => {
156
+ git(clone, "pack-refs", "--all");
157
+ expect(await createFilesystemRepoAdapter(clone).currentRef()).toBe(head);
158
+ });
159
+
160
+ it("ÉTANT DONNÉ une HEAD DÉTACHÉE ALORS le SHA qu'elle porte est rendu directement", async () => {
161
+ const detached = join(base, "detached");
162
+ git(clone, "worktree", "add", "--detach", detached, head);
163
+ expect(await createFilesystemRepoAdapter(detached).currentRef()).toBe(head);
164
+ });
165
+ });
166
+
167
+ describe("repo-adapter — fixture en mémoire", () => {
168
+ const adapter = createInMemoryRepoAdapter({
169
+ "AGENTS.md": "règles",
170
+ "specs/notes.spec.md": "---\nid: notes\n---\n",
171
+ "src/features/notes/schemas.ts": "export const a = 1;\n",
172
+ });
173
+
174
+ it("lit, liste par préfixe et rend des tailles en OCTETS, pas en caractères", async () => {
175
+ expect(await adapter.readFile("AGENTS.md")).toBe("règles");
176
+ const entries = await adapter.listDirectory("specs");
177
+ expect(entries).toEqual([{ path: "notes.spec.md", sizeBytes: 18 }]);
178
+ // "règles" is 6 characters and 7 bytes: a size in characters would lie to
179
+ // every caller enforcing a byte budget.
180
+ const root = await adapter.listDirectory("");
181
+ expect(root.find((entry) => entry.path === "AGENTS.md")?.sizeBytes).toBe(7);
182
+ });
183
+
184
+ it("un fichier absent échoue du MÊME code que sur disque", async () => {
185
+ await expectRefusal(() => adapter.readFile("absent.md"), "REPO_FILE_NOT_FOUND");
186
+ });
187
+
188
+ it("le ref est fourni par la fixture, et peut modéliser un dossier sans git", async () => {
189
+ expect(await adapter.currentRef()).toBe("0".repeat(40));
190
+ expect(await createInMemoryRepoAdapter({}, { ref: null }).currentRef()).toBeNull();
191
+ });
192
+ });
@@ -0,0 +1,307 @@
1
+ /**
2
+ * @intent READ-ONLY access to an Ogygie repo, and the only place the framework
3
+ * touches a repo's disk (DECISIONS.md D10).
4
+ *
5
+ * Two tools already need it — the studio, which projects specs into a local
6
+ * UI, and the MCP server, which serves them to a design space (spec
7
+ * ogygie-mcp). Two filesystem adapters would be a lockstep bug waiting to
8
+ * happen: the traversal guard fixed on one side and not the other. So the
9
+ * interface and its implementations live here, once.
10
+ *
11
+ * Writing is NEVER declared in `RepoAdapter`. A tool that writes COMPOSES —
12
+ * the studio's `SpecStorage` extends this interface with its `write`. That way
13
+ * the read-only promise of a consumer is checkable by what it consumes, not by
14
+ * a comment.
15
+ *
16
+ * No dependency, on purpose: the git ref is read straight out of `.git`
17
+ * (D10 excludes a git library for it), so a project installing the core to
18
+ * validate its manifest drags nothing along.
19
+ */
20
+ import { readFile as readFileFromDisk, readdir, realpath, stat } from "node:fs/promises";
21
+ import { isAbsolute, join, resolve, sep } from "node:path";
22
+ import { z } from "zod";
23
+ import { throwOgygieError } from "./error.schema.js";
24
+
25
+ export const RepoFileEntrySchema = z.object({
26
+ /** Path relative to the LISTED directory, "/" separated, never absolute. */
27
+ path: z.string(),
28
+ sizeBytes: z.number().int().nonnegative(),
29
+ });
30
+ export type RepoFileEntry = z.infer<typeof RepoFileEntrySchema>;
31
+
32
+ /**
33
+ * What a repo can be asked. Four methods, all reading — the shape a consumer
34
+ * declares when it promises to touch nothing.
35
+ */
36
+ export interface RepoAdapter {
37
+ /** UTF-8 content of one file. Refuses a path escaping the repo. */
38
+ readFile(relativePath: string): Promise<string>;
39
+ /**
40
+ * Files under a directory, RECURSIVELY, sorted by path. An absent directory
41
+ * lists EMPTY rather than failing: a feature declared in the manifest but
42
+ * not yet written is a legitimate state the map already explains.
43
+ */
44
+ listDirectory(relativePath: string): Promise<RepoFileEntry[]>;
45
+ exists(relativePath: string): Promise<boolean>;
46
+ /** HEAD commit SHA, or null when the root is not a git clone. */
47
+ currentRef(): Promise<string | null>;
48
+ }
49
+
50
+ /**
51
+ * Directories a repo reader never descends into. Not a blocklist of "bad"
52
+ * paths — a reader that returned forty thousand `node_modules` entries would
53
+ * simply be unusable, and nothing under a dot-directory is repo source. The
54
+ * traversal guard below is what protects, this only keeps listings useful.
55
+ */
56
+ const NEVER_LISTED = new Set(["node_modules"]);
57
+
58
+ function isSkippedDirectory(name: string): boolean {
59
+ return name.startsWith(".") || NEVER_LISTED.has(name);
60
+ }
61
+
62
+ function refusePathOutsideRepo(relativePath: string, cause: string): never {
63
+ return throwOgygieError(
64
+ {
65
+ code: "PATH_OUTSIDE_REPO",
66
+ message: "The requested path resolves outside the repo root.",
67
+ file: relativePath,
68
+ cause,
69
+ suggestedFix:
70
+ "Address a path INSIDE the repo, written relative to its root and without `..`.",
71
+ },
72
+ 400,
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Textual guard: absolute paths and `..` never reach the disk.
78
+ *
79
+ * The prefix test carries a separator on purpose — `/srv/repo-evil` starts
80
+ * with `/srv/repo` as a string, and a guard that forgets it lets a sibling
81
+ * directory through (the form this check had while it lived in the studio).
82
+ */
83
+ function resolveInsideRoot(rootDir: string, relativePath: string): string {
84
+ if (isAbsolute(relativePath)) {
85
+ refusePathOutsideRepo(relativePath, `"${relativePath}" is an absolute path`);
86
+ }
87
+ const root = resolve(rootDir);
88
+ const absolute = resolve(root, relativePath);
89
+ if (absolute !== root && !absolute.startsWith(root + sep)) {
90
+ refusePathOutsideRepo(relativePath, `"${relativePath}" resolves to "${absolute}", outside "${root}"`);
91
+ }
92
+ return absolute;
93
+ }
94
+
95
+ /**
96
+ * Second guard, for what the first cannot see: a symlink INSIDE the repo
97
+ * pointing out of it resolves to a clean relative path and still reads someone
98
+ * else's file. Only checked when the target exists — when it does not, nothing
99
+ * is read anyway, and `exists` must be able to answer "no" without an error.
100
+ */
101
+ async function assertRealPathInsideRoot(
102
+ rootDir: string,
103
+ absolute: string,
104
+ relativePath: string,
105
+ ): Promise<void> {
106
+ let realRoot: string;
107
+ let realTarget: string;
108
+ try {
109
+ realRoot = await realpath(rootDir);
110
+ realTarget = await realpath(absolute);
111
+ } catch {
112
+ return;
113
+ }
114
+ if (realTarget !== realRoot && !realTarget.startsWith(realRoot + sep)) {
115
+ refusePathOutsideRepo(
116
+ relativePath,
117
+ `"${relativePath}" is a symbolic link leading to "${realTarget}", outside "${realRoot}"`,
118
+ );
119
+ }
120
+ }
121
+
122
+ async function listFilesRecursively(
123
+ absoluteDir: string,
124
+ prefix: string,
125
+ ): Promise<RepoFileEntry[]> {
126
+ let entries;
127
+ try {
128
+ entries = await readdir(absoluteDir, { withFileTypes: true });
129
+ } catch {
130
+ return [];
131
+ }
132
+ const files: RepoFileEntry[] = [];
133
+ for (const entry of entries) {
134
+ const relativePath = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
135
+ if (entry.isDirectory()) {
136
+ if (isSkippedDirectory(entry.name)) continue;
137
+ files.push(...(await listFilesRecursively(join(absoluteDir, entry.name), relativePath)));
138
+ } else if (entry.isFile()) {
139
+ const stats = await stat(join(absoluteDir, entry.name));
140
+ files.push({ path: relativePath, sizeBytes: stats.size });
141
+ }
142
+ }
143
+ return files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
144
+ }
145
+
146
+ /**
147
+ * Where git really keeps its state for this root.
148
+ *
149
+ * `.git` is a directory in a plain clone and a FILE in a worktree, pointing at
150
+ * the real git dir; a reader that only knows the first form answers "not a git
151
+ * clone" for every worktree — silently, since a null ref is a legitimate
152
+ * answer elsewhere. Branch refs then live in the COMMON dir shared with the
153
+ * main clone, which `commondir` names.
154
+ */
155
+ async function resolveGitDirs(rootDir: string): Promise<{ gitDir: string; commonDir: string } | null> {
156
+ const dotGit = join(rootDir, ".git");
157
+ let gitDir: string;
158
+ try {
159
+ const stats = await stat(dotGit);
160
+ if (stats.isDirectory()) {
161
+ gitDir = dotGit;
162
+ } else {
163
+ const pointer = (await readFileFromDisk(dotGit, "utf8")).trim();
164
+ const target = pointer.startsWith("gitdir:") ? pointer.slice("gitdir:".length).trim() : "";
165
+ if (target === "") return null;
166
+ gitDir = isAbsolute(target) ? target : resolve(rootDir, target);
167
+ }
168
+ } catch {
169
+ return null;
170
+ }
171
+ try {
172
+ const commonPointer = (await readFileFromDisk(join(gitDir, "commondir"), "utf8")).trim();
173
+ return { gitDir, commonDir: isAbsolute(commonPointer) ? commonPointer : resolve(gitDir, commonPointer) };
174
+ } catch {
175
+ return { gitDir, commonDir: gitDir };
176
+ }
177
+ }
178
+
179
+ /** A ref name resolved to its SHA — loose file first, then `packed-refs`. */
180
+ async function readRefSha(
181
+ dirs: { gitDir: string; commonDir: string },
182
+ refName: string,
183
+ ): Promise<string | null> {
184
+ for (const base of [dirs.gitDir, dirs.commonDir]) {
185
+ try {
186
+ const sha = (await readFileFromDisk(join(base, refName), "utf8")).trim();
187
+ if (sha !== "") return sha;
188
+ } catch {
189
+ // Not stored loose here — try the next location, then packed-refs.
190
+ }
191
+ }
192
+ try {
193
+ const packed = await readFileFromDisk(join(dirs.commonDir, "packed-refs"), "utf8");
194
+ for (const line of packed.split("\n")) {
195
+ if (line.startsWith("#") || line.startsWith("^")) continue;
196
+ const [sha, name] = line.trim().split(" ");
197
+ if (name === refName && sha !== undefined && sha !== "") return sha;
198
+ }
199
+ } catch {
200
+ return null;
201
+ }
202
+ return null;
203
+ }
204
+
205
+ /**
206
+ * The repo as it sits on this disk, at this instant. Every path is relative to
207
+ * `rootDir` and guarded twice (textually, then through symlinks).
208
+ */
209
+ export function createFilesystemRepoAdapter(rootDir: string): RepoAdapter {
210
+ return {
211
+ async readFile(relativePath: string): Promise<string> {
212
+ const absolute = resolveInsideRoot(rootDir, relativePath);
213
+ await assertRealPathInsideRoot(rootDir, absolute, relativePath);
214
+ try {
215
+ return await readFileFromDisk(absolute, "utf8");
216
+ } catch (thrown) {
217
+ return throwOgygieError({
218
+ code: "REPO_FILE_NOT_FOUND",
219
+ message: "The requested file cannot be read from the repo.",
220
+ file: relativePath,
221
+ cause: `reading "${absolute}" failed: ${thrown instanceof Error ? thrown.message : String(thrown)}`,
222
+ suggestedFix:
223
+ "Check the path against what the map declares — the manifest is the only chart of a repo.",
224
+ });
225
+ }
226
+ },
227
+
228
+ async listDirectory(relativePath: string): Promise<RepoFileEntry[]> {
229
+ const absolute = resolveInsideRoot(rootDir, relativePath);
230
+ await assertRealPathInsideRoot(rootDir, absolute, relativePath);
231
+ return listFilesRecursively(absolute, "");
232
+ },
233
+
234
+ async exists(relativePath: string): Promise<boolean> {
235
+ const absolute = resolveInsideRoot(rootDir, relativePath);
236
+ await assertRealPathInsideRoot(rootDir, absolute, relativePath);
237
+ try {
238
+ await stat(absolute);
239
+ return true;
240
+ } catch {
241
+ return false;
242
+ }
243
+ },
244
+
245
+ async currentRef(): Promise<string | null> {
246
+ const dirs = await resolveGitDirs(rootDir);
247
+ if (dirs === null) return null;
248
+ let head: string;
249
+ try {
250
+ head = (await readFileFromDisk(join(dirs.gitDir, "HEAD"), "utf8")).trim();
251
+ } catch {
252
+ return null;
253
+ }
254
+ // Detached HEAD holds the SHA itself; otherwise it names a ref.
255
+ if (!head.startsWith("ref:")) return head === "" ? null : head;
256
+ return readRefSha(dirs, head.slice("ref:".length).trim());
257
+ },
258
+ };
259
+ }
260
+
261
+ /** Fixture repo for the tests (principle 7: no disk, no network). */
262
+ export interface InMemoryRepoAdapterOptions {
263
+ /** What `currentRef()` answers — null models a root that is not a clone. */
264
+ ref?: string | null;
265
+ }
266
+
267
+ export function createInMemoryRepoAdapter(
268
+ files: Record<string, string>,
269
+ options: InMemoryRepoAdapterOptions = {},
270
+ ): RepoAdapter {
271
+ const contents = new Map(Object.entries(files));
272
+ const ref = options.ref === undefined ? "0".repeat(40) : options.ref;
273
+ const sizeOf = (content: string): number => Buffer.byteLength(content, "utf8");
274
+ return {
275
+ async readFile(relativePath: string): Promise<string> {
276
+ const content = contents.get(relativePath);
277
+ if (content === undefined) {
278
+ return throwOgygieError({
279
+ code: "REPO_FILE_NOT_FOUND",
280
+ message: "The requested file does not exist in the in-memory repo.",
281
+ file: relativePath,
282
+ cause: `no entry for "${relativePath}"`,
283
+ suggestedFix: "Arrange the file in the fixture passed to createInMemoryRepoAdapter.",
284
+ });
285
+ }
286
+ return content;
287
+ },
288
+
289
+ async listDirectory(relativePath: string): Promise<RepoFileEntry[]> {
290
+ const prefix = relativePath === "" || relativePath === "." ? "" : `${relativePath.replace(/\/$/, "")}/`;
291
+ return [...contents.entries()]
292
+ .filter(([path]) => path.startsWith(prefix))
293
+ .map(([path, content]) => ({ path: path.slice(prefix.length), sizeBytes: sizeOf(content) }))
294
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
295
+ },
296
+
297
+ async exists(relativePath: string): Promise<boolean> {
298
+ if (contents.has(relativePath)) return true;
299
+ const prefix = `${relativePath.replace(/\/$/, "")}/`;
300
+ return [...contents.keys()].some((path) => path.startsWith(prefix));
301
+ },
302
+
303
+ async currentRef(): Promise<string | null> {
304
+ return ref;
305
+ },
306
+ };
307
+ }
@@ -7,15 +7,7 @@
7
7
  import { describe, expect, it } from "vitest";
8
8
  import type { FeatureManifest } from "./manifest.schema.js";
9
9
  import { FeatureManifestSchema } from "./manifest.schema.js";
10
- import {
11
- dbTableNamesOf,
12
- findTenantColumnsInSharedTables,
13
- mayUseRawDb,
14
- ownsOnlySharedTables,
15
- sharedTableNamesOf,
16
- splitDbSchemaByTable,
17
- tenantTableNamesOf,
18
- } from "./shared-tables.js";
10
+ import { dbTableNamesOf, findTenantColumnsInSharedTables, mayUseRawDb, ownsOnlySharedTables, sharedKindOf, sharedTableNamesOf, splitDbSchemaByTable, tenantTableNamesOf } from "./shared-tables.js";
19
11
 
20
12
  const SOCLE = new Set(["accounts"]);
21
13
 
@@ -134,3 +126,25 @@ export const labels = pgTable("labels", {
134
126
  ).toEqual([{ table: "labels", tenantColumn: "org_id" }]);
135
127
  });
136
128
  });
129
+
130
+ describe("sharedKindOf — quelle brèche dans le rail tenant (D8)", () => {
131
+ it("une table tenant n'a AUCUNE nature : ce n'est pas une brèche", () => {
132
+ expect(sharedKindOf("orders")).toBeNull();
133
+ });
134
+
135
+ it("une table partagée sans `sharedKind` vaut `reference` — le sens historique", () => {
136
+ expect(sharedKindOf({ name: "currency_rates", shared: true })).toBe("reference");
137
+ });
138
+
139
+ it("une table `ownerless` est rendue telle quelle : sa feature l'écrit", () => {
140
+ expect(sharedKindOf({ name: "public_drafts", shared: true, sharedKind: "ownerless" })).toBe(
141
+ "ownerless",
142
+ );
143
+ });
144
+
145
+ it("une table `reference` explicite reste `reference`", () => {
146
+ expect(sharedKindOf({ name: "postal_codes", shared: true, sharedKind: "reference" })).toBe(
147
+ "reference",
148
+ );
149
+ });
150
+ });
@@ -19,7 +19,7 @@
19
19
  * they return. The module is GENERIC infrastructure (D10 amended): it speaks
20
20
  * "tenant", never a word of the product's domain.
21
21
  */
22
- import type { DbTableDeclaration, FeatureManifest } from "./manifest.schema.js";
22
+ import type { SharedTableKind, DbTableDeclaration, FeatureManifest } from "./manifest.schema.js";
23
23
 
24
24
  /** Name of a table however it was declared (bare string or object form). */
25
25
  export function dbTableNameOf(declaration: DbTableDeclaration): string {
@@ -111,3 +111,14 @@ export function findTenantColumnsInSharedTables(
111
111
  }
112
112
  return findings;
113
113
  }
114
+
115
+ /**
116
+ * The nature of a shared table: what the declaration says, or `reference` when
117
+ * it says nothing — the historical meaning of `shared: true`, kept so older
118
+ * manifests keep meaning what they meant (AGENTS.md §Multi-tenant rule 4).
119
+ * A tenant-scoped table has no nature: it is not a hole in the rail.
120
+ */
121
+ export function sharedKindOf(declaration: DbTableDeclaration): SharedTableKind | null {
122
+ if (typeof declaration === "string") return null;
123
+ return declaration.sharedKind ?? "reference";
124
+ }