@ogygie/cli 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,222 @@
1
+ /**
2
+ * @intent CI gate `pnpm ogygie:verify-specs`: every `specs/<feature>.spec.md`
3
+ * must carry a valid frontmatter (SpecFrontmatterSchema), the canonical FRENCH
4
+ * sections in order (specs are human-facing documents — DECISIONS.md D11), and
5
+ * respect the lifecycle rule (draft → no code, implemented → manifest says
6
+ * "delivered"). Frontmatter is deliberately restricted to scalars and inline
7
+ * arrays — the format is owned by @ogygie/core/spec (parseSpecFrontmatter),
8
+ * which REFUSES what it cannot read (D22 hardening, born with its mutation
9
+ * tests in packages/ogygie-core/src/spec.schema.test.ts) — this gate only
10
+ * reports.
11
+ * It also refuses any reference to a decision `D<n>` absent from the decision
12
+ * files — DECISIONS.md (framework) and DECISIONS.product.md (product), one
13
+ * GLOBAL numbering since the Phase 3 split — in a spec (frontmatter `refs` or
14
+ * body), in AGENTS.md or in AGENTS.product.md: a rule founded on a decision
15
+ * nobody took is silent documentary drift. A decision id declared in BOTH
16
+ * files is refused too (ambiguous citation). Logic and mutation tests in
17
+ * @ogygie/core/decision-refs.
18
+ */
19
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import {
22
+ SECTIONS_SPEC_OBLIGATOIRES,
23
+ SpecFrontmatterSchema,
24
+ parseSpecFrontmatter,
25
+ } from "@ogygie/core/spec";
26
+ import {
27
+ findDuplicateDecisionIds,
28
+ findUnknownDecisionReferences,
29
+ parseKnownDecisionIds,
30
+ } from "@ogygie/core/decision-refs";
31
+ import { OgygieManifestSchema } from "@ogygie/core/manifest";
32
+ import { ogygieError, type OgygieError } from "@ogygie/core/errors";
33
+
34
+ const ROOT = new URL("..", import.meta.url).pathname;
35
+ const SPECS_FOLDER = "specs";
36
+ /** One global D<n> numbering across the two files (Phase 3 split, D10 rule 4). */
37
+ const DECISIONS_FILES = ["DECISIONS.md", "DECISIONS.product.md"];
38
+ /** Documents whose decision citations are checked, besides the specs. */
39
+ const AGENTS_FILES = ["AGENTS.md", "AGENTS.product.md"];
40
+ const problems: OgygieError[] = [];
41
+
42
+ function report(input: Parameters<typeof ogygieError>[0]): void {
43
+ problems.push(ogygieError(input));
44
+ }
45
+
46
+ const manifest = OgygieManifestSchema.parse(
47
+ JSON.parse(readFileSync(join(ROOT, "ogygie.manifest.json"), "utf8")),
48
+ );
49
+ const apiApp = manifest.apps.find((app) => app.type === "api");
50
+
51
+ const specFiles = readdirSync(join(ROOT, SPECS_FOLDER)).filter(
52
+ (fileName) => fileName.endsWith(".spec.md") && !fileName.startsWith("_"),
53
+ );
54
+
55
+ for (const fileName of specFiles) {
56
+ const specPath = join(SPECS_FOLDER, fileName);
57
+ const content = readFileSync(join(ROOT, specPath), "utf8");
58
+ const expectedId = fileName.replace(/\.spec\.md$/, "");
59
+
60
+ const frontmatterBlock = /^---\n([\s\S]*?)\n---/.exec(content);
61
+ if (frontmatterBlock === null || frontmatterBlock[1] === undefined) {
62
+ report({
63
+ code: "SPEC_FRONTMATTER_MISSING",
64
+ message: `Spec ${fileName} has no YAML frontmatter between --- markers.`,
65
+ file: specPath,
66
+ cause: "file does not start with a --- ... --- block",
67
+ suggestedFix: "Add the frontmatter following specs/_TEMPLATE.spec.md.",
68
+ });
69
+ continue;
70
+ }
71
+
72
+ const parsedFrontmatter = parseSpecFrontmatter(frontmatterBlock[1]);
73
+ if (parsedFrontmatter.problems.length > 0) {
74
+ report({
75
+ code: "SPEC_FRONTMATTER_INVALID",
76
+ message: `Spec ${fileName} has an invalid frontmatter FORMAT.`,
77
+ file: specPath,
78
+ cause: parsedFrontmatter.problems.join(" ; "),
79
+ suggestedFix:
80
+ "Use `key: value` lines with scalars or INLINE arrays only, balanced quotes (D13) — see parseSpecFrontmatter in packages/ogygie-core/src/spec.schema.ts.",
81
+ });
82
+ continue;
83
+ }
84
+
85
+ const parsed = SpecFrontmatterSchema.safeParse(parsedFrontmatter.value);
86
+ if (!parsed.success) {
87
+ report({
88
+ code: "SPEC_FRONTMATTER_INVALID",
89
+ message: `Spec ${fileName} has an invalid frontmatter.`,
90
+ file: specPath,
91
+ cause: parsed.error.issues
92
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
93
+ .join(" ; "),
94
+ suggestedFix: "Fix the listed fields to match packages/ogygie-core/src/spec.schema.ts.",
95
+ });
96
+ continue;
97
+ }
98
+ const frontmatter = parsed.data;
99
+
100
+ if (frontmatter.id !== expectedId) {
101
+ report({
102
+ code: "SPEC_ID_MISMATCH",
103
+ message: `Spec ${fileName} declares id "${frontmatter.id}" — expected "${expectedId}".`,
104
+ file: specPath,
105
+ cause: `id=${frontmatter.id} ≠ filename ${expectedId}`,
106
+ suggestedFix: "Make the frontmatter id identical to the file name.",
107
+ });
108
+ }
109
+
110
+ let cursor = 0;
111
+ for (const section of SECTIONS_SPEC_OBLIGATOIRES) {
112
+ const position = content.indexOf(`\n${section}`, cursor);
113
+ if (position === -1) {
114
+ report({
115
+ code: "SPEC_SECTION_MISSING",
116
+ message: `Spec ${fileName} is missing section "${section}" (or it is out of order).`,
117
+ file: specPath,
118
+ cause: `"${section}" not found after offset ${cursor}`,
119
+ suggestedFix:
120
+ "Add the canonical sections in the order defined by SECTIONS_SPEC_OBLIGATOIRES.",
121
+ });
122
+ } else {
123
+ cursor = position + section.length;
124
+ }
125
+ }
126
+
127
+ const manifestFeature = manifest.features.find(
128
+ (feature) => feature.name === frontmatter.id,
129
+ );
130
+ const featureDir =
131
+ apiApp !== undefined && manifestFeature !== undefined
132
+ ? join(ROOT, apiApp.path, manifestFeature.folder)
133
+ : null;
134
+
135
+ if (frontmatter.status === "draft") {
136
+ const implemented =
137
+ (manifestFeature !== undefined && manifestFeature.status !== "skeleton") ||
138
+ (featureDir !== null && existsSync(featureDir));
139
+ if (implemented) {
140
+ report({
141
+ code: "SPEC_DRAFT_ALREADY_IMPLEMENTED",
142
+ message: `Spec ${fileName} is draft but an implementation exists.`,
143
+ file: specPath,
144
+ cause: "status=draft with code or a non-skeleton manifest entry",
145
+ suggestedFix:
146
+ "Move the spec to validated/implemented, or remove the premature implementation.",
147
+ });
148
+ }
149
+ }
150
+
151
+ if (frontmatter.status === "implemented") {
152
+ // D13: an implemented spec has nothing left pending — deferred must be empty.
153
+ if (frontmatter.deferred.length > 0) {
154
+ report({
155
+ code: "SPEC_IMPLEMENTED_WITH_DEFERRED",
156
+ message: `Spec ${fileName} is implemented but still defers ${frontmatter.deferred.length} behavior(s).`,
157
+ file: specPath,
158
+ cause: `deferred=[${frontmatter.deferred.join(" ; ")}]`,
159
+ suggestedFix:
160
+ "Implement the deferred behaviors (their blocking features now exist?) and empty `deferred`, or keep the spec in validated.",
161
+ });
162
+ }
163
+ if (manifestFeature === undefined || manifestFeature.status !== "delivered") {
164
+ report({
165
+ code: "SPEC_IMPLEMENTED_NOT_DELIVERED",
166
+ message: `Spec ${fileName} is implemented but the manifest does not say status "delivered".`,
167
+ file: specPath,
168
+ cause: `manifest status=${manifestFeature?.status ?? "absent"}`,
169
+ suggestedFix:
170
+ "Set the feature's status to delivered in the manifest, or keep the spec in validated.",
171
+ });
172
+ }
173
+ }
174
+ }
175
+
176
+ // --------------------------------------------- decision references (D28 lot)
177
+ // Independent of the frontmatter checks above: a spec whose format is broken
178
+ // may also cite a phantom decision, and both deserve to be said in one run.
179
+ const decisionIdsPerFile = DECISIONS_FILES.map((fileName) =>
180
+ parseKnownDecisionIds(readFileSync(join(ROOT, fileName), "utf8")),
181
+ );
182
+ const knownDecisionIds = decisionIdsPerFile.flat();
183
+
184
+ // Phase 3 split: one D<n> lives in exactly ONE of the two files — a duplicate
185
+ // declaration would make every citation ambiguous.
186
+ for (const duplicated of findDuplicateDecisionIds(decisionIdsPerFile)) {
187
+ report({
188
+ code: "DECISION_DECLARED_TWICE",
189
+ message: `Decision ${duplicated} is declared more than once across ${DECISIONS_FILES.join(" and ")}.`,
190
+ file: DECISIONS_FILES.join(" ; "),
191
+ cause: `heading "## ${duplicated} — …" found in more than one place`,
192
+ suggestedFix:
193
+ "Keep the decision in exactly one file (framework rules in DECISIONS.md, product rules in DECISIONS.product.md) — the D<n> numbering is global.",
194
+ });
195
+ }
196
+
197
+ const documentsCitingDecisions = [
198
+ ...specFiles.map((fileName) => join(SPECS_FOLDER, fileName)),
199
+ ...AGENTS_FILES,
200
+ ];
201
+
202
+ for (const documentPath of documentsCitingDecisions) {
203
+ const content = readFileSync(join(ROOT, documentPath), "utf8");
204
+ for (const reference of findUnknownDecisionReferences(content, knownDecisionIds)) {
205
+ report({
206
+ code: "SPEC_UNKNOWN_DECISION_REFERENCE",
207
+ message: `${documentPath} cites decision ${reference.decision}, which does not exist in ${DECISIONS_FILES.join(" nor ")}.`,
208
+ file: documentPath,
209
+ cause: `${reference.decision} cited at ${documentPath}:${reference.line} ; declared decisions: ${knownDecisionIds.join(", ")}`,
210
+ suggestedFix: `Add the decision to DECISIONS.md (framework) or DECISIONS.product.md (product) if it was taken but never committed, or cite the decision that really founds this rule.`,
211
+ });
212
+ }
213
+ }
214
+
215
+ if (problems.length > 0) {
216
+ for (const problem of problems) {
217
+ console.error(JSON.stringify(problem, null, 2));
218
+ }
219
+ console.error(`\nogygie:verify-specs — ${problems.length} problème(s).`);
220
+ process.exit(1);
221
+ }
222
+ console.log(`ogygie:verify-specs — OK, ${specFiles.length} spec(s) conformes.`);
@@ -0,0 +1,37 @@
1
+ ---
2
+ id: nom-de-la-feature
3
+ status: draft
4
+ depends_on: []
5
+ priority: P1
6
+ refs: []
7
+ # Optionnel (D13) : comportements décrits mais bloqués par une feature à venir.
8
+ # Une spec ne peut passer `implemented` que si cette liste est vide.
9
+ # deferred: ["CODE_ERREUR (résumé) blockedBy:nom-de-la-feature"]
10
+ ---
11
+
12
+ # @intent
13
+ Le job-to-be-done de l'utilisateur en 3 lignes max. Pourquoi cette feature
14
+ existe, pas comment elle est construite.
15
+
16
+ # Comportements
17
+ (prose en français ; codes d'erreur, noms de schémas et valeurs d'enum
18
+ directement en anglais — D11/D14, ex. « ALORS OgygieError PLOT_CODE_ALREADY_USED »)
19
+ - ÉTANT DONNÉ <contexte>
20
+ QUAND <action>
21
+ ALORS <résultat observable et vérifiable>
22
+
23
+ # Contrats de données
24
+ Esquisse des schémas (sera traduite en Zod dans schemas.ts) :
25
+ - SomethingInputSchema: { champ: type, ... }
26
+ - SomethingOutputSchema: { champ: type, ... }
27
+
28
+ # États UI
29
+ - vide / chargement / nominal / erreur
30
+ (lister les états, composer avec design/patterns.md — jamais d'invention libre)
31
+
32
+ # Non-objectifs
33
+ Ce que la feature ne fait PAS. Anti-scope-creep, anti-hallucination.
34
+
35
+ # Budget
36
+ - P95 API < 100 ms hors I/O tierces
37
+ - Aucune dépendance npm nouvelle sans justification
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "noUncheckedIndexedAccess": true,
8
+ "exactOptionalPropertyTypes": true,
9
+ "noImplicitOverride": true,
10
+ "verbatimModuleSyntax": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true
13
+ }
14
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "./tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "lib": ["ES2023"],
6
+ "types": ["node"]
7
+ },
8
+ "include": ["scripts/**/*.ts"]
9
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @intent Single Vitest entry point for the whole workspace, so that
3
+ * `pnpm test -- features/<nom>` filters by path from the repo root.
4
+ * Node bench by default; a front app's UI tests opt into jsdom per file
5
+ * (`@vitest-environment jsdom` docblock). `passWithNoTests` because a
6
+ * freshly-inited project has no feature yet — the loop must be green from
7
+ * the first commit.
8
+ */
9
+ import { defineConfig } from "vitest/config";
10
+
11
+ export default defineConfig({
12
+ test: {
13
+ include: [
14
+ "apps/**/src/**/*.test.{ts,tsx}",
15
+ "packages/**/src/**/*.test.{ts,tsx}",
16
+ ],
17
+ environment: "node",
18
+ passWithNoTests: true,
19
+ // A feature test run must stay under 10 s (AGENTS.md principle 7);
20
+ // individual tests get a tight budget so slowness is caught early.
21
+ testTimeout: 5_000,
22
+ },
23
+ });