@ogygie/core 0.1.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,213 @@
1
+ /**
2
+ * @intent TRANSITIVE purity of the contract entry points (DECISIONS.md D29):
3
+ * from a feature's `schemas.ts`, no module with a runtime effect may be
4
+ * REACHABLE — neither directly nor through any chain of intermediaries.
5
+ *
6
+ * `@ogygie/core/module-purity` answers "is THIS file pure?". This module
7
+ * answers "is everything reachable from this contract pure?", which is the
8
+ * question that matters: the server never came back into the browser bundle
9
+ * through a direct import, it came back through an intermediary
10
+ * (a `schemas.ts` → a sibling feature's `index.ts` → its data loader →
11
+ * `node:fs`, FAILURES.md). A contract is imported by apps/web (the zero-drift of D4), so
12
+ * its whole reachable graph is part of the browser bundle.
13
+ *
14
+ * Two rules, both structural:
15
+ * - every external specifier reachable from a contract must be in the
16
+ * allowlist (`zod` and nothing else by default);
17
+ * - no `index.ts` may be reached: it is the SERVER entry point of a feature
18
+ * (AGENTS.md §Structure), the exact edge of the incident above.
19
+ * Plus one honesty rule: a module the walk cannot READ is reported, never
20
+ * skipped — a gate that silently swallows what it does not understand is
21
+ * worse than no gate (D22).
22
+ *
23
+ * Generic tooling, hence the core (D10 amended): it knows the framework's
24
+ * vocabulary (apps, features, schemas.ts, index.ts) and not one word of the
25
+ * product's domain. The filesystem is INJECTED (`readModule`), so the walk is
26
+ * a pure function, tested in memory with its mutations (D22).
27
+ */
28
+ import { parseModuleImports } from "./module-purity.js";
29
+
30
+ /** External packages a contract graph may import at runtime. */
31
+ export const CONTRACT_ALLOWED_RUNTIME_SPECIFIERS = ["zod"] as const;
32
+
33
+ /**
34
+ * The shape of the manifest this audit needs — structurally satisfied by
35
+ * `OgygieManifest`, and small enough to be written by hand in a test.
36
+ */
37
+ export interface ContractPurityManifest {
38
+ apps: readonly { name: string; path: string; type: string }[];
39
+ features: readonly {
40
+ name: string;
41
+ app?: string | undefined;
42
+ folder: string;
43
+ status: string;
44
+ }[];
45
+ }
46
+
47
+ export type ContractImpurityKind =
48
+ /** An external package that is not in the runtime allowlist. */
49
+ | "RUNTIME_PACKAGE"
50
+ /** An `index.ts`: the server surface of a feature, never a contract. */
51
+ | "SERVER_SURFACE"
52
+ /** A relative import resolving to nothing readable — reported, not ignored. */
53
+ | "MODULE_NOT_FOUND";
54
+
55
+ export interface ContractImpurity {
56
+ kind: ContractImpurityKind;
57
+ /** The contract whose graph is broken, e.g. apps/api/src/features/orders/schemas.ts. */
58
+ entryPath: string;
59
+ /** Entry → … → the module holding the faulty import. Shortest known path. */
60
+ chain: readonly string[];
61
+ /** Specifier as written (or the resolved path when nothing could be read). */
62
+ specifier: string;
63
+ /** Raw import statement, quoted back so the fix needs no guessing. */
64
+ statement: string;
65
+ /** The rule broken, in English, ready to be shown to an agent. */
66
+ rule: string;
67
+ }
68
+
69
+ export interface ContractPurityReport {
70
+ /** The contracts audited, one per non-skeleton feature of the api app. */
71
+ entryPaths: readonly string[];
72
+ /** Every module reached across all graphs — the audited surface. */
73
+ reachedPaths: readonly string[];
74
+ impurities: readonly ContractImpurity[];
75
+ }
76
+
77
+ export interface ContractPurityOptions {
78
+ manifest: ContractPurityManifest;
79
+ /** Reads a repo-relative path; `null` when the module does not exist. */
80
+ readModule: (path: string) => string | null;
81
+ /** Defaults to CONTRACT_ALLOWED_RUNTIME_SPECIFIERS. */
82
+ allowedRuntimeSpecifiers?: readonly string[];
83
+ }
84
+
85
+ /**
86
+ * The `schemas.ts` of every non-skeleton feature owned by the api app —
87
+ * derived from the manifest, never a hand-kept list (a feature added without
88
+ * its gate would be a hole).
89
+ */
90
+ export function contractEntryPaths(manifest: ContractPurityManifest): string[] {
91
+ const apiApp = manifest.apps.find((app) => app.type === "api");
92
+ if (apiApp === undefined) return [];
93
+ const apiAppNames = new Set(
94
+ manifest.apps.filter((app) => app.type === "api").map((app) => app.name),
95
+ );
96
+ return manifest.features
97
+ .filter((feature) => feature.status !== "skeleton")
98
+ .filter((feature) => feature.app === undefined || apiAppNames.has(feature.app))
99
+ .map((feature) => `${apiApp.path}/${feature.folder}/schemas.ts`);
100
+ }
101
+
102
+ /** Walks every contract graph and reports what breaks its purity. */
103
+ export function auditContractPurity(options: ContractPurityOptions): ContractPurityReport {
104
+ const allowed = new Set(
105
+ options.allowedRuntimeSpecifiers ?? CONTRACT_ALLOWED_RUNTIME_SPECIFIERS,
106
+ );
107
+ const entryPaths = contractEntryPaths(options.manifest);
108
+ const impurities: ContractImpurity[] = [];
109
+ const reachedPaths = new Set<string>();
110
+
111
+ for (const entryPath of entryPaths) {
112
+ // Breadth-first, so the chain reported for a module is the SHORTEST way
113
+ // the server comes back — the one a human has to cut.
114
+ const queue: { path: string; chain: readonly string[] }[] = [
115
+ { path: entryPath, chain: [entryPath] },
116
+ ];
117
+ const visited = new Set<string>([entryPath]);
118
+
119
+ while (queue.length > 0) {
120
+ const current = queue.shift();
121
+ if (current === undefined) break;
122
+ reachedPaths.add(current.path);
123
+
124
+ const source = options.readModule(current.path);
125
+ if (source === null) {
126
+ impurities.push({
127
+ kind: "MODULE_NOT_FOUND",
128
+ entryPath,
129
+ chain: current.chain,
130
+ specifier: current.path,
131
+ statement:
132
+ current.path === entryPath ? "contract entry point" : "import of the module above",
133
+ rule: `Module ${current.path} cannot be read: a contract graph is audited whole or not at all.`,
134
+ });
135
+ continue;
136
+ }
137
+
138
+ for (const moduleImport of parseModuleImports(source)) {
139
+ // Erased at compile time: no runtime edge, nothing enters the bundle.
140
+ if (moduleImport.typeOnly) continue;
141
+ const resolution = resolveContractImport(moduleImport.specifier, current.path);
142
+
143
+ if (resolution.kind === "external") {
144
+ if (allowed.has(resolution.specifier)) continue;
145
+ impurities.push({
146
+ kind: "RUNTIME_PACKAGE",
147
+ entryPath,
148
+ chain: current.chain,
149
+ specifier: resolution.specifier,
150
+ statement: moduleImport.statement,
151
+ rule: `Package "${resolution.specifier}" is not importable from a contract (allowed: ${[...allowed].join(", ")}).`,
152
+ });
153
+ continue;
154
+ }
155
+ if (resolution.kind === "server-surface") {
156
+ impurities.push({
157
+ kind: "SERVER_SURFACE",
158
+ entryPath,
159
+ chain: current.chain,
160
+ specifier: moduleImport.specifier,
161
+ statement: moduleImport.statement,
162
+ rule: `${resolution.path} is a feature's index.ts: the SERVER entry point, never a contract (AGENTS.md §Structure).`,
163
+ });
164
+ continue;
165
+ }
166
+ if (visited.has(resolution.path)) continue;
167
+ visited.add(resolution.path);
168
+ queue.push({ path: resolution.path, chain: [...current.chain, resolution.path] });
169
+ }
170
+ }
171
+ }
172
+
173
+ return { entryPaths, reachedPaths: [...reachedPaths], impurities };
174
+ }
175
+
176
+ export type ContractImportResolution =
177
+ | { kind: "internal"; path: string }
178
+ | { kind: "server-surface"; path: string }
179
+ | { kind: "external"; specifier: string };
180
+
181
+ /**
182
+ * Where a specifier leads, LEXICALLY (no filesystem): a relative specifier is
183
+ * a repo path with its TypeScript extension restored (`./x.js` → `./x.ts`,
184
+ * NodeNext style), anything else is an external package or a node builtin.
185
+ */
186
+ export function resolveContractImport(
187
+ specifier: string,
188
+ fromPath: string,
189
+ ): ContractImportResolution {
190
+ if (!specifier.startsWith("./") && !specifier.startsWith("../")) {
191
+ return { kind: "external", specifier };
192
+ }
193
+ const segments = fromPath.split("/").slice(0, -1);
194
+ for (const segment of specifier.split("/")) {
195
+ if (segment === "." || segment === "") continue;
196
+ if (segment === "..") {
197
+ segments.pop();
198
+ continue;
199
+ }
200
+ segments.push(segment);
201
+ }
202
+ const path = withSourceExtension(segments.join("/"));
203
+ return path.endsWith("/index.ts") || path === "index.ts"
204
+ ? { kind: "server-surface", path }
205
+ : { kind: "internal", path };
206
+ }
207
+
208
+ function withSourceExtension(path: string): string {
209
+ if (path.endsWith(".js")) return `${path.slice(0, -".js".length)}.ts`;
210
+ if (path.endsWith(".jsx")) return `${path.slice(0, -".jsx".length)}.tsx`;
211
+ if (path.endsWith(".ts") || path.endsWith(".tsx")) return path;
212
+ return `${path}.ts`;
213
+ }
package/src/db.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @intent Generic type of the product's Drizzle connection.
3
+ * The core does NOT pick the driver: node-postgres in production, PGlite in
4
+ * tests — both satisfy this type. Every DB-effectful function receives it
5
+ * by injection.
6
+ */
7
+ import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
8
+
9
+ export type Database = PgDatabase<PgQueryResultHKT>;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @intent Mutation tests of the decision-reference gate (DECISIONS.md D22:
3
+ * every gate is born with its negative test). The violation to catch is the
4
+ * one that really happened: a spec founding its rules on a decision that
5
+ * exists nowhere (D26, consigned in FAILURES.md). A valid citation must stay
6
+ * silent, an orphan one must be detected — otherwise the gate is decoration.
7
+ */
8
+ import { describe, expect, it } from "vitest";
9
+ import {
10
+ findDecisionReferences,
11
+ findDuplicateDecisionIds,
12
+ findUnknownDecisionReferences,
13
+ parseKnownDecisionIds,
14
+ } from "./decision-refs.js";
15
+
16
+ const DECISIONS = [
17
+ "# DECISIONS.md",
18
+ "",
19
+ "## D11 — 2026-07-30 — Anglais maximal dans le code",
20
+ "- **Décision** : les identifiants sont en anglais (voir aussi D99, jamais prise).",
21
+ "",
22
+ "## D26 — 2026-08 — Nomenclature partagée : codes normalisés",
23
+ "- **Décision** : le catalogue global s'appuie sur des codes normalisés.",
24
+ ].join("\n");
25
+
26
+ describe("décisions réellement présentes dans DECISIONS.md", () => {
27
+ it("ne retient que les décisions DÉCLARÉES par leur titre, pas celles citées dans un corps", () => {
28
+ // D99 appears inside D11's body: a cross-reference declares nothing.
29
+ expect(parseKnownDecisionIds(DECISIONS)).toEqual(["D11", "D26"]);
30
+ });
31
+ });
32
+
33
+ describe("numérotation globale sur plusieurs fichiers de décisions (scission Phase 3)", () => {
34
+ it("deux fichiers aux numéros disjoints ne remontent aucun doublon", () => {
35
+ expect(findDuplicateDecisionIds([["D11", "D26"], ["D1", "D2"]])).toEqual([]);
36
+ });
37
+
38
+ it("MUTATION — un même D<n> déclaré dans les DEUX fichiers est DÉTECTÉ (citation ambiguë)", () => {
39
+ expect(findDuplicateDecisionIds([["D11", "D26"], ["D26", "D30"]])).toEqual(["D26"]);
40
+ });
41
+
42
+ it("MUTATION — un titre répété DANS un même fichier est aussi un doublon", () => {
43
+ expect(findDuplicateDecisionIds([["D11", "D11"]])).toEqual(["D11"]);
44
+ });
45
+ });
46
+
47
+ describe("citations de décisions dans un texte", () => {
48
+ it("reconnaît une citation collée à la ponctuation, au gras ou à un tiret", () => {
49
+ const text = 'refs: ["DECISIONS.md D26"]\nRègle **D11** (D13/D15), lot D24-D28.';
50
+
51
+ expect(findDecisionReferences(text)).toEqual([
52
+ { decision: "D26", line: 1 },
53
+ { decision: "D11", line: 2 },
54
+ { decision: "D13", line: 2 },
55
+ { decision: "D15", line: 2 },
56
+ { decision: "D24", line: 2 },
57
+ { decision: "D28", line: 2 },
58
+ ]);
59
+ });
60
+
61
+ it("ne prend pas un fragment de mot ou de nombre pour une décision", () => {
62
+ expect(findDecisionReferences("3D2 UUIDv4 D26bis ADR12")).toEqual([]);
63
+ });
64
+ });
65
+
66
+ describe("gate SPEC_UNKNOWN_DECISION_REFERENCE", () => {
67
+ const known = parseKnownDecisionIds(DECISIONS);
68
+
69
+ it("laisse passer une spec dont toutes les décisions citées existent", () => {
70
+ const spec = [
71
+ "---",
72
+ "id: nomenclature-codes",
73
+ 'refs: ["DECISIONS.md D26 D11"]',
74
+ "---",
75
+ "# @intent",
76
+ "Nomenclature normalisée (D26), identifiants en anglais (D11).",
77
+ ].join("\n");
78
+
79
+ expect(findUnknownDecisionReferences(spec, known)).toEqual([]);
80
+ });
81
+
82
+ it("MUTATION — une référence orpheline dans le CORPS d'une spec est détectée", () => {
83
+ const spec = "# @intent\nLe mapping suit la nomenclature actée en D42.";
84
+
85
+ expect(findUnknownDecisionReferences(spec, known)).toEqual([
86
+ { decision: "D42", line: 2 },
87
+ ]);
88
+ });
89
+
90
+ it("MUTATION — une référence orpheline dans le FRONTMATTER refs est détectée", () => {
91
+ const spec = ['---', 'refs: ["DECISIONS.md D26", "DECISIONS.md D42"]', '---'].join("\n");
92
+
93
+ expect(findUnknownDecisionReferences(spec, known)).toEqual([
94
+ { decision: "D42", line: 2 },
95
+ ]);
96
+ });
97
+
98
+ it("MUTATION — une décision citée par AGENTS.md mais jamais actée est détectée", () => {
99
+ const agents = "## Règles git\n- Une session = un worktree (D25).";
100
+
101
+ expect(findUnknownDecisionReferences(agents, known)).toEqual([
102
+ { decision: "D25", line: 2 },
103
+ ]);
104
+ });
105
+
106
+ it("signale chaque décision orpheline UNE fois, à sa première citation", () => {
107
+ const spec = "D42 ici,\npuis D42 encore,\net D43 ailleurs.";
108
+
109
+ expect(findUnknownDecisionReferences(spec, known)).toEqual([
110
+ { decision: "D42", line: 1 },
111
+ { decision: "D43", line: 3 },
112
+ ]);
113
+ });
114
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @intent Testable heart of the gate that guards DECISION REFERENCES
3
+ * (DECISIONS.md).
4
+ * A spec, or AGENTS.md, may found a rule on a decision `D<n>`; that decision
5
+ * must REALLY exist in a decisions file. A product spec once grounded six
6
+ * passages on D26 while D26 lived in no committed file: the agent followed the
7
+ * prose correctly, and the repo documented a decision nobody had taken. A
8
+ * phantom reference is silent documentary drift — exactly what Ogygie claims to
9
+ * eliminate — so it breaks the build instead.
10
+ *
11
+ * These functions are pure (source text in, findings out) so the negative tests
12
+ * required by DECISIONS.md D22 can feed them known violations directly;
13
+ * scripts/verify-specs.ts only reports what they return.
14
+ */
15
+
16
+ /**
17
+ * Reference boundary: a decision id is never a fragment of a word or of a
18
+ * number (`3D2`, `UUIDv4`), but IS a citation when glued to punctuation,
19
+ * markdown emphasis or a hyphen — `(D24)`, `**D11**`, `D13/D15`, `D24-D28`.
20
+ */
21
+ const DECISION_REFERENCE = /(?<![A-Za-z0-9])D(\d+)(?![A-Za-z0-9])/g;
22
+
23
+ /** A decision is DECLARED by its own heading in DECISIONS.md: `## D<n> — ...`. */
24
+ const DECISION_HEADING = /^##\s+D(\d+)\b/;
25
+
26
+ /** One citation of a decision, located for the human who has to fix it. */
27
+ export interface DecisionReference {
28
+ /** Cited decision, e.g. "D26". */
29
+ decision: string;
30
+ /** 1-based line of the citation in the scanned file. */
31
+ line: number;
32
+ }
33
+
34
+ /**
35
+ * Ids of the decisions REALLY present in DECISIONS.md, in file order.
36
+ * Only headings count: a decision mentioned in the body of another one is a
37
+ * cross-reference, not a declaration.
38
+ */
39
+ export function parseKnownDecisionIds(decisionsMarkdown: string): string[] {
40
+ const ids: string[] = [];
41
+ for (const line of decisionsMarkdown.split("\n")) {
42
+ const heading = DECISION_HEADING.exec(line);
43
+ if (heading !== null) ids.push(`D${heading[1]}`);
44
+ }
45
+ return ids;
46
+ }
47
+
48
+ /**
49
+ * Decision ids declared in MORE than one decisions file (Phase 3 split:
50
+ * framework + product files share ONE global numbering). A duplicate id makes
51
+ * every citation ambiguous, so the gate refuses it instead of picking a side.
52
+ * Returns each duplicated id once, in first-encounter order.
53
+ */
54
+ export function findDuplicateDecisionIds(
55
+ perFileIds: readonly (readonly string[])[],
56
+ ): string[] {
57
+ const seen = new Set<string>();
58
+ const duplicated: string[] = [];
59
+ for (const fileIds of perFileIds) {
60
+ // Within one file, a repeated heading is also a duplicate declaration.
61
+ for (const id of fileIds) {
62
+ if (seen.has(id)) {
63
+ if (!duplicated.includes(id)) duplicated.push(id);
64
+ } else {
65
+ seen.add(id);
66
+ }
67
+ }
68
+ }
69
+ return duplicated;
70
+ }
71
+
72
+ /** Every decision citation found in a text (frontmatter included), in order. */
73
+ export function findDecisionReferences(text: string): DecisionReference[] {
74
+ const references: DecisionReference[] = [];
75
+ const lines = text.split("\n");
76
+ for (let index = 0; index < lines.length; index += 1) {
77
+ const line = lines[index] ?? "";
78
+ for (const match of line.matchAll(DECISION_REFERENCE)) {
79
+ references.push({ decision: `D${match[1]}`, line: index + 1 });
80
+ }
81
+ }
82
+ return references;
83
+ }
84
+
85
+ /**
86
+ * Citations pointing at a decision absent from DECISIONS.md — one entry per
87
+ * distinct decision (the first citation locates it; listing the same orphan
88
+ * twenty times would bury the twenty-first file).
89
+ */
90
+ export function findUnknownDecisionReferences(
91
+ text: string,
92
+ knownDecisionIds: readonly string[],
93
+ ): DecisionReference[] {
94
+ const known = new Set(knownDecisionIds);
95
+ const unknown: DecisionReference[] = [];
96
+ const alreadyReported = new Set<string>();
97
+ for (const reference of findDecisionReferences(text)) {
98
+ if (known.has(reference.decision) || alreadyReported.has(reference.decision)) continue;
99
+ alreadyReported.add(reference.decision);
100
+ unknown.push(reference);
101
+ }
102
+ return unknown;
103
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * @intent Proves the behaviors described in specs/ogygie-design-brief.spec.md.
3
+ * Each Given/When/Then block of the spec = one identically named test.
4
+ * Fixtures use the neutral "orders" domain of AGENTS.md — the core knows no
5
+ * product vocabulary, and neither do its tests.
6
+ */
7
+ import { describe, expect, it } from "vitest";
8
+ import { z } from "zod";
9
+ import {
10
+ buildDesignBrief,
11
+ fieldDescriptorsOfJsonSchema,
12
+ renderDesignBriefMarkdown,
13
+ type DesignBriefSources,
14
+ type JsonSchemaNode,
15
+ } from "./design-brief.js";
16
+
17
+ const OrderSchema = z.object({
18
+ id: z.uuid(),
19
+ reference: z.string().min(1).max(120),
20
+ totalCents: z.number().int(),
21
+ widthMeters: z.number().optional(),
22
+ shippedAt: z.iso.datetime().nullable(),
23
+ status: z.enum(["draft", "sent"]),
24
+ mysteryBlob: z.string(),
25
+ });
26
+
27
+ const listOutputJsonSchema = z.toJSONSchema(
28
+ z.object({ orders: z.array(OrderSchema) }),
29
+ ) as JsonSchemaNode;
30
+ const createInputJsonSchema = z.toJSONSchema(
31
+ z.object({
32
+ reference: z.string().min(1).max(120),
33
+ quantity: z.number().int().positive().max(1_000_000),
34
+ }),
35
+ ) as JsonSchemaNode;
36
+
37
+ const SPEC_WITH_UI_STATES = `---
38
+ id: orders
39
+ status: validated
40
+ depends_on: []
41
+ priority: P1
42
+ refs: []
43
+ deferred: ["ORDER_EXPORT (export document) blockedBy:documents"]
44
+ ---
45
+
46
+ # @intent
47
+ Commandes d'exemple.
48
+
49
+ # Comportements
50
+ - ÉTANT DONNÉ un contexte
51
+ ALORS un résultat
52
+ - Le tri par défaut reste non tranché (par date ou par référence ?).
53
+
54
+ # Contrats de données
55
+ - ListOrdersOutputSchema: { orders: [...] }
56
+
57
+ # États UI
58
+ - vide (aucune ligne)
59
+ - chargement
60
+ - nominal (liste)
61
+ - erreur (bandeau OgygieError)
62
+
63
+ # Non-objectifs
64
+ Rien.
65
+
66
+ # Budget
67
+ - P95 API < 100 ms
68
+ `;
69
+
70
+ function sourcesFixture(overrides: Partial<DesignBriefSources> = {}): DesignBriefSources {
71
+ return {
72
+ featureName: "orders",
73
+ validFeatureNames: ["orders", "documents"],
74
+ visualTokensJson: '{\n "color": { "surface": "#ffffff" }\n}\n',
75
+ patternsCatalogDoc: "# Catalogue\n\n## DataTable\n\nTableau avec tri.\n\n## EmptyState\n\nÉtat vide.\n",
76
+ patternModules: [{ name: "DataTable", intent: "Sortable data table." }],
77
+ routes: [
78
+ {
79
+ method: "GET",
80
+ path: "/api/orders",
81
+ description: "Liste les commandes.",
82
+ inputSchemaName: "ListOrdersInputSchema",
83
+ outputSchemaName: "ListOrdersOutputSchema",
84
+ inputJsonSchema: z.toJSONSchema(z.object({})) as JsonSchemaNode,
85
+ outputJsonSchema: listOutputJsonSchema,
86
+ },
87
+ {
88
+ method: "POST",
89
+ path: "/api/orders",
90
+ description: "Crée une commande.",
91
+ inputSchemaName: "CreateOrderInputSchema",
92
+ outputSchemaName: "CreateOrderOutputSchema",
93
+ inputJsonSchema: createInputJsonSchema,
94
+ outputJsonSchema: z.toJSONSchema(z.object({ order: OrderSchema })) as JsonSchemaNode,
95
+ },
96
+ ],
97
+ specContent: SPEC_WITH_UI_STATES,
98
+ featureSources: [
99
+ {
100
+ file: "src/features/orders/service.ts",
101
+ content: 'throwOgygieError({ code: "ORDER_REFERENCE_ALREADY_USED", message: "…" }, 409);',
102
+ },
103
+ ],
104
+ fixtureCounts: [{ table: "orders", count: 42 }],
105
+ ...overrides,
106
+ };
107
+ }
108
+
109
+ const ALL_SECTIONS_IN_ORDER = [
110
+ "## Langage visuel",
111
+ "## Vocabulaire de composition",
112
+ "## Données à afficher",
113
+ "## Données à saisir",
114
+ "## Actions",
115
+ "## États obligatoires",
116
+ "## Volumétries réelles",
117
+ "## Contraintes non négociables",
118
+ "## Questions ouvertes",
119
+ ];
120
+
121
+ describe("ogygie-design-brief", () => {
122
+ it("ÉTANT DONNÉ une feature connue ALORS un document Markdown autoporteur contient toutes les sections, sans aucune section vide non signalée", () => {
123
+ const result = buildDesignBrief(sourcesFixture());
124
+ expect(result.failure).toBeNull();
125
+ if (result.brief === null) throw new Error("brief attendu");
126
+ const markdown = renderDesignBriefMarkdown(result.brief);
127
+
128
+ let cursor = -1;
129
+ for (const section of ALL_SECTIONS_IN_ORDER) {
130
+ const position = markdown.indexOf(section);
131
+ expect(position, `section absente : ${section}`).toBeGreaterThan(cursor);
132
+ cursor = position;
133
+ }
134
+ // Every derived datum is there — the exact value, not a sentence around it.
135
+ expect(markdown).toContain('"surface": "#ffffff"');
136
+ expect(markdown).toContain("**DataTable** — Sortable data table.");
137
+ expect(markdown).toContain("**EmptyState** — État vide.");
138
+ expect(markdown).toContain("`ListOrdersOutputSchema` — GET /api/orders");
139
+ expect(markdown).toContain("| `orders[].reference` | string | oui | longueur 1..120 | unknown |");
140
+ expect(markdown).toContain("mutation : un bouton ou une commande à placer");
141
+ expect(markdown).toContain("- `ORDER_REFERENCE_ALREADY_USED`");
142
+ expect(markdown).toContain("- `orders` : 42 ligne(s) de fixtures");
143
+ expect(markdown).toContain("- vide (aucune ligne)");
144
+ expect(markdown).toContain("Le tri par défaut reste non tranché (par date ou par référence ?).");
145
+ expect(markdown).toContain("deferred — ORDER_EXPORT (export document) blockedBy:documents");
146
+ // Nothing was silently emptied: the full fixture triggers no signal marker.
147
+ expect(markdown).not.toContain("Section vide");
148
+ expect(result.brief.warnings).toEqual([]);
149
+
150
+ // An emptied source is SIGNALED in its section, never silent.
151
+ const bare = buildDesignBrief(
152
+ sourcesFixture({ visualTokensJson: null, patternsCatalogDoc: null, patternModules: [] }),
153
+ );
154
+ if (bare.brief === null) throw new Error("brief attendu");
155
+ const bareMarkdown = renderDesignBriefMarkdown(bare.brief);
156
+ expect(bareMarkdown).toContain(
157
+ "_Section vide — signalée, jamais devinée : design/tokens.json introuvable._",
158
+ );
159
+ expect(bareMarkdown).toContain(
160
+ "_Section vide — signalée, jamais devinée : ni design/patterns.md, ni dossier patterns/ d'une app front._",
161
+ );
162
+ });
163
+
164
+ it("ÉTANT DONNÉ une feature dont la spec n'a pas de section « États UI » ALORS le brief est produit MAIS porte DESIGN_BRIEF_MISSING_UI_STATES en tête", () => {
165
+ const result = buildDesignBrief(
166
+ sourcesFixture({ specContent: SPEC_WITH_UI_STATES.replace(/# États UI[\s\S]*?# Non-objectifs/, "# Non-objectifs") }),
167
+ );
168
+ expect(result.failure).toBeNull();
169
+ if (result.brief === null) throw new Error("brief attendu");
170
+ expect(result.brief.warnings[0]).toMatch(/^DESIGN_BRIEF_MISSING_UI_STATES/);
171
+
172
+ const markdown = renderDesignBriefMarkdown(result.brief);
173
+ const warningPosition = markdown.indexOf("DESIGN_BRIEF_MISSING_UI_STATES");
174
+ expect(warningPosition).toBeGreaterThan(-1);
175
+ // "en tête" : before the first content section of the document.
176
+ expect(warningPosition).toBeLessThan(markdown.indexOf("## Langage visuel"));
177
+ });
178
+
179
+ it("ÉTANT DONNÉ une feature inconnue du manifeste ALORS OgygieError DESIGN_BRIEF_UNKNOWN_FEATURE avec la liste des features", () => {
180
+ const result = buildDesignBrief(sourcesFixture({ featureName: "inconnue" }));
181
+ expect(result.brief).toBeNull();
182
+ if (result.failure === null) throw new Error("failure attendue");
183
+ expect(result.failure.code).toBe("DESIGN_BRIEF_UNKNOWN_FEATURE");
184
+ expect(result.failure.message).toContain("orders, documents");
185
+ });
186
+
187
+ it("ÉTANT DONNÉ un champ dont le nom ne suit aucune convention ALORS il est listé avec format: unknown — et les conventions connues sont appliquées", () => {
188
+ const fields = fieldDescriptorsOfJsonSchema(listOutputJsonSchema);
189
+ const byName = new Map(fields.map((field) => [field.name, field]));
190
+
191
+ expect(byName.get("orders[].mysteryBlob")?.format).toBe("unknown");
192
+ expect(byName.get("orders[].totalCents")?.format).toBe("currency-amount");
193
+ expect(byName.get("orders[].widthMeters")?.format).toBe("measure");
194
+ expect(byName.get("orders[].widthMeters")?.required).toBe(false);
195
+ expect(byName.get("orders[].shippedAt")?.format).toBe("local-date");
196
+ expect(byName.get("orders[].shippedAt")?.type).toBe("string(date-time) | null");
197
+ expect(byName.get("orders[].status")?.format).toBe("translated-enum-label");
198
+ expect(byName.get("orders[].status")?.type).toBe("enum(draft ; sent)");
199
+ expect(byName.get("orders[].id")?.type).toBe("string(uuid)");
200
+ });
201
+
202
+ it("ÉTANT DONNÉ des fixtures absentes ALORS les volumétries sont marquées « non mesurées » plutôt qu'omises", () => {
203
+ const result = buildDesignBrief(
204
+ sourcesFixture({ fixtureCounts: [{ table: "orders", count: null }] }),
205
+ );
206
+ if (result.brief === null) throw new Error("brief attendu");
207
+ const markdown = renderDesignBriefMarkdown(result.brief);
208
+ expect(markdown).toContain(
209
+ "- `orders` : non mesurée (aucune fixture trouvée dans test-fixtures/)",
210
+ );
211
+ });
212
+
213
+ it("ÉTANT DONNÉ deux exécutions successives sur une source inchangée ALORS le document produit est identique octet pour octet", () => {
214
+ const first = buildDesignBrief(sourcesFixture());
215
+ const second = buildDesignBrief(sourcesFixture());
216
+ if (first.brief === null || second.brief === null) throw new Error("briefs attendus");
217
+ expect(renderDesignBriefMarkdown(first.brief)).toBe(
218
+ renderDesignBriefMarkdown(second.brief),
219
+ );
220
+ });
221
+ });