@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 +1 -0
- package/package.json +2 -1
- package/src/manifest-owners.test.ts +79 -0
- package/src/manifest-owners.ts +72 -0
- package/src/manifest.schema.test.ts +125 -0
- package/src/manifest.schema.ts +135 -2
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.
|
|
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
|
+
});
|
package/src/manifest.schema.ts
CHANGED
|
@@ -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(
|
|
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,
|