@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 +21 -0
- package/README.md +139 -0
- package/package.json +77 -0
- package/src/bundle-budget.test.ts +64 -0
- package/src/bundle-budget.ts +51 -0
- package/src/clock.ts +24 -0
- package/src/contract-purity.test.ts +185 -0
- package/src/contract-purity.ts +213 -0
- package/src/db.ts +9 -0
- package/src/decision-refs.test.ts +114 -0
- package/src/decision-refs.ts +103 -0
- package/src/design-brief.test.ts +221 -0
- package/src/design-brief.ts +581 -0
- package/src/error.schema.ts +62 -0
- package/src/examples/erreur-convention-ci.json +10 -0
- package/src/examples/erreur-validation-runtime.json +10 -0
- package/src/manifest-client.test.ts +287 -0
- package/src/manifest-client.ts +313 -0
- package/src/manifest-gateway.test.ts +541 -0
- package/src/manifest-gateway.ts +392 -0
- package/src/manifest.schema.test.ts +255 -0
- package/src/manifest.schema.ts +187 -0
- package/src/module-purity.test.ts +117 -0
- package/src/module-purity.ts +127 -0
- package/src/product-vocabulary.scan.test.ts +142 -0
- package/src/shared-tables.test.ts +136 -0
- package/src/shared-tables.ts +113 -0
- package/src/spec.schema.test.ts +143 -0
- package/src/spec.schema.ts +182 -0
- package/src/tenant.ts +207 -0
- package/src/test-kit.ts +30 -0
- package/src/vocabulary.test.ts +46 -0
- package/src/vocabulary.ts +36 -0
- package/src/web-lint.test.ts +100 -0
- package/src/web-lint.ts +176 -0
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
|
+
});
|