@ogygie/cli 0.14.0 → 0.16.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/dist/cli.js CHANGED
@@ -15200,6 +15200,50 @@ function checkProject(deps) {
15200
15200
  return problems;
15201
15201
  }
15202
15202
 
15203
+ // src/commands/generate.ts
15204
+ import { createRequire } from "node:module";
15205
+ import { join as join5 } from "node:path";
15206
+ import { pathToFileURL } from "node:url";
15207
+ var GENERATE_CLI_ENTRY = "@ogygie/generate/cli";
15208
+ var GenerateNotResolved = class extends Error {
15209
+ };
15210
+ async function loadGenerateFrom(projectRoot) {
15211
+ const resolveFromProject = createRequire(join5(projectRoot, "package.json"));
15212
+ let entryPoint;
15213
+ try {
15214
+ entryPoint = resolveFromProject.resolve(GENERATE_CLI_ENTRY);
15215
+ } catch (thrown) {
15216
+ throw new GenerateNotResolved(thrown instanceof Error ? thrown.message : String(thrown));
15217
+ }
15218
+ return await import(pathToFileURL(entryPoint).href);
15219
+ }
15220
+ async function runGenerate(deps) {
15221
+ const importGenerate = deps.importGenerate ?? (() => loadGenerateFrom(deps.projectRoot));
15222
+ let generator;
15223
+ try {
15224
+ generator = await importGenerate();
15225
+ } catch (thrown) {
15226
+ const reason = thrown instanceof Error ? thrown.message : String(thrown);
15227
+ if (thrown instanceof GenerateNotResolved) {
15228
+ throwOgygieError({
15229
+ code: "GENERATE_NOT_INSTALLED",
15230
+ message: "The Ogygie generator is not installed in this project.",
15231
+ file: "package.json",
15232
+ cause: `${GENERATE_CLI_ENTRY} cannot be resolved: ${reason}`,
15233
+ suggestedFix: `The generator is experimental (spec ogygie-generate): until its promotion it lives in the framework repo as packages/ogygie-generate \u2014 add it to the workspace as @ogygie/generate (workspace:*) there, or wait for the published package at the framework's version (${deps.cliVersion}, lockstep D2).`
15234
+ });
15235
+ }
15236
+ throwOgygieError({
15237
+ code: "GENERATE_LOAD_FAILED",
15238
+ message: "The Ogygie generator is installed but could not be loaded.",
15239
+ file: "package.json",
15240
+ cause: reason,
15241
+ suggestedFix: "Reinstalling will not help. The package ships as TypeScript source until its promotion: run the command through a TypeScript host (`pnpm ogygie:generate`, `pnpm ogygie:verify-generation`, or `pnpm exec tsx` on the CLI entry), and check that @ogygie/generate matches @ogygie/cli (lockstep D2)."
15242
+ });
15243
+ }
15244
+ return generator.runGenerateCommand({ projectRoot: deps.projectRoot, argv: deps.argv, log: deps.log, error: deps.error });
15245
+ }
15246
+
15203
15247
  // src/commands/init.ts
15204
15248
  import {
15205
15249
  existsSync as existsSync5,
@@ -15209,16 +15253,16 @@ import {
15209
15253
  statSync,
15210
15254
  writeFileSync as writeFileSync3
15211
15255
  } from "node:fs";
15212
- import { dirname as dirname2, join as join5 } from "node:path";
15256
+ import { dirname as dirname2, join as join6 } from "node:path";
15213
15257
  var PRODUCT_NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
15214
15258
  var RENAMED_TEMPLATE_FILES = {
15215
15259
  gitignore: ".gitignore"
15216
15260
  };
15217
15261
  function listTemplateFiles(root, folder = "") {
15218
15262
  const files = [];
15219
- for (const entry of readdirSync(join5(root, folder)).sort()) {
15263
+ for (const entry of readdirSync(join6(root, folder)).sort()) {
15220
15264
  const relativePath = folder === "" ? entry : `${folder}/${entry}`;
15221
- if (statSync(join5(root, relativePath)).isDirectory()) {
15265
+ if (statSync(join6(root, relativePath)).isDirectory()) {
15222
15266
  files.push(...listTemplateFiles(root, relativePath));
15223
15267
  } else {
15224
15268
  files.push(relativePath);
@@ -15243,12 +15287,12 @@ function initProject(deps, productName2) {
15243
15287
  suggestedFix: "Pick a kebab-case name, e.g. `ogygie init order-desk`."
15244
15288
  });
15245
15289
  }
15246
- if (existsSync5(join5(deps.targetDir, MANIFEST_FILE_NAME))) {
15290
+ if (existsSync5(join6(deps.targetDir, MANIFEST_FILE_NAME))) {
15247
15291
  throwOgygieError({
15248
15292
  code: "OGYGIE_INIT_TARGET_NOT_EMPTY",
15249
15293
  message: `The target directory already hosts an Ogygie project (${MANIFEST_FILE_NAME} found).`,
15250
15294
  file: MANIFEST_FILE_NAME,
15251
- cause: `${join5(deps.targetDir, MANIFEST_FILE_NAME)} exists`,
15295
+ cause: `${join6(deps.targetDir, MANIFEST_FILE_NAME)} exists`,
15252
15296
  suggestedFix: "Run `pnpm ogygie upgrade` in that project instead of init."
15253
15297
  });
15254
15298
  }
@@ -15262,12 +15306,12 @@ function initProject(deps, productName2) {
15262
15306
  suggestedFix: "Start from an empty directory (only a .git folder is tolerated)."
15263
15307
  });
15264
15308
  }
15265
- const projectTemplates = join5(deps.templatesDir, "project");
15309
+ const projectTemplates = join6(deps.templatesDir, "project");
15266
15310
  const writtenFiles = [];
15267
15311
  for (const templateRelativePath of listTemplateFiles(projectTemplates)) {
15268
- const content = readFileSync5(join5(projectTemplates, templateRelativePath), "utf8").replaceAll("__PRODUCT_NAME__", productName2).replaceAll("__OGYGIE_VERSION__", deps.cliVersion);
15312
+ const content = readFileSync5(join6(projectTemplates, templateRelativePath), "utf8").replaceAll("__PRODUCT_NAME__", productName2).replaceAll("__OGYGIE_VERSION__", deps.cliVersion);
15269
15313
  const targetRelativePath = targetPathOf(templateRelativePath);
15270
- const absoluteTarget = join5(deps.targetDir, targetRelativePath);
15314
+ const absoluteTarget = join6(deps.targetDir, targetRelativePath);
15271
15315
  mkdirSync2(dirname2(absoluteTarget), { recursive: true });
15272
15316
  writeFileSync3(absoluteTarget, content);
15273
15317
  writtenFiles.push(targetRelativePath);
@@ -15284,14 +15328,14 @@ function initProject(deps, productName2) {
15284
15328
  }
15285
15329
 
15286
15330
  // src/commands/studio.ts
15287
- import { createRequire } from "node:module";
15288
- import { join as join6 } from "node:path";
15289
- import { pathToFileURL } from "node:url";
15331
+ import { createRequire as createRequire2 } from "node:module";
15332
+ import { join as join7 } from "node:path";
15333
+ import { pathToFileURL as pathToFileURL2 } from "node:url";
15290
15334
  var PUBLISHED_STUDIO_SERVER = "@ogygie/studio/server";
15291
15335
  async function loadStudioFrom(projectRoot) {
15292
- const resolveFromProject = createRequire(join6(projectRoot, "package.json"));
15336
+ const resolveFromProject = createRequire2(join7(projectRoot, "package.json"));
15293
15337
  const entryPoint = resolveFromProject.resolve(PUBLISHED_STUDIO_SERVER);
15294
- return await import(pathToFileURL(entryPoint).href);
15338
+ return await import(pathToFileURL2(entryPoint).href);
15295
15339
  }
15296
15340
  function isModuleNotFound(thrown) {
15297
15341
  const code = thrown?.code;
@@ -15329,14 +15373,14 @@ async function runStudio(deps) {
15329
15373
 
15330
15374
  // src/commands/mcp.ts
15331
15375
  import { readFileSync as readFileSync6 } from "node:fs";
15332
- import { createRequire as createRequire2 } from "node:module";
15333
- import { join as join7 } from "node:path";
15334
- import { pathToFileURL as pathToFileURL2 } from "node:url";
15376
+ import { createRequire as createRequire3 } from "node:module";
15377
+ import { join as join8 } from "node:path";
15378
+ import { pathToFileURL as pathToFileURL3 } from "node:url";
15335
15379
  var PUBLISHED_MCP_SERVER = "@ogygie/mcp/server";
15336
15380
  async function loadMcpFrom(projectRoot) {
15337
- const resolveFromProject = createRequire2(join7(projectRoot, "package.json"));
15381
+ const resolveFromProject = createRequire3(join8(projectRoot, "package.json"));
15338
15382
  const entryPoint = resolveFromProject.resolve(PUBLISHED_MCP_SERVER);
15339
- return await import(pathToFileURL2(entryPoint).href);
15383
+ return await import(pathToFileURL3(entryPoint).href);
15340
15384
  }
15341
15385
  function isModuleNotFound2(thrown) {
15342
15386
  const code = thrown?.code;
@@ -15377,7 +15421,7 @@ async function runMcp(deps) {
15377
15421
 
15378
15422
  // src/commands/upgrade.ts
15379
15423
  import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "node:fs";
15380
- import { dirname as dirname3, join as join8 } from "node:path";
15424
+ import { dirname as dirname3, join as join9 } from "node:path";
15381
15425
 
15382
15426
  // src/version.ts
15383
15427
  function compareVersions(a, b) {
@@ -15425,7 +15469,7 @@ async function upgradeProject(deps) {
15425
15469
  for (const governed of governedFiles) {
15426
15470
  const expected = installedChecksums[governed.targetPath];
15427
15471
  if (expected === void 0) continue;
15428
- const absolutePath = join8(deps.projectRoot, governed.targetPath);
15472
+ const absolutePath = join9(deps.projectRoot, governed.targetPath);
15429
15473
  const actual = existsSync6(absolutePath) ? sha256Hex(readFileSync7(absolutePath, "utf8")) : "<absent>";
15430
15474
  if (actual !== expected) {
15431
15475
  throwOgygieError({
@@ -15441,10 +15485,10 @@ async function upgradeProject(deps) {
15441
15485
  const diffBlocks = [];
15442
15486
  for (const governed of governedFiles) {
15443
15487
  const content = readFileSync7(
15444
- join8(deps.templatesDir, governed.templatePath),
15488
+ join9(deps.templatesDir, governed.templatePath),
15445
15489
  "utf8"
15446
15490
  );
15447
- const absolutePath = join8(deps.projectRoot, governed.targetPath);
15491
+ const absolutePath = join9(deps.projectRoot, governed.targetPath);
15448
15492
  const current = existsSync6(absolutePath) ? readFileSync7(absolutePath, "utf8") : "";
15449
15493
  const diff = diffLines(current, content);
15450
15494
  if (diff !== "") {
@@ -15465,7 +15509,7 @@ ${diff}`);
15465
15509
  };
15466
15510
  }
15467
15511
  for (const write of plannedWrites) {
15468
- const absolutePath = join8(deps.projectRoot, write.targetPath);
15512
+ const absolutePath = join9(deps.projectRoot, write.targetPath);
15469
15513
  mkdirSync3(dirname3(absolutePath), { recursive: true });
15470
15514
  writeFileSync4(absolutePath, write.content);
15471
15515
  }
@@ -15487,7 +15531,7 @@ ${diff}`);
15487
15531
 
15488
15532
  // src/commands/verify-vocabulary.ts
15489
15533
  import { existsSync as existsSync7, readFileSync as readFileSync8, readdirSync as readdirSync2, statSync as statSync2 } from "node:fs";
15490
- import { join as join9, relative, sep } from "node:path";
15534
+ import { join as join10, relative, sep } from "node:path";
15491
15535
 
15492
15536
  // ../ogygie-core/src/vocabulary.ts
15493
15537
  var TOKEN_PATTERN = new RegExp("\\p{L}[\\p{L}\\p{N}]*(?:[-_$][\\p{L}\\p{N}]+)*", "gu");
@@ -15632,7 +15676,7 @@ function isScannableDirectory(name) {
15632
15676
  // src/commands/verify-vocabulary.ts
15633
15677
  var CODE_EXTENSIONS = [".ts", ".tsx", ".mts", ".mjs", ".js", ".jsx"];
15634
15678
  function readManifest(projectRoot) {
15635
- const manifestPath = join9(projectRoot, MANIFEST_FILE_NAME);
15679
+ const manifestPath = join10(projectRoot, MANIFEST_FILE_NAME);
15636
15680
  if (!existsSync7(manifestPath)) {
15637
15681
  throwOgygieError({
15638
15682
  code: "OGYGIE_CLI_USAGE",
@@ -15657,13 +15701,13 @@ function readManifest(projectRoot) {
15657
15701
  return parsed.data;
15658
15702
  }
15659
15703
  function collectCodeFiles(projectRoot, folder) {
15660
- const absoluteFolder = join9(projectRoot, folder);
15704
+ const absoluteFolder = join10(projectRoot, folder);
15661
15705
  if (!existsSync7(absoluteFolder)) return [];
15662
15706
  const files = [];
15663
15707
  const walk = (directory) => {
15664
15708
  for (const entry of readdirSync2(directory)) {
15665
15709
  if (!isScannableDirectory(entry)) continue;
15666
- const entryPath = join9(directory, entry);
15710
+ const entryPath = join10(directory, entry);
15667
15711
  if (statSync2(entryPath).isDirectory()) {
15668
15712
  walk(entryPath);
15669
15713
  continue;
@@ -15706,9 +15750,9 @@ function verifyVocabulary(deps) {
15706
15750
  const sourceFiles = productCodeFoldersOf(manifest).flatMap(
15707
15751
  (folder) => collectCodeFiles(deps.projectRoot, folder)
15708
15752
  );
15709
- const governed = governedFiles.filter((file2) => existsSync7(join9(deps.projectRoot, file2.targetPath))).map((file2) => ({
15753
+ const governed = governedFiles.filter((file2) => existsSync7(join10(deps.projectRoot, file2.targetPath))).map((file2) => ({
15710
15754
  path: file2.targetPath,
15711
- content: readFileSync8(join9(deps.projectRoot, file2.targetPath), "utf8")
15755
+ content: readFileSync8(join10(deps.projectRoot, file2.targetPath), "utf8")
15712
15756
  }));
15713
15757
  return auditProductVocabulary({
15714
15758
  vocabulary: manifest.vocabulary,
@@ -15872,6 +15916,30 @@ var governedChecksumsByVersion = {
15872
15916
  "scripts/verify-specs.ts": "03d58baa11822ad7ae67080f990f7a82f4f94d223b12777728b8b9ddd7ebd843",
15873
15917
  "tsconfig.base.json": "0f8919e9ed8bbc88e0308fa2fa91471dabb18d1df721bd78c04076597eba89a2"
15874
15918
  },
15919
+ "0.15.0": {
15920
+ ".github/workflows/ogygie-gates.yml": "48c40efe9fe66dd0f9c3b94287026d80eae9afd6782b5833fdf8846d8662babf",
15921
+ "AGENTS.md": "74725a71e531502d4d4898518bad68e610a370bd410058b58e11220532dc327f",
15922
+ "CLAUDE.md": "785ee8d90e368806d8cf0b4d84a15f29d1dc961168bda86cf1a6c60a923c8c9b",
15923
+ "scripts/lint-interdits.ts": "4644e1aaabc1d19ed42a0bdf8e2e55b9451f789d87d59754bce3432de7c357e7",
15924
+ "scripts/ogygie-design-brief.ts": "aba1585717b30808dd0bc8930a37a1ad2d83ee6869f969dbd06ca595e37a4a89",
15925
+ "scripts/ogygie-impact.ts": "f06cc8b4a46ddfbcfb32ba747c2454548ca49918123d04ab13d210b4a89c1fb9",
15926
+ "scripts/verify-contracts.ts": "9cd64c88dcc3b54b65bf1e1b7a68eb00353ee16f2a9ce8bca3c49ba063c2a08b",
15927
+ "scripts/verify-manifest.ts": "4e9caa31323442f7a5429e9cc2ae0a4d6407db6261b5744f51415c291236fb97",
15928
+ "scripts/verify-specs.ts": "03d58baa11822ad7ae67080f990f7a82f4f94d223b12777728b8b9ddd7ebd843",
15929
+ "tsconfig.base.json": "0f8919e9ed8bbc88e0308fa2fa91471dabb18d1df721bd78c04076597eba89a2"
15930
+ },
15931
+ "0.16.0": {
15932
+ ".github/workflows/ogygie-gates.yml": "48c40efe9fe66dd0f9c3b94287026d80eae9afd6782b5833fdf8846d8662babf",
15933
+ "AGENTS.md": "74725a71e531502d4d4898518bad68e610a370bd410058b58e11220532dc327f",
15934
+ "CLAUDE.md": "785ee8d90e368806d8cf0b4d84a15f29d1dc961168bda86cf1a6c60a923c8c9b",
15935
+ "scripts/lint-interdits.ts": "4644e1aaabc1d19ed42a0bdf8e2e55b9451f789d87d59754bce3432de7c357e7",
15936
+ "scripts/ogygie-design-brief.ts": "aba1585717b30808dd0bc8930a37a1ad2d83ee6869f969dbd06ca595e37a4a89",
15937
+ "scripts/ogygie-impact.ts": "f06cc8b4a46ddfbcfb32ba747c2454548ca49918123d04ab13d210b4a89c1fb9",
15938
+ "scripts/verify-contracts.ts": "9cd64c88dcc3b54b65bf1e1b7a68eb00353ee16f2a9ce8bca3c49ba063c2a08b",
15939
+ "scripts/verify-manifest.ts": "6c448fa3a19b87666f686ca57c6ca834d9e169b1380e4fe6e4c19b995bf94f3c",
15940
+ "scripts/verify-specs.ts": "03d58baa11822ad7ae67080f990f7a82f4f94d223b12777728b8b9ddd7ebd843",
15941
+ "tsconfig.base.json": "0f8919e9ed8bbc88e0308fa2fa91471dabb18d1df721bd78c04076597eba89a2"
15942
+ },
15875
15943
  "0.2.0": {
15876
15944
  ".github/workflows/ogygie-gates.yml": "48c40efe9fe66dd0f9c3b94287026d80eae9afd6782b5833fdf8846d8662babf",
15877
15945
  "AGENTS.md": "ca73ab619d8c55fac11858d815bf5fbab6537ecebe7ae4123cbdeb3e3e36bf1a",
@@ -15966,22 +16034,22 @@ var governedChecksumsByVersion = {
15966
16034
 
15967
16035
  // src/paths.ts
15968
16036
  import { existsSync as existsSync8, readFileSync as readFileSync9 } from "node:fs";
15969
- import { dirname as dirname4, join as join10 } from "node:path";
16037
+ import { dirname as dirname4, join as join11 } from "node:path";
15970
16038
  import { fileURLToPath } from "node:url";
15971
- var PACKAGE_ROOT = join10(dirname4(fileURLToPath(import.meta.url)), "..");
16039
+ var PACKAGE_ROOT = join11(dirname4(fileURLToPath(import.meta.url)), "..");
15972
16040
  var PackageVersionSchema2 = external_exports.object({ version: external_exports.string() });
15973
16041
  function cliVersion() {
15974
16042
  return PackageVersionSchema2.parse(
15975
- JSON.parse(readFileSync9(join10(PACKAGE_ROOT, "package.json"), "utf8"))
16043
+ JSON.parse(readFileSync9(join11(PACKAGE_ROOT, "package.json"), "utf8"))
15976
16044
  ).version;
15977
16045
  }
15978
16046
  function templatesDir() {
15979
- const embedded = join10(PACKAGE_ROOT, "templates");
16047
+ const embedded = join11(PACKAGE_ROOT, "templates");
15980
16048
  if (existsSync8(embedded)) return embedded;
15981
16049
  return repoTemplatesDir();
15982
16050
  }
15983
16051
  function repoTemplatesDir() {
15984
- return join10(PACKAGE_ROOT, "..", "..", "templates");
16052
+ return join11(PACKAGE_ROOT, "..", "..", "templates");
15985
16053
  }
15986
16054
 
15987
16055
  // src/cli.ts
@@ -16074,14 +16142,23 @@ ogygie verify-vocabulary \u2014 ${problems.length} probl\xE8me(s).`);
16074
16142
  cliVersion: cliVersion(),
16075
16143
  argv: process.argv.slice(3)
16076
16144
  });
16145
+ } else if (command === "generate") {
16146
+ const exitCode = await runGenerate({
16147
+ projectRoot: process.cwd(),
16148
+ cliVersion: cliVersion(),
16149
+ argv: process.argv.slice(3),
16150
+ log: (line) => console.log(line),
16151
+ error: (line) => console.error(line)
16152
+ });
16153
+ if (exitCode !== 0) process.exit(exitCode);
16077
16154
  } else {
16078
16155
  printError(
16079
16156
  ogygieError({
16080
16157
  code: "OGYGIE_CLI_USAGE",
16081
16158
  message: `Unknown command ${JSON.stringify(command ?? "")}.`,
16082
16159
  file: null,
16083
- cause: `expected one of: init <product-name>, adopt, upgrade, check, verify-vocabulary, studio, mcp (flags: --json, --yes)`,
16084
- suggestedFix: "Run `ogygie init <product-name>`, `ogygie adopt`, `ogygie upgrade`, `ogygie check`, `ogygie verify-vocabulary`, `ogygie studio` or `ogygie mcp`."
16160
+ cause: `expected one of: init <product-name>, adopt, upgrade, check, verify-vocabulary, studio, mcp, generate <check|generate|verify> (flags: --json, --yes)`,
16161
+ suggestedFix: "Run `ogygie init <product-name>`, `ogygie adopt`, `ogygie upgrade`, `ogygie check`, `ogygie verify-vocabulary`, `ogygie studio`, `ogygie mcp` or `ogygie generate <check|generate|verify>`."
16085
16162
  })
16086
16163
  );
16087
16164
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogygie/cli",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Ogygie framework CLI: deterministic scaffolding (init), governed-file upgrades (upgrade) and lockstep verification (check) for Ogygie products.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -17,7 +17,7 @@
17
17
  "access": "public"
18
18
  },
19
19
  "devDependencies": {
20
- "@ogygie/core": "0.14.0",
20
+ "@ogygie/core": "0.16.0",
21
21
  "@types/node": "^24.0.0",
22
22
  "esbuild": "^0.28.0",
23
23
  "tsx": "^4.20.0",
@@ -16,6 +16,12 @@
16
16
  * `url: "/api/..."` found in a feature's routes.ts and every `pgTable("...")`
17
17
  * found in a feature's db.schema.ts must be declared in the manifest
18
18
  * (MANIFEST_ROUTE_UNDECLARED / MANIFEST_TABLE_UNDECLARED).
19
+ *
20
+ * A GENERATED feature (Ogygie generator, spec ogygie-generate) carries the
21
+ * generator's twin of a canonical file — `routes.gen.ts` where a hand-written
22
+ * feature has `routes.ts` — and this gate reads whichever nature is on disk,
23
+ * in both directions. `index.ts` keeps its canonical name by exception and
24
+ * `schemas.ts` has no twin: it is the human gesture the generator starts from.
19
25
  */
20
26
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
21
27
  import { join } from "node:path";
@@ -148,6 +154,36 @@ for (const productPackage of manifest.packages ?? []) {
148
154
  const apiApp = manifest.apps.find((app) => app.type === "api");
149
155
  const featureNames = new Set(manifest.features.map((feature) => feature.name));
150
156
 
157
+ /**
158
+ * Canonical file → its generated twin (`.gen` infix, Ogygie generator). A
159
+ * feature carries one nature or the other, never both by hand; when both
160
+ * exist, both are read, so a route or a table never hides in the file the gate
161
+ * did not open.
162
+ */
163
+ const GENERATED_TWIN: Readonly<Record<string, string>> = {
164
+ "routes.ts": "routes.gen.ts",
165
+ "service.ts": "service.gen.ts",
166
+ "repository.ts": "repository.gen.ts",
167
+ "db.schema.ts": "db.schema.gen.ts",
168
+ };
169
+
170
+ function naturesOf(fileName: string): string[] {
171
+ const twin = GENERATED_TWIN[fileName];
172
+ return twin === undefined ? [fileName] : [fileName, twin];
173
+ }
174
+
175
+ /** `routes.ts` → `routes.ts or routes.gen.ts`, for the messages. */
176
+ function eitherNature(fileName: string): string {
177
+ return naturesOf(fileName).join(" or ");
178
+ }
179
+
180
+ /** The canonical file and/or its generated twin, as they exist in the folder. */
181
+ function readNatures(dir: string, fileName: string): Array<{ name: string; content: string }> {
182
+ return naturesOf(fileName)
183
+ .filter((name) => existsSync(join(dir, name)))
184
+ .map((name) => ({ name, content: readFileSync(join(dir, name), "utf8") }));
185
+ }
186
+
151
187
  for (const feature of manifest.features) {
152
188
  const manifestRef = `${MANIFEST_PATH}#features.${feature.name}`;
153
189
 
@@ -229,21 +265,22 @@ for (const feature of manifest.features) {
229
265
 
230
266
  const canonicalFiles = ["index.ts", "routes.ts", "schemas.ts", "service.ts", "repository.ts"];
231
267
  for (const fileName of canonicalFiles) {
232
- if (!existsSync(join(featureDir, fileName))) {
268
+ if (!naturesOf(fileName).some((name) => existsSync(join(featureDir, name)))) {
233
269
  report({
234
270
  code: "FEATURE_CANONICAL_FILE_MISSING",
235
- message: `Feature "${feature.name}" is missing canonical file ${fileName}.`,
271
+ message: `Feature "${feature.name}" is missing canonical file ${eitherNature(fileName)}.`,
236
272
  file: join(apiApp.path, feature.folder, fileName),
237
- cause: `${fileName} absent (AGENTS.md §Structure d'une feature)`,
238
- suggestedFix: "Create the file following the canonical template in AGENTS.md.",
273
+ cause: `${eitherNature(fileName)} absent (AGENTS.md §Structure d'une feature; the generated twin counts)`,
274
+ suggestedFix: "Create the file following the canonical template in AGENTS.md, or generate the feature (spec ogygie-generate).",
239
275
  });
240
276
  }
241
277
  }
242
278
 
243
279
  const schemasPath = join(featureDir, "schemas.ts");
244
- const routesPath = join(featureDir, "routes.ts");
245
280
  const schemasContent = existsSync(schemasPath) ? readFileSync(schemasPath, "utf8") : "";
246
- const routesContent = existsSync(routesPath) ? readFileSync(routesPath, "utf8") : "";
281
+ const routeNatures = readNatures(featureDir, "routes.ts");
282
+ const routesFile = routeNatures[0]?.name ?? "routes.ts";
283
+ const routesContent = routeNatures.map((nature) => nature.content).join("\n");
247
284
 
248
285
  for (const route of feature.routes) {
249
286
  for (const schemaName of [route.schemaInput, route.schemaOutput]) {
@@ -261,26 +298,27 @@ for (const feature of manifest.features) {
261
298
  if (!routesContent.includes(`"${route.path}"`)) {
262
299
  report({
263
300
  code: "MANIFEST_ROUTE_NOT_FOUND",
264
- message: `Route ${route.method} ${route.path} is declared in the manifest but absent from routes.ts.`,
265
- file: join(apiApp.path, feature.folder, "routes.ts"),
266
- cause: `"${route.path}" not found in routes.ts`,
267
- suggestedFix: "Register the route in routes.ts or remove it from the manifest.",
301
+ message: `Route ${route.method} ${route.path} is declared in the manifest but absent from ${routesFile}.`,
302
+ file: join(apiApp.path, feature.folder, routesFile),
303
+ cause: `"${route.path}" not found in ${eitherNature("routes.ts")}`,
304
+ suggestedFix: "Register the route in routes.ts (or regenerate routes.gen.ts from the spec) or remove it from the manifest.",
268
305
  });
269
306
  }
270
307
  }
271
308
 
272
309
  if (feature.dbTables.length > 0) {
273
- const dbSchemaPath = join(featureDir, "db.schema.ts");
274
- const dbSchemaContent = existsSync(dbSchemaPath) ? readFileSync(dbSchemaPath, "utf8") : "";
310
+ const dbSchemaNatures = readNatures(featureDir, "db.schema.ts");
311
+ const dbSchemaFile = dbSchemaNatures[0]?.name ?? "db.schema.ts";
312
+ const dbSchemaContent = dbSchemaNatures.map((nature) => nature.content).join("\n");
275
313
  for (const table of dbTableNamesOf(feature)) {
276
314
  const tableDefinition = new RegExp(`pgTable\\(\\s*"${table}"`);
277
315
  if (!tableDefinition.test(dbSchemaContent)) {
278
316
  report({
279
317
  code: "MANIFEST_TABLE_NOT_FOUND",
280
- message: `Feature "${feature.name}" declares table "${table}" but db.schema.ts does not define it.`,
281
- file: join(apiApp.path, feature.folder, "db.schema.ts"),
282
- cause: `pgTable("${table}") not found`,
283
- suggestedFix: "Define the table in db.schema.ts or remove it from dbTables.",
318
+ message: `Feature "${feature.name}" declares table "${table}" but ${dbSchemaFile} does not define it.`,
319
+ file: join(apiApp.path, feature.folder, dbSchemaFile),
320
+ cause: `pgTable("${table}") not found in ${eitherNature("db.schema.ts")}`,
321
+ suggestedFix: "Define the table in db.schema.ts (or regenerate db.schema.gen.ts from the spec) or remove it from dbTables.",
284
322
  });
285
323
  }
286
324
  }
@@ -297,7 +335,7 @@ for (const feature of manifest.features) {
297
335
  report({
298
336
  code: "SHARED_TABLE_WITH_TENANT_COLUMN",
299
337
  message: `Table "${finding.table}" is declared shared in the manifest but its Drizzle definition still carries "${finding.tenantColumn}".`,
300
- file: join(apiApp.path, feature.folder, "db.schema.ts"),
338
+ file: join(apiApp.path, feature.folder, dbSchemaFile),
301
339
  cause: `pgTable("${finding.table}") declares column "${finding.tenantColumn}" while the manifest says shared: true`,
302
340
  suggestedFix:
303
341
  "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.",
@@ -333,16 +371,14 @@ if (apiApp !== undefined) {
333
371
  ? ` (no manifest entry has folder=${relativeFolder})`
334
372
  : "";
335
373
 
336
- const routesPath = join(featuresRoot, dirName, "routes.ts");
337
- if (existsSync(routesPath)) {
338
- const routesContent = readFileSync(routesPath, "utf8");
339
- for (const match of routesContent.matchAll(/url:\s*"(\/api\/[^"]*)"/g)) {
374
+ for (const nature of readNatures(join(featuresRoot, dirName), "routes.ts")) {
375
+ for (const match of nature.content.matchAll(/url:\s*"(\/api\/[^"]*)"/g)) {
340
376
  const routePath = match[1];
341
377
  if (routePath !== undefined && !declaredPaths.has(routePath)) {
342
378
  report({
343
379
  code: "MANIFEST_ROUTE_UNDECLARED",
344
- message: `Route ${routePath} is registered in ${relativeFolder}/routes.ts but not declared in the manifest.`,
345
- file: join(apiApp.path, relativeFolder, "routes.ts"),
380
+ message: `Route ${routePath} is registered in ${relativeFolder}/${nature.name} but not declared in the manifest.`,
381
+ file: join(apiApp.path, relativeFolder, nature.name),
346
382
  cause: `url: "${routePath}" found in code, absent from the manifest${missingEntryCause}`,
347
383
  suggestedFix:
348
384
  "Declare the route (method, path, schemas) in ogygie.manifest.json — the manifest must never lie by omission.",
@@ -351,16 +387,14 @@ if (apiApp !== undefined) {
351
387
  }
352
388
  }
353
389
 
354
- const dbSchemaPath = join(featuresRoot, dirName, "db.schema.ts");
355
- if (existsSync(dbSchemaPath)) {
356
- const dbSchemaContent = readFileSync(dbSchemaPath, "utf8");
357
- for (const match of dbSchemaContent.matchAll(/pgTable\(\s*"([^"]+)"/g)) {
390
+ for (const nature of readNatures(join(featuresRoot, dirName), "db.schema.ts")) {
391
+ for (const match of nature.content.matchAll(/pgTable\(\s*"([^"]+)"/g)) {
358
392
  const tableName = match[1];
359
393
  if (tableName !== undefined && !declaredTables.has(tableName)) {
360
394
  report({
361
395
  code: "MANIFEST_TABLE_UNDECLARED",
362
- message: `Table "${tableName}" is defined in ${relativeFolder}/db.schema.ts but not declared in the manifest.`,
363
- file: join(apiApp.path, relativeFolder, "db.schema.ts"),
396
+ message: `Table "${tableName}" is defined in ${relativeFolder}/${nature.name} but not declared in the manifest.`,
397
+ file: join(apiApp.path, relativeFolder, nature.name),
364
398
  cause: `pgTable("${tableName}") found in code, absent from dbTables${missingEntryCause}`,
365
399
  suggestedFix:
366
400
  "Add the table to the feature's dbTables in ogygie.manifest.json (one table = one owner).",