@ogygie/studio 0.5.0 → 0.5.2
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/dist/server.js +918 -0
- package/package.json +8 -8
- package/src/index.ts +0 -41
- package/src/server/app.ts +0 -84
- package/src/server/routes.test.ts +0 -164
- package/src/server/routes.ts +0 -87
- package/src/server/schemas.ts +0 -92
- package/src/server/service.test.ts +0 -305
- package/src/server/service.ts +0 -426
- package/src/server/start.ts +0 -80
- package/src/server/storage/local-adapter.test.ts +0 -52
- package/src/server/storage/local-adapter.ts +0 -67
- package/src/server/storage/memory-adapter.ts +0 -49
- package/src/server/storage/spec-storage.ts +0 -22
- package/src/ui/app.css +0 -3
- package/src/ui/features/manifest-view/ManifestView.test.tsx +0 -67
- package/src/ui/features/manifest-view/ManifestView.tsx +0 -80
- package/src/ui/features/spec-board/SpecBoard.test.tsx +0 -92
- package/src/ui/features/spec-board/SpecBoard.tsx +0 -198
- package/src/ui/features/spec-editor/MarkdownPreview.tsx +0 -49
- package/src/ui/features/spec-editor/SpecEditor.test.tsx +0 -79
- package/src/ui/features/spec-editor/SpecEditor.tsx +0 -237
- package/src/ui/features/spec-editor/frontmatter-fields.test.ts +0 -54
- package/src/ui/features/spec-editor/frontmatter-fields.ts +0 -85
- package/src/ui/features/spec-editor/markdown-blocks.test.ts +0 -37
- package/src/ui/features/spec-editor/markdown-blocks.ts +0 -82
- package/src/ui/index.html +0 -12
- package/src/ui/lib/fetchContract.ts +0 -40
- package/src/ui/lib/queries.ts +0 -69
- package/src/ui/main.tsx +0 -27
- package/src/ui/patterns/EmptyState.tsx +0 -24
- package/src/ui/patterns/ErrorBanner.tsx +0 -51
- package/src/ui/patterns/Modal.tsx +0 -38
- package/src/ui/router.tsx +0 -76
- package/src/ui/test-utils.tsx +0 -56
|
@@ -1,305 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @intent Proves the behaviors described in specs/ogygie-studio.spec.md at the
|
|
3
|
-
* service level, over the in-memory SpecStorage (no fs, no network). Each
|
|
4
|
-
* Given/When/Then block of the spec = one identically named test; the
|
|
5
|
-
* serialization round-trip tests are the gate-style mutation checks (a
|
|
6
|
-
* frontmatter the studio writes must be re-readable by the parser the CI uses).
|
|
7
|
-
*/
|
|
8
|
-
import { describe, expect, it } from "vitest";
|
|
9
|
-
import { parseSpecFrontmatter, SpecFrontmatterSchema } from "@ogygie/core/spec";
|
|
10
|
-
import { createMemorySpecStorage } from "./storage/memory-adapter.js";
|
|
11
|
-
import {
|
|
12
|
-
createSpec,
|
|
13
|
-
getManifest,
|
|
14
|
-
getSpec,
|
|
15
|
-
listSpecSummaries,
|
|
16
|
-
saveSpec,
|
|
17
|
-
serializeSpecFrontmatter,
|
|
18
|
-
splitSpecFile,
|
|
19
|
-
} from "./service.js";
|
|
20
|
-
|
|
21
|
-
const SECTION_BODIES: Record<string, string> = {
|
|
22
|
-
"# @intent": "Le pourquoi de la feature.",
|
|
23
|
-
"# Comportements": "- ÉTANT DONNÉ x QUAND y ALORS z",
|
|
24
|
-
"# Contrats de données": "- SomethingInputSchema: { champ: type }",
|
|
25
|
-
"# États UI": "- vide / chargement / nominal / erreur",
|
|
26
|
-
"# Non-objectifs": "Rien d'autre.",
|
|
27
|
-
"# Budget": "- P95 API < 100 ms",
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
/** Builds a canonical spec file; `omitSections` drops mandatory sections. */
|
|
31
|
-
function specFile(options: {
|
|
32
|
-
id: string;
|
|
33
|
-
status?: string;
|
|
34
|
-
priority?: string;
|
|
35
|
-
omitSections?: string[];
|
|
36
|
-
}): string {
|
|
37
|
-
const omitted = new Set(options.omitSections ?? []);
|
|
38
|
-
const sections = Object.entries(SECTION_BODIES)
|
|
39
|
-
.filter(([title]) => !omitted.has(title))
|
|
40
|
-
.map(([title, body]) => `${title}\n${body}`)
|
|
41
|
-
.join("\n\n");
|
|
42
|
-
return [
|
|
43
|
-
"---",
|
|
44
|
-
`id: ${options.id}`,
|
|
45
|
-
`status: ${options.status ?? "draft"}`,
|
|
46
|
-
"depends_on: []",
|
|
47
|
-
`priority: ${options.priority ?? "P1"}`,
|
|
48
|
-
"refs: []",
|
|
49
|
-
"---",
|
|
50
|
-
"",
|
|
51
|
-
sections,
|
|
52
|
-
"",
|
|
53
|
-
].join("\n");
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const TEMPLATE = specFile({ id: "nom-de-la-feature" });
|
|
57
|
-
|
|
58
|
-
const VALID_MANIFEST = JSON.stringify({
|
|
59
|
-
version: 1,
|
|
60
|
-
product: { name: "starter", description: "Squelette de produit Ogygie pour les tests." },
|
|
61
|
-
apps: [{ name: "api", path: "apps/api", type: "api", entryPoint: "apps/api/src/server.ts" }],
|
|
62
|
-
features: [],
|
|
63
|
-
conventions: { agentsMd: "AGENTS.md", failuresMd: "FAILURES.md", specsFolder: "specs/" },
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
function benchWith(files: Record<string, string>) {
|
|
67
|
-
const storage = createMemorySpecStorage({
|
|
68
|
-
"specs/_TEMPLATE.spec.md": TEMPLATE,
|
|
69
|
-
"ogygie.manifest.json": VALID_MANIFEST,
|
|
70
|
-
...files,
|
|
71
|
-
});
|
|
72
|
-
return { storage, deps: { storage } };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
describe("ogygie-studio — service", () => {
|
|
76
|
-
it("ÉTANT DONNÉ un repo Ogygie valide QUAND le studio charge le dossier specs/ ALORS chaque spec est résumée avec le statut lu depuis son frontmatter", async () => {
|
|
77
|
-
const { deps } = benchWith({
|
|
78
|
-
"specs/alpha.spec.md": specFile({ id: "alpha", status: "draft" }),
|
|
79
|
-
"specs/beta.spec.md": specFile({ id: "beta", status: "validated" }),
|
|
80
|
-
"specs/gamma.spec.md": specFile({ id: "gamma", status: "implemented" }),
|
|
81
|
-
});
|
|
82
|
-
const summaries = await listSpecSummaries(deps);
|
|
83
|
-
expect(summaries).toHaveLength(3);
|
|
84
|
-
expect(summaries).toEqual(
|
|
85
|
-
expect.arrayContaining([
|
|
86
|
-
expect.objectContaining({ id: "alpha", status: "draft" }),
|
|
87
|
-
expect.objectContaining({ id: "beta", status: "validated" }),
|
|
88
|
-
expect.objectContaining({ id: "gamma", status: "implemented" }),
|
|
89
|
-
]),
|
|
90
|
-
);
|
|
91
|
-
const alpha = summaries.find((summary) => summary.id === "alpha");
|
|
92
|
-
expect(alpha).toMatchObject({
|
|
93
|
-
filePath: "specs/alpha.spec.md",
|
|
94
|
-
presentSections: [
|
|
95
|
-
"# @intent",
|
|
96
|
-
"# Comportements",
|
|
97
|
-
"# Contrats de données",
|
|
98
|
-
"# États UI",
|
|
99
|
-
"# Non-objectifs",
|
|
100
|
-
"# Budget",
|
|
101
|
-
],
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("ÉTANT DONNÉ une spec dont le frontmatter viole SpecFrontmatterSchema QUAND le studio charge le dossier specs/ ALORS elle apparaît en carte d'erreur SPEC_FRONTMATTER_INVALID et n'est jamais réparée", async () => {
|
|
106
|
-
const broken = "---\nid: broken\nstatus: nonsense\ndepends_on: []\npriority: P1\n---\n\n# @intent\nx\n";
|
|
107
|
-
const { deps, storage } = benchWith({ "specs/broken.spec.md": broken });
|
|
108
|
-
const summaries = await listSpecSummaries(deps);
|
|
109
|
-
expect(summaries).toHaveLength(1);
|
|
110
|
-
expect(summaries[0]).toMatchObject({
|
|
111
|
-
id: "broken",
|
|
112
|
-
filePath: "specs/broken.spec.md",
|
|
113
|
-
error: expect.objectContaining({
|
|
114
|
-
code: "SPEC_FRONTMATTER_INVALID",
|
|
115
|
-
file: "specs/broken.spec.md",
|
|
116
|
-
}),
|
|
117
|
-
});
|
|
118
|
-
// Never silently repaired: the file is byte-identical and nothing was written.
|
|
119
|
-
expect(storage.files.get("specs/broken.spec.md")).toBe(broken);
|
|
120
|
-
expect(storage.messages).toEqual([]);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("ÉTANT DONNÉ une spec en draft QUAND on modifie le frontmatter et sauvegarde ALORS le fichier est réécrit conforme au schéma et les sections markdown sont préservées à l'octet près", async () => {
|
|
124
|
-
const original = specFile({ id: "alpha", status: "draft", priority: "P1" });
|
|
125
|
-
const { deps, storage } = benchWith({ "specs/alpha.spec.md": original });
|
|
126
|
-
const before = await getSpec(deps, "alpha");
|
|
127
|
-
|
|
128
|
-
await saveSpec(deps, "alpha", {
|
|
129
|
-
frontmatter: { ...before.frontmatter, priority: "P0" },
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
const written = storage.files.get("specs/alpha.spec.md");
|
|
133
|
-
expect(written).toBeDefined();
|
|
134
|
-
const split = splitSpecFile(written ?? "");
|
|
135
|
-
expect(split?.body).toBe(before.body); // byte-exact markdown
|
|
136
|
-
const reparsed = SpecFrontmatterSchema.parse(
|
|
137
|
-
parseSpecFrontmatter(split?.frontmatterText ?? "").value,
|
|
138
|
-
);
|
|
139
|
-
expect(reparsed.priority).toBe("P0");
|
|
140
|
-
expect(reparsed.status).toBe("draft");
|
|
141
|
-
expect(storage.messages).toEqual(["spec: save alpha"]);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it("ÉTANT DONNÉ une spec en draft dont les 6 sections obligatoires sont présentes QUAND on valide ALORS status passe à validated via SpecStorage.write avec le message \"spec: validate <id>\"", async () => {
|
|
145
|
-
const { deps, storage } = benchWith({
|
|
146
|
-
"specs/alpha.spec.md": specFile({ id: "alpha", status: "draft" }),
|
|
147
|
-
});
|
|
148
|
-
const before = await getSpec(deps, "alpha");
|
|
149
|
-
|
|
150
|
-
const summary = await saveSpec(deps, "alpha", {
|
|
151
|
-
frontmatter: { ...before.frontmatter, status: "validated" },
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
expect(summary.status).toBe("validated");
|
|
155
|
-
expect(storage.messages).toEqual(["spec: validate alpha"]);
|
|
156
|
-
const after = await getSpec(deps, "alpha");
|
|
157
|
-
expect(after.frontmatter.status).toBe("validated");
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it("ÉTANT DONNÉ une spec en draft dont une section obligatoire manque QUAND on tente « Valider » ALORS OgygieError SPEC_SECTION_MISSING liste les sections absentes et le statut ne change pas", async () => {
|
|
161
|
-
const original = specFile({ id: "alpha", status: "draft", omitSections: ["# Budget"] });
|
|
162
|
-
const { deps, storage } = benchWith({ "specs/alpha.spec.md": original });
|
|
163
|
-
const before = await getSpec(deps, "alpha");
|
|
164
|
-
|
|
165
|
-
await expect(
|
|
166
|
-
saveSpec(deps, "alpha", {
|
|
167
|
-
frontmatter: { ...before.frontmatter, status: "validated" },
|
|
168
|
-
}),
|
|
169
|
-
).rejects.toMatchObject({
|
|
170
|
-
httpStatus: 409,
|
|
171
|
-
error: expect.objectContaining({
|
|
172
|
-
code: "SPEC_SECTION_MISSING",
|
|
173
|
-
details: { missingSections: ["# Budget"] },
|
|
174
|
-
}),
|
|
175
|
-
});
|
|
176
|
-
expect(storage.files.get("specs/alpha.spec.md")).toBe(original);
|
|
177
|
-
expect(storage.messages).toEqual([]);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it("ÉTANT DONNÉ une spec quelconque QUAND on tente de forcer status: implemented depuis le studio ALORS OgygieError STUDIO_TRANSITION_FORBIDDEN", async () => {
|
|
181
|
-
const { deps } = benchWith({
|
|
182
|
-
"specs/alpha.spec.md": specFile({ id: "alpha", status: "validated" }),
|
|
183
|
-
});
|
|
184
|
-
const before = await getSpec(deps, "alpha");
|
|
185
|
-
|
|
186
|
-
await expect(
|
|
187
|
-
saveSpec(deps, "alpha", {
|
|
188
|
-
frontmatter: { ...before.frontmatter, status: "implemented" },
|
|
189
|
-
}),
|
|
190
|
-
).rejects.toMatchObject({
|
|
191
|
-
httpStatus: 409,
|
|
192
|
-
error: expect.objectContaining({ code: "STUDIO_TRANSITION_FORBIDDEN" }),
|
|
193
|
-
});
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it("ÉTANT DONNÉ une spec validated QUAND on tente de la repasser en draft depuis le studio ALORS OgygieError STUDIO_TRANSITION_FORBIDDEN (le studio ne fait que draft → validated)", async () => {
|
|
197
|
-
const { deps } = benchWith({
|
|
198
|
-
"specs/alpha.spec.md": specFile({ id: "alpha", status: "validated" }),
|
|
199
|
-
});
|
|
200
|
-
const before = await getSpec(deps, "alpha");
|
|
201
|
-
|
|
202
|
-
await expect(
|
|
203
|
-
saveSpec(deps, "alpha", {
|
|
204
|
-
frontmatter: { ...before.frontmatter, status: "draft" },
|
|
205
|
-
}),
|
|
206
|
-
).rejects.toMatchObject({
|
|
207
|
-
error: expect.objectContaining({ code: "STUDIO_TRANSITION_FORBIDDEN" }),
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it("ÉTANT DONNÉ le board affiché QUAND on crée une nouvelle spec ALORS specs/<id>.spec.md naît de _TEMPLATE.spec.md avec status: draft", async () => {
|
|
212
|
-
const { deps, storage } = benchWith({});
|
|
213
|
-
|
|
214
|
-
const summary = await createSpec(deps, "ma-feature");
|
|
215
|
-
|
|
216
|
-
expect(summary).toMatchObject({ id: "ma-feature", status: "draft" });
|
|
217
|
-
const written = storage.files.get("specs/ma-feature.spec.md") ?? "";
|
|
218
|
-
expect(written).toContain("id: ma-feature");
|
|
219
|
-
expect(written).toContain("status: draft");
|
|
220
|
-
expect(storage.messages).toEqual(["spec: create ma-feature"]);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it("ÉTANT DONNÉ une spec existante QUAND on tente de créer le même id ALORS SPEC_ALREADY_EXISTS et rien n'est écrasé", async () => {
|
|
224
|
-
const original = specFile({ id: "alpha", status: "validated" });
|
|
225
|
-
const { deps, storage } = benchWith({ "specs/alpha.spec.md": original });
|
|
226
|
-
|
|
227
|
-
await expect(createSpec(deps, "alpha")).rejects.toMatchObject({
|
|
228
|
-
httpStatus: 409,
|
|
229
|
-
error: expect.objectContaining({ code: "SPEC_ALREADY_EXISTS" }),
|
|
230
|
-
});
|
|
231
|
-
expect(storage.files.get("specs/alpha.spec.md")).toBe(original);
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it("ÉTANT DONNÉ le manifeste du repo QUAND on ouvre la vue manifeste ALORS son contenu parsé est retourné (lecture seule : aucune écriture n'existe dans le service)", async () => {
|
|
235
|
-
const { deps } = benchWith({});
|
|
236
|
-
const manifest = await getManifest(deps);
|
|
237
|
-
expect(manifest.product.name).toBe("starter");
|
|
238
|
-
expect(manifest.apps[0]?.type).toBe("api");
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it("ÉTANT DONNÉ un manifeste invalide QUAND on ouvre la vue manifeste ALORS OgygieError MANIFEST_INVALID (jamais un contenu réparé)", async () => {
|
|
242
|
-
const { deps, storage } = benchWith({});
|
|
243
|
-
storage.files.set("ogygie.manifest.json", JSON.stringify({ version: 2 }));
|
|
244
|
-
|
|
245
|
-
await expect(getManifest(deps)).rejects.toMatchObject({
|
|
246
|
-
httpStatus: 422,
|
|
247
|
-
error: expect.objectContaining({ code: "MANIFEST_INVALID" }),
|
|
248
|
-
});
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
it("ÉTANT DONNÉ une spec inconnue QUAND on la lit ALORS SPEC_NOT_FOUND", async () => {
|
|
252
|
-
const { deps } = benchWith({});
|
|
253
|
-
await expect(getSpec(deps, "fantome")).rejects.toMatchObject({
|
|
254
|
-
httpStatus: 404,
|
|
255
|
-
error: expect.objectContaining({ code: "SPEC_NOT_FOUND" }),
|
|
256
|
-
});
|
|
257
|
-
});
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
describe("ogygie-studio — sérialisation du frontmatter (round-trip, style mutation)", () => {
|
|
261
|
-
const base = {
|
|
262
|
-
id: "alpha",
|
|
263
|
-
status: "draft",
|
|
264
|
-
depends_on: ["beta", "gamma"],
|
|
265
|
-
priority: "P2",
|
|
266
|
-
refs: ["specs/OGYGIE-STUDIO-ARCHI.md", "modele §4.2, alinéa b"],
|
|
267
|
-
deferred: ["X_CODE (résumé) blockedBy:beta"],
|
|
268
|
-
} as const;
|
|
269
|
-
|
|
270
|
-
it("ÉTANT DONNÉ un frontmatter valide (virgule dans une ref, deferred non vide) QUAND on le sérialise ALORS parseSpecFrontmatter le relit à l'identique", () => {
|
|
271
|
-
const frontmatter = SpecFrontmatterSchema.parse(base);
|
|
272
|
-
const serialized = serializeSpecFrontmatter(frontmatter, "specs/alpha.spec.md");
|
|
273
|
-
const reparsed = parseSpecFrontmatter(serialized);
|
|
274
|
-
expect(reparsed.problems).toEqual([]);
|
|
275
|
-
expect(SpecFrontmatterSchema.parse(reparsed.value)).toEqual(frontmatter);
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
it("ÉTANT DONNÉ une ref contenant un guillemet double QUAND on sérialise ALORS elle est écrite entre quotes simples et relue à l'identique", () => {
|
|
279
|
-
const frontmatter = SpecFrontmatterSchema.parse({
|
|
280
|
-
...base,
|
|
281
|
-
refs: ['la section "Budget" du modèle'],
|
|
282
|
-
deferred: [],
|
|
283
|
-
});
|
|
284
|
-
const serialized = serializeSpecFrontmatter(frontmatter, "specs/alpha.spec.md");
|
|
285
|
-
const reparsed = parseSpecFrontmatter(serialized);
|
|
286
|
-
expect(reparsed.problems).toEqual([]);
|
|
287
|
-
expect(SpecFrontmatterSchema.parse(reparsed.value).refs).toEqual(frontmatter.refs);
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
it("ÉTANT DONNÉ une ref mêlant les deux types de guillemets QUAND on sérialise ALORS SPEC_FRONTMATTER_UNSERIALIZABLE — jamais un fichier que le gate refuserait", () => {
|
|
291
|
-
const frontmatter = SpecFrontmatterSchema.parse({
|
|
292
|
-
...base,
|
|
293
|
-
refs: [`mélange "double" et 'simple'`],
|
|
294
|
-
deferred: [],
|
|
295
|
-
});
|
|
296
|
-
try {
|
|
297
|
-
serializeSpecFrontmatter(frontmatter, "specs/alpha.spec.md");
|
|
298
|
-
expect.unreachable("serialization should have refused the element");
|
|
299
|
-
} catch (thrown) {
|
|
300
|
-
expect(thrown).toMatchObject({
|
|
301
|
-
error: expect.objectContaining({ code: "SPEC_FRONTMATTER_UNSERIALIZABLE" }),
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
});
|
|
305
|
-
});
|