@ogygie/core 0.5.4 → 0.7.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/README.md CHANGED
@@ -9,6 +9,7 @@ dans `apps/`.
9
9
  | Export | Rôle |
10
10
  |---|---|
11
11
  | `@ogygie/core/errors` | Format `OgygieError` (code stable, `file`, `cause`, `suggestedFix`) |
12
+ | `@ogygie/core/manifest-owners` | Quelle entrée de la carte possède une spec — feature ou package du produit |
12
13
  | `@ogygie/core/manifest` | Schéma du manifeste `ogygie.manifest.json` |
13
14
  | `@ogygie/core/spec` | Frontmatter des specs (`parseSpecFrontmatter`) et sections canoniques |
14
15
  | `@ogygie/core/db` · `/clock` · `/tenant` · `/test-kit` | Accès DB, horloge injectable, façade multi-tenant, kit de test |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogygie/core",
3
- "version": "0.5.4",
3
+ "version": "0.7.0",
4
4
  "description": "Ogygie framework core: manifest/spec/error schemas, generic tenancy and gate tooling. Ships TypeScript sources — Ogygie products run tsx/vitest by construction (D1).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,6 +14,7 @@
14
14
  },
15
15
  "exports": {
16
16
  "./manifest": "./src/manifest.schema.ts",
17
+ "./manifest-owners": "./src/manifest-owners.ts",
17
18
  "./vocabulary": "./src/vocabulary.ts",
18
19
  "./vocabulary-audit": "./src/vocabulary-audit.ts",
19
20
  "./web-lint": "./src/web-lint.ts",
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @intent Mutation tests of the spec-lifecycle lookup (spec manifest-packages
3
+ * B2). The rule it serves is unchanged — a spec claims `implemented` only when
4
+ * the map confirms — so what is proven here is that BOTH natures of entry can
5
+ * confirm, and that neither shadows the other.
6
+ */
7
+ import { describe, expect, it } from "vitest";
8
+ import { findSpecOwner } from "./manifest-owners.js";
9
+ import { OgygieManifestSchema } from "./manifest.schema.js";
10
+
11
+ function manifestWith(extra: Record<string, unknown>) {
12
+ return OgygieManifestSchema.parse({
13
+ version: 1,
14
+ product: { name: "acme", description: "SaaS générique servant de fixture aux tests du manifeste." },
15
+ apps: [{ name: "api", path: "apps/api", type: "api", entryPoint: "apps/api/src/server.ts" }],
16
+ features: [],
17
+ conventions: { agentsMd: "AGENTS.md", failuresMd: "FAILURES.md", specsFolder: "specs/" },
18
+ ...extra,
19
+ });
20
+ }
21
+
22
+ const gadgetEngine = {
23
+ name: "gadget-engine",
24
+ path: "packages/gadget-engine",
25
+ intent: "Moteur pur : aucune I/O, aucune horloge, portable navigateur.",
26
+ spec: "specs/gadget-engine.spec.md",
27
+ dependsOn: [],
28
+ status: "delivered",
29
+ };
30
+
31
+ describe("findSpecOwner — feature ou package, la carte confirme les deux", () => {
32
+ it("ÉTANT DONNÉ une spec dont le code vit dans un PACKAGE ALORS la carte le confirme", () => {
33
+ const owner = findSpecOwner(manifestWith({ packages: [gadgetEngine] }), "gadget-engine");
34
+ expect(owner).toEqual({
35
+ kind: "package",
36
+ name: "gadget-engine",
37
+ status: "delivered",
38
+ folder: "packages/gadget-engine",
39
+ });
40
+ });
41
+
42
+ it("une feature reste trouvée, et son dossier est joint au chemin de son app", () => {
43
+ const owner = findSpecOwner(
44
+ manifestWith({
45
+ features: [
46
+ {
47
+ name: "orders",
48
+ intent: "Feature d'exemple servant de fixture aux tests du manifeste.",
49
+ folder: "src/features/orders",
50
+ spec: "specs/orders.spec.md",
51
+ routes: [],
52
+ dbTables: [],
53
+ dependsOn: [],
54
+ status: "delivered",
55
+ },
56
+ ],
57
+ }),
58
+ "orders",
59
+ );
60
+ expect(owner?.kind).toBe("feature");
61
+ expect(owner?.folder).toBe("apps/api/src/features/orders");
62
+ });
63
+
64
+ it("une spec sans entrée dans la carte n'a AUCUN propriétaire — le refus reste dû", () => {
65
+ expect(findSpecOwner(manifestWith({}), "inconnue")).toBeUndefined();
66
+ });
67
+
68
+ it("le statut du package est rendu tel quel : un package en cours ne vaut pas livré", () => {
69
+ const owner = findSpecOwner(
70
+ manifestWith({ packages: [{ ...gadgetEngine, status: "in-progress" }] }),
71
+ "gadget-engine",
72
+ );
73
+ expect(owner?.status).toBe("in-progress");
74
+ });
75
+
76
+ it("un manifeste SANS bloc packages se comporte exactement comme avant", () => {
77
+ expect(findSpecOwner(manifestWith({}), "gadget-engine")).toBeUndefined();
78
+ });
79
+ });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @intent Answers the ONE question the spec lifecycle rests on: which entry of
3
+ * the map owns this spec, and how far along is it?
4
+ *
5
+ * The rule itself does not change — a spec only claims `implemented` when the
6
+ * manifest confirms it. What changes is that the map now has two kinds of
7
+ * entry able to confirm: a feature living under an app, and a PRODUCT PACKAGE
8
+ * living outside every app (spec manifest-packages). Before that, a spec whose
9
+ * code sat in `packages/` could never be marked finished, and nothing said why
10
+ * — the gate answered `manifest status=absent` and suggested two fixes, both
11
+ * impossible.
12
+ *
13
+ * Lives in the core, not in the gate script, so the rule travels with the
14
+ * versioned package instead of with a copy each project keeps.
15
+ */
16
+ import type { OgygieManifest } from "./manifest.schema.js";
17
+
18
+ export type SpecOwnerKind = "feature" | "package";
19
+
20
+ export interface SpecOwner {
21
+ kind: SpecOwnerKind;
22
+ name: string;
23
+ status: "skeleton" | "in-progress" | "delivered";
24
+ /**
25
+ * Folder holding the code, relative to the REPO root — already joined with
26
+ * the owning app's path for a feature, whose own `folder` is relative to it.
27
+ * Null when the map cannot say (a feature whose app is not declared).
28
+ */
29
+ folder: string | null;
30
+ }
31
+
32
+ /**
33
+ * The entry owning `specId`, feature first. Names are unique across the whole
34
+ * map (the schema refuses a package named like a feature), so the order only
35
+ * decides which lookup runs first, never which answer is right.
36
+ */
37
+ export function findSpecOwner(
38
+ manifest: OgygieManifest,
39
+ specId: string,
40
+ ): SpecOwner | undefined {
41
+ const feature = manifest.features.find((candidate) => candidate.name === specId);
42
+ if (feature !== undefined) {
43
+ const owningApp =
44
+ feature.app === undefined
45
+ ? manifest.apps.find((app) => app.type === "api")
46
+ : manifest.apps.find((app) => app.name === feature.app);
47
+ return {
48
+ kind: "feature",
49
+ name: feature.name,
50
+ status: feature.status,
51
+ folder: owningApp === undefined ? null : `${owningApp.path}/${feature.folder}`,
52
+ };
53
+ }
54
+
55
+ const productPackage = (manifest.packages ?? []).find(
56
+ (candidate) => candidate.name === specId,
57
+ );
58
+ if (productPackage !== undefined) {
59
+ return {
60
+ kind: "package",
61
+ name: productPackage.name,
62
+ status: productPackage.status,
63
+ folder: productPackage.path,
64
+ };
65
+ }
66
+
67
+ return undefined;
68
+ }
69
+
70
+ /** What a refusal should suggest — both natures, never one that cannot exist. */
71
+ export const SPEC_OWNER_FIX =
72
+ "Set the owning entry to delivered in the manifest — a feature under `features`, or a library under `packages` — or keep the spec in validated.";
@@ -373,3 +373,128 @@ describe("manifest — apps vendored (spec product-vocabulary-gate B3, v3)", ()
373
373
  expect(OgygieManifestSchema.safeParse(manifestWithApp({ vendored: false })).success).toBe(false);
374
374
  });
375
375
  });
376
+
377
+ /** Packages du produit (spec manifest-packages B1/B3). */
378
+ function manifestWithPackages(packages: unknown, features: unknown[] = []) {
379
+ return {
380
+ version: 1,
381
+ product: { name: "acme", description: "SaaS générique servant de fixture aux tests du manifeste." },
382
+ apps: [{ name: "api", path: "apps/api", type: "api", entryPoint: "apps/api/src/server.ts" }],
383
+ features,
384
+ packages,
385
+ conventions: { agentsMd: "AGENTS.md", failuresMd: "FAILURES.md", specsFolder: "specs/" },
386
+ };
387
+ }
388
+
389
+ const enginePackage = {
390
+ name: "gadget-engine",
391
+ path: "packages/gadget-engine",
392
+ intent: "Moteur pur : aucune I/O, aucune horloge, portable navigateur.",
393
+ spec: "specs/gadget-engine.spec.md",
394
+ dependsOn: [],
395
+ status: "delivered",
396
+ };
397
+
398
+ const featureNamed = (name: string, dependsOn: string[] = []) => ({
399
+ name,
400
+ intent: "Feature d'exemple servant de fixture aux tests du manifeste.",
401
+ folder: `src/features/${name}`,
402
+ spec: null,
403
+ routes: [],
404
+ dbTables: [],
405
+ dependsOn,
406
+ status: "delivered",
407
+ });
408
+
409
+ describe("manifest — packages du produit (spec manifest-packages)", () => {
410
+ it("un bloc packages valide est accepté", () => {
411
+ expect(OgygieManifestSchema.safeParse(manifestWithPackages([enginePackage])).success).toBe(true);
412
+ });
413
+
414
+ it("un manifeste SANS bloc packages reste valide (l'extension est invisible)", () => {
415
+ const withoutPackages = manifestWithPackages(undefined);
416
+ delete (withoutPackages as Record<string, unknown>)["packages"];
417
+ expect(OgygieManifestSchema.safeParse(withoutPackages).success).toBe(true);
418
+ });
419
+
420
+ it("une feature peut dépendre d'un package déclaré", () => {
421
+ const parsed = OgygieManifestSchema.safeParse(
422
+ manifestWithPackages([enginePackage], [featureNamed("orders", ["package:gadget-engine"])]),
423
+ );
424
+ expect(parsed.success).toBe(true);
425
+ });
426
+
427
+ it("MUTATION : une dépendance vers un package INEXISTANT est refusée", () => {
428
+ const parsed = OgygieManifestSchema.safeParse(
429
+ manifestWithPackages([enginePackage], [featureNamed("orders", ["package:fantome"])]),
430
+ );
431
+ expect(parsed.success).toBe(false);
432
+ const issue = parsed.success ? undefined : parsed.error.issues[0];
433
+ expect(issue?.message).toContain("matches no declared package");
434
+ });
435
+
436
+ it("MUTATION : un package portant le nom d'une feature est refusé", () => {
437
+ const parsed = OgygieManifestSchema.safeParse(
438
+ manifestWithPackages([{ ...enginePackage, name: "orders" }], [featureNamed("orders")]),
439
+ );
440
+ expect(parsed.success).toBe(false);
441
+ const messages = parsed.success ? [] : parsed.error.issues.map((issue) => issue.message);
442
+ expect(messages.join(" ")).toContain("already the name of a feature");
443
+ });
444
+
445
+ it("MUTATION : un package déclaré deux fois est refusé", () => {
446
+ const parsed = OgygieManifestSchema.safeParse(
447
+ manifestWithPackages([enginePackage, { ...enginePackage, path: "packages/autre" }]),
448
+ );
449
+ expect(parsed.success).toBe(false);
450
+ });
451
+
452
+ it("MUTATION : un package ne dépend PAS d'une feature (forme package:<nom> exigée)", () => {
453
+ const parsed = OgygieManifestSchema.safeParse(
454
+ manifestWithPackages([{ ...enginePackage, dependsOn: ["orders"] }], [featureNamed("orders")]),
455
+ );
456
+ expect(parsed.success).toBe(false);
457
+ });
458
+ });
459
+
460
+ describe("manifest — bloc tenancy (spec ogygie-standalone-repo v3)", () => {
461
+ const socle = featureNamed("espaces-et-comptes");
462
+
463
+ it("un bloc tenancy valide est accepté, rawDbAllowed a un défaut vide", () => {
464
+ const parsed = OgygieManifestSchema.safeParse(
465
+ manifestWithPackages(undefined, [socle]),
466
+ );
467
+ expect(parsed.success).toBe(true);
468
+ const withTenancy = OgygieManifestSchema.safeParse({
469
+ ...manifestWithPackages(undefined, [socle]),
470
+ tenancy: { column: "workspace_id" },
471
+ });
472
+ expect(withTenancy.success && withTenancy.data.tenancy?.rawDbAllowed).toEqual([]);
473
+ });
474
+
475
+ it("le socle peut nommer les features autorisées à la base brute", () => {
476
+ const parsed = OgygieManifestSchema.safeParse({
477
+ ...manifestWithPackages(undefined, [socle]),
478
+ tenancy: { column: "workspace_id", rawDbAllowed: ["espaces-et-comptes"] },
479
+ });
480
+ expect(parsed.success).toBe(true);
481
+ });
482
+
483
+ it("MUTATION : un socle nommant une feature INEXISTANTE est refusé", () => {
484
+ const parsed = OgygieManifestSchema.safeParse({
485
+ ...manifestWithPackages(undefined, [socle]),
486
+ tenancy: { column: "workspace_id", rawDbAllowed: ["fantome"] },
487
+ });
488
+ expect(parsed.success).toBe(false);
489
+ const issue = parsed.success ? undefined : parsed.error.issues[0];
490
+ expect(issue?.message).toContain("matches no declared feature");
491
+ });
492
+
493
+ it("MUTATION : une colonne tenant vide est refusée — elle ne garderait rien", () => {
494
+ const parsed = OgygieManifestSchema.safeParse({
495
+ ...manifestWithPackages(undefined, [socle]),
496
+ tenancy: { column: "" },
497
+ });
498
+ expect(parsed.success).toBe(false);
499
+ });
500
+ });
@@ -10,6 +10,30 @@ export const FeatureNameSchema = z
10
10
  .string()
11
11
  .regex(/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/, "kebab-case required");
12
12
 
13
+ /**
14
+ * A dependency on a PRODUCT package, written `package:<name>` so it can never
15
+ * be confused with a feature name. The prefix is the whole point: two tiroirs
16
+ * of the map, one syntax each, and no guessing about what a bare name means.
17
+ */
18
+ export const PackageDependencySchema = z
19
+ .string()
20
+ .regex(/^package:[a-z][a-z0-9]*(-[a-z0-9]+)*$/, "expected package:<kebab-case-name>");
21
+
22
+ /**
23
+ * A package of the PRODUCT: code that does not run and belongs to no app — a
24
+ * pure library, a portable engine, a computation without I/O. It answers the
25
+ * same questions as a feature, minus the ones that make no sense outside an
26
+ * app: no routes, no tables, no owning app.
27
+ *
28
+ * Without this, such code had nowhere to be declared, and the spec describing
29
+ * it could never say `implemented` — the gate rightly refuses a spec the map
30
+ * does not confirm, and the map had no way to confirm it (spec
31
+ * manifest-packages).
32
+ *
33
+ * Not to be confused with `framework.packages`, which describes what a
34
+ * FRAMEWORK repo publishes on npm. This block describes what a product keeps
35
+ * for itself.
36
+ */
13
37
  export const HttpRouteSchema = z.object({
14
38
  method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
15
39
  path: z.string().startsWith("/api/"),
@@ -67,8 +91,10 @@ export const FeatureManifestSchema = z.object({
67
91
  "Drizzle tables owned by this feature (one table = one owner). String = tenant-scoped; { name, shared: true } = shared referential",
68
92
  ),
69
93
  dependsOn: z
70
- .array(FeatureNameSchema)
71
- .describe("Features imported through their index.ts — nothing else"),
94
+ .array(z.union([FeatureNameSchema, PackageDependencySchema]))
95
+ .describe(
96
+ "Features imported through their index.ts, and product packages as package:<name> — nothing else",
97
+ ),
72
98
  status: z.enum(["skeleton", "in-progress", "delivered"]),
73
99
  });
74
100
 
@@ -145,6 +171,46 @@ export const ProductVocabularySchema = z.object({
145
171
  });
146
172
  export type ProductVocabulary = z.infer<typeof ProductVocabularySchema>;
147
173
 
174
+ export const PackageManifestSchema = z.object({
175
+ name: FeatureNameSchema,
176
+ path: z.string().describe("Folder relative to the repo root, e.g. packages/solver"),
177
+ intent: z.string().min(20).max(300).describe("The package's 'why' in 1-3 sentences"),
178
+ spec: z.string().nullable(),
179
+ dependsOn: z
180
+ .array(PackageDependencySchema)
181
+ .describe("Other PRODUCT packages, as package:<name> — never a feature: a package that depended on one would live under an app, and would be a feature"),
182
+ status: z.enum(["skeleton", "in-progress", "delivered"]),
183
+ });
184
+ export type PackageManifest = z.infer<typeof PackageManifestSchema>;
185
+
186
+ /**
187
+ * The two product values the framework's own gates need to do their job.
188
+ *
189
+ * They used to live as constants INSIDE the gate scripts, which is what kept
190
+ * those scripts out of governance: a governed file is identical in every
191
+ * Ogygie project, and these are not. Declared here, the scripts become
192
+ * identical everywhere and can finally be governed — so a corrected gate
193
+ * reaches every project through `ogygie upgrade` instead of waiting for each
194
+ * one to recopy it (spec ogygie-standalone-repo v3).
195
+ *
196
+ * Absent, the checks that depend on them simply do not run: a project without
197
+ * tenancy has nothing to guard, and saying so explicitly beats guarding
198
+ * nothing while pretending otherwise.
199
+ */
200
+ export const TenancyManifestSchema = z.object({
201
+ column: z
202
+ .string()
203
+ .min(1)
204
+ .describe("SQL name of the tenant column, e.g. workspace_id — the one AGENTS.product.md announces"),
205
+ rawDbAllowed: z
206
+ .array(FeatureNameSchema)
207
+ .default([])
208
+ .describe(
209
+ "Features allowed to reach the raw database: the tenancy socle, which BUILDS the contexts and therefore predates them. Every other feature goes through tenantDb",
210
+ ),
211
+ });
212
+ export type TenancyManifest = z.infer<typeof TenancyManifestSchema>;
213
+
148
214
  export const OgygieManifestSchema = z.object({
149
215
  version: z.literal(1),
150
216
  product: z.object({
@@ -175,6 +241,13 @@ export const OgygieManifestSchema = z.object({
175
241
  }),
176
242
  ),
177
243
  features: z.array(FeatureManifestSchema),
244
+ tenancy: TenancyManifestSchema.optional().describe(
245
+ "Product values the framework gates need — absent, the tenancy checks do not run",
246
+ ),
247
+ packages: z
248
+ .array(PackageManifestSchema)
249
+ .optional()
250
+ .describe("Product code living outside every app — libraries, engines. Absent = the project has none"),
178
251
  vocabulary: ProductVocabularySchema.optional().describe(
179
252
  "The product's closed vocabulary — declared here, enforced by `ogygie verify-vocabulary`. Absent = gate silent",
180
253
  ),
@@ -190,6 +263,66 @@ export const OgygieManifestSchema = z.object({
190
263
  // front CONSUMES contracts, the gateway RELAYS declared routes; neither
191
264
  // OWNS any).
192
265
  .superRefine((manifest, context) => {
266
+ // One name, one thing in the map (spec manifest-packages B1): a package
267
+ // sharing a feature's name would make `dependsOn` ambiguous — and worse,
268
+ // silently so, since both would resolve.
269
+ const featureNames = new Set(manifest.features.map((feature) => feature.name));
270
+ const packageNames = new Set<string>();
271
+ (manifest.packages ?? []).forEach((packageEntry, index) => {
272
+ if (featureNames.has(packageEntry.name)) {
273
+ context.addIssue({
274
+ code: "custom",
275
+ path: ["packages", index, "name"],
276
+ message: `"${packageEntry.name}" is already the name of a feature — one name designates one thing`,
277
+ });
278
+ }
279
+ if (packageNames.has(packageEntry.name)) {
280
+ context.addIssue({
281
+ code: "custom",
282
+ path: ["packages", index, "name"],
283
+ message: `package "${packageEntry.name}" is declared twice`,
284
+ });
285
+ }
286
+ packageNames.add(packageEntry.name);
287
+ });
288
+
289
+ // Every `package:<name>` must designate a DECLARED package, wherever it is
290
+ // written: a dangling dependency describes a map nobody can follow.
291
+ const checkPackageDependencies = (
292
+ dependsOn: readonly string[],
293
+ path: (string | number)[],
294
+ ): void => {
295
+ dependsOn.forEach((dependency, dependencyIndex) => {
296
+ if (!dependency.startsWith("package:")) return;
297
+ const target = dependency.slice("package:".length);
298
+ if (!packageNames.has(target)) {
299
+ context.addIssue({
300
+ code: "custom",
301
+ path: [...path, dependencyIndex],
302
+ message: `dependency "${dependency}" matches no declared package`,
303
+ });
304
+ }
305
+ });
306
+ };
307
+ manifest.features.forEach((feature, index) => {
308
+ checkPackageDependencies(feature.dependsOn, ["features", index, "dependsOn"]);
309
+ });
310
+ (manifest.packages ?? []).forEach((packageEntry, index) => {
311
+ checkPackageDependencies(packageEntry.dependsOn, ["packages", index, "dependsOn"]);
312
+ });
313
+
314
+ // A socle that names no existing feature guards nothing, and nobody would
315
+ // notice: the lint would simply allow one fewer folder.
316
+ (manifest.tenancy?.rawDbAllowed ?? []).forEach((featureName, index) => {
317
+ if (!featureNames.has(featureName)) {
318
+ context.addIssue({
319
+ code: "custom",
320
+ path: ["tenancy", "rawDbAllowed", index],
321
+ message: `"${featureName}" matches no declared feature`,
322
+ });
323
+ }
324
+ });
325
+
193
326
  // Vocabulary coherence (B2): one concept = one term. A canonical declared
194
327
  // twice, an alias claimed by two entries, or an alias that IS someone's
195
328
  // canonical would make the gate's suggested fix ambiguous — refused here,