@ogygie/core 0.9.0 → 0.10.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogygie/core",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
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,7 @@
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",
23
24
  "./repo-adapter": "./src/repo-adapter.ts",
24
25
  "./decision-refs": "./src/decision-refs.ts",
25
26
  "./design-brief": "./src/design-brief.ts",
@@ -0,0 +1,276 @@
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 : le nom d'une table dans une feature FRONT ne compte pas — elle ne parle pas à la base", async () => {
221
+ // Found on a real repo: widening the scan to other apps turned every
222
+ // `queryKey: ["orders", "list"]` into a boundary violation — fifteen in one
223
+ // report. A front feature holds no database client; the string is a query
224
+ // key or a route, and a rule applied where its premise does not hold makes
225
+ // noise, which is how a report stops being read.
226
+ const files = {
227
+ ...FILES,
228
+ "apps/web/src/features/orders-screen/queries.ts":
229
+ 'import { ListOrdersOutputSchema } from "@acme/api/features/orders/schemas";\nexport const key = ["orders", "list"];\nexport const s = ListOrdersOutputSchema;\n',
230
+ };
231
+ const report = await analyze(files);
232
+ expect(codesOf(report)).not.toContain("TABLE_REFERENCED_OUTSIDE_OWNER");
233
+ // …et la dérogation se DIT, elle ne s'applique pas en silence (D12).
234
+ expect(report.scope.outOfReach.join(" ")).toContain("front/gateway");
235
+ });
236
+
237
+ it("une feature d'API touchant la table d'une autre reste signalée — la règle garde sa portée", async () => {
238
+ const files = {
239
+ ...FILES,
240
+ "apps/api/src/features/invoices/repository.ts": 'export const t = "orders";\n',
241
+ };
242
+ expect(codesOf(await analyze(files))).toContain("TABLE_REFERENCED_OUTSIDE_OWNER");
243
+ });
244
+
245
+ it("MUTATION : la même table dans un TEST ne compte pas — c'est le patron de fixture", async () => {
246
+ const files = {
247
+ ...FILES,
248
+ "apps/api/src/features/invoices/service.test.ts": 'const t = "orders";\nexport default t;\n',
249
+ };
250
+ expect(codesOf(await analyze(files))).not.toContain("TABLE_REFERENCED_OUTSIDE_OWNER");
251
+ });
252
+ });
253
+
254
+ describe("B5 — la commande de test couvre TOUS les dépendants", () => {
255
+ it("elle nomme les dossiers réels, de toutes les apps", async () => {
256
+ const report = await analyze();
257
+ // Yesterday it prefixed everything with `features/`, so a front dependent
258
+ // was named by a path that runs nothing.
259
+ expect(report.testCommand).toContain("apps/api/src/features/orders");
260
+ expect(report.testCommand).toContain("apps/web/src/features/orders-screen");
261
+ });
262
+ });
263
+
264
+ describe("refus", () => {
265
+ it("MUTATION : une cible inconnue est refusée AVEC la liste de ce que la carte déclare", async () => {
266
+ try {
267
+ await analyze(FILES, "jamais-declaree");
268
+ expect.unreachable("l'analyse aurait dû être refusée");
269
+ } catch (thrown) {
270
+ expect(thrown).toBeInstanceOf(OgygieFailure);
271
+ const failure = thrown as OgygieFailure;
272
+ expect(failure.error.code).toBe("IMPACT_UNKNOWN_FEATURE");
273
+ expect(failure.error.suggestedFix).toContain("orders-screen");
274
+ }
275
+ });
276
+ });
package/src/impact.ts ADDED
@@ -0,0 +1,384 @@
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
+ // So is anything that cannot touch a database. A front feature consumes
241
+ // contracts and a gateway relays — neither holds Drizzle — so the string
242
+ // a table name in their code is a query key or a route, not a table. Found
243
+ // on a real repo: widening the scan to other apps turned every
244
+ // `queryKey: ["orders", "list"]` into a boundary violation (15 of them in
245
+ // one report). A rule applied where its premise does not hold produces
246
+ // noise, and noise is how a report stops being read.
247
+ if (entry.appType === "front" || entry.appType === "gateway") continue;
248
+ for (const table of ownedTables) {
249
+ if (!content.includes(`"${table}"`)) continue;
250
+ tableReadersOutsideOwner.push({ table, feature: entry.name, file: filePath });
251
+ findings.push(
252
+ ogygieError({
253
+ code: "TABLE_REFERENCED_OUTSIDE_OWNER",
254
+ message: `Table "${table}" is owned by "${target.name}" but referenced by production code of "${entry.name}".`,
255
+ file: filePath,
256
+ cause: `"${table}" found in a non-test file outside its owner`,
257
+ suggestedFix: `Go through the public contracts of ${target.name} instead of touching the table — one table, one owner.`,
258
+ }),
259
+ );
260
+ }
261
+ }
262
+ }
263
+
264
+ findings.push(...manifestDependencyFindings(deps.manifest, target, dependents));
265
+
266
+ const feature = deps.manifest.features.find((candidate) => candidate.name === target.name);
267
+ const report = {
268
+ target: { name: target.name, kind: target.kind, folder: target.folder ?? "" },
269
+ dependents: [...dependents.entries()]
270
+ .map(([name, accumulator]) => ({
271
+ name,
272
+ kind: accumulator.kind,
273
+ importedSymbols: [...accumulator.importedSymbols].sort(),
274
+ viaIndexOnly: accumulator.viaIndexOnly,
275
+ }))
276
+ .sort((left, right) => (left.name < right.name ? -1 : 1)),
277
+ routes: (feature?.routes ?? []).map((route) => ({
278
+ method: route.method,
279
+ path: route.path,
280
+ description: route.description,
281
+ })),
282
+ ownedTables,
283
+ tableReadersOutsideOwner,
284
+ findings,
285
+ testCommand: testCommandFor(target, [...dependents.keys()], entries),
286
+ scope: {
287
+ scannedFolders: scannedFolders.sort(),
288
+ coveredKinds: [...new Set(entries.map((entry) => entry.kind))],
289
+ outOfReach: [...outOfReach, ...outOfReachOf(target, entries)],
290
+ },
291
+ };
292
+ return ImpactReportSchema.parse(report);
293
+ }
294
+
295
+ /** The import clause of a statement, for the symbols it carries. */
296
+ function clauseOf(statement: string): string {
297
+ const match = /\bimport\b([\s\S]*?)\bfrom\b/.exec(statement);
298
+ return match?.[1] ?? "";
299
+ }
300
+
301
+ function tablesOf(manifest: OgygieManifest, featureName: string): string[] {
302
+ const feature = manifest.features.find((candidate) => candidate.name === featureName);
303
+ return feature === undefined ? [] : dbTableNamesOf(feature);
304
+ }
305
+
306
+ /** What no analysis of this repo can reach, named rather than implied (D12). */
307
+ function outOfReachOf(target: MapEntry, entries: readonly MapEntry[]): string[] {
308
+ const limits = [
309
+ "code that no manifest entry declares — the map is the only chart",
310
+ "anything outside this repo: no network, no npm registry",
311
+ ];
312
+ // Said rather than silently applied: a table rule that skips some folders
313
+ // has to name them, or its silence reads as "nothing found there".
314
+ const spared = entries.filter(
315
+ (entry) => entry.appType === "front" || entry.appType === "gateway",
316
+ );
317
+ if (spared.length > 0 && target.kind === "feature") {
318
+ limits.push(
319
+ `table ownership was NOT checked in ${spared.length} front/gateway entr${spared.length === 1 ? "y" : "ies"} — that code holds no database client, so a table name there is a query key or a route`,
320
+ );
321
+ }
322
+ if (target.kind === "framework-package" || target.kind === "package") {
323
+ limits.unshift(
324
+ `consumers of "${target.name}" OUTSIDE this repo — it is a package, and a published package is imported by projects this analysis cannot see`,
325
+ );
326
+ }
327
+ return limits;
328
+ }
329
+
330
+ /** The map must not lie by omission, in either direction. */
331
+ function manifestDependencyFindings(
332
+ manifest: OgygieManifest,
333
+ target: MapEntry,
334
+ dependents: Map<string, DependentAccumulator>,
335
+ ): OgygieError[] {
336
+ const findings: OgygieError[] = [];
337
+ const declared = manifest.features.filter((feature) =>
338
+ feature.dependsOn.includes(target.name),
339
+ );
340
+ for (const feature of declared) {
341
+ if (!dependents.has(feature.name)) {
342
+ findings.push(
343
+ ogygieError({
344
+ code: "MANIFEST_DEPENDENCY_STALE",
345
+ message: `Feature "${feature.name}" declares dependsOn "${target.name}" but never imports it.`,
346
+ file: `ogygie.manifest.json#features.${feature.name}`,
347
+ cause: `dependsOn contains "${target.name}", no import of it found in its code`,
348
+ suggestedFix:
349
+ "Remove the stale dependsOn entry, or make the dependency real through the entry's index.ts.",
350
+ }),
351
+ );
352
+ }
353
+ }
354
+ for (const [name, accumulator] of dependents) {
355
+ if (accumulator.kind !== "feature") continue;
356
+ if (declared.some((feature) => feature.name === name)) continue;
357
+ findings.push(
358
+ ogygieError({
359
+ code: "MANIFEST_DEPENDENCY_UNDECLARED",
360
+ message: `Feature "${name}" imports "${target.name}" but does not declare it in dependsOn.`,
361
+ file: `ogygie.manifest.json#features.${name}`,
362
+ cause: `imports of "${target.name}" found in its code, absent from its dependsOn`,
363
+ suggestedFix: `Add "${target.name}" to the dependsOn of "${name}" — the manifest must never lie by omission.`,
364
+ }),
365
+ );
366
+ }
367
+ return findings;
368
+ }
369
+
370
+ /**
371
+ * The command that keeps every dependent green. It names FOLDERS, not a
372
+ * `features/` prefix: dependents now come from several apps and from
373
+ * packages, and a prefix that only fits api features would run nothing.
374
+ */
375
+ function testCommandFor(
376
+ target: MapEntry,
377
+ dependentNames: string[],
378
+ entries: MapEntry[],
379
+ ): string {
380
+ const folders = [target, ...entries.filter((entry) => dependentNames.includes(entry.name))]
381
+ .map((entry) => entry.folder)
382
+ .filter((folder): folder is string => folder !== null);
383
+ return ["pnpm test --", ...[...new Set(folders)].sort()].join(" ");
384
+ }
@@ -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>) {
@@ -144,3 +144,109 @@ describe("findSpecOwner — le package d'un framework confirme aussi", () => {
144
144
  });
145
145
 
146
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
+ {
238
+ kind: "feature",
239
+ name: "orders",
240
+ status: "delivered",
241
+ folder: null,
242
+ specId: "orders",
243
+ // No app carries it, so nothing can be said of what it may touch.
244
+ appType: null,
245
+ },
246
+ ]);
247
+ });
248
+
249
+ it("un manifeste minimal rend une liste VIDE — jamais une exception", () => {
250
+ expect(declaredEntries(withApps({}))).toEqual([]);
251
+ });
252
+ });
@@ -52,57 +52,110 @@ export interface SpecOwner {
52
52
  folder: string | null;
53
53
  }
54
54
 
55
+ /** One entry of the map, with everything a tool needs to go read its code. */
56
+ export interface MapEntry extends SpecOwner {
57
+ /** The spec this entry owns, or null when it owns none. */
58
+ specId: string | null;
59
+ /**
60
+ * Type of the app carrying this entry — null for a package, which lives
61
+ * under no app.
62
+ *
63
+ * It answers a question no tool can guess from a folder: WHAT this code is
64
+ * allowed to touch. A front feature never speaks to the database (it
65
+ * consumes contracts), a gateway only relays — so a rule about database
66
+ * tables has no business examining either.
67
+ */
68
+ appType: "api" | "front" | "gateway" | null;
69
+ }
70
+
55
71
  /**
56
- * The entry owning `specId`, feature first. Names are unique across the whole
57
- * map (the schema refuses a package named like a feature), so the order only
58
- * decides which lookup runs first, never which answer is right.
72
+ * EVERYTHING the map declares, each entry with its folder already resolved
73
+ * through the app that carries it.
74
+ *
75
+ * One resolution for the whole framework. The spec-lifecycle gate and the
76
+ * impact analyzer used to resolve folders on their own, and the analyzer got
77
+ * it wrong in the way that does not show: it picked ONE app —
78
+ * `apps.find(app => app.type === "api")` — and joined every feature's folder
79
+ * to that one, so a front feature was looked for under the api app and never
80
+ * found. It also refused to run at all when no api app existed. The map
81
+ * already answered per entry, through `feature.app`, which the schema REQUIRES
82
+ * a front or gateway feature to declare.
83
+ *
84
+ * A fourth nature of entry is therefore added HERE, once, and every tool sees
85
+ * it (FAILURES.md 2026-08-25: a verifier that ignores a category cannot report
86
+ * it).
59
87
  */
60
- export function findSpecOwner(
61
- manifest: OgygieManifest,
62
- specId: string,
63
- ): SpecOwner | undefined {
64
- const feature = manifest.features.find((candidate) => candidate.name === specId);
65
- if (feature !== undefined) {
88
+ export function declaredEntries(manifest: OgygieManifest): MapEntry[] {
89
+ const entries: MapEntry[] = [];
90
+
91
+ for (const feature of manifest.features) {
92
+ // The app that CARRIES this feature — never a globally chosen one.
66
93
  const owningApp =
67
94
  feature.app === undefined
68
95
  ? manifest.apps.find((app) => app.type === "api")
69
96
  : manifest.apps.find((app) => app.name === feature.app);
70
- return {
97
+ entries.push({
71
98
  kind: "feature",
72
99
  name: feature.name,
73
100
  status: feature.status,
74
101
  folder: owningApp === undefined ? null : `${owningApp.path}/${feature.folder}`,
75
- };
102
+ specId: feature.name,
103
+ appType: owningApp?.type ?? null,
104
+ });
76
105
  }
77
106
 
78
- const productPackage = (manifest.packages ?? []).find(
79
- (candidate) => candidate.name === specId,
80
- );
81
- if (productPackage !== undefined) {
82
- return {
107
+ for (const productPackage of manifest.packages ?? []) {
108
+ entries.push({
83
109
  kind: "package",
84
110
  name: productPackage.name,
85
111
  status: productPackage.status,
86
112
  folder: productPackage.path,
87
- };
113
+ specId: productPackage.name,
114
+ appType: null,
115
+ });
88
116
  }
89
117
 
90
- const frameworkPackages = (manifest.framework?.packages ?? []).filter(
91
- (candidate) => candidate.spec !== null && specIdOf(candidate.spec) === specId,
92
- );
93
- const leastAdvanced = frameworkPackages
94
- .slice()
95
- .sort((a, b) => ADVANCEMENT.indexOf(a.status) - ADVANCEMENT.indexOf(b.status))[0];
96
- if (leastAdvanced !== undefined) {
97
- return {
118
+ for (const frameworkPackage of manifest.framework?.packages ?? []) {
119
+ entries.push({
98
120
  kind: "framework-package",
99
- name: leastAdvanced.name,
100
- status: leastAdvanced.status,
101
- folder: leastAdvanced.path,
102
- };
121
+ name: frameworkPackage.name,
122
+ status: frameworkPackage.status,
123
+ folder: frameworkPackage.path,
124
+ // A published npm name is not a spec id: the link is DECLARED.
125
+ specId: frameworkPackage.spec === null ? null : specIdOf(frameworkPackage.spec),
126
+ appType: null,
127
+ });
103
128
  }
104
129
 
105
- return undefined;
130
+ return entries;
131
+ }
132
+
133
+ /**
134
+ * The entry owning `specId`, feature first. Names are unique across the whole
135
+ * map (the schema refuses a package named like a feature), so the order only
136
+ * decides which lookup runs first, never which answer is right.
137
+ */
138
+ export function findSpecOwner(
139
+ manifest: OgygieManifest,
140
+ specId: string,
141
+ ): SpecOwner | undefined {
142
+ const owning = declaredEntries(manifest).filter((entry) => entry.specId === specId);
143
+ if (owning.length === 0) return undefined;
144
+ // Feature first: names are unique across the map (the schema refuses a
145
+ // package named like a feature), so the order decides which lookup runs
146
+ // first, never which answer is right. Among several framework packages
147
+ // pointing at the SAME spec — a framework spec often spans the core and the
148
+ // CLI — the LEAST advanced answers: a spec is delivered when all its code is.
149
+ const byPriority = owning.slice().sort((left, right) => {
150
+ if (left.kind !== right.kind) {
151
+ const order: SpecOwnerKind[] = ["feature", "package", "framework-package"];
152
+ return order.indexOf(left.kind) - order.indexOf(right.kind);
153
+ }
154
+ return ADVANCEMENT.indexOf(left.status) - ADVANCEMENT.indexOf(right.status);
155
+ });
156
+ const owner = byPriority[0];
157
+ if (owner === undefined) return undefined;
158
+ return { kind: owner.kind, name: owner.name, status: owner.status, folder: owner.folder };
106
159
  }
107
160
 
108
161
  /** What a refusal should suggest — both natures, never one that cannot exist. */