@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.
@@ -0,0 +1,187 @@
1
+ /**
2
+ * @intent Zod schema of `ogygie.manifest.json`, the machine-readable map of the repo.
3
+ * It is the FIRST file an agent reads after AGENTS.md. Verified in CI: any
4
+ * divergence between the manifest and the actual code breaks the build.
5
+ * Keys and enum values are English (DECISIONS.md D11 — machine-readable = English).
6
+ */
7
+ import { z } from "zod";
8
+
9
+ export const FeatureNameSchema = z
10
+ .string()
11
+ .regex(/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/, "kebab-case required");
12
+
13
+ export const HttpRouteSchema = z.object({
14
+ method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
15
+ path: z.string().startsWith("/api/"),
16
+ description: z.string().min(10),
17
+ schemaInput: z
18
+ .string()
19
+ .describe("Exported name of the input Zod schema, e.g. CreateOrderInputSchema"),
20
+ schemaOutput: z.string().describe("Exported name of the output Zod schema"),
21
+ invalidates: z
22
+ .array(z.string())
23
+ .optional()
24
+ .describe(
25
+ "Cache roots this mutation stales BEYOND its own path root (cross-feature dependency, read by the manifest-derived client — never guessed in a screen). Each value must be an existing feature name or route root",
26
+ ),
27
+ });
28
+
29
+ /**
30
+ * One table owned by a feature. A bare string is the DEFAULT and by far the
31
+ * common case: a TENANT-SCOPED table, carrying the tenant column, reachable
32
+ * only through `tenantDb`, protected by its RLS policy.
33
+ *
34
+ * The object form declares a SHARED referential table (AGENTS.md §Multi-tenant
35
+ * rule 4): universal knowledge no tenant owns (an external open-data edition
36
+ * loaded by an extraction script), hence NO tenant column, NO tenant RLS policy, and
37
+ * read-only for the API. It is a deliberate hole in the strongest rail of the
38
+ * product, so it is declared HERE, machine-readably, and the gates check it
39
+ * (see shared-tables.ts) — never left to a reader's judgement.
40
+ * `shared` is a literal `true`: a table is either shared (and says so) or
41
+ * tenant-scoped (and stays a plain string) — no `shared: false` third state.
42
+ */
43
+ export const DbTableDeclarationSchema = z.union([
44
+ z.string(),
45
+ z.object({ name: z.string(), shared: z.literal(true) }),
46
+ ]);
47
+ export type DbTableDeclaration = z.infer<typeof DbTableDeclarationSchema>;
48
+
49
+ export const FeatureManifestSchema = z.object({
50
+ name: FeatureNameSchema,
51
+ intent: z.string().min(20).max(300).describe("The feature's 'why' in 1-3 sentences"),
52
+ app: z
53
+ .string()
54
+ .optional()
55
+ .describe("Name of the owning app (apps[].name); defaults to the api app"),
56
+ folder: z
57
+ .string()
58
+ .describe("Path relative to the app root, e.g. src/features/orders"),
59
+ spec: z
60
+ .string()
61
+ .nullable()
62
+ .describe("Path to the spec, e.g. specs/orders.spec.md, null for technical features"),
63
+ routes: z.array(HttpRouteSchema),
64
+ dbTables: z
65
+ .array(DbTableDeclarationSchema)
66
+ .describe(
67
+ "Drizzle tables owned by this feature (one table = one owner). String = tenant-scoped; { name, shared: true } = shared referential",
68
+ ),
69
+ dependsOn: z
70
+ .array(FeatureNameSchema)
71
+ .describe("Features imported through their index.ts — nothing else"),
72
+ status: z.enum(["skeleton", "in-progress", "delivered"]),
73
+ });
74
+
75
+ /**
76
+ * Version of the Ogygie framework a CONSUMER project is pinned to
77
+ * (spec ogygie-standalone-repo). Injected by `ogygie init`, bumped only by
78
+ * `ogygie upgrade`, checked in CI by `ogygie check` against the installed
79
+ * @ogygie/cli and @ogygie/core versions (lockstep, D2). Absent from the
80
+ * framework repo's own manifest — the framework IS the version.
81
+ */
82
+ export const OgygieVersionSchema = z
83
+ .string()
84
+ .regex(/^\d+\.\d+\.\d+$/, "expected a plain x.y.z version");
85
+
86
+ /**
87
+ * Self-description of the FRAMEWORK repo (spec ogygie-standalone-repo B1):
88
+ * the published packages, the template folders `init` copies, and the module
89
+ * declaring the governed-files list — so an agent reading the manifest knows
90
+ * them without exploring the repo. Consumer manifests never carry this block.
91
+ */
92
+ export const FrameworkManifestSchema = z.object({
93
+ packages: z.array(
94
+ z.object({
95
+ name: z.string().describe("Published npm name, e.g. @ogygie/core"),
96
+ path: z.string().describe("Folder relative to the repo root"),
97
+ intent: z.string().min(20),
98
+ }),
99
+ ),
100
+ templates: z.object({
101
+ project: z.string().describe("Folder copied by `ogygie init`"),
102
+ feature: z.string().describe("Reserved for `ogygie generate feature` (not wired yet)"),
103
+ }),
104
+ governedFiles: z
105
+ .string()
106
+ .describe("Module declaring the exhaustive list of files `upgrade` may overwrite (D3)"),
107
+ });
108
+
109
+ export const OgygieManifestSchema = z.object({
110
+ version: z.literal(1),
111
+ product: z.object({
112
+ name: z.string(),
113
+ description: z.string().min(20),
114
+ }),
115
+ ogygieVersion: OgygieVersionSchema.optional(),
116
+ framework: FrameworkManifestSchema.optional(),
117
+ apps: z.array(
118
+ z.object({
119
+ name: z.string(),
120
+ path: z.string(),
121
+ type: z.enum(["api", "front", "gateway"]),
122
+ entryPoint: z.string(),
123
+ }),
124
+ ),
125
+ features: z.array(FeatureManifestSchema),
126
+ conventions: z.object({
127
+ agentsMd: z.literal("AGENTS.md"),
128
+ failuresMd: z.literal("FAILURES.md"),
129
+ specsFolder: z.literal("specs/"),
130
+ }),
131
+ })
132
+ // Non-api features (front and gateway specs): `app` must reference a
133
+ // declared app, and a feature owned by a front or gateway app declares
134
+ // neither HTTP routes nor DB tables — those belong to api features (the
135
+ // front CONSUMES contracts, the gateway RELAYS declared routes; neither
136
+ // OWNS any).
137
+ .superRefine((manifest, context) => {
138
+ const appsByName = new Map(manifest.apps.map((app) => [app.name, app]));
139
+ // `invalidates` targets must EXIST: a feature name, or the root segment of
140
+ // a declared route path ("/api/order-archive" → "order-archive"). A typo
141
+ // here would silently never invalidate anything — refused at parse time.
142
+ const knownInvalidationTargets = new Set<string>(
143
+ manifest.features.map((feature) => feature.name),
144
+ );
145
+ for (const feature of manifest.features) {
146
+ for (const route of feature.routes) {
147
+ const root = route.path.split("/")[2];
148
+ if (root !== undefined) knownInvalidationTargets.add(root);
149
+ }
150
+ }
151
+ manifest.features.forEach((feature, index) => {
152
+ feature.routes.forEach((route, routeIndex) => {
153
+ for (const target of route.invalidates ?? []) {
154
+ if (!knownInvalidationTargets.has(target)) {
155
+ context.addIssue({
156
+ code: "custom",
157
+ path: ["features", index, "routes", routeIndex, "invalidates"],
158
+ message: `invalidates target "${target}" matches no feature name and no route root`,
159
+ });
160
+ }
161
+ }
162
+ });
163
+ });
164
+ manifest.features.forEach((feature, index) => {
165
+ if (feature.app === undefined) return;
166
+ const owningApp = appsByName.get(feature.app);
167
+ if (owningApp === undefined) {
168
+ context.addIssue({
169
+ code: "custom",
170
+ path: ["features", index, "app"],
171
+ message: `app "${feature.app}" is not declared in apps[]`,
172
+ });
173
+ return;
174
+ }
175
+ if (owningApp.type !== "api" && (feature.routes.length > 0 || feature.dbTables.length > 0)) {
176
+ context.addIssue({
177
+ code: "custom",
178
+ path: ["features", index],
179
+ message:
180
+ "a non-api feature owns no HTTP routes and no DB tables — declare them on the api feature",
181
+ });
182
+ }
183
+ });
184
+ });
185
+
186
+ export type OgygieManifest = z.infer<typeof OgygieManifestSchema>;
187
+ export type FeatureManifest = z.infer<typeof FeatureManifestSchema>;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @intent Proves the purity checker AND its mutations (DECISIONS.md D22): a
3
+ * gate is only worth its negative test. Every case below is a source a pure
4
+ * module could plausibly drift into — the checker must catch each one, and
5
+ * must NOT cry wolf on the erased forms.
6
+ */
7
+ import { describe, expect, it } from "vitest";
8
+ import { findImpureImports, parseModuleImports } from "./module-purity.js";
9
+
10
+ const PURE_MODULE = `
11
+ /** @intent A pure module: value objects in, numbers out. */
12
+ import type { LineCostInputs } from "./schemas.js";
13
+
14
+ export function computeLineCost(inputs: LineCostInputs): number {
15
+ return inputs.quantity * inputs.amountCents;
16
+ }
17
+ `;
18
+
19
+ describe("module-purity", () => {
20
+ it("un module sans aucun import runtime est pur", () => {
21
+ expect(findImpureImports(PURE_MODULE)).toEqual([]);
22
+ expect(findImpureImports("export const RATE = 2;\n")).toEqual([]);
23
+ });
24
+
25
+ it("accepte les formes ERASÉES : import type, et bindings tous préfixés type", () => {
26
+ expect(findImpureImports(`import type { A } from "./schemas.js";`)).toEqual([]);
27
+ expect(findImpureImports(`import { type A, type B } from "./schemas.js";`)).toEqual([]);
28
+ expect(
29
+ findImpureImports(`import type {\n A,\n B,\n} from "./schemas.js";`),
30
+ ).toEqual([]);
31
+ });
32
+
33
+ // ------------------------------------------------------------- mutations
34
+ it("MUTATION — un import de valeur casse la pureté (db, clock, réseau, feature voisine)", () => {
35
+ for (const impure of [
36
+ `import { tenantDb } from "@ogygie/core/tenant";`,
37
+ `import { fixedClock } from "@ogygie/core/clock";`,
38
+ `import { listOrders } from "../orders/index.js";`,
39
+ `import { z } from "zod";`,
40
+ ]) {
41
+ const found = findImpureImports(`${PURE_MODULE}\n${impure}`);
42
+ expect(found, impure).toHaveLength(1);
43
+ expect(found[0]?.typeOnly).toBe(false);
44
+ }
45
+ });
46
+
47
+ it("MUTATION — un import à effet de bord nu (sans binding) est détecté", () => {
48
+ const found = findImpureImports(`import "./fastify-decorations.js";`);
49
+ expect(found).toHaveLength(1);
50
+ expect(found[0]?.specifier).toBe("./fastify-decorations.js");
51
+ });
52
+
53
+ it("MUTATION — un binding de valeur mêlé à des types ne s'efface pas", () => {
54
+ const found = findImpureImports(`import { type A, computeThing } from "./schemas.js";`);
55
+ expect(found).toHaveLength(1);
56
+ });
57
+
58
+ it("MUTATION — import par défaut ou namespace : jamais erasé", () => {
59
+ expect(findImpureImports(`import schemas from "./schemas.js";`)).toHaveLength(1);
60
+ expect(findImpureImports(`import * as schemas from "./schemas.js";`)).toHaveLength(1);
61
+ });
62
+
63
+ it("MUTATION — le chargement dynamique (import(), require()) est une arête runtime", () => {
64
+ expect(findImpureImports(`const db = await import("@ogygie/core/db");`)).toHaveLength(1);
65
+ expect(findImpureImports(`const db = require("@ogygie/core/db");`)).toHaveLength(1);
66
+ });
67
+
68
+ it("MUTATION — un ré-export est une arête runtime (la forme même d'un index.ts)", () => {
69
+ for (const impure of [
70
+ `export * from "./loader.js";`,
71
+ `export * as loader from "./loader.js";`,
72
+ `export { runLoader } from "./loader.js";`,
73
+ `export { runLoader as loader } from "./loader.js";`,
74
+ ]) {
75
+ const found = findImpureImports(impure);
76
+ expect(found, impure).toHaveLength(1);
77
+ expect(found[0]?.specifier, impure).toBe("./loader.js");
78
+ }
79
+ });
80
+
81
+ it("les ré-exports ERASÉS ne comptent pas, et un export local ne s'apparie pas avec un import", () => {
82
+ expect(findImpureImports(`export type { Order } from "./schemas.js";`)).toEqual([]);
83
+ expect(findImpureImports(`export { type Order } from "./schemas.js";`)).toEqual([]);
84
+ const localThenImport = `export const RATE = 2;\nimport type { A } from "./a.js";\n`;
85
+ expect(findImpureImports(localThenImport)).toEqual([]);
86
+ });
87
+
88
+ it("MUTATION — signale TOUS les imports fautifs d'un coup, pas seulement le premier", () => {
89
+ const found = findImpureImports(
90
+ `import { a } from "./a.js";\nimport { b } from "./b.js";\nimport type { C } from "./c.js";\n`,
91
+ );
92
+ expect(found.map((moduleImport) => moduleImport.specifier)).toEqual(["./a.js", "./b.js"]);
93
+ });
94
+
95
+ it("un import commenté ou cité en prose ne compte pas comme une arête", () => {
96
+ expect(
97
+ findImpureImports(`/* import { tenantDb } from "@ogygie/core/tenant"; */\n`),
98
+ ).toEqual([]);
99
+ expect(findImpureImports(`// import { z } from "zod";\n`)).toEqual([]);
100
+ });
101
+
102
+ it("la politique peut autoriser explicitement un spécificateur runtime", () => {
103
+ const source = `import { z } from "zod";`;
104
+ expect(findImpureImports(source)).toHaveLength(1);
105
+ expect(findImpureImports(source, { allowedRuntimeSpecifiers: ["zod"] })).toEqual([]);
106
+ });
107
+
108
+ it("parseModuleImports rend le relevé complet, type-only compris", () => {
109
+ const imports = parseModuleImports(
110
+ `import type { A } from "./a.js";\nimport { b } from "./b.js";\n`,
111
+ );
112
+ expect(imports).toEqual([
113
+ { specifier: "./a.js", typeOnly: true, statement: `import type { A } from "./a.js"` },
114
+ { specifier: "./b.js", typeOnly: false, statement: `import { b } from "./b.js"` },
115
+ ]);
116
+ });
117
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @intent Static purity check for the product's PURE modules (computation
3
+ * formulas, costing engines — anything advertised as "pure compute"):
4
+ * a pure module must have NO runtime import at all — no db, no clock, no
5
+ * network, no sibling feature — so its results depend on nothing but its
6
+ * arguments, and a test can attack it directly.
7
+ *
8
+ * Generic tooling, hence the core (DECISIONS.md D10 amended): it knows no
9
+ * product vocabulary, only TypeScript import syntax. Deliberately a source
10
+ * scan and not a runtime probe: an import with a side effect has already run
11
+ * by the time a test could observe it.
12
+ *
13
+ * Type-only imports (`import type ...`, and specifiers whose bindings are all
14
+ * `type X`) are ERASED at compile time: they carry no runtime edge and stay
15
+ * allowed. Everything else — value imports, bare side-effect imports,
16
+ * re-exports (`export ... from`), `import(...)`, `require(...)` — is impure
17
+ * unless explicitly allowed. A re-export is an import that hides its name: it
18
+ * loads the module all the same, and it is the form an `index.ts` is made of.
19
+ */
20
+
21
+ export interface ModuleImport {
22
+ /** Module specifier as written, e.g. "./schemas.js". */
23
+ specifier: string;
24
+ /** True when the whole statement is erased at compile time. */
25
+ typeOnly: boolean;
26
+ /** Raw statement, quoted back in failures so the fix is obvious. */
27
+ statement: string;
28
+ }
29
+
30
+ /** `import ... from "x"` / `import "x"` — the multiline form included. */
31
+ const IMPORT_STATEMENT = /\bimport\b(?![.(])([\s\S]*?)from\s*["']([^"']+)["']|\bimport\s+["']([^"']+)["']/g;
32
+ /** Dynamic loading: no static form, no way to erase it — always a runtime edge. */
33
+ const DYNAMIC_LOAD = /\b(?:import\s*\(|require\s*\()\s*["']([^"']+)["']/g;
34
+ /**
35
+ * `export * from "x"` / `export * as ns from "x"` / `export { a } from "x"`.
36
+ * The clause is spelled out rather than matched lazily so a plain
37
+ * `export const a = 1` followed by an import never pairs up by accident.
38
+ */
39
+ const REEXPORT_STATEMENT =
40
+ /\bexport\s+(type\s+)?(\*(?:\s+as\s+[A-Za-z_$][\w$]*)?|\{[^}]*\})\s*from\s*["']([^"']+)["']/g;
41
+
42
+ /**
43
+ * Every static import of a module source. Comments are stripped first so a
44
+ * commented-out example never counts as a real edge.
45
+ */
46
+ export function parseModuleImports(source: string): ModuleImport[] {
47
+ const code = stripComments(source);
48
+ const imports: ModuleImport[] = [];
49
+
50
+ for (const match of code.matchAll(IMPORT_STATEMENT)) {
51
+ const [statement, clause, fromSpecifier, bareSpecifier] = match;
52
+ const specifier = fromSpecifier ?? bareSpecifier;
53
+ if (specifier === undefined) continue;
54
+ imports.push({
55
+ specifier,
56
+ typeOnly: clause !== undefined && isTypeOnlyClause(clause),
57
+ statement: normalizeWhitespace(statement),
58
+ });
59
+ }
60
+ for (const match of code.matchAll(REEXPORT_STATEMENT)) {
61
+ const [statement, typeKeyword, clause, specifier] = match;
62
+ if (specifier === undefined || clause === undefined) continue;
63
+ imports.push({
64
+ specifier,
65
+ typeOnly: typeKeyword !== undefined || isTypeOnlyClause(clause),
66
+ statement: normalizeWhitespace(statement),
67
+ });
68
+ }
69
+ for (const match of code.matchAll(DYNAMIC_LOAD)) {
70
+ const [statement, specifier] = match;
71
+ if (specifier === undefined) continue;
72
+ imports.push({
73
+ specifier,
74
+ typeOnly: false,
75
+ statement: normalizeWhitespace(statement),
76
+ });
77
+ }
78
+ return imports;
79
+ }
80
+
81
+ export interface PurityPolicy {
82
+ /**
83
+ * Specifiers a pure module may import AT RUNTIME. Empty by default: the
84
+ * strictest form, and the one the product's pure modules hold themselves to.
85
+ */
86
+ allowedRuntimeSpecifiers?: readonly string[];
87
+ }
88
+
89
+ /**
90
+ * The imports that break purity — empty array means the module is pure.
91
+ * Report them all at once: a module fixed one import per run is a module
92
+ * fixed three times.
93
+ */
94
+ export function findImpureImports(
95
+ source: string,
96
+ policy: PurityPolicy = {},
97
+ ): ModuleImport[] {
98
+ const allowed = new Set(policy.allowedRuntimeSpecifiers ?? []);
99
+ return parseModuleImports(source).filter(
100
+ (moduleImport) => !moduleImport.typeOnly && !allowed.has(moduleImport.specifier),
101
+ );
102
+ }
103
+
104
+ /**
105
+ * A clause is erased when the statement itself says `type`, or when EVERY
106
+ * named binding does (`import { type A, type B } from ...`). A default or
107
+ * namespace binding among them keeps the import alive.
108
+ */
109
+ function isTypeOnlyClause(clause: string): boolean {
110
+ const trimmed = clause.trim();
111
+ if (trimmed.startsWith("type ")) return true;
112
+ const named = /^\{([\s\S]*)\}$/.exec(trimmed);
113
+ if (named === null || named[1] === undefined) return false;
114
+ const bindings = named[1]
115
+ .split(",")
116
+ .map((binding) => binding.trim())
117
+ .filter((binding) => binding.length > 0);
118
+ return bindings.length > 0 && bindings.every((binding) => binding.startsWith("type "));
119
+ }
120
+
121
+ function stripComments(source: string): string {
122
+ return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
123
+ }
124
+
125
+ function normalizeWhitespace(statement: string): string {
126
+ return statement.replace(/\s+/g, " ").trim();
127
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @intent D10 rule 1 ("test du vocabulaire") applied to the WHOLE core
3
+ * (Phase 3 extension): EVERY file under packages/ogygie-core/src/** — modules,
4
+ * tests, fixtures, JSON examples — must name no product concept: no domain
5
+ * word, no product name, no feature id. Fixtures leak vocabulary just as
6
+ * easily as code, so nothing is exempt.
7
+ *
8
+ * This file is deliberately the ONLY place allowed to WRITE the forbidden
9
+ * words, and therefore the only file excluded from the scan. The checker is a
10
+ * pure shared function (@ogygie/core/vocabulary — extracted here on its third
11
+ * occurrence, rule of three); its matching semantics have their own mutation
12
+ * tests in vocabulary.test.ts. The mutations below prove the GATE: a module
13
+ * that does name a product concept is caught.
14
+ */
15
+ import { readdirSync, readFileSync, statSync } from "node:fs";
16
+ import { join, relative } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { describe, expect, it } from "vitest";
19
+ import { findProductVocabulary } from "./vocabulary.js";
20
+
21
+ /**
22
+ * Forbidden stems, matched case-insensitively at a word start (so "plot"
23
+ * catches "plots" and "plotId", "variet" catches variety/varietes/varieties).
24
+ * Two families: the consuming product's identity and feature ids, and the
25
+ * agricultural domain itself in both languages.
26
+ */
27
+ const FORBIDDEN_STEMS = [
28
+ // product identity and feature ids
29
+ "nisaba",
30
+ "exploitations-et-comptes",
31
+ "plan-semis",
32
+ "calculs-quantites",
33
+ "catalogue-prix",
34
+ "socle-web",
35
+ "bff-gateway",
36
+ "especes-eppo",
37
+ "referentiel-cycles",
38
+ "couts-previsionnels",
39
+ // domain vocabulary (fr / en)
40
+ "agricol",
41
+ "agricultur",
42
+ "maraich",
43
+ "exploitation",
44
+ "ferme",
45
+ "farm",
46
+ "parcelle",
47
+ "plot",
48
+ "planche",
49
+ "culture",
50
+ "crop",
51
+ "variet",
52
+ "semis",
53
+ "sowing",
54
+ "graine",
55
+ "seed",
56
+ "recolte",
57
+ "récolte",
58
+ "harvest",
59
+ "intrant",
60
+ "engrais",
61
+ "fertilizer",
62
+ "paillage",
63
+ "mulch",
64
+ "campagne",
65
+ "campaign",
66
+ "fournisseur",
67
+ "supplier",
68
+ "prix",
69
+ "price",
70
+ "espece",
71
+ "species",
72
+ "eppo",
73
+ ] as const;
74
+
75
+ const SRC_DIR = fileURLToPath(new URL(".", import.meta.url));
76
+ const SELF = fileURLToPath(import.meta.url);
77
+
78
+ /** Every file under src/**, whatever its extension — fixtures included. */
79
+ function listFilesRecursively(dir: string): string[] {
80
+ const files: string[] = [];
81
+ for (const entry of readdirSync(dir)) {
82
+ const entryPath = join(dir, entry);
83
+ if (statSync(entryPath).isDirectory()) {
84
+ files.push(...listFilesRecursively(entryPath));
85
+ } else {
86
+ files.push(entryPath);
87
+ }
88
+ }
89
+ return files;
90
+ }
91
+
92
+ const scannedFiles = listFilesRecursively(SRC_DIR).filter((path) => path !== SELF);
93
+
94
+ describe("core — test du vocabulaire (D10) sur tout src/**", () => {
95
+ it("le scan couvre réellement le répertoire (garde anti-liste-vide)", () => {
96
+ expect(scannedFiles.length).toBeGreaterThan(20);
97
+ });
98
+
99
+ for (const filePath of scannedFiles) {
100
+ it(`ÉTANT DONNÉ ${relative(SRC_DIR, filePath)} QUAND on le lit ALORS il ne nomme aucun concept produit`, () => {
101
+ expect(findProductVocabulary(readFileSync(filePath, "utf8"), FORBIDDEN_STEMS)).toEqual([]);
102
+ });
103
+ }
104
+
105
+ // ------------------------------------------------------------- mutations
106
+ it("MUTATION : un module qui nomme un concept du domaine est DÉTECTÉ", () => {
107
+ const source = `
108
+ /** @intent Relays the plots of the farm. */
109
+ export function relayPlots(farmId: string, campaignName: string) {
110
+ return { farmId, campaignName };
111
+ }
112
+ `;
113
+ expect(findProductVocabulary(source, FORBIDDEN_STEMS)).toEqual([
114
+ "farm",
115
+ "plot",
116
+ "campaign",
117
+ ]);
118
+ });
119
+
120
+ it("MUTATION : une référence au produit ou à une feature est DÉTECTÉE", () => {
121
+ const source = `import { buildSchemaRegistry } from "@nisaba/gateway/schemaRegistry";
122
+ // derived for the plan-semis feature`;
123
+ // "plan-semis" is caught twice over: as a feature id, and as the domain
124
+ // stem "semis" it contains — hyphens are word starts too.
125
+ expect(findProductVocabulary(source, FORBIDDEN_STEMS)).toEqual([
126
+ "nisaba",
127
+ "plan-semis",
128
+ "semis",
129
+ ]);
130
+ });
131
+
132
+ it("un vocabulaire strictement générique passe (pas de faux positif)", () => {
133
+ const source = `
134
+ /** @intent Generic relay: manifest routes become typed relays. */
135
+ const embedderPolicy = "cross-origin-embedder-policy";
136
+ export function mountRelay(upstreamBaseUrl: string) {
137
+ return { upstreamBaseUrl, embedderPolicy };
138
+ }
139
+ `;
140
+ expect(findProductVocabulary(source, FORBIDDEN_STEMS)).toEqual([]);
141
+ });
142
+ });