@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.
- package/package.json +3 -1
- package/src/decision-refs.test.ts +31 -0
- package/src/decision-refs.ts +33 -0
- package/src/impact.test.ts +251 -0
- package/src/impact.ts +366 -0
- package/src/manifest-owners.test.ts +166 -1
- package/src/manifest-owners.ts +104 -26
- package/src/manifest.schema.test.ts +72 -0
- package/src/manifest.schema.ts +35 -0
- package/src/repo-adapter.test.ts +192 -0
- package/src/repo-adapter.ts +307 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ogygie/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Ogygie framework core: manifest/spec/error schemas, generic tenancy and gate tooling. Ships TypeScript sources — Ogygie products run tsx/vitest by construction (D1).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
"./web-lint": "./src/web-lint.ts",
|
|
21
21
|
"./bundle-budget": "./src/bundle-budget.ts",
|
|
22
22
|
"./spec": "./src/spec.schema.ts",
|
|
23
|
+
"./impact": "./src/impact.ts",
|
|
24
|
+
"./repo-adapter": "./src/repo-adapter.ts",
|
|
23
25
|
"./decision-refs": "./src/decision-refs.ts",
|
|
24
26
|
"./design-brief": "./src/design-brief.ts",
|
|
25
27
|
"./module-purity": "./src/module-purity.ts",
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { describe, expect, it } from "vitest";
|
|
9
9
|
import {
|
|
10
|
+
extractDecisionSection,
|
|
10
11
|
findDecisionReferences,
|
|
11
12
|
findDuplicateDecisionIds,
|
|
12
13
|
findUnknownDecisionReferences,
|
|
@@ -112,3 +113,33 @@ describe("gate SPEC_UNKNOWN_DECISION_REFERENCE", () => {
|
|
|
112
113
|
]);
|
|
113
114
|
});
|
|
114
115
|
});
|
|
116
|
+
|
|
117
|
+
describe("extraction d'UNE décision (spec ogygie-mcp)", () => {
|
|
118
|
+
it("ÉTANT DONNÉ une décision déclarée QUAND on l'extrait ALORS on reçoit sa section, titre compris", () => {
|
|
119
|
+
expect(extractDecisionSection(DECISIONS, "D11")).toBe(
|
|
120
|
+
[
|
|
121
|
+
"## D11 — 2026-07-30 — Anglais maximal dans le code",
|
|
122
|
+
"- **Décision** : les identifiants sont en anglais (voir aussi D99, jamais prise).",
|
|
123
|
+
].join("\n"),
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("la DERNIÈRE décision du fichier va jusqu'à la fin, sans ligne vide traînante", () => {
|
|
128
|
+
expect(extractDecisionSection(`${DECISIONS}\n\n`, "D26")).toBe(
|
|
129
|
+
[
|
|
130
|
+
"## D26 — 2026-08 — Nomenclature partagée : codes normalisés",
|
|
131
|
+
"- **Décision** : le catalogue global s'appuie sur des codes normalisés.",
|
|
132
|
+
].join("\n"),
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("MUTATION : une décision seulement CITÉE dans le corps d'une autre ne s'extrait pas", () => {
|
|
137
|
+
// D99 is mentioned inside D11. Cutting on a citation instead of a heading
|
|
138
|
+
// would serve half of D11 as if it were a decision of its own.
|
|
139
|
+
expect(extractDecisionSection(DECISIONS, "D99")).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("MUTATION : un préfixe ne fait pas une décision — D1 n'est pas D11", () => {
|
|
143
|
+
expect(extractDecisionSection(DECISIONS, "D1")).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
});
|
package/src/decision-refs.ts
CHANGED
|
@@ -69,6 +69,39 @@ export function findDuplicateDecisionIds(
|
|
|
69
69
|
return duplicated;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The section of ONE decision — its declaring heading and everything up to the
|
|
74
|
+
* next one — or null when no decisions file declares it.
|
|
75
|
+
*
|
|
76
|
+
* Serving a whole decisions file to answer a question about one decision is
|
|
77
|
+
* how a reader's context fills with rules nobody asked about (spec ogygie-mcp,
|
|
78
|
+
* `ogygie_read_decisions`). The cut is made on the DECLARING heading, the very
|
|
79
|
+
* one `parseKnownDecisionIds` counts, so what can be cited and what can be
|
|
80
|
+
* served never drift apart.
|
|
81
|
+
*/
|
|
82
|
+
export function extractDecisionSection(
|
|
83
|
+
decisionsMarkdown: string,
|
|
84
|
+
decisionId: string,
|
|
85
|
+
): string | null {
|
|
86
|
+
const lines = decisionsMarkdown.split("\n");
|
|
87
|
+
const trimTrailingBlanks = (section: string[]): string => {
|
|
88
|
+
while (section.length > 0 && (section[section.length - 1] ?? "").trim() === "") section.pop();
|
|
89
|
+
return section.join("\n");
|
|
90
|
+
};
|
|
91
|
+
let start = -1;
|
|
92
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
93
|
+
const heading = DECISION_HEADING.exec(lines[index] ?? "");
|
|
94
|
+
if (heading === null) continue;
|
|
95
|
+
if (start === -1) {
|
|
96
|
+
if (`D${heading[1]}` === decisionId) start = index;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// The next declaration ends the one being cut.
|
|
100
|
+
return trimTrailingBlanks(lines.slice(start, index));
|
|
101
|
+
}
|
|
102
|
+
return start === -1 ? null : trimTrailingBlanks(lines.slice(start));
|
|
103
|
+
}
|
|
104
|
+
|
|
72
105
|
/** Every decision citation found in a text (frontmatter included), in order. */
|
|
73
106
|
export function findDecisionReferences(text: string): DecisionReference[] {
|
|
74
107
|
const references: DecisionReference[] = [];
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Proves specs/impact-analyzer-core.spec.md on an in-memory repo — the
|
|
3
|
+
* tests the script NEVER had, though it is the first command of the evolution
|
|
4
|
+
* protocol.
|
|
5
|
+
*
|
|
6
|
+
* Two families here. The four findings it already produced, each with the
|
|
7
|
+
* mutation that would silence it. And the four blind spots it had, each with
|
|
8
|
+
* the case that fails against yesterday's version: a dependent under another
|
|
9
|
+
* app, a dependent that is a package, an import by package specifier, and a
|
|
10
|
+
* report that does not say what it looked at (D12).
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import { OgygieFailure } from "./error.schema.js";
|
|
14
|
+
import { analyzeImpact, type ImpactReport } from "./impact.js";
|
|
15
|
+
import { OgygieManifestSchema } from "./manifest.schema.js";
|
|
16
|
+
import { createInMemoryRepoAdapter } from "./repo-adapter.js";
|
|
17
|
+
|
|
18
|
+
const MANIFEST = {
|
|
19
|
+
version: 1,
|
|
20
|
+
product: { name: "acme", description: "SaaS générique servant de fixture aux tests d'impact." },
|
|
21
|
+
apps: [
|
|
22
|
+
{ name: "api", path: "apps/api", type: "api", entryPoint: "apps/api/src/server.ts" },
|
|
23
|
+
{ name: "web", path: "apps/web", type: "front", entryPoint: "apps/web/src/main.tsx" },
|
|
24
|
+
],
|
|
25
|
+
features: [
|
|
26
|
+
{
|
|
27
|
+
name: "orders",
|
|
28
|
+
intent: "Feature cible : elle possède la table orders et expose une route.",
|
|
29
|
+
folder: "src/features/orders",
|
|
30
|
+
spec: "specs/orders.spec.md",
|
|
31
|
+
routes: [
|
|
32
|
+
{ method: "POST", path: "/api/orders", description: "Crée une commande.", schemaInput: "CreateOrderInputSchema", schemaOutput: "CreateOrderOutputSchema" },
|
|
33
|
+
],
|
|
34
|
+
dbTables: ["orders"],
|
|
35
|
+
dependsOn: [],
|
|
36
|
+
status: "delivered",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "invoices",
|
|
40
|
+
intent: "Feature d'API dépendante : elle importe orders par son index.",
|
|
41
|
+
folder: "src/features/invoices",
|
|
42
|
+
spec: "specs/invoices.spec.md",
|
|
43
|
+
routes: [],
|
|
44
|
+
dbTables: [],
|
|
45
|
+
dependsOn: ["orders"],
|
|
46
|
+
status: "delivered",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "orders-screen",
|
|
50
|
+
intent: "Feature FRONT : elle importe le contrat d'orders par specifier de package.",
|
|
51
|
+
app: "web",
|
|
52
|
+
folder: "src/features/orders-screen",
|
|
53
|
+
spec: "specs/orders-screen.spec.md",
|
|
54
|
+
routes: [],
|
|
55
|
+
dbTables: [],
|
|
56
|
+
dependsOn: ["orders"],
|
|
57
|
+
status: "delivered",
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
conventions: { agentsMd: "AGENTS.md", failuresMd: "FAILURES.md", specsFolder: "specs/" },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const FILES: Record<string, string> = {
|
|
64
|
+
"apps/api/src/features/orders/index.ts": "export { createOrder } from './service.js';\n",
|
|
65
|
+
"apps/api/src/features/orders/service.ts": "export function createOrder() { return 1; }\n",
|
|
66
|
+
"apps/api/src/features/invoices/service.ts":
|
|
67
|
+
'import { createOrder } from "../orders/index.js";\nexport const a = createOrder;\n',
|
|
68
|
+
"apps/web/src/features/orders-screen/queries.ts":
|
|
69
|
+
'import { ListOrdersOutputSchema } from "@acme/api/features/orders/schemas";\nexport const s = ListOrdersOutputSchema;\n',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function manifestOf(overrides: Record<string, unknown> = {}) {
|
|
73
|
+
return OgygieManifestSchema.parse({ ...MANIFEST, ...overrides });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function analyze(
|
|
77
|
+
files: Record<string, string> = FILES,
|
|
78
|
+
target = "orders",
|
|
79
|
+
manifest = manifestOf(),
|
|
80
|
+
): Promise<ImpactReport> {
|
|
81
|
+
return analyzeImpact({ manifest, adapter: createInMemoryRepoAdapter(files) }, target);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const codesOf = (report: ImpactReport): string[] =>
|
|
85
|
+
report.findings.map((finding) => finding.code).sort();
|
|
86
|
+
|
|
87
|
+
describe("B2/B3 — les angles morts d'hier", () => {
|
|
88
|
+
it("ÉTANT DONNÉ une feature FRONT qui importe le contrat d'une feature d'API ALORS elle est un dépendant", async () => {
|
|
89
|
+
// Yesterday's version joined every folder to the api app AND only knew
|
|
90
|
+
// relative imports: this dependent was invisible twice.
|
|
91
|
+
const report = await analyze();
|
|
92
|
+
expect(report.dependents.map((dependent) => dependent.name).sort()).toEqual([
|
|
93
|
+
"invoices",
|
|
94
|
+
"orders-screen",
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("les symboles importés remontent, quelle que soit la forme de l'import", async () => {
|
|
99
|
+
const report = await analyze();
|
|
100
|
+
const front = report.dependents.find((dependent) => dependent.name === "orders-screen");
|
|
101
|
+
expect(front?.importedSymbols).toEqual(["ListOrdersOutputSchema"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("ÉTANT DONNÉ un repo SANS app de type api ALORS l'analyse tourne quand même", async () => {
|
|
105
|
+
// The version of yesterday threw "no app of type api in the manifest" and
|
|
106
|
+
// analysed nothing at all.
|
|
107
|
+
const manifest = manifestOf({
|
|
108
|
+
apps: [{ name: "web", path: "apps/web", type: "front", entryPoint: "apps/web/src/main.tsx" }],
|
|
109
|
+
features: [MANIFEST.features[2]],
|
|
110
|
+
});
|
|
111
|
+
const report = await analyze(FILES, "orders-screen", manifest);
|
|
112
|
+
expect(report.target.folder).toBe("apps/web/src/features/orders-screen");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("un PACKAGE est une cible comme une autre, et ses dépendants se voient", async () => {
|
|
116
|
+
const manifest = manifestOf({
|
|
117
|
+
packages: [
|
|
118
|
+
{
|
|
119
|
+
name: "solver",
|
|
120
|
+
path: "packages/solver",
|
|
121
|
+
intent: "Package produit servant de fixture : importé par une feature d'API.",
|
|
122
|
+
spec: "specs/solver.spec.md",
|
|
123
|
+
dependsOn: [],
|
|
124
|
+
status: "delivered",
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
});
|
|
128
|
+
const files = {
|
|
129
|
+
...FILES,
|
|
130
|
+
"packages/solver/index.ts": "export const solve = () => 1;\n",
|
|
131
|
+
"apps/api/src/features/orders/service.ts":
|
|
132
|
+
'import { solve } from "solver";\nexport function createOrder() { return solve(); }\n',
|
|
133
|
+
};
|
|
134
|
+
const report = await analyze(files, "solver", manifest);
|
|
135
|
+
expect(report.dependents.map((dependent) => dependent.name)).toEqual(["orders"]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("B4 — le rapport dit ce qu'il a regardé (D12)", () => {
|
|
140
|
+
it("le périmètre nomme les dossiers scannés et les natures couvertes", async () => {
|
|
141
|
+
const report = await analyze();
|
|
142
|
+
expect(report.scope.scannedFolders).toContain("apps/web/src/features/orders-screen");
|
|
143
|
+
expect(report.scope.coveredKinds).toContain("feature");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("MUTATION : un PACKAGE sans dépendant interne dit que ses consommateurs EXTERNES sont hors de portée", async () => {
|
|
147
|
+
// "Aucun dépendant" tout court est le mensonge que D12 interdit.
|
|
148
|
+
const manifest = manifestOf({
|
|
149
|
+
packages: [
|
|
150
|
+
{
|
|
151
|
+
name: "solver",
|
|
152
|
+
path: "packages/solver",
|
|
153
|
+
intent: "Package produit servant de fixture : personne ne l'importe dans ce dépôt.",
|
|
154
|
+
spec: "specs/solver.spec.md",
|
|
155
|
+
dependsOn: [],
|
|
156
|
+
status: "delivered",
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
});
|
|
160
|
+
const report = await analyze({ ...FILES, "packages/solver/index.ts": "export const a = 1;\n" }, "solver", manifest);
|
|
161
|
+
expect(report.dependents).toEqual([]);
|
|
162
|
+
expect(report.scope.outOfReach.join(" ")).toContain("OUTSIDE this repo");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("une entrée dont la carte ne résout pas le dossier est NOMMÉE, l'analyse continue", async () => {
|
|
166
|
+
const manifest = manifestOf({
|
|
167
|
+
apps: [{ name: "web", path: "apps/web", type: "front", entryPoint: "apps/web/src/main.tsx" }],
|
|
168
|
+
});
|
|
169
|
+
const report = await analyze(FILES, "orders-screen", manifest);
|
|
170
|
+
// orders and invoices count on an api app that no longer exists: they are
|
|
171
|
+
// NAMED, and the analysis still returns a report instead of throwing.
|
|
172
|
+
expect(report.scope.outOfReach.join(" ")).toContain('"orders"');
|
|
173
|
+
expect(report.scope.outOfReach.join(" ")).toContain('"invoices"');
|
|
174
|
+
expect(report.target.folder).toBe("apps/web/src/features/orders-screen");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("B5 — les quatre findings, chacun avec sa mutation", () => {
|
|
179
|
+
it("aucun finding sur un repo conforme", async () => {
|
|
180
|
+
expect(await analyze().then(codesOf)).toEqual([]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("FEATURE_BOUNDARY_VIOLATION : importer un fichier INTERNE au lieu de l'index", async () => {
|
|
184
|
+
const files = {
|
|
185
|
+
...FILES,
|
|
186
|
+
"apps/api/src/features/invoices/service.ts":
|
|
187
|
+
'import { createOrder } from "../orders/service.js";\nexport const a = createOrder;\n',
|
|
188
|
+
};
|
|
189
|
+
const report = await analyze(files);
|
|
190
|
+
expect(codesOf(report)).toContain("FEATURE_BOUNDARY_VIOLATION");
|
|
191
|
+
expect(report.dependents.find((d) => d.name === "invoices")?.viaIndexOnly).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("MANIFEST_DEPENDENCY_STALE : un dependsOn que le code ne justifie plus", async () => {
|
|
195
|
+
const files = { ...FILES, "apps/api/src/features/invoices/service.ts": "export const a = 1;\n" };
|
|
196
|
+
expect(codesOf(await analyze(files))).toContain("MANIFEST_DEPENDENCY_STALE");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("MANIFEST_DEPENDENCY_UNDECLARED : un import que le manifeste tait", async () => {
|
|
200
|
+
const manifest = manifestOf({
|
|
201
|
+
features: MANIFEST.features.map((feature) =>
|
|
202
|
+
feature.name === "invoices" ? { ...feature, dependsOn: [] } : feature,
|
|
203
|
+
),
|
|
204
|
+
});
|
|
205
|
+
expect(codesOf(await analyze(FILES, "orders", manifest))).toContain(
|
|
206
|
+
"MANIFEST_DEPENDENCY_UNDECLARED",
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("TABLE_REFERENCED_OUTSIDE_OWNER : une table touchée hors de son propriétaire", async () => {
|
|
211
|
+
const files = {
|
|
212
|
+
...FILES,
|
|
213
|
+
"apps/api/src/features/invoices/repository.ts": 'export const t = "orders";\n',
|
|
214
|
+
};
|
|
215
|
+
const report = await analyze(files);
|
|
216
|
+
expect(codesOf(report)).toContain("TABLE_REFERENCED_OUTSIDE_OWNER");
|
|
217
|
+
expect(report.tableReadersOutsideOwner[0]?.feature).toBe("invoices");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("MUTATION : la même table dans un TEST ne compte pas — c'est le patron de fixture", async () => {
|
|
221
|
+
const files = {
|
|
222
|
+
...FILES,
|
|
223
|
+
"apps/api/src/features/invoices/service.test.ts": 'const t = "orders";\nexport default t;\n',
|
|
224
|
+
};
|
|
225
|
+
expect(codesOf(await analyze(files))).not.toContain("TABLE_REFERENCED_OUTSIDE_OWNER");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("B5 — la commande de test couvre TOUS les dépendants", () => {
|
|
230
|
+
it("elle nomme les dossiers réels, de toutes les apps", async () => {
|
|
231
|
+
const report = await analyze();
|
|
232
|
+
// Yesterday it prefixed everything with `features/`, so a front dependent
|
|
233
|
+
// was named by a path that runs nothing.
|
|
234
|
+
expect(report.testCommand).toContain("apps/api/src/features/orders");
|
|
235
|
+
expect(report.testCommand).toContain("apps/web/src/features/orders-screen");
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("refus", () => {
|
|
240
|
+
it("MUTATION : une cible inconnue est refusée AVEC la liste de ce que la carte déclare", async () => {
|
|
241
|
+
try {
|
|
242
|
+
await analyze(FILES, "jamais-declaree");
|
|
243
|
+
expect.unreachable("l'analyse aurait dû être refusée");
|
|
244
|
+
} catch (thrown) {
|
|
245
|
+
expect(thrown).toBeInstanceOf(OgygieFailure);
|
|
246
|
+
const failure = thrown as OgygieFailure;
|
|
247
|
+
expect(failure.error.code).toBe("IMPACT_UNKNOWN_FEATURE");
|
|
248
|
+
expect(failure.error.suggestedFix).toContain("orders-screen");
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
});
|