@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/src/impact.ts
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent The blast radius of a change, as a FUNCTION (spec
|
|
3
|
+
* impact-analyzer-core). `pnpm ogygie:impact <target>` is the first command of
|
|
4
|
+
* the evolution protocol — the one run before touching delivered code — so
|
|
5
|
+
* what it says has to be true, and what it cannot see has to be said (D12).
|
|
6
|
+
*
|
|
7
|
+
* The judgement lives here, in the core: it reads through a `RepoAdapter`
|
|
8
|
+
* (D10), so a test attacks it with an in-memory repo and the MCP server can
|
|
9
|
+
* call it on a repo it serves. The governed script keeps the collecting half.
|
|
10
|
+
*
|
|
11
|
+
* Deliberate limits, all declared in the report's `scope`:
|
|
12
|
+
* - textual import reading, no TypeScript compiler — same trade as
|
|
13
|
+
* `module-purity`, and the same one the analyzer already made;
|
|
14
|
+
* - depth 1: direct dependents only. Transitivity is read by re-running;
|
|
15
|
+
* - nothing outside the map, and nothing outside the repo.
|
|
16
|
+
*/
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import {
|
|
19
|
+
OgygieErrorSchema,
|
|
20
|
+
ogygieError,
|
|
21
|
+
throwOgygieError,
|
|
22
|
+
type OgygieError,
|
|
23
|
+
} from "./error.schema.js";
|
|
24
|
+
import { declaredEntries, type MapEntry } from "./manifest-owners.js";
|
|
25
|
+
import type { OgygieManifest } from "./manifest.schema.js";
|
|
26
|
+
import { parseModuleImports } from "./module-purity.js";
|
|
27
|
+
import type { RepoAdapter } from "./repo-adapter.js";
|
|
28
|
+
import { dbTableNamesOf } from "./shared-tables.js";
|
|
29
|
+
|
|
30
|
+
export const MapEntryKindSchema = z.enum(["feature", "package", "framework-package"]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What the analysis really covered, and what it cannot reach (D12). A vacant
|
|
34
|
+
* `dependents` means nothing without it: "none" and "I did not look there"
|
|
35
|
+
* read the same.
|
|
36
|
+
*/
|
|
37
|
+
export const ImpactScopeSchema = z.object({
|
|
38
|
+
scannedFolders: z.array(z.string()),
|
|
39
|
+
coveredKinds: z.array(MapEntryKindSchema),
|
|
40
|
+
outOfReach: z.array(z.string()),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const ImpactReportSchema = z.object({
|
|
44
|
+
target: z.object({
|
|
45
|
+
name: z.string(),
|
|
46
|
+
kind: MapEntryKindSchema,
|
|
47
|
+
folder: z.string(),
|
|
48
|
+
}),
|
|
49
|
+
dependents: z.array(
|
|
50
|
+
z.object({
|
|
51
|
+
name: z.string(),
|
|
52
|
+
kind: MapEntryKindSchema,
|
|
53
|
+
importedSymbols: z.array(z.string()),
|
|
54
|
+
viaIndexOnly: z.boolean(),
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
routes: z.array(
|
|
58
|
+
z.object({ method: z.string(), path: z.string(), description: z.string() }),
|
|
59
|
+
),
|
|
60
|
+
ownedTables: z.array(z.string()),
|
|
61
|
+
tableReadersOutsideOwner: z.array(
|
|
62
|
+
z.object({ table: z.string(), feature: z.string(), file: z.string() }),
|
|
63
|
+
),
|
|
64
|
+
findings: z.array(OgygieErrorSchema),
|
|
65
|
+
testCommand: z.string(),
|
|
66
|
+
scope: ImpactScopeSchema,
|
|
67
|
+
});
|
|
68
|
+
export type ImpactReport = z.infer<typeof ImpactReportSchema>;
|
|
69
|
+
|
|
70
|
+
export interface AnalyzeImpactDeps {
|
|
71
|
+
manifest: OgygieManifest;
|
|
72
|
+
/** The repo, read-only (D10): no path, no fs, no cwd. */
|
|
73
|
+
adapter: RepoAdapter;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const SOURCE_EXTENSIONS = [".ts", ".tsx"];
|
|
77
|
+
|
|
78
|
+
function isSource(path: string): boolean {
|
|
79
|
+
return SOURCE_EXTENSIONS.some((extension) => path.endsWith(extension));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isTest(path: string): boolean {
|
|
83
|
+
return /\.test\.tsx?$/.test(path);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Named/default/namespace symbols of one import clause ("type" stripped). */
|
|
87
|
+
export function symbolsOfImportClause(clause: string): string[] {
|
|
88
|
+
const symbols: string[] = [];
|
|
89
|
+
const braces = /\{([^}]*)\}/.exec(clause);
|
|
90
|
+
if (braces?.[1] !== undefined) {
|
|
91
|
+
for (const rawEntry of braces[1].split(",")) {
|
|
92
|
+
const name = rawEntry.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim();
|
|
93
|
+
if (name !== undefined && name !== "") symbols.push(name);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const outsideBraces = clause.replace(/\{[^}]*\}/, "").replace(/^type\s+/, "").trim();
|
|
97
|
+
if (/\*\s*as\s+/.test(outsideBraces)) symbols.push("*");
|
|
98
|
+
else if (outsideBraces.replace(/,/g, "").trim() !== "") symbols.push("default");
|
|
99
|
+
return symbols;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Every way a module can name the target, derived from the MAP — never
|
|
104
|
+
* guessed. Two families, because a repo has two:
|
|
105
|
+
* - the relative path between two features of the same app (`../orders/…`),
|
|
106
|
+
* the only one the analyzer used to know;
|
|
107
|
+
* - the package specifier, which is how a front imports an API contract
|
|
108
|
+
* (`@product/api/features/orders/schemas`) and how anything imports a
|
|
109
|
+
* package. Missing it made every front dependent invisible.
|
|
110
|
+
*/
|
|
111
|
+
function specifiersMatching(target: MapEntry, source: string): boolean {
|
|
112
|
+
const folder = target.folder;
|
|
113
|
+
if (folder === null) return false;
|
|
114
|
+
const directory = folder.split("/").pop() ?? folder;
|
|
115
|
+
if (new RegExp(`(^|/)\\.\\./${directory}/`).test(source)) return true;
|
|
116
|
+
if (source.startsWith(`../${directory}/`) || source === `../${directory}`) return true;
|
|
117
|
+
// Package specifier: the target's name, or its folder segment, as a path
|
|
118
|
+
// segment of the specifier — `@acme/api/features/orders/schemas` names
|
|
119
|
+
// `orders`, `@acme/solver` names `solver`.
|
|
120
|
+
if (source === target.name || source.startsWith(`${target.name}/`)) return true;
|
|
121
|
+
const segments = source.split("/");
|
|
122
|
+
return segments.includes(directory) && !source.startsWith(".");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether an import pierces the target's public door — and it is only ever
|
|
127
|
+
* ASKED of a relative import between neighbours.
|
|
128
|
+
*
|
|
129
|
+
* The rule it serves ("a feature imports another only through its index.ts")
|
|
130
|
+
* governs siblings inside one app. A front importing an API contract —
|
|
131
|
+
* `@product/api/features/orders/schemas`, the canonical rail of the stack —
|
|
132
|
+
* names a file that is NOT an index and is not a violation for it: that path
|
|
133
|
+
* is precisely what AGENTS.md prescribes. Judging it by the same measure would
|
|
134
|
+
* flag every front screen in every project.
|
|
135
|
+
*/
|
|
136
|
+
function piercesTheIndex(specifier: string): boolean {
|
|
137
|
+
if (!specifier.startsWith(".")) return false;
|
|
138
|
+
return !/(^|\/)index(\.js)?$/.test(specifier);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface DependentAccumulator {
|
|
142
|
+
kind: MapEntry["kind"];
|
|
143
|
+
importedSymbols: Set<string>;
|
|
144
|
+
viaIndexOnly: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The blast radius of `targetName`: who depends on it, which of its symbols
|
|
149
|
+
* they use, which tables it owns and who else touches them, plus the exact
|
|
150
|
+
* test command to keep every dependent green.
|
|
151
|
+
*/
|
|
152
|
+
export async function analyzeImpact(
|
|
153
|
+
deps: AnalyzeImpactDeps,
|
|
154
|
+
targetName: string,
|
|
155
|
+
): Promise<ImpactReport> {
|
|
156
|
+
const entries = declaredEntries(deps.manifest);
|
|
157
|
+
const target = entries.find((entry) => entry.name === targetName);
|
|
158
|
+
if (target === undefined) {
|
|
159
|
+
// The code stays IMPACT_UNKNOWN_FEATURE although a target may now be a
|
|
160
|
+
// package: it is the code projects already know, and renaming it would
|
|
161
|
+
// restrict a contract for nothing. What changes is the list offered —
|
|
162
|
+
// every nature the map declares, not only the features.
|
|
163
|
+
return throwOgygieError({
|
|
164
|
+
code: "IMPACT_UNKNOWN_FEATURE",
|
|
165
|
+
message: `"${targetName}" is unknown to the manifest.`,
|
|
166
|
+
file: "ogygie.manifest.json",
|
|
167
|
+
cause: `no entry of the map is named "${targetName}"`,
|
|
168
|
+
suggestedFix: `Pick one of: ${entries.map((entry) => entry.name).join(", ") || "<the map declares nothing>"}.`,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return buildReport(deps, entries, target);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Kept apart so the refusal above stays a one-liner and the report a whole. */
|
|
175
|
+
async function buildReport(
|
|
176
|
+
deps: AnalyzeImpactDeps,
|
|
177
|
+
entries: MapEntry[],
|
|
178
|
+
target: MapEntry,
|
|
179
|
+
): Promise<ImpactReport> {
|
|
180
|
+
const findings: OgygieError[] = [];
|
|
181
|
+
const scannedFolders: string[] = [];
|
|
182
|
+
const outOfReach: string[] = [];
|
|
183
|
+
|
|
184
|
+
if (target.folder === null) {
|
|
185
|
+
outOfReach.push(
|
|
186
|
+
`the target "${target.name}" has no folder the map can resolve — its app is not declared`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const dependents = new Map<string, DependentAccumulator>();
|
|
191
|
+
const tableReadersOutsideOwner: ImpactReport["tableReadersOutsideOwner"] = [];
|
|
192
|
+
const ownedTables = target.kind === "feature" ? tablesOf(deps.manifest, target.name) : [];
|
|
193
|
+
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
if (entry.name === target.name) continue;
|
|
196
|
+
if (entry.folder === null) {
|
|
197
|
+
outOfReach.push(
|
|
198
|
+
`"${entry.name}" (${entry.kind}) — the map declares no resolvable folder for it`,
|
|
199
|
+
);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const files = (await deps.adapter.listDirectory(entry.folder)).filter((file) =>
|
|
203
|
+
isSource(file.path),
|
|
204
|
+
);
|
|
205
|
+
if (files.length > 0) scannedFolders.push(entry.folder);
|
|
206
|
+
|
|
207
|
+
for (const file of files) {
|
|
208
|
+
const filePath = `${entry.folder}/${file.path}`;
|
|
209
|
+
const content = await deps.adapter.readFile(filePath);
|
|
210
|
+
|
|
211
|
+
for (const imported of parseModuleImports(content)) {
|
|
212
|
+
if (!specifiersMatching(target, imported.specifier)) continue;
|
|
213
|
+
const accumulator = dependents.get(entry.name) ?? {
|
|
214
|
+
kind: entry.kind,
|
|
215
|
+
importedSymbols: new Set<string>(),
|
|
216
|
+
viaIndexOnly: true,
|
|
217
|
+
};
|
|
218
|
+
for (const symbol of symbolsOfImportClause(clauseOf(imported.statement))) {
|
|
219
|
+
accumulator.importedSymbols.add(symbol);
|
|
220
|
+
}
|
|
221
|
+
if (piercesTheIndex(imported.specifier)) {
|
|
222
|
+
accumulator.viaIndexOnly = false;
|
|
223
|
+
findings.push(
|
|
224
|
+
ogygieError({
|
|
225
|
+
code: "FEATURE_BOUNDARY_VIOLATION",
|
|
226
|
+
message: `"${entry.name}" imports an internal file of "${target.name}" instead of its index.ts.`,
|
|
227
|
+
file: filePath,
|
|
228
|
+
cause: `import from "${imported.specifier}" bypasses the public index of ${target.name}`,
|
|
229
|
+
suggestedFix: `Export the needed symbols from the index of ${target.name} and import them from there.`,
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
dependents.set(entry.name, accumulator);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// A table belongs to ONE feature. Tests are exempt: populating another
|
|
237
|
+
// feature's table as a fixture is the canonical bench pattern, and
|
|
238
|
+
// flagging it would make every run red.
|
|
239
|
+
if (isTest(file.path)) continue;
|
|
240
|
+
for (const table of ownedTables) {
|
|
241
|
+
if (!content.includes(`"${table}"`)) continue;
|
|
242
|
+
tableReadersOutsideOwner.push({ table, feature: entry.name, file: filePath });
|
|
243
|
+
findings.push(
|
|
244
|
+
ogygieError({
|
|
245
|
+
code: "TABLE_REFERENCED_OUTSIDE_OWNER",
|
|
246
|
+
message: `Table "${table}" is owned by "${target.name}" but referenced by production code of "${entry.name}".`,
|
|
247
|
+
file: filePath,
|
|
248
|
+
cause: `"${table}" found in a non-test file outside its owner`,
|
|
249
|
+
suggestedFix: `Go through the public contracts of ${target.name} instead of touching the table — one table, one owner.`,
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
findings.push(...manifestDependencyFindings(deps.manifest, target, dependents));
|
|
257
|
+
|
|
258
|
+
const feature = deps.manifest.features.find((candidate) => candidate.name === target.name);
|
|
259
|
+
const report = {
|
|
260
|
+
target: { name: target.name, kind: target.kind, folder: target.folder ?? "" },
|
|
261
|
+
dependents: [...dependents.entries()]
|
|
262
|
+
.map(([name, accumulator]) => ({
|
|
263
|
+
name,
|
|
264
|
+
kind: accumulator.kind,
|
|
265
|
+
importedSymbols: [...accumulator.importedSymbols].sort(),
|
|
266
|
+
viaIndexOnly: accumulator.viaIndexOnly,
|
|
267
|
+
}))
|
|
268
|
+
.sort((left, right) => (left.name < right.name ? -1 : 1)),
|
|
269
|
+
routes: (feature?.routes ?? []).map((route) => ({
|
|
270
|
+
method: route.method,
|
|
271
|
+
path: route.path,
|
|
272
|
+
description: route.description,
|
|
273
|
+
})),
|
|
274
|
+
ownedTables,
|
|
275
|
+
tableReadersOutsideOwner,
|
|
276
|
+
findings,
|
|
277
|
+
testCommand: testCommandFor(target, [...dependents.keys()], entries),
|
|
278
|
+
scope: {
|
|
279
|
+
scannedFolders: scannedFolders.sort(),
|
|
280
|
+
coveredKinds: [...new Set(entries.map((entry) => entry.kind))],
|
|
281
|
+
outOfReach: [...outOfReach, ...outOfReachOf(target)],
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
return ImpactReportSchema.parse(report);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The import clause of a statement, for the symbols it carries. */
|
|
288
|
+
function clauseOf(statement: string): string {
|
|
289
|
+
const match = /\bimport\b([\s\S]*?)\bfrom\b/.exec(statement);
|
|
290
|
+
return match?.[1] ?? "";
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function tablesOf(manifest: OgygieManifest, featureName: string): string[] {
|
|
294
|
+
const feature = manifest.features.find((candidate) => candidate.name === featureName);
|
|
295
|
+
return feature === undefined ? [] : dbTableNamesOf(feature);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** What no analysis of this repo can reach, named rather than implied (D12). */
|
|
299
|
+
function outOfReachOf(target: MapEntry): string[] {
|
|
300
|
+
const limits = [
|
|
301
|
+
"code that no manifest entry declares — the map is the only chart",
|
|
302
|
+
"anything outside this repo: no network, no npm registry",
|
|
303
|
+
];
|
|
304
|
+
if (target.kind === "framework-package" || target.kind === "package") {
|
|
305
|
+
limits.unshift(
|
|
306
|
+
`consumers of "${target.name}" OUTSIDE this repo — it is a package, and a published package is imported by projects this analysis cannot see`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
return limits;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The map must not lie by omission, in either direction. */
|
|
313
|
+
function manifestDependencyFindings(
|
|
314
|
+
manifest: OgygieManifest,
|
|
315
|
+
target: MapEntry,
|
|
316
|
+
dependents: Map<string, DependentAccumulator>,
|
|
317
|
+
): OgygieError[] {
|
|
318
|
+
const findings: OgygieError[] = [];
|
|
319
|
+
const declared = manifest.features.filter((feature) =>
|
|
320
|
+
feature.dependsOn.includes(target.name),
|
|
321
|
+
);
|
|
322
|
+
for (const feature of declared) {
|
|
323
|
+
if (!dependents.has(feature.name)) {
|
|
324
|
+
findings.push(
|
|
325
|
+
ogygieError({
|
|
326
|
+
code: "MANIFEST_DEPENDENCY_STALE",
|
|
327
|
+
message: `Feature "${feature.name}" declares dependsOn "${target.name}" but never imports it.`,
|
|
328
|
+
file: `ogygie.manifest.json#features.${feature.name}`,
|
|
329
|
+
cause: `dependsOn contains "${target.name}", no import of it found in its code`,
|
|
330
|
+
suggestedFix:
|
|
331
|
+
"Remove the stale dependsOn entry, or make the dependency real through the entry's index.ts.",
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
for (const [name, accumulator] of dependents) {
|
|
337
|
+
if (accumulator.kind !== "feature") continue;
|
|
338
|
+
if (declared.some((feature) => feature.name === name)) continue;
|
|
339
|
+
findings.push(
|
|
340
|
+
ogygieError({
|
|
341
|
+
code: "MANIFEST_DEPENDENCY_UNDECLARED",
|
|
342
|
+
message: `Feature "${name}" imports "${target.name}" but does not declare it in dependsOn.`,
|
|
343
|
+
file: `ogygie.manifest.json#features.${name}`,
|
|
344
|
+
cause: `imports of "${target.name}" found in its code, absent from its dependsOn`,
|
|
345
|
+
suggestedFix: `Add "${target.name}" to the dependsOn of "${name}" — the manifest must never lie by omission.`,
|
|
346
|
+
}),
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
return findings;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The command that keeps every dependent green. It names FOLDERS, not a
|
|
354
|
+
* `features/` prefix: dependents now come from several apps and from
|
|
355
|
+
* packages, and a prefix that only fits api features would run nothing.
|
|
356
|
+
*/
|
|
357
|
+
function testCommandFor(
|
|
358
|
+
target: MapEntry,
|
|
359
|
+
dependentNames: string[],
|
|
360
|
+
entries: MapEntry[],
|
|
361
|
+
): string {
|
|
362
|
+
const folders = [target, ...entries.filter((entry) => dependentNames.includes(entry.name))]
|
|
363
|
+
.map((entry) => entry.folder)
|
|
364
|
+
.filter((folder): folder is string => folder !== null);
|
|
365
|
+
return ["pnpm test --", ...[...new Set(folders)].sort()].join(" ");
|
|
366
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* confirm, and that neither shadows the other.
|
|
6
6
|
*/
|
|
7
7
|
import { describe, expect, it } from "vitest";
|
|
8
|
-
import { findSpecOwner } from "./manifest-owners.js";
|
|
8
|
+
import { declaredEntries, findSpecOwner } from "./manifest-owners.js";
|
|
9
9
|
import { OgygieManifestSchema } from "./manifest.schema.js";
|
|
10
10
|
|
|
11
11
|
function manifestWith(extra: Record<string, unknown>) {
|
|
@@ -77,3 +77,168 @@ describe("findSpecOwner — feature ou package, la carte confirme les deux", ()
|
|
|
77
77
|
expect(findSpecOwner(manifestWith({}), "gadget-engine")).toBeUndefined();
|
|
78
78
|
});
|
|
79
79
|
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The nature added the day a framework spec could not be confirmed. The
|
|
83
|
+
* mutation these tests guard is the one that was there for months: the lookup
|
|
84
|
+
* IGNORING a nature the map declares, and answering "no owner" instead of
|
|
85
|
+
* "this owner, not delivered yet".
|
|
86
|
+
*/
|
|
87
|
+
const frameworkWith = (packages: unknown[]) => ({
|
|
88
|
+
framework: {
|
|
89
|
+
packages,
|
|
90
|
+
templates: { project: "templates/project", feature: "templates/feature" },
|
|
91
|
+
governedFiles: "packages/ogygie-cli/src/governed-files.ts",
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const mcpPackage = {
|
|
96
|
+
name: "@acme/mcp",
|
|
97
|
+
path: "packages/acme-mcp",
|
|
98
|
+
intent: "Serveur du protocole, publié à part parce qu'il porte sa propre dépendance.",
|
|
99
|
+
spec: "specs/acme-mcp.spec.md",
|
|
100
|
+
status: "delivered",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
describe("findSpecOwner — le package d'un framework confirme aussi", () => {
|
|
104
|
+
it("ÉTANT DONNÉ une spec décrite par un package du framework ALORS la carte le confirme", () => {
|
|
105
|
+
const owner = findSpecOwner(manifestWith(frameworkWith([mcpPackage])), "acme-mcp");
|
|
106
|
+
expect(owner).toEqual({
|
|
107
|
+
kind: "framework-package",
|
|
108
|
+
name: "@acme/mcp",
|
|
109
|
+
status: "delivered",
|
|
110
|
+
folder: "packages/acme-mcp",
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("l'appariement se fait par le champ `spec`, JAMAIS par le nom npm publié", () => {
|
|
115
|
+
// The mutation this kills: matching `candidate.name === specId`, which
|
|
116
|
+
// silently finds nothing — "@acme/mcp" is not "acme-mcp".
|
|
117
|
+
expect(findSpecOwner(manifestWith(frameworkWith([mcpPackage])), "@acme/mcp")).toBeUndefined();
|
|
118
|
+
expect(findSpecOwner(manifestWith(frameworkWith([mcpPackage])), "acme-mcp")?.name).toBe(
|
|
119
|
+
"@acme/mcp",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("une spec portée par PLUSIEURS packages vaut le MOINS avancé d'entre eux", () => {
|
|
124
|
+
const owner = findSpecOwner(
|
|
125
|
+
manifestWith(
|
|
126
|
+
frameworkWith([
|
|
127
|
+
{ ...mcpPackage, name: "@acme/core", path: "packages/acme-core" },
|
|
128
|
+
{ ...mcpPackage, name: "@acme/cli", path: "packages/acme-cli", status: "in-progress" },
|
|
129
|
+
]),
|
|
130
|
+
),
|
|
131
|
+
"acme-mcp",
|
|
132
|
+
);
|
|
133
|
+
expect(owner?.status).toBe("in-progress");
|
|
134
|
+
expect(owner?.name).toBe("@acme/cli");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// The "a feature and a framework package claim the same spec" case is not
|
|
138
|
+
// tested here: the manifest schema refuses it upstream (see
|
|
139
|
+
// manifest.schema.test.ts), so it cannot reach this lookup.
|
|
140
|
+
it("un package du framework SANS spec ne possède rien", () => {
|
|
141
|
+
expect(
|
|
142
|
+
findSpecOwner(manifestWith(frameworkWith([{ ...mcpPackage, spec: null }])), "acme-mcp"),
|
|
143
|
+
).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The list every tool of the framework reads. What these guard is the defect
|
|
150
|
+
* that made the impact analyzer blind: a folder resolved through ONE globally
|
|
151
|
+
* chosen app instead of the app that carries each entry.
|
|
152
|
+
*/
|
|
153
|
+
describe("declaredEntries — tout ce que la carte déclare, à un seul endroit", () => {
|
|
154
|
+
const front = {
|
|
155
|
+
name: "web-shell",
|
|
156
|
+
intent: "Feature front servant de fixture : elle vit sous l'app web, pas sous l'api.",
|
|
157
|
+
app: "web",
|
|
158
|
+
folder: "src/features/web-shell",
|
|
159
|
+
spec: "specs/web-shell.spec.md",
|
|
160
|
+
routes: [],
|
|
161
|
+
dbTables: [],
|
|
162
|
+
dependsOn: [],
|
|
163
|
+
status: "delivered",
|
|
164
|
+
};
|
|
165
|
+
const api = {
|
|
166
|
+
name: "orders",
|
|
167
|
+
intent: "Feature d'API servant de fixture aux tests du propriétaire de spec.",
|
|
168
|
+
folder: "src/features/orders",
|
|
169
|
+
spec: "specs/orders.spec.md",
|
|
170
|
+
routes: [],
|
|
171
|
+
dbTables: [],
|
|
172
|
+
dependsOn: [],
|
|
173
|
+
status: "delivered",
|
|
174
|
+
};
|
|
175
|
+
const withApps = (extra: Record<string, unknown>) =>
|
|
176
|
+
OgygieManifestSchema.parse({
|
|
177
|
+
version: 1,
|
|
178
|
+
product: { name: "acme", description: "SaaS générique servant de fixture aux tests du manifeste." },
|
|
179
|
+
apps: [
|
|
180
|
+
{ name: "api", path: "apps/api", type: "api", entryPoint: "apps/api/src/server.ts" },
|
|
181
|
+
{ name: "web", path: "apps/web", type: "front", entryPoint: "apps/web/src/main.tsx" },
|
|
182
|
+
],
|
|
183
|
+
features: [],
|
|
184
|
+
conventions: { agentsMd: "AGENTS.md", failuresMd: "FAILURES.md", specsFolder: "specs/" },
|
|
185
|
+
...extra,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("ÉTANT DONNÉ des features sous DEUX apps ALORS chaque dossier passe par l'app qui la porte", () => {
|
|
189
|
+
// The mutation this kills: joining every folder to the api app, which
|
|
190
|
+
// sends the front feature's reader to apps/api/src/features/web-shell —
|
|
191
|
+
// a path that does not exist, so the feature looks empty instead of wrong.
|
|
192
|
+
const entries = declaredEntries(withApps({ features: [api, front] }));
|
|
193
|
+
expect(entries.map((entry) => entry.folder)).toEqual([
|
|
194
|
+
"apps/api/src/features/orders",
|
|
195
|
+
"apps/web/src/features/web-shell",
|
|
196
|
+
]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("les TROIS natures entrent dans la liste, chacune avec sa spec", () => {
|
|
200
|
+
const entries = declaredEntries(
|
|
201
|
+
withApps({
|
|
202
|
+
features: [api],
|
|
203
|
+
packages: [
|
|
204
|
+
{
|
|
205
|
+
name: "solver",
|
|
206
|
+
path: "packages/solver",
|
|
207
|
+
intent: "Package produit servant de fixture aux tests de la carte.",
|
|
208
|
+
spec: "specs/solver.spec.md",
|
|
209
|
+
dependsOn: [],
|
|
210
|
+
status: "in-progress",
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
...frameworkWith([mcpPackage]),
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
expect(entries.map((entry) => [entry.kind, entry.name, entry.specId])).toEqual([
|
|
217
|
+
["feature", "orders", "orders"],
|
|
218
|
+
["package", "solver", "solver"],
|
|
219
|
+
["framework-package", "@acme/mcp", "acme-mcp"],
|
|
220
|
+
]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("MUTATION : une feature dont l'app est INTROUVABLE garde un dossier null, sans faire tomber la liste", () => {
|
|
224
|
+
// Today the analyzer throws on this case and analyses NOTHING. The map
|
|
225
|
+
// says what it can, and names what it cannot — the rest is still usable.
|
|
226
|
+
const entries = declaredEntries(
|
|
227
|
+
OgygieManifestSchema.parse({
|
|
228
|
+
version: 1,
|
|
229
|
+
product: { name: "acme", description: "SaaS générique servant de fixture aux tests du manifeste." },
|
|
230
|
+
// No app of type api at all: the case of a framework repo.
|
|
231
|
+
apps: [{ name: "studio", path: "packages/studio", type: "front", entryPoint: "packages/studio/src/start.ts" }],
|
|
232
|
+
features: [api],
|
|
233
|
+
conventions: { agentsMd: "AGENTS.md", failuresMd: "FAILURES.md", specsFolder: "specs/" },
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
expect(entries).toEqual([
|
|
237
|
+
{ kind: "feature", name: "orders", status: "delivered", folder: null, specId: "orders" },
|
|
238
|
+
]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("un manifeste minimal rend une liste VIDE — jamais une exception", () => {
|
|
242
|
+
expect(declaredEntries(withApps({}))).toEqual([]);
|
|
243
|
+
});
|
|
244
|
+
});
|