@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,581 @@
1
+ /**
2
+ * @intent Testable heart of `pnpm ogygie:design-brief <feature>`
3
+ * (specs/ogygie-design-brief.spec.md): turns the repo's sources of truth
4
+ * (manifest routes, Zod contracts as JSON Schema, feature spec, design tokens,
5
+ * pattern catalog, fixture counts) into a DesignBrief object and its Markdown
6
+ * rendering. A derived brief cannot forget a field, invent an absent datum or
7
+ * skip an error state — the three diseases of unimplementable mockups.
8
+ *
9
+ * Generic tooling, hence the core (AGENTS.md workflow step 5, tooling
10
+ * exception): it knows naming CONVENTIONS (*Cents, *Meters, *At/*Date, enums)
11
+ * but not one word of any product's domain. Pure functions — sources in,
12
+ * brief out — so the spec's Given/When/Then blocks attack them directly; the
13
+ * fs walking and dynamic imports live in scripts/ogygie-design-brief.ts.
14
+ *
15
+ * Determinism is a contract (spec behavior 6): nothing here reads the clock
16
+ * or randomness, so two runs over unchanged sources render byte-identical
17
+ * documents. Signaled, never guessed: an empty section renders with an
18
+ * explicit marker instead of silence, and unknown field formats say so.
19
+ */
20
+ import { z } from "zod";
21
+ import { ogygieError, type OgygieError } from "./error.schema.js";
22
+ import { SpecFrontmatterSchema, parseSpecFrontmatter } from "./spec.schema.js";
23
+
24
+ // ------------------------------------------------------------- field formats
25
+ /**
26
+ * Display format deduced from the FIELD NAME conventions of the repo —
27
+ * never from the value. A name following no known convention is listed as
28
+ * "unknown": signaled, never guessed (spec behavior 4).
29
+ */
30
+ export const FieldFormatSchema = z.enum([
31
+ "currency-amount", // *Cents → firm currency amount (tabular-nums, unit shown)
32
+ "measure", // *Meters → SI measure, converted at presentation time only
33
+ "local-date", // *At / *Date → UTC ISO 8601 rendered as a local date
34
+ "translated-enum-label", // enum → the CODE is English, the label is translated
35
+ "unknown",
36
+ ]);
37
+ export type FieldFormat = z.infer<typeof FieldFormatSchema>;
38
+
39
+ export function formatOfField(fieldName: string, isEnum: boolean): FieldFormat {
40
+ const leaf = (fieldName.split(".").pop() ?? fieldName).replace(/\[\]$/, "");
41
+ if (/Cents$/.test(leaf)) return "currency-amount";
42
+ if (/Meters$/.test(leaf)) return "measure";
43
+ if (/(At|Date)$/.test(leaf)) return "local-date";
44
+ if (isEnum) return "translated-enum-label";
45
+ return "unknown";
46
+ }
47
+
48
+ // ------------------------------------------------------ JSON Schema flatten
49
+ /**
50
+ * The subset of JSON Schema (draft 2020-12) that `z.toJSONSchema` emits for
51
+ * our contracts. Structural on purpose: the caller asserts zod's output onto
52
+ * it, and every read below tolerates absence.
53
+ */
54
+ export interface JsonSchemaNode {
55
+ type?: string | string[];
56
+ properties?: Record<string, JsonSchemaNode>;
57
+ required?: string[];
58
+ items?: JsonSchemaNode;
59
+ enum?: Array<string | number | boolean | null>;
60
+ anyOf?: JsonSchemaNode[];
61
+ oneOf?: JsonSchemaNode[];
62
+ format?: string;
63
+ minLength?: number;
64
+ maxLength?: number;
65
+ pattern?: string;
66
+ minimum?: number;
67
+ maximum?: number;
68
+ exclusiveMinimum?: number;
69
+ exclusiveMaximum?: number;
70
+ }
71
+
72
+ export const FieldDescriptorSchema = z.object({
73
+ name: z.string().describe("Dotted path; [] marks an array level, e.g. lines[].label"),
74
+ type: z.string().describe('Human label, e.g. "string(uuid)", "int", "string | null"'),
75
+ required: z.boolean(),
76
+ constraints: z.array(z.string()),
77
+ format: FieldFormatSchema,
78
+ });
79
+ export type FieldDescriptor = z.infer<typeof FieldDescriptorSchema>;
80
+
81
+ /** `z.toJSONSchema` pads integers with ±MAX_SAFE_INTEGER bounds: noise, not a constraint. */
82
+ const SAFE_INTEGER_BOUND = 9_007_199_254_740_991;
83
+
84
+ /** Strips the nullable wrapper (`anyOf: [X, {type:"null"}]`) zod emits. */
85
+ function unwrapNullable(node: JsonSchemaNode): { node: JsonSchemaNode; nullable: boolean } {
86
+ const members = node.anyOf ?? node.oneOf;
87
+ if (members !== undefined) {
88
+ const nonNull = members.filter((member) => member.type !== "null");
89
+ if (nonNull.length === 1 && nonNull[0] !== undefined) {
90
+ return { node: nonNull[0], nullable: nonNull.length < members.length };
91
+ }
92
+ }
93
+ if (Array.isArray(node.type) && node.type.includes("null")) {
94
+ const remaining = node.type.filter((typeName) => typeName !== "null");
95
+ return { node: { ...node, type: remaining[0] ?? remaining }, nullable: true };
96
+ }
97
+ return { node, nullable: false };
98
+ }
99
+
100
+ function typeLabelOf(node: JsonSchemaNode): string {
101
+ if (node.enum !== undefined) {
102
+ return `enum(${node.enum.map((value) => String(value)).join(" ; ")})`;
103
+ }
104
+ const members = node.anyOf ?? node.oneOf;
105
+ if (members !== undefined) {
106
+ return members.map((member) => typeLabelOf(member)).join(" | ");
107
+ }
108
+ if (Array.isArray(node.type)) return node.type.join(" | ");
109
+ if (node.type === "integer") return node.format === undefined ? "int" : `int(${node.format})`;
110
+ if (node.type === undefined) return "unknown";
111
+ return node.format === undefined ? node.type : `${node.type}(${node.format})`;
112
+ }
113
+
114
+ function constraintsOf(node: JsonSchemaNode): string[] {
115
+ const constraints: string[] = [];
116
+ if (node.minLength !== undefined || node.maxLength !== undefined) {
117
+ constraints.push(`longueur ${node.minLength ?? 0}..${node.maxLength ?? "∞"}`);
118
+ }
119
+ // A pattern accompanying a named format (uuid, date-time…) is the format's
120
+ // own machine regex — noise for a design tool, the format already says it.
121
+ if (node.pattern !== undefined && node.format === undefined) {
122
+ constraints.push(`motif ${node.pattern}`);
123
+ }
124
+ if (node.minimum !== undefined && Math.abs(node.minimum) !== SAFE_INTEGER_BOUND) {
125
+ constraints.push(`min ${node.minimum}`);
126
+ }
127
+ if (node.exclusiveMinimum !== undefined) constraints.push(`> ${node.exclusiveMinimum}`);
128
+ if (node.maximum !== undefined && Math.abs(node.maximum) !== SAFE_INTEGER_BOUND) {
129
+ constraints.push(`max ${node.maximum}`);
130
+ }
131
+ if (node.exclusiveMaximum !== undefined) constraints.push(`< ${node.exclusiveMaximum}`);
132
+ return constraints;
133
+ }
134
+
135
+ /**
136
+ * Flattens an object contract into one descriptor per LEAF field, in the
137
+ * declaration order of the schema (stable, hence deterministic output).
138
+ */
139
+ export function fieldDescriptorsOfJsonSchema(root: JsonSchemaNode): FieldDescriptor[] {
140
+ const fields: FieldDescriptor[] = [];
141
+ walkProperties(root, "", fields);
142
+ return fields;
143
+ }
144
+
145
+ function walkProperties(node: JsonSchemaNode, prefix: string, out: FieldDescriptor[]): void {
146
+ for (const [name, child] of Object.entries(node.properties ?? {})) {
147
+ const path = `${prefix}${name}`;
148
+ const required = (node.required ?? []).includes(name);
149
+ const { node: bare, nullable } = unwrapNullable(child);
150
+ if (bare.properties !== undefined) {
151
+ walkProperties(bare, `${path}.`, out);
152
+ continue;
153
+ }
154
+ if (bare.type === "array" && bare.items?.properties !== undefined) {
155
+ walkProperties(bare.items, `${path}[].`, out);
156
+ continue;
157
+ }
158
+ const leaf = bare.type === "array" && bare.items !== undefined ? bare.items : bare;
159
+ const insideArray = leaf !== bare;
160
+ const baseLabel = typeLabelOf(unwrapNullable(leaf).node);
161
+ const label = insideArray ? `array<${baseLabel}>` : baseLabel;
162
+ out.push({
163
+ name: path,
164
+ type: nullable || unwrapNullable(leaf).nullable ? `${label} | null` : label,
165
+ required,
166
+ constraints: constraintsOf(unwrapNullable(leaf).node),
167
+ format: formatOfField(path, unwrapNullable(leaf).node.enum !== undefined),
168
+ });
169
+ }
170
+ }
171
+
172
+ // --------------------------------------------------------------- spec reads
173
+ /** Body of the spec's « # États UI » section, one trimmed line per entry. */
174
+ export function uiStatesOfSpec(specContent: string): string[] {
175
+ const section = /\n# États UI\n([\s\S]*?)(?=\n# |$)/.exec(specContent);
176
+ if (section === null || section[1] === undefined) return [];
177
+ return section[1]
178
+ .split("\n")
179
+ .map((line) => line.trim().replace(/^-\s+/, ""))
180
+ .filter((line) => line !== "");
181
+ }
182
+
183
+ /** Spec lines flagged as undecided — the convention is the words themselves. */
184
+ export function openPointsOfSpec(specContent: string): string[] {
185
+ return specContent
186
+ .split("\n")
187
+ .map((line) => line.trim().replace(/^-\s+/, ""))
188
+ .filter((line) => /non tranché|à trancher/i.test(line));
189
+ }
190
+
191
+ /** Deferred behaviors of the spec frontmatter; unreadable frontmatter ⇒ none. */
192
+ export function deferredOfSpec(specContent: string): string[] {
193
+ const block = /^---\n([\s\S]*?)\n---/.exec(specContent);
194
+ if (block === null || block[1] === undefined) return [];
195
+ const parsed = parseSpecFrontmatter(block[1]);
196
+ if (parsed.problems.length > 0) return [];
197
+ const frontmatter = SpecFrontmatterSchema.safeParse(parsed.value);
198
+ return frontmatter.success ? frontmatter.data.deferred : [];
199
+ }
200
+
201
+ // ------------------------------------------------------------- error codes
202
+ /** Stable error codes raised by the feature's production sources. */
203
+ export function errorCodesOfSources(
204
+ sources: Array<{ file: string; content: string }>,
205
+ ): string[] {
206
+ const codes = new Set<string>();
207
+ for (const source of sources) {
208
+ for (const match of source.content.matchAll(/\bcode:\s*"([A-Z][A-Z0-9_]+)"/g)) {
209
+ if (match[1] !== undefined) codes.add(match[1]);
210
+ }
211
+ }
212
+ return [...codes].sort();
213
+ }
214
+
215
+ // ------------------------------------------------------------- the contract
216
+ export const SchemaFieldGroupSchema = z.object({
217
+ schemaName: z.string(),
218
+ route: z.string().describe('e.g. "GET /api/orders"'),
219
+ fields: z.array(FieldDescriptorSchema),
220
+ });
221
+ export type SchemaFieldGroup = z.infer<typeof SchemaFieldGroupSchema>;
222
+
223
+ export const DesignBriefSchema = z.object({
224
+ feature: z.string(),
225
+ visualLanguage: z
226
+ .string()
227
+ .nullable()
228
+ .describe("Raw design/tokens.json text, verbatim — null when absent"),
229
+ patterns: z.array(
230
+ z.object({
231
+ name: z.string(),
232
+ intent: z.string().nullable(),
233
+ }),
234
+ ),
235
+ outputFields: z.array(SchemaFieldGroupSchema),
236
+ inputFields: z.array(SchemaFieldGroupSchema),
237
+ actions: z.array(
238
+ z.object({
239
+ method: z.string(),
240
+ path: z.string(),
241
+ description: z.string(),
242
+ kind: z.enum(["read", "mutation"]),
243
+ }),
244
+ ),
245
+ uiStates: z.array(z.string()),
246
+ errorCodes: z.array(z.string()),
247
+ volumes: z.array(
248
+ z.object({
249
+ table: z.string(),
250
+ fixtureRowCount: z
251
+ .number()
252
+ .int()
253
+ .nullable()
254
+ .describe("null = not measured (no fixture found)"),
255
+ }),
256
+ ),
257
+ constraints: z.array(z.string()),
258
+ openQuestions: z.array(z.string()),
259
+ warnings: z.array(z.string()),
260
+ });
261
+ export type DesignBrief = z.infer<typeof DesignBriefSchema>;
262
+
263
+ /** Non-negotiable constraints of every screen (AGENTS.md §Front + i18n). */
264
+ export const NON_NEGOTIABLE_DESIGN_CONSTRAINTS: readonly string[] = [
265
+ "i18n fr/en : la longueur des libellés varie fortement — prévoir l'élasticité, jamais de largeur figée sur un texte",
266
+ "mobile-first : la mise en page se conçoit d'abord pour un écran étroit",
267
+ "montants : `tabular-nums`, alignés à droite, unité explicite",
268
+ "accessibilité : parcours clavier complet et rôles ARIA sur chaque composant interactif",
269
+ "budget bundle : aucune dépendance front nouvelle pour un écran (AGENTS.md §Front)",
270
+ ];
271
+
272
+ // -------------------------------------------------------------- the builder
273
+ export interface DesignBriefSources {
274
+ featureName: string;
275
+ /** Feature names known to the manifest — membership is checked here. */
276
+ validFeatureNames: string[];
277
+ /** Raw text of design/tokens.json, null when the file does not exist. */
278
+ visualTokensJson: string | null;
279
+ /** Raw text of design/patterns.md, null when the file does not exist. */
280
+ patternsCatalogDoc: string | null;
281
+ /** Pattern modules found in the front apps' patterns folders. */
282
+ patternModules: Array<{ name: string; intent: string | null }>;
283
+ routes: Array<{
284
+ method: string;
285
+ path: string;
286
+ description: string;
287
+ inputSchemaName: string;
288
+ outputSchemaName: string;
289
+ inputJsonSchema: JsonSchemaNode | null;
290
+ outputJsonSchema: JsonSchemaNode | null;
291
+ }>;
292
+ /** Full text of the feature's spec, null when the file does not exist. */
293
+ specContent: string | null;
294
+ /** Production sources of the feature (tests excluded), for the error scan. */
295
+ featureSources: Array<{ file: string; content: string }>;
296
+ /** One entry per owned table; null count = no fixture found. */
297
+ fixtureCounts: Array<{ table: string; count: number | null }>;
298
+ }
299
+
300
+ export type DesignBriefBuildResult =
301
+ | { brief: DesignBrief; failure: null }
302
+ | { brief: null; failure: OgygieError };
303
+
304
+ /** Catalog entries: every `## <Name>` heading of design/patterns.md. */
305
+ function patternsOfCatalogDoc(catalogDoc: string): Array<{ name: string; intent: string | null }> {
306
+ const entries: Array<{ name: string; intent: string | null }> = [];
307
+ const headings = [...catalogDoc.matchAll(/\n## +(.+?) *\n([\s\S]*?)(?=\n## |$)/g)];
308
+ for (const heading of headings) {
309
+ if (heading[1] === undefined) continue;
310
+ const body = (heading[2] ?? "").trim();
311
+ entries.push({ name: heading[1], intent: body === "" ? null : body });
312
+ }
313
+ return entries;
314
+ }
315
+
316
+ export function buildDesignBrief(sources: DesignBriefSources): DesignBriefBuildResult {
317
+ if (!sources.validFeatureNames.includes(sources.featureName)) {
318
+ return {
319
+ brief: null,
320
+ failure: ogygieError({
321
+ code: "DESIGN_BRIEF_UNKNOWN_FEATURE",
322
+ message: `Feature "${sources.featureName}" is unknown to the manifest. Valid features: ${sources.validFeatureNames.join(", ")}.`,
323
+ file: "ogygie.manifest.json",
324
+ cause: `no features[] entry has name="${sources.featureName}"`,
325
+ suggestedFix: "Pick one of the valid feature names listed in the message.",
326
+ }),
327
+ };
328
+ }
329
+
330
+ // Catalog first, then modules: a module's @intent overrides the catalog
331
+ // entry of the same name (the code is closer to the truth than the doc).
332
+ const patternsByName = new Map<string, { name: string; intent: string | null }>();
333
+ for (const entry of patternsOfCatalogDoc(sources.patternsCatalogDoc ?? "")) {
334
+ patternsByName.set(entry.name, entry);
335
+ }
336
+ for (const moduleEntry of sources.patternModules) {
337
+ const known = patternsByName.get(moduleEntry.name);
338
+ patternsByName.set(moduleEntry.name, {
339
+ name: moduleEntry.name,
340
+ intent: moduleEntry.intent ?? known?.intent ?? null,
341
+ });
342
+ }
343
+ const patterns = [...patternsByName.values()].sort((a, b) =>
344
+ a.name.localeCompare(b.name, "en"),
345
+ );
346
+
347
+ const outputFields: SchemaFieldGroup[] = [];
348
+ const inputFields: SchemaFieldGroup[] = [];
349
+ const unreadableSchemas: string[] = [];
350
+ for (const route of sources.routes) {
351
+ const routeLabel = `${route.method} ${route.path}`;
352
+ if (route.outputJsonSchema !== null) {
353
+ outputFields.push({
354
+ schemaName: route.outputSchemaName,
355
+ route: routeLabel,
356
+ fields: fieldDescriptorsOfJsonSchema(route.outputJsonSchema),
357
+ });
358
+ } else {
359
+ unreadableSchemas.push(`${route.outputSchemaName} (${routeLabel})`);
360
+ }
361
+ if (route.inputJsonSchema !== null) {
362
+ inputFields.push({
363
+ schemaName: route.inputSchemaName,
364
+ route: routeLabel,
365
+ fields: fieldDescriptorsOfJsonSchema(route.inputJsonSchema),
366
+ });
367
+ } else {
368
+ unreadableSchemas.push(`${route.inputSchemaName} (${routeLabel})`);
369
+ }
370
+ }
371
+
372
+ const uiStates = sources.specContent === null ? [] : uiStatesOfSpec(sources.specContent);
373
+ const openQuestions =
374
+ sources.specContent === null
375
+ ? []
376
+ : [
377
+ ...openPointsOfSpec(sources.specContent),
378
+ ...deferredOfSpec(sources.specContent).map((entry) => `deferred — ${entry}`),
379
+ ];
380
+
381
+ // The spec-mandated warning comes first: a brief without states produces a
382
+ // happy-path-only mockup, and it must be said AT THE HEAD of the document.
383
+ const warnings: string[] = [];
384
+ if (uiStates.length === 0) {
385
+ warnings.push(
386
+ "DESIGN_BRIEF_MISSING_UI_STATES — la spec de la feature n'a pas de section « États UI » exploitable : un brief sans états produit une maquette du seul cas heureux.",
387
+ );
388
+ }
389
+ if (sources.visualTokensJson === null) {
390
+ warnings.push(
391
+ "DESIGN_BRIEF_MISSING_DESIGN_TOKENS — design/tokens.json introuvable : la section « Langage visuel » est vide. L'outil de design n'invente PAS de palette ; déclarer les tokens d'abord.",
392
+ );
393
+ }
394
+ if (patterns.length === 0) {
395
+ warnings.push(
396
+ "DESIGN_BRIEF_MISSING_PATTERNS — aucun pattern trouvé (ni design/patterns.md, ni dossier patterns/ d'une app front) : le vocabulaire de composition est vide.",
397
+ );
398
+ }
399
+ for (const unreadable of unreadableSchemas) {
400
+ warnings.push(
401
+ `DESIGN_BRIEF_SCHEMA_UNREADABLE — ${unreadable} : schéma introuvable ou non convertible, ses champs manquent au brief.`,
402
+ );
403
+ }
404
+
405
+ const brief = DesignBriefSchema.parse({
406
+ feature: sources.featureName,
407
+ visualLanguage: sources.visualTokensJson,
408
+ patterns,
409
+ outputFields,
410
+ inputFields,
411
+ actions: sources.routes.map((route) => ({
412
+ method: route.method,
413
+ path: route.path,
414
+ description: route.description,
415
+ kind: route.method.toUpperCase() === "GET" ? "read" : "mutation",
416
+ })),
417
+ uiStates,
418
+ errorCodes: errorCodesOfSources(sources.featureSources),
419
+ volumes: sources.fixtureCounts.map((entry) => ({
420
+ table: entry.table,
421
+ fixtureRowCount: entry.count,
422
+ })),
423
+ constraints: [...NON_NEGOTIABLE_DESIGN_CONSTRAINTS],
424
+ openQuestions,
425
+ warnings,
426
+ });
427
+ return { brief, failure: null };
428
+ }
429
+
430
+ // ------------------------------------------------------------- the renderer
431
+ /** Escapes what would break a Markdown table cell. */
432
+ function cell(text: string): string {
433
+ return text.replaceAll("|", "\\|").replaceAll("\n", " ");
434
+ }
435
+
436
+ /** The canonical marker of an empty section: signaled, never guessed. */
437
+ function signaledEmpty(reason: string): string {
438
+ return `_Section vide — signalée, jamais devinée : ${reason}._`;
439
+ }
440
+
441
+ function fieldTable(groups: SchemaFieldGroup[], lines: string[]): void {
442
+ for (const group of groups) {
443
+ lines.push(`### \`${group.schemaName}\` — ${group.route}`, "");
444
+ if (group.fields.length === 0) {
445
+ lines.push("(aucun champ)", "");
446
+ continue;
447
+ }
448
+ lines.push(
449
+ "| Champ | Type | Obligatoire | Contraintes | Format |",
450
+ "|---|---|---|---|---|",
451
+ );
452
+ for (const field of group.fields) {
453
+ lines.push(
454
+ `| \`${cell(field.name)}\` | ${cell(field.type)} | ${field.required ? "oui" : "non"} | ${
455
+ field.constraints.length === 0 ? "—" : cell(field.constraints.join(" ; "))
456
+ } | ${field.format} |`,
457
+ );
458
+ }
459
+ lines.push("");
460
+ }
461
+ }
462
+
463
+ export function renderDesignBriefMarkdown(brief: DesignBrief): string {
464
+ const lines: string[] = [
465
+ `# Brief de design — ${brief.feature}`,
466
+ "",
467
+ `> Document DÉRIVÉ des sources de vérité du repo par \`pnpm ogygie:design-brief ${brief.feature}\`.`,
468
+ "> Ne pas éditer à la main : corriger la source, puis régénérer.",
469
+ "> Destinataire : un outil de design (humain ou IA) qui produit une mise en page — jamais du code.",
470
+ "",
471
+ ];
472
+
473
+ if (brief.warnings.length > 0) {
474
+ lines.push("## ⚠ Avertissements", "");
475
+ for (const warning of brief.warnings) lines.push(`- ${warning}`);
476
+ lines.push("");
477
+ }
478
+
479
+ lines.push("## Langage visuel", "");
480
+ if (brief.visualLanguage === null) {
481
+ lines.push(signaledEmpty("design/tokens.json introuvable"), "");
482
+ } else {
483
+ lines.push(
484
+ "Tokens déclarés (`design/tokens.json`) — l'outil de design n'invente ni couleur, ni typo, ni espacement :",
485
+ "",
486
+ "```json",
487
+ brief.visualLanguage.trimEnd(),
488
+ "```",
489
+ "",
490
+ );
491
+ }
492
+
493
+ lines.push("## Vocabulaire de composition", "");
494
+ if (brief.patterns.length === 0) {
495
+ lines.push(
496
+ signaledEmpty("ni design/patterns.md, ni dossier patterns/ d'une app front"),
497
+ "",
498
+ );
499
+ } else {
500
+ lines.push(
501
+ "Tout écran est une COMPOSITION de ces patterns — un pattern manquant se crée dans le catalogue, jamais dans l'écran :",
502
+ "",
503
+ );
504
+ for (const pattern of brief.patterns) {
505
+ lines.push(`- **${pattern.name}** — ${pattern.intent ?? "(intent non déclaré)"}`);
506
+ }
507
+ lines.push("");
508
+ }
509
+
510
+ lines.push("## Données à afficher", "");
511
+ if (brief.outputFields.length === 0) {
512
+ lines.push(signaledEmpty("aucun schéma de sortie exploitable pour cette feature"), "");
513
+ } else {
514
+ fieldTable(brief.outputFields, lines);
515
+ }
516
+
517
+ lines.push("## Données à saisir", "");
518
+ if (brief.inputFields.length === 0) {
519
+ lines.push(signaledEmpty("aucun schéma d'entrée exploitable pour cette feature"), "");
520
+ } else {
521
+ fieldTable(brief.inputFields, lines);
522
+ }
523
+
524
+ lines.push("## Actions", "");
525
+ if (brief.actions.length === 0) {
526
+ lines.push(signaledEmpty("aucune route au manifeste pour cette feature"), "");
527
+ } else {
528
+ for (const action of brief.actions) {
529
+ const placement =
530
+ action.kind === "mutation"
531
+ ? "mutation : un bouton ou une commande à placer"
532
+ : "lecture : alimente l'écran";
533
+ lines.push(`- **${action.method} ${action.path}** — ${action.description} — ${placement}`);
534
+ }
535
+ lines.push("");
536
+ }
537
+
538
+ lines.push("## États obligatoires", "");
539
+ if (brief.uiStates.length === 0 && brief.errorCodes.length === 0) {
540
+ lines.push(
541
+ signaledEmpty("aucune section « États UI » dans la spec, aucun code d'erreur dans le code"),
542
+ "",
543
+ );
544
+ } else {
545
+ for (const state of brief.uiStates) lines.push(`- ${state}`);
546
+ if (brief.uiStates.length > 0) lines.push("");
547
+ if (brief.errorCodes.length > 0) {
548
+ lines.push("Codes d'erreur réels (chaque code = un état d'erreur à maquetter) :", "");
549
+ for (const code of brief.errorCodes) lines.push(`- \`${code}\``);
550
+ lines.push("");
551
+ }
552
+ }
553
+
554
+ lines.push("## Volumétries réelles", "");
555
+ if (brief.volumes.length === 0) {
556
+ lines.push(signaledEmpty("aucune table possédée par cette feature"), "");
557
+ } else {
558
+ for (const volume of brief.volumes) {
559
+ lines.push(
560
+ volume.fixtureRowCount === null
561
+ ? `- \`${volume.table}\` : non mesurée (aucune fixture trouvée dans test-fixtures/)`
562
+ : `- \`${volume.table}\` : ${volume.fixtureRowCount} ligne(s) de fixtures`,
563
+ );
564
+ }
565
+ lines.push("");
566
+ }
567
+
568
+ lines.push("## Contraintes non négociables", "");
569
+ for (const constraint of brief.constraints) lines.push(`- ${constraint}`);
570
+ lines.push("");
571
+
572
+ lines.push("## Questions ouvertes", "");
573
+ if (brief.openQuestions.length === 0) {
574
+ lines.push("_Aucune question ouverte : rien de non tranché dans la spec, aucun deferred._", "");
575
+ } else {
576
+ for (const question of brief.openQuestions) lines.push(`- ${question}`);
577
+ lines.push("");
578
+ }
579
+
580
+ return `${lines.join("\n").trimEnd()}\n`;
581
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @intent Single error format of Ogygie (runtime, validation, CI).
3
+ * Designed for the agent loop: generate → verify → fix.
4
+ * Every error says WHERE (file), WHAT (cause), and HOW to fix (suggestedFix).
5
+ */
6
+ import { z } from "zod";
7
+
8
+ export const OgygieErrorSchema = z.object({
9
+ ogygie: z.literal(true).describe("Marker: lets an agent tell our errors from noise"),
10
+ code: z
11
+ .string()
12
+ .regex(/^[A-Z][A-Z0-9_]+$/, "SCREAMING_SNAKE_CASE, stable over time"),
13
+ message: z.string().min(10).describe("Human-readable sentence (technical English)"),
14
+ file: z
15
+ .string()
16
+ .nullable()
17
+ .describe("Relative path of the file involved, null when not localizable"),
18
+ cause: z.string().describe("The precise datum or condition that triggered the error"),
19
+ suggestedFix: z.string().describe("Concrete action to fix — never empty"),
20
+ details: z.record(z.string(), z.unknown()).default({}),
21
+ timestamp: z.iso.datetime(),
22
+ });
23
+ export type OgygieError = z.infer<typeof OgygieErrorSchema>;
24
+
25
+ /** Canonical factory — the only way to build an error payload in the repo. */
26
+ export function ogygieError(
27
+ input: Omit<OgygieError, "ogygie" | "timestamp" | "details"> &
28
+ Partial<Pick<OgygieError, "details">>,
29
+ ): OgygieError {
30
+ return OgygieErrorSchema.parse({
31
+ ogygie: true,
32
+ timestamp: new Date().toISOString(),
33
+ details: {},
34
+ ...input,
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Throwable wrapper around an OgygieError payload. It carries the HTTP status
40
+ * OUTSIDE the payload (the payload is the wire contract, the status is
41
+ * transport). Fastify's error handler maps it to a response; only the payload
42
+ * fields ever reach the client (never a stack trace).
43
+ */
44
+ export class OgygieFailure extends Error {
45
+ public readonly error: OgygieError;
46
+ public readonly httpStatus: number;
47
+
48
+ constructor(error: OgygieError, httpStatus = 500) {
49
+ super(error.message);
50
+ this.name = "OgygieFailure";
51
+ this.error = error;
52
+ this.httpStatus = httpStatus;
53
+ }
54
+ }
55
+
56
+ /** Build-and-throw shortcut: `throwOgygieError({...}, 403)`. */
57
+ export function throwOgygieError(
58
+ input: Parameters<typeof ogygieError>[0],
59
+ httpStatus = 500,
60
+ ): never {
61
+ throw new OgygieFailure(ogygieError(input), httpStatus);
62
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "ogygie": true,
3
+ "code": "MANIFEST_GHOST_ROUTE",
4
+ "message": "The manifest declares a route that does not exist in the code.",
5
+ "file": "ogygie.manifest.json",
6
+ "cause": "Route 'DELETE /api/orders/:id' declared for feature 'orders', but no matching route registration in src/features/orders/routes.ts",
7
+ "suggestedFix": "Either implement the route in src/features/orders/routes.ts, or remove it from the manifest. The manifest must never lie.",
8
+ "details": { "feature": "orders", "method": "DELETE", "path": "/api/orders/:id" },
9
+ "timestamp": "2026-07-29T10:15:00.000Z"
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "ogygie": true,
3
+ "code": "HTTP_INPUT_VALIDATION",
4
+ "message": "The body of the POST /api/orders request is invalid.",
5
+ "file": "src/features/orders/schemas.ts",
6
+ "cause": "field 'quantity' = -3: violates the positive() constraint of CreateOrderInputSchema",
7
+ "suggestedFix": "Send a strictly positive quantity. See the CreateOrderInputSchema contract in src/features/orders/schemas.ts.",
8
+ "details": { "field": "quantity", "receivedValue": -3, "constraint": "positive" },
9
+ "timestamp": "2026-07-29T10:12:00.000Z"
10
+ }