@ogygie/core 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 x10sion LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # @ogygie/core
2
+
3
+ Le framework **Ogygie** : l'outillage et l'infrastructure GÉNÉRIQUES du repo.
4
+ Règle de séparation (DECISIONS.md D10) — le core ne connaît **aucun mot du
5
+ domaine** du produit qui le consomme ; la dépendance va produit → core, jamais
6
+ l'inverse. Un module qui contient une seule décision propre au produit reste
7
+ dans `apps/`.
8
+
9
+ | Export | Rôle |
10
+ |---|---|
11
+ | `@ogygie/core/errors` | Format `OgygieError` (code stable, `file`, `cause`, `suggestedFix`) |
12
+ | `@ogygie/core/manifest` | Schéma du manifeste `ogygie.manifest.json` |
13
+ | `@ogygie/core/spec` | Frontmatter des specs (`parseSpecFrontmatter`) et sections canoniques |
14
+ | `@ogygie/core/db` · `/clock` · `/tenant` · `/test-kit` | Accès DB, horloge injectable, façade multi-tenant, kit de test |
15
+ | `@ogygie/core/web-lint` · `/bundle-budget` | Parties testables des gates front |
16
+ | `@ogygie/core/vocabulary` | Checker du test du vocabulaire (D10) — les stems appartiennent au produit |
17
+ | `@ogygie/core/gateway` | Passerelle publique dérivée du manifeste (ci-dessous) |
18
+ | `@ogygie/core/client` | Client d'API front dérivé du manifeste (clés de cache, invalidations) |
19
+
20
+ ## Le test du vocabulaire couvre TOUT le core
21
+
22
+ Le gate `src/product-vocabulary.scan.test.ts` scanne chaque fichier de
23
+ `src/**` — modules, tests, fixtures, JSON d'exemples — et casse la CI au
24
+ moindre stem produit (nom du produit, id de feature, mot du domaine, dans
25
+ les deux langues). Les fixtures fuient le vocabulaire aussi facilement que
26
+ le code : rien n'est exempté, sauf le fichier de scan lui-même, seul endroit
27
+ autorisé à ÉCRIRE les mots interdits.
28
+
29
+ ## Hypothèses structurelles assumées (non-vocabulaires)
30
+
31
+ Sans domaine ne veut pas dire sans opinion. Les hypothèses ci-dessous sont
32
+ GRAVÉES dans les modules du core ; on les DÉCLARE ici plutôt que de les
33
+ diluer — un produit qui ne peut pas les accepter ne peut pas consommer ce
34
+ core tel quel.
35
+
36
+ 1. **SaaS multi-tenant à colonne tenant unique.** `TenantContext`
37
+ (`tenantId`, `userId`, `db`, `tenantColumnKey`) est le contrat d'accès aux
38
+ données : chaque table métier porte UNE colonne tenant et tout accès passe
39
+ par la façade `tenantDb` (injection à l'écriture, filtre à la lecture,
40
+ transactions comprises). Seule exception : les référentiels PARTAGÉS,
41
+ déclarés au manifeste (`shared: true`) et vérifiés par `shared-tables`.
42
+ Le produit nomme son tenant comme il veut (un alias) ; le core impose le
43
+ MODÈLE.
44
+ 2. **PostgreSQL, via Drizzle.** `Database` est un `PgDatabase`
45
+ (drizzle-orm/pg-core) ; migrations en SQL Postgres ; policy RLS attendue
46
+ sur toute table tenant (second rempart) ; transactions imbriquées =
47
+ savepoints Postgres ; test-kit sur PGlite (Postgres en mémoire). Ni
48
+ MySQL, ni SQLite, ni un autre ORM.
49
+ 3. **Fastify comme runtime HTTP.** `manifest-gateway` produit une app
50
+ Fastify (helmet, rate-limit, static) et l'API du produit est supposée
51
+ être un `FastifyInstance` décoré (`app.db`, `app.clock`).
52
+ 4. **Zod aux frontières.** Toute entrée/sortie a un schéma Zod, source
53
+ unique de vérité (validation runtime + type inféré + doc) ; le graphe
54
+ d'un `schemas.ts` n'autorise par défaut QUE `zod` (`contract-purity`,
55
+ D29).
56
+ 5. **Une devise de travail par tenant.** L'argent est un entier en centimes
57
+ + code ISO 4217 ; aucune conversion de change ne traverse le core. Les
58
+ futurs helpers monétaires naîtront ici sous cette hypothèse.
59
+ 6. **Temps en UTC, horloge injectée.** `Clock` est la seule porte d'accès
60
+ au temps ; les instants persistés sont de l'ISO 8601 UTC.
61
+ 7. **Repo cartographié par un manifeste.** `ogygie.manifest.json` à la
62
+ racine décrit apps, features, routes, tables, dépendances — les gates et
63
+ les dérivations (passerelle, client) le tiennent pour LA carte du repo.
64
+ 8. **Monorepo TypeScript ESM strict.** Modules NodeNext (imports relatifs
65
+ en `.js`), pnpm workspace, vitest, TypeScript strict ; les analyses
66
+ statiques (module-purity, contract-purity) lisent la syntaxe d'import
67
+ TypeScript — pas un bundler, pas un graphe runtime.
68
+ 9. **Anglais machine-lisible.** Identifiants, codes d'erreur, valeurs
69
+ d'enum et commentaires en anglais (D11) ; les documents humains du repo
70
+ consommateur restent en français.
71
+
72
+ ## Passerelle dérivée du manifeste — `@ogygie/core/gateway`
73
+
74
+ `createManifestGateway(manifest, options)` dérive une passerelle Fastify d'un
75
+ manifeste Ogygie : **chaque route déclarée devient un relais typé**, et rien
76
+ d'autre n'est atteignable. Zéro ligne de code par feature — livrer une route
77
+ n'ajoute aucun travail ici, l'oublier au manifeste la rend inatteignable.
78
+
79
+ C'est un **relais pur** : il ne décide jamais qui a le droit de quoi
80
+ (l'autorisation reste dans l'API — un relais qui autorise devient un confused
81
+ deputy), ne transforme aucun payload, ne détient aucune session.
82
+
83
+ ### Usage minimal
84
+
85
+ ```ts
86
+ import { readFileSync } from "node:fs";
87
+ import { createManifestGateway } from "@ogygie/core/gateway";
88
+ import * as widgetSchemas from "@my-product/api/features/widgets/schemas";
89
+
90
+ const manifest: unknown = JSON.parse(readFileSync("ogygie.manifest.json", "utf8"));
91
+
92
+ const app = await createManifestGateway(manifest, {
93
+ upstreamBaseUrl: "http://127.0.0.1:3000", // l'api, sur le réseau privé
94
+ schemas: { ...widgetSchemas }, // contrats indexés par nom exporté
95
+ staticRoot: "apps/web/dist", // build du front + fallback SPA
96
+ rateLimit: { max: 300, timeWindow: "1 minute" },
97
+ timeoutMs: 10_000,
98
+ csp: { nonce: true },
99
+ passthroughPrefixes: ["/api/auth/"], // liste FERMÉE, relais sans validation
100
+ });
101
+
102
+ await app.listen({ port: 8080, host: "0.0.0.0" });
103
+ console.log(`${app.gatewayRoutes.length} relais montés depuis le manifeste`);
104
+ for (const route of app.gatewayRoutes) console.log(route.method, route.path);
105
+ ```
106
+
107
+ ### Options
108
+
109
+ | Option | Requis | Effet |
110
+ |---|---|---|
111
+ | `upstreamBaseUrl` | oui | Adresse privée de l'api. Jamais exposée au client, même en erreur. |
112
+ | `schemas` | oui | `Record<string, ZodType>` indexé par nom exporté. Doit contenir **tous** les `schemaInput`/`schemaOutput` du manifeste, sinon le démarrage est refusé. |
113
+ | `timeoutMs` | oui | Délai au-delà duquel l'appel amont est abandonné (→ 504). |
114
+ | `csp.nonce` | oui | Pose un nonce CSP par requête et l'estampille sur les `<script>` de l'`index.html` servi. |
115
+ | `staticRoot` | non | Dossier du build front : fichiers statiques + fallback `index.html` pour le routage client. Absent = rien n'est servi. |
116
+ | `rateLimit` | non | `{ max, timeWindow }` ; défaut `300 / 1 minute`. |
117
+ | `passthroughPrefixes` | non | Préfixes relayés **sans validation de contrat** (ex. un catch-all d'auth possédé par l'api, absent du manifeste par nature). Seule brèche à la règle « rien ne passe qui ne soit au manifeste » : liste fermée, chaque entrée justifiée, jamais un motif large. |
118
+
119
+ Ce que le module N'inclut PAS, et qui reste à la charge de l'application : le
120
+ registre de schémas (produit-conscient par nature), le contrat d'environnement,
121
+ le point d'entrée, et le contenu de `passthroughPrefixes`.
122
+
123
+ ### Ce qui est posé pour tout le produit
124
+
125
+ En-têtes de sécurité **possédés par la passerelle** (une copie amont ne peut pas
126
+ les écraser) : CSP stricte sans `unsafe-inline`, HSTS, `X-Content-Type-Options`,
127
+ `Referrer-Policy`, `Permissions-Policy`, plus le rate limit. Le statut, le corps
128
+ et les `Set-Cookie` de l'amont, eux, traversent **octet pour octet** — une
129
+ `OgygieError` métier arrive au client sans reformatage.
130
+
131
+ ### Erreurs
132
+
133
+ | Code | Statut | Quand |
134
+ |---|---|---|
135
+ | `GATEWAY_MANIFEST_INVALID` | 500, **au démarrage** | Manifeste illisible, chemin de route malformé, route déclarée deux fois, ou schéma déclaré absent de `schemas`. Levée en `OgygieFailure` : le processus ne démarre pas — un démarrage impossible vaut mieux qu'un relais qui ment. |
136
+ | `CONTRACT_VALIDATION_FAILED` | 400 | L'entrée ne satisfait pas le schéma déclaré. Émise PAR la passerelle : le trafic invalide n'atteint jamais le réseau privé. Le corps JSON est validé sur POST/PUT/PATCH, `{ ...query, ...params }` sur GET/DELETE. |
137
+ | `GATEWAY_ROUTE_UNKNOWN` | 404 | Aucune route déclarée ne correspond (et aucun fallback SPA applicable). |
138
+ | `GATEWAY_UPSTREAM_UNAVAILABLE` | 502 | L'amont est injoignable. Aucun détail interne ne fuit : ni URL privée, ni stack. |
139
+ | `GATEWAY_UPSTREAM_TIMEOUT` | 504 | L'amont n'a pas répondu dans `timeoutMs`. |
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@ogygie/core",
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "type": "module",
7
+ "files": [
8
+ "src",
9
+ "README.md",
10
+ "LICENSE"
11
+ ],
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "exports": {
16
+ "./manifest": "./src/manifest.schema.ts",
17
+ "./vocabulary": "./src/vocabulary.ts",
18
+ "./web-lint": "./src/web-lint.ts",
19
+ "./bundle-budget": "./src/bundle-budget.ts",
20
+ "./spec": "./src/spec.schema.ts",
21
+ "./decision-refs": "./src/decision-refs.ts",
22
+ "./design-brief": "./src/design-brief.ts",
23
+ "./module-purity": "./src/module-purity.ts",
24
+ "./contract-purity": "./src/contract-purity.ts",
25
+ "./errors": "./src/error.schema.ts",
26
+ "./db": "./src/db.ts",
27
+ "./clock": "./src/clock.ts",
28
+ "./tenant": "./src/tenant.ts",
29
+ "./shared-tables": "./src/shared-tables.ts",
30
+ "./test-kit": "./src/test-kit.ts",
31
+ "./gateway": "./src/manifest-gateway.ts",
32
+ "./client": "./src/manifest-client.ts"
33
+ },
34
+ "scripts": {
35
+ "typecheck": "tsc -p tsconfig.json"
36
+ },
37
+ "peerDependencies": {
38
+ "@electric-sql/pglite": "^0.3.0",
39
+ "@fastify/helmet": "^13.0.0",
40
+ "@fastify/rate-limit": "^10.0.0",
41
+ "@fastify/static": "^10.1.2",
42
+ "drizzle-orm": "^0.45.2",
43
+ "fastify": "^5.0.0",
44
+ "zod": "^4.0.0"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "@electric-sql/pglite": {
48
+ "optional": true
49
+ },
50
+ "@fastify/helmet": {
51
+ "optional": true
52
+ },
53
+ "@fastify/rate-limit": {
54
+ "optional": true
55
+ },
56
+ "@fastify/static": {
57
+ "optional": true
58
+ },
59
+ "drizzle-orm": {
60
+ "optional": true
61
+ },
62
+ "fastify": {
63
+ "optional": true
64
+ }
65
+ },
66
+ "devDependencies": {
67
+ "@electric-sql/pglite": "^0.3.0",
68
+ "@fastify/helmet": "^13.0.0",
69
+ "@fastify/rate-limit": "^10.0.0",
70
+ "@fastify/static": "^10.1.2",
71
+ "@types/node": "^24.0.0",
72
+ "drizzle-orm": "^0.45.2",
73
+ "fastify": "^5.0.0",
74
+ "typescript": "^5.8.0",
75
+ "zod": "^4.0.0"
76
+ }
77
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @intent Mutation tests of the bundle-budget gate logic (D22: a known
3
+ * violation must be DETECTED, not just the happy path).
4
+ */
5
+ import { describe, expect, it } from "vitest";
6
+ import { bundleBudgetVerdict, entryAssetPathsOf } from "./bundle-budget.js";
7
+
8
+ const BUILT_INDEX_HTML = `<!doctype html>
9
+ <html>
10
+ <head>
11
+ <script type="module" crossorigin src="/assets/index-C3PO.js"></script>
12
+ <link rel="modulepreload" crossorigin href="/assets/vendor-R2D2.js">
13
+ <link rel="stylesheet" crossorigin href="/assets/index-BB88.css">
14
+ <link rel="icon" href="/favicon.svg">
15
+ </head>
16
+ <body><div id="root"></div></body>
17
+ </html>`;
18
+
19
+ describe("bundle-budget", () => {
20
+ it("collecte script d'entrée, modulepreload et stylesheet — et EXCLUT les liens non initiaux", () => {
21
+ const paths = entryAssetPathsOf(BUILT_INDEX_HTML);
22
+ expect(paths).toEqual([
23
+ "/assets/index-C3PO.js",
24
+ "/assets/vendor-R2D2.js",
25
+ "/assets/index-BB88.css",
26
+ ]);
27
+ });
28
+
29
+ it("ne compte pas un chunk paresseux non référencé par index.html", () => {
30
+ // A lazy chunk (locale catalog…) never appears in index.html: the parser
31
+ // must not invent it. Mutation guard: a regex that matched every asset
32
+ // in dist/ would return it.
33
+ expect(entryAssetPathsOf(BUILT_INDEX_HTML)).not.toContain("/assets/fr-LOCALE.js");
34
+ });
35
+
36
+ it("verdict NÉGATIF : un bundle au-delà du budget est refusé (jamais avalé)", () => {
37
+ const verdict = bundleBudgetVerdict(
38
+ [
39
+ { path: "/assets/index.js", gzipBytes: 200_000 },
40
+ { path: "/assets/vendor.js", gzipBytes: 60_000 },
41
+ ],
42
+ 250 * 1024,
43
+ );
44
+ expect(verdict.totalGzipBytes).toBe(260_000);
45
+ expect(verdict.withinBudget).toBe(false);
46
+ });
47
+
48
+ it("verdict nominal : sous le budget, assets triés du plus lourd au plus léger", () => {
49
+ const verdict = bundleBudgetVerdict(
50
+ [
51
+ { path: "/assets/a.js", gzipBytes: 10 },
52
+ { path: "/assets/b.js", gzipBytes: 20 },
53
+ ],
54
+ 250 * 1024,
55
+ );
56
+ expect(verdict.withinBudget).toBe(true);
57
+ expect(verdict.assets[0]?.path).toBe("/assets/b.js");
58
+ });
59
+
60
+ it("verdict NÉGATIF : la limite est stricte (250 kB pile = refusé, la spec dit « < »)", () => {
61
+ const verdict = bundleBudgetVerdict([{ path: "/a.js", gzipBytes: 256_000 }], 256_000);
62
+ expect(verdict.withinBudget).toBe(false);
63
+ });
64
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @intent Testable logic of the CI gate `ogygie:verify-bundle` (D22: a gate is
3
+ * born with its mutation test — see bundle-budget.test.ts). PURE on purpose
4
+ * (no node imports — the core tsconfig has no node types): the script
5
+ * scripts/verify-bundle.ts reads the files and gzips them, this module decides
6
+ * WHICH assets count as the initial bundle and whether the budget holds.
7
+ * "Initial bundle" = everything the browser loads before first paint: entry
8
+ * scripts, stylesheets and modulepreload'ed chunks referenced by index.html —
9
+ * lazy chunks (e.g. locale catalogs) deliberately excluded.
10
+ */
11
+
12
+ export interface BundleAsset {
13
+ path: string;
14
+ gzipBytes: number;
15
+ }
16
+
17
+ export interface BundleBudgetVerdict {
18
+ assets: BundleAsset[];
19
+ totalGzipBytes: number;
20
+ budgetBytes: number;
21
+ withinBudget: boolean;
22
+ }
23
+
24
+ /** Extracts the initial-load asset paths referenced by a built index.html. */
25
+ export function entryAssetPathsOf(indexHtml: string): string[] {
26
+ const paths: string[] = [];
27
+ for (const match of indexHtml.matchAll(/<script[^>]*\bsrc="([^"]+)"/g)) {
28
+ if (match[1] !== undefined) paths.push(match[1]);
29
+ }
30
+ for (const match of indexHtml.matchAll(/<link[^>]+>/g)) {
31
+ const tag = match[0];
32
+ const isInitial = /\brel="(?:stylesheet|modulepreload)"/.test(tag);
33
+ const href = /\bhref="([^"]+)"/.exec(tag)?.[1];
34
+ if (isInitial && href !== undefined) paths.push(href);
35
+ }
36
+ return paths;
37
+ }
38
+
39
+ /** Sums the gzip sizes and compares against the budget — nothing is dropped silently. */
40
+ export function bundleBudgetVerdict(
41
+ assets: BundleAsset[],
42
+ budgetBytes: number,
43
+ ): BundleBudgetVerdict {
44
+ const totalGzipBytes = assets.reduce((total, asset) => total + asset.gzipBytes, 0);
45
+ return {
46
+ assets: [...assets].sort((a, b) => b.gzipBytes - a.gzipBytes),
47
+ totalGzipBytes,
48
+ budgetBytes,
49
+ withinBudget: totalGzipBytes < budgetBytes,
50
+ };
51
+ }
package/src/clock.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @intent Injectable clock: any function that needs the current time receives it
3
+ * as a dependency (declared-effects principle). Never a bare `new Date()` inside
4
+ * a service — the clock is the only access point to time.
5
+ */
6
+
7
+ export interface Clock {
8
+ /** Current instant, UTC (dates are ALWAYS stored as UTC ISO 8601). */
9
+ now(): Date;
10
+ }
11
+
12
+ /** Real system clock — only injected at bootstrap (server.ts). */
13
+ export const systemClock: Clock = {
14
+ now: () => new Date(),
15
+ };
16
+
17
+ /** Frozen clock for tests: `fixedClock("2026-07-01")`. */
18
+ export function fixedClock(isoInstant: string): Clock {
19
+ const instant = new Date(isoInstant);
20
+ if (Number.isNaN(instant.getTime())) {
21
+ throw new Error(`fixedClock: invalid ISO instant "${isoInstant}"`);
22
+ }
23
+ return { now: () => instant };
24
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * @intent Proves the transitive contract gate AND its mutations (D22): each
3
+ * case below is a way the server has come back — or could come back — into a
4
+ * contract graph, and the audit must catch every one of them without crying
5
+ * wolf on the erased forms. The keystone case is the historical incident
6
+ * (schemas.ts → index.ts → data loader → node:fs): it is caught THROUGH the
7
+ * chain, which is precisely what a single-file check cannot do.
8
+ */
9
+ import { describe, expect, it } from "vitest";
10
+ import {
11
+ auditContractPurity,
12
+ contractEntryPaths,
13
+ resolveContractImport,
14
+ type ContractPurityManifest,
15
+ } from "./contract-purity.js";
16
+
17
+ const MANIFEST: ContractPurityManifest = {
18
+ apps: [
19
+ { name: "api", path: "apps/api", type: "api" },
20
+ { name: "web", path: "apps/web", type: "front" },
21
+ ],
22
+ features: [
23
+ { name: "orders", folder: "src/features/orders", status: "delivered" },
24
+ { name: "labels", folder: "src/features/labels", status: "delivered" },
25
+ { name: "ecrans", app: "web", folder: "src/features", status: "delivered" },
26
+ { name: "futur", folder: "src/features/futur", status: "skeleton" },
27
+ ],
28
+ };
29
+
30
+ const ORDERS = "apps/api/src/features/orders/schemas.ts";
31
+ const LABELS = "apps/api/src/features/labels/schemas.ts";
32
+
33
+ /** A pure two-feature graph: zod, a shared contract, a sibling contract. */
34
+ const PURE_MODULES: Record<string, string> = {
35
+ [ORDERS]: `
36
+ import { z } from "zod";
37
+ import { LabelCodeSchema } from "../labels/schemas.js";
38
+ import { SourceProvenanceSchema } from "../../provenance.js";
39
+ export const OrderSchema = z.object({ labelCode: LabelCodeSchema });
40
+ `,
41
+ [LABELS]: `import { z } from "zod";\nexport const LabelCodeSchema = z.string();`,
42
+ "apps/api/src/provenance.ts": `import { z } from "zod";\nexport const SourceProvenanceSchema = z.object({});`,
43
+ };
44
+
45
+ function auditWith(overrides: Record<string, string>) {
46
+ const modules = { ...PURE_MODULES, ...overrides };
47
+ return auditContractPurity({
48
+ manifest: MANIFEST,
49
+ readModule: (path) => modules[path] ?? null,
50
+ });
51
+ }
52
+
53
+ describe("contract-purity", () => {
54
+ it("les points d'entrée sont DÉRIVÉS du manifeste : api uniquement, skeleton exclu", () => {
55
+ expect(contractEntryPaths(MANIFEST)).toEqual([ORDERS, LABELS]);
56
+ });
57
+
58
+ it("un graphe de contrats pur ne remonte aucune impureté", () => {
59
+ const report = auditWith({});
60
+ expect(report.impurities).toEqual([]);
61
+ expect(report.reachedPaths).toContain("apps/api/src/provenance.ts");
62
+ });
63
+
64
+ // ------------------------------------------------------------- mutations
65
+ it("MUTATION — l'incident historique : schemas.ts → index.ts → loader.ts → node:fs", () => {
66
+ const report = auditWith({
67
+ [ORDERS]: `import { searchLabels } from "../labels/index.js";`,
68
+ "apps/api/src/features/labels/index.ts": `export * from "./loader.js";`,
69
+ "apps/api/src/features/labels/loader.ts": `import { readFileSync } from "node:fs";`,
70
+ });
71
+ expect(report.impurities).toHaveLength(1);
72
+ expect(report.impurities[0]?.kind).toBe("SERVER_SURFACE");
73
+ expect(report.impurities[0]?.entryPath).toBe(ORDERS);
74
+ });
75
+
76
+ it("MUTATION — la TRANSITIVITÉ : un intermédiaire innocent qui ramène node:fs", () => {
77
+ const report = auditWith({
78
+ [ORDERS]: `import { ORDER_CODES } from "./constants.js";`,
79
+ "apps/api/src/features/orders/constants.ts": `export * from "./loader.js";`,
80
+ "apps/api/src/features/orders/loader.ts": `import { readFileSync } from "node:fs";`,
81
+ });
82
+ expect(report.impurities).toHaveLength(1);
83
+ const [impurity] = report.impurities;
84
+ expect(impurity?.kind).toBe("RUNTIME_PACKAGE");
85
+ expect(impurity?.specifier).toBe("node:fs");
86
+ expect(impurity?.chain).toEqual([
87
+ ORDERS,
88
+ "apps/api/src/features/orders/constants.ts",
89
+ "apps/api/src/features/orders/loader.ts",
90
+ ]);
91
+ });
92
+
93
+ it("MUTATION — Drizzle importé directement dans un contrat casse le gate", () => {
94
+ const report = auditWith({
95
+ [ORDERS]: `import { pgTable } from "drizzle-orm/pg-core";\nimport { z } from "zod";`,
96
+ });
97
+ expect(report.impurities).toHaveLength(1);
98
+ expect(report.impurities[0]?.specifier).toBe("drizzle-orm/pg-core");
99
+ expect(report.impurities[0]?.kind).toBe("RUNTIME_PACKAGE");
100
+ });
101
+
102
+ it("MUTATION — un index.ts PUR reste interdit : c'est la surface serveur", () => {
103
+ const report = auditWith({
104
+ [ORDERS]: `import { LABELS_LIST } from "../labels/index.js";`,
105
+ "apps/api/src/features/labels/index.ts": `export const LABELS_LIST = [];`,
106
+ });
107
+ expect(report.impurities).toHaveLength(1);
108
+ expect(report.impurities[0]?.kind).toBe("SERVER_SURFACE");
109
+ });
110
+
111
+ it("MUTATION — un module illisible est SIGNALÉ, jamais avalé en silence", () => {
112
+ const report = auditWith({ [ORDERS]: `import { X } from "./disparu.js";` });
113
+ expect(report.impurities).toHaveLength(1);
114
+ expect(report.impurities[0]?.kind).toBe("MODULE_NOT_FOUND");
115
+ expect(report.impurities[0]?.specifier).toBe("apps/api/src/features/orders/disparu.ts");
116
+ });
117
+
118
+ it("MUTATION — un contrat absent du disque est signalé (le gate ne saute pas une feature)", () => {
119
+ const report = auditContractPurity({
120
+ manifest: MANIFEST,
121
+ readModule: (path) => (path === ORDERS ? PURE_MODULES[path] ?? null : null),
122
+ });
123
+ expect(report.impurities.map((impurity) => impurity.kind)).toEqual([
124
+ "MODULE_NOT_FOUND", // ../labels/schemas.js, atteint depuis orders
125
+ "MODULE_NOT_FOUND", // ../../provenance.js
126
+ "MODULE_NOT_FOUND", // l'entrée labels elle-même
127
+ ]);
128
+ });
129
+
130
+ it("MUTATION — chaque contrat cassé est rapporté séparément, même faute partagée", () => {
131
+ const report = auditWith({
132
+ [LABELS]: `import { sql } from "drizzle-orm";`,
133
+ });
134
+ // orders atteint labels/schemas.ts, labels est aussi une entrée.
135
+ expect(report.impurities.map((impurity) => impurity.entryPath)).toEqual([ORDERS, LABELS]);
136
+ });
137
+
138
+ it("un import type vers la surface serveur reste permis : il est effacé à la compilation", () => {
139
+ const report = auditWith({
140
+ [ORDERS]: `import type { Label } from "../labels/index.js";\nimport { z } from "zod";`,
141
+ });
142
+ expect(report.impurities).toEqual([]);
143
+ });
144
+
145
+ it("un cycle d'imports ne fait pas tourner le gate en rond", () => {
146
+ const report = auditWith({
147
+ [ORDERS]: `import { A } from "./a.js";`,
148
+ "apps/api/src/features/orders/a.ts": `import { B } from "./b.js";`,
149
+ "apps/api/src/features/orders/b.ts": `import { A } from "./a.js";`,
150
+ });
151
+ expect(report.impurities).toEqual([]);
152
+ });
153
+
154
+ it("la politique d'autorisation est explicite : élargir est une décision", () => {
155
+ const modules: Record<string, string> = {
156
+ ...PURE_MODULES,
157
+ [ORDERS]: `import { format } from "date-fns";`,
158
+ };
159
+ const options = { manifest: MANIFEST, readModule: (path: string) => modules[path] ?? null };
160
+ expect(auditContractPurity(options).impurities).toHaveLength(1);
161
+ expect(
162
+ auditContractPurity({ ...options, allowedRuntimeSpecifiers: ["zod", "date-fns"] }).impurities,
163
+ ).toEqual([]);
164
+ });
165
+
166
+ it("resolveContractImport rend le chemin du dépôt, extension TypeScript restaurée", () => {
167
+ expect(resolveContractImport("zod", ORDERS)).toEqual({ kind: "external", specifier: "zod" });
168
+ expect(resolveContractImport("node:fs", ORDERS)).toEqual({
169
+ kind: "external",
170
+ specifier: "node:fs",
171
+ });
172
+ expect(resolveContractImport("../../provenance.js", ORDERS)).toEqual({
173
+ kind: "internal",
174
+ path: "apps/api/src/provenance.ts",
175
+ });
176
+ expect(resolveContractImport("./formulas", ORDERS)).toEqual({
177
+ kind: "internal",
178
+ path: "apps/api/src/features/orders/formulas.ts",
179
+ });
180
+ expect(resolveContractImport("../labels/index.js", ORDERS)).toEqual({
181
+ kind: "server-surface",
182
+ path: "apps/api/src/features/labels/index.ts",
183
+ });
184
+ });
185
+ });