@ogygie/core 0.8.0 → 0.10.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.
@@ -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;
@@ -29,44 +52,99 @@ export interface SpecOwner {
29
52
  folder: string | null;
30
53
  }
31
54
 
55
+ /** One entry of the map, with everything a tool needs to go read its code. */
56
+ export interface MapEntry extends SpecOwner {
57
+ /** The spec this entry owns, or null when it owns none. */
58
+ specId: string | null;
59
+ }
60
+
32
61
  /**
33
- * The entry owning `specId`, feature first. Names are unique across the whole
34
- * map (the schema refuses a package named like a feature), so the order only
35
- * decides which lookup runs first, never which answer is right.
62
+ * EVERYTHING the map declares, each entry with its folder already resolved
63
+ * through the app that carries it.
64
+ *
65
+ * One resolution for the whole framework. The spec-lifecycle gate and the
66
+ * impact analyzer used to resolve folders on their own, and the analyzer got
67
+ * it wrong in the way that does not show: it picked ONE app —
68
+ * `apps.find(app => app.type === "api")` — and joined every feature's folder
69
+ * to that one, so a front feature was looked for under the api app and never
70
+ * found. It also refused to run at all when no api app existed. The map
71
+ * already answered per entry, through `feature.app`, which the schema REQUIRES
72
+ * a front or gateway feature to declare.
73
+ *
74
+ * A fourth nature of entry is therefore added HERE, once, and every tool sees
75
+ * it (FAILURES.md 2026-08-25: a verifier that ignores a category cannot report
76
+ * it).
36
77
  */
37
- export function findSpecOwner(
38
- manifest: OgygieManifest,
39
- specId: string,
40
- ): SpecOwner | undefined {
41
- const feature = manifest.features.find((candidate) => candidate.name === specId);
42
- if (feature !== undefined) {
78
+ export function declaredEntries(manifest: OgygieManifest): MapEntry[] {
79
+ const entries: MapEntry[] = [];
80
+
81
+ for (const feature of manifest.features) {
82
+ // The app that CARRIES this feature — never a globally chosen one.
43
83
  const owningApp =
44
84
  feature.app === undefined
45
85
  ? manifest.apps.find((app) => app.type === "api")
46
86
  : manifest.apps.find((app) => app.name === feature.app);
47
- return {
87
+ entries.push({
48
88
  kind: "feature",
49
89
  name: feature.name,
50
90
  status: feature.status,
51
91
  folder: owningApp === undefined ? null : `${owningApp.path}/${feature.folder}`,
52
- };
92
+ specId: feature.name,
93
+ });
53
94
  }
54
95
 
55
- const productPackage = (manifest.packages ?? []).find(
56
- (candidate) => candidate.name === specId,
57
- );
58
- if (productPackage !== undefined) {
59
- return {
96
+ for (const productPackage of manifest.packages ?? []) {
97
+ entries.push({
60
98
  kind: "package",
61
99
  name: productPackage.name,
62
100
  status: productPackage.status,
63
101
  folder: productPackage.path,
64
- };
102
+ specId: productPackage.name,
103
+ });
104
+ }
105
+
106
+ for (const frameworkPackage of manifest.framework?.packages ?? []) {
107
+ entries.push({
108
+ kind: "framework-package",
109
+ name: frameworkPackage.name,
110
+ status: frameworkPackage.status,
111
+ folder: frameworkPackage.path,
112
+ // A published npm name is not a spec id: the link is DECLARED.
113
+ specId: frameworkPackage.spec === null ? null : specIdOf(frameworkPackage.spec),
114
+ });
65
115
  }
66
116
 
67
- return undefined;
117
+ return entries;
118
+ }
119
+
120
+ /**
121
+ * The entry owning `specId`, feature first. Names are unique across the whole
122
+ * map (the schema refuses a package named like a feature), so the order only
123
+ * decides which lookup runs first, never which answer is right.
124
+ */
125
+ export function findSpecOwner(
126
+ manifest: OgygieManifest,
127
+ specId: string,
128
+ ): SpecOwner | undefined {
129
+ const owning = declaredEntries(manifest).filter((entry) => entry.specId === specId);
130
+ if (owning.length === 0) return undefined;
131
+ // Feature first: names are unique across the map (the schema refuses a
132
+ // package named like a feature), so the order decides which lookup runs
133
+ // first, never which answer is right. Among several framework packages
134
+ // pointing at the SAME spec — a framework spec often spans the core and the
135
+ // CLI — the LEAST advanced answers: a spec is delivered when all its code is.
136
+ const byPriority = owning.slice().sort((left, right) => {
137
+ if (left.kind !== right.kind) {
138
+ const order: SpecOwnerKind[] = ["feature", "package", "framework-package"];
139
+ return order.indexOf(left.kind) - order.indexOf(right.kind);
140
+ }
141
+ return ADVANCEMENT.indexOf(left.status) - ADVANCEMENT.indexOf(right.status);
142
+ });
143
+ const owner = byPriority[0];
144
+ if (owner === undefined) return undefined;
145
+ return { kind: owner.kind, name: owner.name, status: owner.status, folder: owner.folder };
68
146
  }
69
147
 
70
148
  /** What a refusal should suggest — both natures, never one that cannot exist. */
71
149
  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.";
150
+ "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" },
@@ -145,6 +145,20 @@ export const FrameworkManifestSchema = z.object({
145
145
  name: z.string().describe("Published npm name, e.g. @ogygie/core"),
146
146
  path: z.string().describe("Folder relative to the repo root"),
147
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"]),
148
162
  }),
149
163
  ),
150
164
  templates: z.object({
@@ -310,6 +324,27 @@ export const OgygieManifestSchema = z.object({
310
324
  packageNames.add(packageEntry.name);
311
325
  });
312
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
+
313
348
  // Every `package:<name>` must designate a DECLARED package, wherever it is
314
349
  // written: a dangling dependency describes a map nobody can follow.
315
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
+ });