@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,301 @@
1
+ /**
2
+ * @intent Session tool `pnpm ogygie:impact <feature>` (specs/ogygie-impact.spec.md,
3
+ * DECISIONS.md D21): prints the blast radius of a change to a delivered
4
+ * feature — who depends on it (declared AND actually imported symbols), its
5
+ * routes, its owned tables and any outside reader of them, plus the exact
6
+ * test command to keep every dependent green. Same family as verify-*:
7
+ * fs + textual import analysis, no new dependency, findings as OgygieError.
8
+ *
9
+ * Output contract (spec §États UI reconciled with §Comportements): default
10
+ * prints the JSON report THEN the human summary; `--json` prints the JSON
11
+ * report only. Exit 0 when findings are empty, 1 otherwise.
12
+ *
13
+ * Deliberate scope choices:
14
+ * - Depth 1 only (direct dependents) — spec §Non-objectifs.
15
+ * - The owned-tables scan ignores *.test.ts files: seeding ANOTHER feature's
16
+ * table as a fixture is the canonical bench pattern ("the socle feature owns
17
+ * that model" — see any service.test.ts); flagging it would make every run
18
+ * red and the tool useless. Production code referencing a foreign table
19
+ * remains a potential violation and IS flagged.
20
+ */
21
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { z } from "zod";
24
+ import { OgygieManifestSchema } from "@ogygie/core/manifest";
25
+ import { dbTableNamesOf } from "@ogygie/core/shared-tables";
26
+ import { OgygieErrorSchema, ogygieError, type OgygieError } from "@ogygie/core/errors";
27
+
28
+ const ROOT = new URL("..", import.meta.url).pathname;
29
+ const MANIFEST_PATH = "ogygie.manifest.json";
30
+
31
+ // ---------------------------------------------------------------- contract
32
+ /** In @ogygie/core once the pattern proves generic (spec §Contrats, D10 rule 3). */
33
+ export const ImpactReportSchema = z.object({
34
+ feature: z.string(),
35
+ dependents: z.array(
36
+ z.object({
37
+ name: z.string(),
38
+ importedSymbols: z.array(z.string()),
39
+ viaIndexOnly: z.boolean(),
40
+ }),
41
+ ),
42
+ routes: z.array(
43
+ z.object({ method: z.string(), path: z.string(), description: z.string() }),
44
+ ),
45
+ ownedTables: z.array(z.string()),
46
+ tableReadersOutsideOwner: z.array(
47
+ z.object({ table: z.string(), feature: z.string(), file: z.string() }),
48
+ ),
49
+ findings: z.array(OgygieErrorSchema),
50
+ testCommand: z.string(),
51
+ });
52
+ export type ImpactReport = z.infer<typeof ImpactReportSchema>;
53
+
54
+ // ---------------------------------------------------------------- helpers
55
+ function walkTsFiles(dir: string): string[] {
56
+ const files: string[] = [];
57
+ for (const entry of readdirSync(dir)) {
58
+ const fullPath = join(dir, entry);
59
+ if (statSync(fullPath).isDirectory()) files.push(...walkTsFiles(fullPath));
60
+ else if (entry.endsWith(".ts") || entry.endsWith(".tsx")) files.push(fullPath);
61
+ }
62
+ return files;
63
+ }
64
+
65
+ /** Named/default/namespace symbols of one import clause ("type" stripped). */
66
+ function symbolsOfImportClause(clause: string): string[] {
67
+ const symbols: string[] = [];
68
+ const braces = /\{([^}]*)\}/.exec(clause);
69
+ if (braces?.[1] !== undefined) {
70
+ for (const rawEntry of braces[1].split(",")) {
71
+ const name = rawEntry.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim();
72
+ if (name !== undefined && name !== "") symbols.push(name);
73
+ }
74
+ }
75
+ const outsideBraces = clause.replace(/\{[^}]*\}/, "").replace(/^type\s+/, "").trim();
76
+ if (/\*\s*as\s+/.test(outsideBraces)) symbols.push("*");
77
+ else if (outsideBraces.replace(/,/g, "").trim() !== "") symbols.push("default");
78
+ return symbols;
79
+ }
80
+
81
+ // ---------------------------------------------------------------- report
82
+ export function buildImpactReport(featureName: string): ImpactReport {
83
+ const manifest = OgygieManifestSchema.parse(
84
+ JSON.parse(readFileSync(join(ROOT, MANIFEST_PATH), "utf8")),
85
+ );
86
+ const apiApp = manifest.apps.find((app) => app.type === "api");
87
+ if (apiApp === undefined) {
88
+ throw new Error("no app of type api in the manifest");
89
+ }
90
+ const target = manifest.features.find((feature) => feature.name === featureName);
91
+ if (target === undefined) {
92
+ const failure = ogygieError({
93
+ code: "IMPACT_UNKNOWN_FEATURE",
94
+ message: `Feature "${featureName}" is unknown to the manifest. Valid features: ${manifest.features.map((feature) => feature.name).join(", ")}.`,
95
+ file: MANIFEST_PATH,
96
+ cause: `no features[] entry has name="${featureName}"`,
97
+ suggestedFix: "Pick one of the valid feature names listed in the message.",
98
+ });
99
+ console.error(JSON.stringify(failure, null, 2));
100
+ console.error("\nogygie:impact — feature inconnue.");
101
+ process.exit(1);
102
+ }
103
+
104
+ const findings: OgygieError[] = [];
105
+ const targetDirName = target.folder.split("/").pop() ?? target.folder;
106
+
107
+ // (a)+(b) dependents: declared in the manifest AND/OR actually importing.
108
+ const importersByFeature = new Map<
109
+ string,
110
+ { importedSymbols: Set<string>; viaIndexOnly: boolean }
111
+ >();
112
+ for (const feature of manifest.features) {
113
+ if (feature.name === target.name) continue;
114
+ const featureDir = join(ROOT, apiApp.path, feature.folder);
115
+ if (!existsSync(featureDir)) continue;
116
+ for (const filePath of walkTsFiles(featureDir)) {
117
+ const content = readFileSync(filePath, "utf8");
118
+ const relativeFile = filePath.slice(ROOT.length);
119
+ for (const match of content.matchAll(
120
+ /import\s+([^'"]+?)\s+from\s+"([^"]+)"/g,
121
+ )) {
122
+ const clause = match[1];
123
+ const source = match[2];
124
+ if (clause === undefined || source === undefined) continue;
125
+ const sibling = new RegExp(`^\\.\\./${targetDirName}/(.+)$`).exec(source);
126
+ if (sibling === null || sibling[1] === undefined) continue;
127
+ const entry = importersByFeature.get(feature.name) ?? {
128
+ importedSymbols: new Set<string>(),
129
+ viaIndexOnly: true,
130
+ };
131
+ for (const symbol of symbolsOfImportClause(clause)) {
132
+ entry.importedSymbols.add(symbol);
133
+ }
134
+ if (sibling[1] !== "index.js") {
135
+ entry.viaIndexOnly = false;
136
+ findings.push(
137
+ ogygieError({
138
+ code: "FEATURE_BOUNDARY_VIOLATION",
139
+ message: `Feature "${feature.name}" imports an internal file of "${target.name}" instead of its index.ts.`,
140
+ file: relativeFile,
141
+ cause: `import from "${source}" bypasses ${targetDirName}/index.ts`,
142
+ suggestedFix: `Export the needed symbols from ${targetDirName}/index.ts and import them from "../${targetDirName}/index.js".`,
143
+ }),
144
+ );
145
+ }
146
+ importersByFeature.set(feature.name, entry);
147
+ }
148
+ }
149
+ }
150
+
151
+ const declaredDependents = manifest.features.filter((feature) =>
152
+ feature.dependsOn.includes(target.name),
153
+ );
154
+ for (const declared of declaredDependents) {
155
+ if (!importersByFeature.has(declared.name)) {
156
+ findings.push(
157
+ ogygieError({
158
+ code: "MANIFEST_DEPENDENCY_STALE",
159
+ message: `Feature "${declared.name}" declares dependsOn "${target.name}" but never imports it.`,
160
+ file: `${MANIFEST_PATH}#features.${declared.name}`,
161
+ cause: `dependsOn contains "${target.name}", no import of ../${targetDirName}/ found in its code`,
162
+ suggestedFix:
163
+ "Remove the stale dependsOn entry, or make the dependency real through the feature's index.ts.",
164
+ }),
165
+ );
166
+ }
167
+ }
168
+ for (const importerName of importersByFeature.keys()) {
169
+ if (!declaredDependents.some((declared) => declared.name === importerName)) {
170
+ findings.push(
171
+ ogygieError({
172
+ code: "MANIFEST_DEPENDENCY_UNDECLARED",
173
+ message: `Feature "${importerName}" imports "${target.name}" but does not declare it in dependsOn.`,
174
+ file: `${MANIFEST_PATH}#features.${importerName}`,
175
+ cause: `imports of ../${targetDirName}/ found in its code, "${target.name}" absent from its dependsOn`,
176
+ suggestedFix: `Add "${target.name}" to the dependsOn of "${importerName}" — the manifest must never lie by omission.`,
177
+ }),
178
+ );
179
+ }
180
+ }
181
+
182
+ const dependentNames = [
183
+ ...new Set([
184
+ ...declaredDependents.map((feature) => feature.name),
185
+ ...importersByFeature.keys(),
186
+ ]),
187
+ ].sort();
188
+ const dependents = dependentNames.map((name) => {
189
+ const imports = importersByFeature.get(name);
190
+ return {
191
+ name,
192
+ importedSymbols: [...(imports?.importedSymbols ?? [])].sort(),
193
+ viaIndexOnly: imports?.viaIndexOnly ?? true,
194
+ };
195
+ });
196
+
197
+ // (d) owned tables referenced by another feature's PRODUCTION code.
198
+ const tableReadersOutsideOwner: ImpactReport["tableReadersOutsideOwner"] = [];
199
+ for (const feature of manifest.features) {
200
+ if (feature.name === target.name) continue;
201
+ const featureDir = join(ROOT, apiApp.path, feature.folder);
202
+ if (!existsSync(featureDir)) continue;
203
+ for (const filePath of walkTsFiles(featureDir)) {
204
+ if (filePath.endsWith(".test.ts") || filePath.endsWith(".test.tsx")) continue;
205
+ const content = readFileSync(filePath, "utf8");
206
+ const relativeFile = filePath.slice(ROOT.length);
207
+ for (const table of dbTableNamesOf(target)) {
208
+ if (!content.includes(`"${table}"`)) continue;
209
+ tableReadersOutsideOwner.push({
210
+ table,
211
+ feature: feature.name,
212
+ file: relativeFile,
213
+ });
214
+ findings.push(
215
+ ogygieError({
216
+ code: "TABLE_REFERENCED_OUTSIDE_OWNER",
217
+ message: `Table "${table}" is owned by "${target.name}" but referenced by production code of "${feature.name}".`,
218
+ file: relativeFile,
219
+ cause: `"${table}" found in a non-test file outside its owner feature`,
220
+ suggestedFix: `Go through the public contracts of ${targetDirName}/index.ts instead of touching the table — one table = one owner.`,
221
+ }),
222
+ );
223
+ }
224
+ }
225
+ }
226
+
227
+ const dependentDirNames = dependentNames.map((name) => {
228
+ const feature = manifest.features.find((candidate) => candidate.name === name);
229
+ return feature?.folder.split("/").pop() ?? name;
230
+ });
231
+ const testCommand = ["pnpm test --", `features/${targetDirName}`, ...dependentDirNames.map((dirName) => `features/${dirName}`)].join(" ");
232
+
233
+ return ImpactReportSchema.parse({
234
+ feature: target.name,
235
+ dependents,
236
+ routes: target.routes.map((route) => ({
237
+ method: route.method,
238
+ path: route.path,
239
+ description: route.description,
240
+ })),
241
+ ownedTables: dbTableNamesOf(target),
242
+ tableReadersOutsideOwner,
243
+ findings,
244
+ testCommand,
245
+ });
246
+ }
247
+
248
+ // ---------------------------------------------------------------- CLI
249
+ function printSummary(report: ImpactReport): void {
250
+ console.log(`\n── ogygie:impact ${report.feature} ──`);
251
+ if (report.dependents.length === 0) {
252
+ console.log("Dépendants : aucun.");
253
+ } else {
254
+ console.log(`Dépendants (${report.dependents.length}) :`);
255
+ for (const dependent of report.dependents) {
256
+ const symbols =
257
+ dependent.importedSymbols.length > 0
258
+ ? `importe { ${dependent.importedSymbols.join(", ")} }`
259
+ : "n'importe aucun symbole";
260
+ const boundary = dependent.viaIndexOnly ? "via index.ts uniquement" : "⚠ contourne index.ts";
261
+ console.log(` • ${dependent.name} — ${symbols} — ${boundary}`);
262
+ }
263
+ }
264
+ const routes = report.routes.map((route) => `${route.method} ${route.path}`).join(" · ");
265
+ console.log(`Routes exposées (${report.routes.length}) : ${routes === "" ? "aucune" : routes}`);
266
+ console.log(
267
+ `Tables possédées : ${report.ownedTables.length === 0 ? "aucune" : report.ownedTables.join(", ")}`,
268
+ );
269
+ if (report.tableReadersOutsideOwner.length > 0) {
270
+ for (const reader of report.tableReadersOutsideOwner) {
271
+ console.log(` ⚠ "${reader.table}" référencée par ${reader.feature} (${reader.file})`);
272
+ }
273
+ }
274
+ console.log(
275
+ report.findings.length === 0
276
+ ? "Findings : aucun."
277
+ : `Findings : ${report.findings.length} (détail dans le JSON ci-dessus).`,
278
+ );
279
+ console.log(`Tests à relancer : ${report.testCommand}\n`);
280
+ }
281
+
282
+ const cliArguments = process.argv.slice(2);
283
+ const jsonOnly = cliArguments.includes("--json");
284
+ const featureArgument = cliArguments.find((argument) => !argument.startsWith("--"));
285
+
286
+ if (featureArgument === undefined) {
287
+ const failure = ogygieError({
288
+ code: "IMPACT_UNKNOWN_FEATURE",
289
+ message: "No feature name given. Usage: pnpm ogygie:impact <feature> [--json].",
290
+ file: null,
291
+ cause: "process.argv carries no non-flag argument",
292
+ suggestedFix: "Pass a feature name from ogygie.manifest.json, e.g. `pnpm ogygie:impact cultures`.",
293
+ });
294
+ console.error(JSON.stringify(failure, null, 2));
295
+ process.exit(1);
296
+ }
297
+
298
+ const report = buildImpactReport(featureArgument);
299
+ console.log(JSON.stringify(report, null, 2));
300
+ if (!jsonOnly) printSummary(report);
301
+ process.exit(report.findings.length === 0 ? 0 : 1);
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @intent CI gate `pnpm ogygie:verify-contracts` (DECISIONS.md D29): from every
3
+ * feature's `schemas.ts`, walk the WHOLE import graph and prove that nothing
4
+ * with a runtime effect is reachable — the transitivity is the point, since
5
+ * that is how the server came back into the browser bundle
6
+ * (`cultures/schemas.ts` → `especes-eppo/index.ts` → `seed.ts` → `node:fs`,
7
+ * FAILURES.md).
8
+ *
9
+ * The decision logic lives in @ogygie/core/contract-purity, born with its
10
+ * mutation tests (D22) — this script only reads the manifest, the disk, and
11
+ * reports as OgygieError so an agent can fix without guessing.
12
+ */
13
+ import { existsSync, readFileSync, statSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { OgygieManifestSchema } from "@ogygie/core/manifest";
17
+ import {
18
+ auditContractPurity,
19
+ type ContractImpurity,
20
+ type ContractImpurityKind,
21
+ } from "@ogygie/core/contract-purity";
22
+ import { ogygieError } from "@ogygie/core/errors";
23
+
24
+ const ROOT = fileURLToPath(new URL("..", import.meta.url));
25
+ const MANIFEST_PATH = "ogygie.manifest.json";
26
+
27
+ /** One stable error code per rule broken (D11 — machine-readable = English). */
28
+ const ERROR_CODES: Record<ContractImpurityKind, string> = {
29
+ RUNTIME_PACKAGE: "CONTRACT_IMPURE_PACKAGE",
30
+ SERVER_SURFACE: "CONTRACT_REACHES_SERVER_SURFACE",
31
+ MODULE_NOT_FOUND: "CONTRACT_MODULE_NOT_FOUND",
32
+ };
33
+
34
+ const SUGGESTED_FIXES: Record<ContractImpurityKind, string> = {
35
+ RUNTIME_PACKAGE:
36
+ "Move what needs this package to service.ts/repository.ts, or make the import type-only — a contract is imported by apps/web.",
37
+ SERVER_SURFACE:
38
+ "Import the other feature's schemas.ts, never its index.ts (AGENTS.md §Structure, bounded exception).",
39
+ MODULE_NOT_FOUND:
40
+ "Fix the import path or create the missing module: the graph of a contract is audited whole, never partially.",
41
+ };
42
+
43
+ const manifest = OgygieManifestSchema.parse(
44
+ JSON.parse(readFileSync(join(ROOT, MANIFEST_PATH), "utf8")),
45
+ );
46
+
47
+ const report = auditContractPurity({
48
+ manifest,
49
+ readModule: (path) => {
50
+ const absolutePath = join(ROOT, path);
51
+ if (!existsSync(absolutePath) || !statSync(absolutePath).isFile()) return null;
52
+ return readFileSync(absolutePath, "utf8");
53
+ },
54
+ });
55
+
56
+ function describe(impurity: ContractImpurity): string {
57
+ const chain = impurity.chain.join("\n → ");
58
+ return `${impurity.rule}\n imported as: ${impurity.statement}\n chain: ${chain}`;
59
+ }
60
+
61
+ for (const impurity of report.impurities) {
62
+ console.error(
63
+ JSON.stringify(
64
+ ogygieError({
65
+ code: ERROR_CODES[impurity.kind],
66
+ message: `Contract ${impurity.entryPath} reaches a module it must not: ${impurity.specifier}.`,
67
+ file: impurity.chain.at(-1) ?? impurity.entryPath,
68
+ cause: describe(impurity),
69
+ suggestedFix: SUGGESTED_FIXES[impurity.kind],
70
+ }),
71
+ null,
72
+ 2,
73
+ ),
74
+ );
75
+ }
76
+
77
+ if (report.impurities.length > 0) {
78
+ console.error(
79
+ `\nogygie:verify-contracts — ${report.impurities.length} impureté(s) sur ${report.entryPaths.length} contrat(s).`,
80
+ );
81
+ process.exit(1);
82
+ }
83
+
84
+ console.log(
85
+ `ogygie:verify-contracts — OK, ${report.entryPaths.length} contrats purs (${report.reachedPaths.length} modules atteints).`,
86
+ );
@@ -0,0 +1,306 @@
1
+ /**
2
+ * @intent CI gate `pnpm ogygie:verify-manifest`: proves that ogygie.manifest.json
3
+ * tells the truth about the actual code. A lying manifest must break the build
4
+ * faster than a red test. Every finding is printed as an OgygieError (JSON) so
5
+ * an agent can fix it without guessing.
6
+ *
7
+ * Checks, per feature:
8
+ * - the manifest parses against OgygieManifestSchema;
9
+ * - the spec file exists (when declared);
10
+ * - `dependsOn` only references features that exist in the manifest;
11
+ * - for non-"skeleton" features: the folder exists with the canonical files,
12
+ * every route's input/output schema is exported from schemas.ts, every route
13
+ * path appears in routes.ts, every owned table appears in db.schema.ts.
14
+ *
15
+ * And in the REVERSE direction (the most likely lie is the omission): every
16
+ * `url: "/api/..."` found in a feature's routes.ts and every `pgTable("...")`
17
+ * found in a feature's db.schema.ts must be declared in the manifest
18
+ * (MANIFEST_ROUTE_UNDECLARED / MANIFEST_TABLE_UNDECLARED).
19
+ */
20
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { OgygieManifestSchema } from "@ogygie/core/manifest";
23
+ import {
24
+ dbTableNamesOf,
25
+ findTenantColumnsInSharedTables,
26
+ sharedTableNamesOf,
27
+ } from "@ogygie/core/shared-tables";
28
+ import { ogygieError, type OgygieError } from "@ogygie/core/errors";
29
+
30
+ /**
31
+ * Tenant column names of the PRODUCT (the core stays generic). A table
32
+ * declared `shared: true` must carry none of them.
33
+ * TODO(product): declare your tenant column here (the SQL name announced in
34
+ * AGENTS.product.md, e.g. "workspace_id") — empty, this check guards nothing.
35
+ */
36
+ const TENANT_COLUMN_NAMES: readonly string[] = [];
37
+
38
+ const ROOT = new URL("..", import.meta.url).pathname;
39
+ const MANIFEST_PATH = "ogygie.manifest.json";
40
+ const problems: OgygieError[] = [];
41
+
42
+ function report(input: Parameters<typeof ogygieError>[0]): void {
43
+ problems.push(ogygieError(input));
44
+ }
45
+
46
+ function conclude(): never {
47
+ if (problems.length > 0) {
48
+ for (const problem of problems) {
49
+ console.error(JSON.stringify(problem, null, 2));
50
+ }
51
+ console.error(`\nogygie:verify-manifest — ${problems.length} problème(s).`);
52
+ process.exit(1);
53
+ }
54
+ console.log("ogygie:verify-manifest — OK, le manifeste dit la vérité.");
55
+ process.exit(0);
56
+ }
57
+
58
+ const rawManifest: unknown = JSON.parse(readFileSync(join(ROOT, MANIFEST_PATH), "utf8"));
59
+ const parsed = OgygieManifestSchema.safeParse(rawManifest);
60
+ if (!parsed.success) {
61
+ report({
62
+ code: "MANIFEST_INVALID",
63
+ message: "ogygie.manifest.json does not satisfy OgygieManifestSchema.",
64
+ file: MANIFEST_PATH,
65
+ cause: parsed.error.issues
66
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
67
+ .join(" ; "),
68
+ suggestedFix:
69
+ "Fix the listed fields to match packages/ogygie-core/src/manifest.schema.ts.",
70
+ });
71
+ conclude();
72
+ }
73
+ const manifest = parsed.data;
74
+
75
+ for (const app of manifest.apps) {
76
+ if (!existsSync(join(ROOT, app.path))) {
77
+ report({
78
+ code: "MANIFEST_APP_NOT_FOUND",
79
+ message: `App "${app.name}" declares folder ${app.path} which does not exist.`,
80
+ file: MANIFEST_PATH,
81
+ cause: `apps[].path=${app.path} absent from disk`,
82
+ suggestedFix: "Create the folder or fix the manifest entry.",
83
+ });
84
+ }
85
+ }
86
+
87
+ const apiApp = manifest.apps.find((app) => app.type === "api");
88
+ const featureNames = new Set(manifest.features.map((feature) => feature.name));
89
+
90
+ for (const feature of manifest.features) {
91
+ const manifestRef = `${MANIFEST_PATH}#features.${feature.name}`;
92
+
93
+ if (feature.spec !== null && !existsSync(join(ROOT, feature.spec))) {
94
+ report({
95
+ code: "MANIFEST_SPEC_NOT_FOUND",
96
+ message: `Feature "${feature.name}" declares spec ${feature.spec} which does not exist.`,
97
+ file: manifestRef,
98
+ cause: `spec=${feature.spec} absent from disk`,
99
+ suggestedFix: "Create the spec file or fix the manifest entry.",
100
+ });
101
+ }
102
+
103
+ for (const dependency of feature.dependsOn) {
104
+ if (!featureNames.has(dependency)) {
105
+ report({
106
+ code: "MANIFEST_UNKNOWN_DEPENDENCY",
107
+ message: `Feature "${feature.name}" depends on "${dependency}" which is not in the manifest.`,
108
+ file: manifestRef,
109
+ cause: `dependsOn contains "${dependency}"`,
110
+ suggestedFix: "Declare the missing feature in the manifest or remove the dependency.",
111
+ });
112
+ }
113
+ }
114
+
115
+ // A "skeleton" feature has no code yet — nothing more to check.
116
+ if (feature.status === "skeleton") continue;
117
+
118
+ // Non-api feature (front or gateway — manifest schema guarantees
119
+ // routes/dbTables are empty): its truth check is the folder's existence
120
+ // under ITS app — the canonical api files (routes.ts, repository.ts…) do
121
+ // not apply to a front or gateway folder.
122
+ const owningApp =
123
+ feature.app === undefined
124
+ ? apiApp
125
+ : manifest.apps.find((app) => app.name === feature.app);
126
+ if (owningApp !== undefined && owningApp.type !== "api") {
127
+ if (!existsSync(join(ROOT, owningApp.path, feature.folder))) {
128
+ report({
129
+ code: "MANIFEST_FOLDER_NOT_FOUND",
130
+ message: `Feature "${feature.name}" declares folder ${feature.folder} which does not exist under ${owningApp.path}.`,
131
+ file: manifestRef,
132
+ cause: `${join(owningApp.path, feature.folder)} absent from disk`,
133
+ suggestedFix: "Create the feature folder or fix `folder` in the manifest.",
134
+ });
135
+ }
136
+ continue;
137
+ }
138
+
139
+ if (apiApp === undefined) {
140
+ report({
141
+ code: "MANIFEST_API_APP_MISSING",
142
+ message: 'No app of type "api" in the manifest, cannot locate feature folders.',
143
+ file: MANIFEST_PATH,
144
+ cause: "apps[] has no entry with type=api",
145
+ suggestedFix: "Declare the api app in the manifest.",
146
+ });
147
+ break;
148
+ }
149
+
150
+ const featureDir = join(ROOT, apiApp.path, feature.folder);
151
+ if (!existsSync(featureDir)) {
152
+ report({
153
+ code: "MANIFEST_FOLDER_NOT_FOUND",
154
+ message: `Feature "${feature.name}" declares folder ${feature.folder} which does not exist under ${apiApp.path}.`,
155
+ file: manifestRef,
156
+ cause: `${join(apiApp.path, feature.folder)} absent from disk`,
157
+ suggestedFix: "Create the feature folder or fix `folder` in the manifest.",
158
+ });
159
+ continue;
160
+ }
161
+
162
+ const canonicalFiles = ["index.ts", "routes.ts", "schemas.ts", "service.ts", "repository.ts"];
163
+ for (const fileName of canonicalFiles) {
164
+ if (!existsSync(join(featureDir, fileName))) {
165
+ report({
166
+ code: "FEATURE_CANONICAL_FILE_MISSING",
167
+ message: `Feature "${feature.name}" is missing canonical file ${fileName}.`,
168
+ file: join(apiApp.path, feature.folder, fileName),
169
+ cause: `${fileName} absent (AGENTS.md §Structure d'une feature)`,
170
+ suggestedFix: "Create the file following the canonical template in AGENTS.md.",
171
+ });
172
+ }
173
+ }
174
+
175
+ const schemasPath = join(featureDir, "schemas.ts");
176
+ const routesPath = join(featureDir, "routes.ts");
177
+ const schemasContent = existsSync(schemasPath) ? readFileSync(schemasPath, "utf8") : "";
178
+ const routesContent = existsSync(routesPath) ? readFileSync(routesPath, "utf8") : "";
179
+
180
+ for (const route of feature.routes) {
181
+ for (const schemaName of [route.schemaInput, route.schemaOutput]) {
182
+ if (!schemasContent.includes(`export const ${schemaName}`)) {
183
+ report({
184
+ code: "MANIFEST_SCHEMA_NOT_EXPORTED",
185
+ message: `Route ${route.method} ${route.path} declares schema ${schemaName}, not exported by schemas.ts.`,
186
+ file: join(apiApp.path, feature.folder, "schemas.ts"),
187
+ cause: `\`export const ${schemaName}\` not found`,
188
+ suggestedFix:
189
+ "Export the schema from schemas.ts or fix schemaInput/schemaOutput in the manifest.",
190
+ });
191
+ }
192
+ }
193
+ if (!routesContent.includes(`"${route.path}"`)) {
194
+ report({
195
+ code: "MANIFEST_ROUTE_NOT_FOUND",
196
+ message: `Route ${route.method} ${route.path} is declared in the manifest but absent from routes.ts.`,
197
+ file: join(apiApp.path, feature.folder, "routes.ts"),
198
+ cause: `"${route.path}" not found in routes.ts`,
199
+ suggestedFix: "Register the route in routes.ts or remove it from the manifest.",
200
+ });
201
+ }
202
+ }
203
+
204
+ if (feature.dbTables.length > 0) {
205
+ const dbSchemaPath = join(featureDir, "db.schema.ts");
206
+ const dbSchemaContent = existsSync(dbSchemaPath) ? readFileSync(dbSchemaPath, "utf8") : "";
207
+ for (const table of dbTableNamesOf(feature)) {
208
+ const tableDefinition = new RegExp(`pgTable\\(\\s*"${table}"`);
209
+ if (!tableDefinition.test(dbSchemaContent)) {
210
+ report({
211
+ code: "MANIFEST_TABLE_NOT_FOUND",
212
+ message: `Feature "${feature.name}" declares table "${table}" but db.schema.ts does not define it.`,
213
+ file: join(apiApp.path, feature.folder, "db.schema.ts"),
214
+ cause: `pgTable("${table}") not found`,
215
+ suggestedFix: "Define the table in db.schema.ts or remove it from dbTables.",
216
+ });
217
+ }
218
+ }
219
+
220
+ // A table declared `shared: true` escapes tenantDb AND the tenant RLS
221
+ // policy (AGENTS.md §Multi-tenant rule 4). If it still carries a tenant
222
+ // column, the declaration and the shape contradict each other — and an
223
+ // audited exception silently becomes an unguarded table.
224
+ for (const finding of findTenantColumnsInSharedTables(
225
+ dbSchemaContent,
226
+ sharedTableNamesOf(feature),
227
+ TENANT_COLUMN_NAMES,
228
+ )) {
229
+ report({
230
+ code: "SHARED_TABLE_WITH_TENANT_COLUMN",
231
+ message: `Table "${finding.table}" is declared shared in the manifest but its Drizzle definition still carries "${finding.tenantColumn}".`,
232
+ file: join(apiApp.path, feature.folder, "db.schema.ts"),
233
+ cause: `pgTable("${finding.table}") declares column "${finding.tenantColumn}" while the manifest says shared: true`,
234
+ suggestedFix:
235
+ "Either drop the tenant column (the table is a shared referential: no tenant owns it, no RLS tenant policy), or remove `shared: true` and go back through tenantDb.",
236
+ });
237
+ }
238
+ }
239
+ }
240
+
241
+ // Reverse direction: everything the code exposes must be declared in the
242
+ // manifest. Scans every folder under src/features/, including folders that
243
+ // have no manifest entry at all.
244
+ if (apiApp !== undefined) {
245
+ const featuresRoot = join(ROOT, apiApp.path, "src", "features");
246
+ const featureDirNames = existsSync(featuresRoot)
247
+ ? readdirSync(featuresRoot).filter((entry) =>
248
+ statSync(join(featuresRoot, entry)).isDirectory(),
249
+ )
250
+ : [];
251
+
252
+ for (const dirName of featureDirNames) {
253
+ const relativeFolder = `src/features/${dirName}`;
254
+ const manifestFeature = manifest.features.find(
255
+ (feature) => feature.folder === relativeFolder,
256
+ );
257
+ const declaredPaths = new Set(
258
+ manifestFeature?.routes.map((route) => route.path) ?? [],
259
+ );
260
+ const declaredTables = new Set(
261
+ manifestFeature === undefined ? [] : dbTableNamesOf(manifestFeature),
262
+ );
263
+ const missingEntryCause =
264
+ manifestFeature === undefined
265
+ ? ` (no manifest entry has folder=${relativeFolder})`
266
+ : "";
267
+
268
+ const routesPath = join(featuresRoot, dirName, "routes.ts");
269
+ if (existsSync(routesPath)) {
270
+ const routesContent = readFileSync(routesPath, "utf8");
271
+ for (const match of routesContent.matchAll(/url:\s*"(\/api\/[^"]*)"/g)) {
272
+ const routePath = match[1];
273
+ if (routePath !== undefined && !declaredPaths.has(routePath)) {
274
+ report({
275
+ code: "MANIFEST_ROUTE_UNDECLARED",
276
+ message: `Route ${routePath} is registered in ${relativeFolder}/routes.ts but not declared in the manifest.`,
277
+ file: join(apiApp.path, relativeFolder, "routes.ts"),
278
+ cause: `url: "${routePath}" found in code, absent from the manifest${missingEntryCause}`,
279
+ suggestedFix:
280
+ "Declare the route (method, path, schemas) in ogygie.manifest.json — the manifest must never lie by omission.",
281
+ });
282
+ }
283
+ }
284
+ }
285
+
286
+ const dbSchemaPath = join(featuresRoot, dirName, "db.schema.ts");
287
+ if (existsSync(dbSchemaPath)) {
288
+ const dbSchemaContent = readFileSync(dbSchemaPath, "utf8");
289
+ for (const match of dbSchemaContent.matchAll(/pgTable\(\s*"([^"]+)"/g)) {
290
+ const tableName = match[1];
291
+ if (tableName !== undefined && !declaredTables.has(tableName)) {
292
+ report({
293
+ code: "MANIFEST_TABLE_UNDECLARED",
294
+ message: `Table "${tableName}" is defined in ${relativeFolder}/db.schema.ts but not declared in the manifest.`,
295
+ file: join(apiApp.path, relativeFolder, "db.schema.ts"),
296
+ cause: `pgTable("${tableName}") found in code, absent from dbTables${missingEntryCause}`,
297
+ suggestedFix:
298
+ "Add the table to the feature's dbTables in ogygie.manifest.json (one table = one owner).",
299
+ });
300
+ }
301
+ }
302
+ }
303
+ }
304
+ }
305
+
306
+ conclude();