@ogygie/core 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,18 +13,22 @@ dans `apps/`.
13
13
  | `@ogygie/core/spec` | Frontmatter des specs (`parseSpecFrontmatter`) et sections canoniques |
14
14
  | `@ogygie/core/db` · `/clock` · `/tenant` · `/test-kit` | Accès DB, horloge injectable, façade multi-tenant, kit de test |
15
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 |
16
+ | `@ogygie/core/vocabulary` | Appariement des racines de vocabulaire sur les identifiants machine — les racines appartiennent au produit |
17
+ | `@ogygie/core/vocabulary-audit` | Jugement du gate de vocabulaire : alias interdits du code, pureté des fichiers gouvernés |
17
18
  | `@ogygie/core/gateway` | Passerelle publique dérivée du manifeste (ci-dessous) |
18
19
  | `@ogygie/core/client` | Client d'API front dérivé du manifeste (clés de cache, invalidations) |
19
20
 
20
21
  ## Le test du vocabulaire couvre TOUT le core
21
22
 
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.
23
+ Le gate `pnpm ogygie:verify-core-vocabulary` du repo du framework scanne
24
+ chaque fichier de ce package — modules, tests, fixtures, JSON d'exemples — et
25
+ casse la CI à la moindre racine produit (nom du produit, id de feature, mot du
26
+ domaine, dans les deux langues). Les fixtures fuient le vocabulaire aussi
27
+ facilement que le code : rien n'est exempté. Les mots interdits, eux, vivent
28
+ dans `scripts/` du repo du framework, JAMAIS ici : ce package est publié
29
+ (`files: ["src"]`), donc une liste de mots produit stockée dedans SERAIT la
30
+ fuite — c'est exactement ainsi que la 0.1.0 a publié le vocabulaire complet
31
+ d'un domaine produit, dans un fichier de test.
28
32
 
29
33
  ## Hypothèses structurelles assumées (non-vocabulaires)
30
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogygie/core",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",
@@ -15,6 +15,7 @@
15
15
  "exports": {
16
16
  "./manifest": "./src/manifest.schema.ts",
17
17
  "./vocabulary": "./src/vocabulary.ts",
18
+ "./vocabulary-audit": "./src/vocabulary-audit.ts",
18
19
  "./web-lint": "./src/web-lint.ts",
19
20
  "./bundle-budget": "./src/bundle-budget.ts",
20
21
  "./spec": "./src/spec.schema.ts",
@@ -31,9 +32,6 @@
31
32
  "./gateway": "./src/manifest-gateway.ts",
32
33
  "./client": "./src/manifest-client.ts"
33
34
  },
34
- "scripts": {
35
- "typecheck": "tsc -p tsconfig.json"
36
- },
37
35
  "peerDependencies": {
38
36
  "@electric-sql/pglite": "^0.3.0",
39
37
  "@fastify/helmet": "^13.0.0",
@@ -73,5 +71,8 @@
73
71
  "fastify": "^5.0.0",
74
72
  "typescript": "^5.8.0",
75
73
  "zod": "^4.0.0"
74
+ },
75
+ "scripts": {
76
+ "typecheck": "tsc -p tsconfig.json"
76
77
  }
77
- }
78
+ }
@@ -253,3 +253,99 @@ describe("manifest — app gateway, suite (D27)", () => {
253
253
  expect(parsed.success).toBe(false);
254
254
  });
255
255
  });
256
+
257
+ /**
258
+ * Vocabulary block (spec product-vocabulary-gate B2). The fixture vocabulary
259
+ * is INVENTED: this package names no product's domain.
260
+ */
261
+ function manifestWithVocabulary(vocabulary: unknown) {
262
+ return {
263
+ version: 1,
264
+ product: { name: "acme", description: "SaaS générique servant de fixture aux tests du manifeste." },
265
+ apps: [{ name: "api", path: "apps/api", type: "api", entryPoint: "apps/api/src/server.ts" }],
266
+ features: [],
267
+ vocabulary,
268
+ conventions: { agentsMd: "AGENTS.md", failuresMd: "FAILURES.md", specsFolder: "specs/" },
269
+ };
270
+ }
271
+
272
+ describe("manifest — bloc vocabulary (spec product-vocabulary-gate B2)", () => {
273
+ it("un bloc valide (canonique camelCase, alias minuscules) est accepté", () => {
274
+ const parsed = OgygieManifestSchema.safeParse(
275
+ manifestWithVocabulary({
276
+ entries: [
277
+ { canonical: "gadget", forbiddenAliases: ["widget", "gizmo"], reference: "#gadget" },
278
+ { canonical: "gadgetRegistry", forbiddenAliases: ["gadget-catalog"] },
279
+ ],
280
+ }),
281
+ );
282
+ expect(parsed.success).toBe(true);
283
+ });
284
+
285
+ it("un manifeste SANS bloc vocabulary reste valide (le gate n'est jamais bloquant par défaut)", () => {
286
+ const withoutVocabulary = manifestWithVocabulary(undefined);
287
+ delete (withoutVocabulary as Record<string, unknown>)["vocabulary"];
288
+ expect(OgygieManifestSchema.safeParse(withoutVocabulary).success).toBe(true);
289
+ });
290
+
291
+ it("MUTATION : une casse non conforme est refusée, à l'entrée fautive", () => {
292
+ const parsed = OgygieManifestSchema.safeParse(
293
+ manifestWithVocabulary({ entries: [{ canonical: "Gadget", forbiddenAliases: ["Widget"] }] }),
294
+ );
295
+ expect(parsed.success).toBe(false);
296
+ const paths = parsed.success ? [] : parsed.error.issues.map((issue) => issue.path.join("."));
297
+ expect(paths).toContain("vocabulary.entries.0.canonical");
298
+ expect(paths).toContain("vocabulary.entries.0.forbiddenAliases.0");
299
+ });
300
+
301
+ it("MUTATION : une entrée sans aucun alias interdit est refusée (elle n'interdirait rien)", () => {
302
+ const parsed = OgygieManifestSchema.safeParse(
303
+ manifestWithVocabulary({ entries: [{ canonical: "gadget", forbiddenAliases: [] }] }),
304
+ );
305
+ expect(parsed.success).toBe(false);
306
+ });
307
+
308
+ it("MUTATION : un alias dupliqué entre deux entrées est refusé, à la seconde", () => {
309
+ const parsed = OgygieManifestSchema.safeParse(
310
+ manifestWithVocabulary({
311
+ entries: [
312
+ { canonical: "gadget", forbiddenAliases: ["widget"] },
313
+ { canonical: "sprocket", forbiddenAliases: ["widget"] },
314
+ ],
315
+ }),
316
+ );
317
+ expect(parsed.success).toBe(false);
318
+ const issue = parsed.success ? undefined : parsed.error.issues[0];
319
+ expect(issue?.path.join(".")).toBe("vocabulary.entries.1.forbiddenAliases.0");
320
+ expect(issue?.message).toContain("already forbidden by entry 0");
321
+ });
322
+
323
+ it("MUTATION : un alias identique au canonique d'une autre entrée est refusé", () => {
324
+ const parsed = OgygieManifestSchema.safeParse(
325
+ manifestWithVocabulary({
326
+ entries: [
327
+ { canonical: "gadget", forbiddenAliases: ["widget"] },
328
+ { canonical: "sprocket", forbiddenAliases: ["gadget"] },
329
+ ],
330
+ }),
331
+ );
332
+ expect(parsed.success).toBe(false);
333
+ const issue = parsed.success ? undefined : parsed.error.issues[0];
334
+ expect(issue?.path.join(".")).toBe("vocabulary.entries.1.forbiddenAliases.0");
335
+ expect(issue?.message).toContain("canonical term of entry 0");
336
+ });
337
+
338
+ it("MUTATION : un canonique déclaré deux fois est refusé", () => {
339
+ const parsed = OgygieManifestSchema.safeParse(
340
+ manifestWithVocabulary({
341
+ entries: [
342
+ { canonical: "gadget", forbiddenAliases: ["widget"] },
343
+ { canonical: "gadget", forbiddenAliases: ["gizmo"] },
344
+ ],
345
+ }),
346
+ );
347
+ expect(parsed.success).toBe(false);
348
+ const issue = parsed.success ? undefined : parsed.error.issues[0];
349
+ expect(issue?.path.join(".")).toBe("vocabulary.entries.1.canonical");
350
+ });
351
+ });
@@ -106,6 +106,45 @@ export const FrameworkManifestSchema = z.object({
106
106
  .describe("Module declaring the exhaustive list of files `upgrade` may overwrite (D3)"),
107
107
  });
108
108
 
109
+ /**
110
+ * One concept of the product domain: THE canonical machine term, and the
111
+ * aliases the code may never use for it. The machine part lives here and
112
+ * nowhere else — `AGENTS.product.md` explains the business meaning in prose
113
+ * and references the canonical term, but a second parseable location would be
114
+ * a second source of truth.
115
+ *
116
+ * Both fields are matched by ROOT and case-insensitively (see
117
+ * `@ogygie/core/vocabulary`), so an alias is declared in its plainest machine
118
+ * form — lowercase, kebab-case at most. Anything else (spaces, punctuation,
119
+ * capitals) would match nothing and give a false sense of protection, so it is
120
+ * refused at parse time rather than silently ignored.
121
+ */
122
+ export const VocabularyEntrySchema = z.object({
123
+ canonical: z
124
+ .string()
125
+ .regex(/^[a-z][a-zA-Z0-9]*$/, "camelCase English term required")
126
+ .describe("The one machine term for this concept, e.g. gadget"),
127
+ forbiddenAliases: z
128
+ .array(
129
+ z
130
+ .string()
131
+ .min(2)
132
+ .regex(/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/, "lowercase root (kebab-case allowed) required"),
133
+ )
134
+ .min(1)
135
+ .describe("Roots the code may never carry for this concept, e.g. widget"),
136
+ reference: z
137
+ .string()
138
+ .optional()
139
+ .describe("Anchor into AGENTS.product.md where the concept is explained"),
140
+ });
141
+ export type VocabularyEntry = z.infer<typeof VocabularyEntrySchema>;
142
+
143
+ export const ProductVocabularySchema = z.object({
144
+ entries: z.array(VocabularyEntrySchema),
145
+ });
146
+ export type ProductVocabulary = z.infer<typeof ProductVocabularySchema>;
147
+
109
148
  export const OgygieManifestSchema = z.object({
110
149
  version: z.literal(1),
111
150
  product: z.object({
@@ -123,6 +162,9 @@ export const OgygieManifestSchema = z.object({
123
162
  }),
124
163
  ),
125
164
  features: z.array(FeatureManifestSchema),
165
+ vocabulary: ProductVocabularySchema.optional().describe(
166
+ "The product's closed vocabulary — declared here, enforced by `ogygie verify-vocabulary`. Absent = gate silent",
167
+ ),
126
168
  conventions: z.object({
127
169
  agentsMd: z.literal("AGENTS.md"),
128
170
  failuresMd: z.literal("FAILURES.md"),
@@ -135,6 +177,50 @@ export const OgygieManifestSchema = z.object({
135
177
  // front CONSUMES contracts, the gateway RELAYS declared routes; neither
136
178
  // OWNS any).
137
179
  .superRefine((manifest, context) => {
180
+ // Vocabulary coherence (B2): one concept = one term. A canonical declared
181
+ // twice, an alias claimed by two entries, or an alias that IS someone's
182
+ // canonical would make the gate's suggested fix ambiguous — refused here,
183
+ // at the entry that carries it, rather than discovered at scan time.
184
+ const canonicalOwners = new Map<string, number>();
185
+ const aliasOwners = new Map<string, number>();
186
+ (manifest.vocabulary?.entries ?? []).forEach((entry, index) => {
187
+ const canonicalRoot = entry.canonical.toLowerCase();
188
+ const firstOwner = canonicalOwners.get(canonicalRoot);
189
+ if (firstOwner !== undefined) {
190
+ context.addIssue({
191
+ code: "custom",
192
+ path: ["vocabulary", "entries", index, "canonical"],
193
+ message: `canonical "${entry.canonical}" is already declared by entry ${firstOwner}`,
194
+ });
195
+ } else {
196
+ canonicalOwners.set(canonicalRoot, index);
197
+ }
198
+ });
199
+ (manifest.vocabulary?.entries ?? []).forEach((entry, index) => {
200
+ entry.forbiddenAliases.forEach((alias, aliasIndex) => {
201
+ const path = ["vocabulary", "entries", index, "forbiddenAliases", aliasIndex];
202
+ const canonicalOwner = canonicalOwners.get(alias);
203
+ if (canonicalOwner !== undefined) {
204
+ context.addIssue({
205
+ code: "custom",
206
+ path,
207
+ message: `alias "${alias}" is the canonical term of entry ${canonicalOwner} — a term cannot be both`,
208
+ });
209
+ return;
210
+ }
211
+ const firstOwner = aliasOwners.get(alias);
212
+ if (firstOwner !== undefined) {
213
+ context.addIssue({
214
+ code: "custom",
215
+ path,
216
+ message: `alias "${alias}" is already forbidden by entry ${firstOwner} — one alias belongs to one concept`,
217
+ });
218
+ return;
219
+ }
220
+ aliasOwners.set(alias, index);
221
+ });
222
+ });
223
+
138
224
  const appsByName = new Map(manifest.apps.map((app) => [app.name, app]));
139
225
  // `invalidates` targets must EXIST: a feature name, or the root segment of
140
226
  // a declared route path ("/api/order-archive" → "order-archive"). A typo
@@ -0,0 +1,174 @@
1
+ /**
2
+ * @intent Mutation tests of the vocabulary JUDGEMENT (spec
3
+ * product-vocabulary-gate B3/B4). Everything is proven in memory: files are
4
+ * handed in as text, so the rules — aliases only in source, every root in
5
+ * governed files, comments free on one side and judged on the other — are
6
+ * checked without touching a disk. The fixture vocabulary is INVENTED.
7
+ */
8
+ import { describe, expect, it } from "vitest";
9
+ import {
10
+ auditProductVocabulary,
11
+ canonicalByAlias,
12
+ vocabularyRootsOf,
13
+ } from "./vocabulary-audit.js";
14
+
15
+ const VOCABULARY = {
16
+ entries: [
17
+ { canonical: "gadget", forbiddenAliases: ["widget", "gizmo"] },
18
+ { canonical: "gadgetRegistry", forbiddenAliases: ["gadget-catalog"] },
19
+ ],
20
+ };
21
+
22
+ describe("auditProductVocabulary — B3 : les alias interdits dans le code produit", () => {
23
+ it("ÉTANT DONNÉ un alias interdit QUAND le code l'emploie ALORS la violation nomme le fichier, l'identifiant et le canonique", () => {
24
+ const violations = auditProductVocabulary({
25
+ vocabulary: VOCABULARY,
26
+ sourceFiles: [
27
+ { path: "apps/api/src/features/stock/service.ts", content: "const widgetId = 1;" },
28
+ ],
29
+ governedFiles: [],
30
+ });
31
+ expect(violations).toEqual([
32
+ {
33
+ kind: "FORBIDDEN_ALIAS",
34
+ filePath: "apps/api/src/features/stock/service.ts",
35
+ match: { root: "widget", identifier: "widgetId", line: 1 },
36
+ canonical: "gadget",
37
+ },
38
+ ]);
39
+ });
40
+
41
+ it("attrape l'alias sous toutes ses formes d'identifiant (camel, Pascal, snake)", () => {
42
+ const violations = auditProductVocabulary({
43
+ vocabulary: VOCABULARY,
44
+ sourceFiles: [
45
+ {
46
+ path: "apps/api/src/features/stock/db.schema.ts",
47
+ content: ["const widgetId = 1;", "class WidgetTable {}", "const widget_code = 'x';"].join("\n"),
48
+ },
49
+ ],
50
+ governedFiles: [],
51
+ });
52
+ expect(violations.map((violation) => violation.match.identifier)).toEqual([
53
+ "widgetId",
54
+ "WidgetTable",
55
+ "widget_code",
56
+ ]);
57
+ expect(violations.map((violation) => violation.match.line)).toEqual([1, 2, 3]);
58
+ });
59
+
60
+ it("le terme CANONIQUE dans le code produit ne déclenche rien — c'est le mot juste", () => {
61
+ expect(
62
+ auditProductVocabulary({
63
+ vocabulary: VOCABULARY,
64
+ sourceFiles: [{ path: "apps/api/src/x.ts", content: "const gadgetId = 1;" }],
65
+ governedFiles: [],
66
+ }),
67
+ ).toEqual([]);
68
+ });
69
+
70
+ it("la prose des commentaires est libre dans le code produit", () => {
71
+ expect(
72
+ auditProductVocabulary({
73
+ vocabulary: VOCABULARY,
74
+ sourceFiles: [
75
+ { path: "apps/api/src/x.ts", content: "// on disait widget avant\nconst gadgetId = 1;" },
76
+ ],
77
+ governedFiles: [],
78
+ }),
79
+ ).toEqual([]);
80
+ });
81
+
82
+ it("un manifeste SANS bloc vocabulary ne produit aucune violation", () => {
83
+ expect(
84
+ auditProductVocabulary({
85
+ vocabulary: undefined,
86
+ sourceFiles: [{ path: "apps/api/src/x.ts", content: "const widgetId = 1;" }],
87
+ governedFiles: [{ path: "AGENTS.md", content: "gadget partout" }],
88
+ }),
89
+ ).toEqual([]);
90
+ });
91
+
92
+ it("un bloc vocabulary VIDE ne produit aucune violation non plus", () => {
93
+ expect(
94
+ auditProductVocabulary({
95
+ vocabulary: { entries: [] },
96
+ sourceFiles: [{ path: "apps/api/src/x.ts", content: "const widgetId = 1;" }],
97
+ governedFiles: [],
98
+ }),
99
+ ).toEqual([]);
100
+ });
101
+ });
102
+
103
+ describe("auditProductVocabulary — B4 : la pureté des fichiers gouvernés", () => {
104
+ it("ÉTANT DONNÉ un fichier gouverné QUAND il nomme un terme CANONIQUE ALORS c'est une violation", () => {
105
+ const violations = auditProductVocabulary({
106
+ vocabulary: VOCABULARY,
107
+ sourceFiles: [],
108
+ governedFiles: [{ path: "AGENTS.md", content: "Chaque gadget porte un identifiant." }],
109
+ });
110
+ expect(violations).toEqual([
111
+ {
112
+ kind: "GOVERNED_FILE",
113
+ filePath: "AGENTS.md",
114
+ match: { root: "gadget", identifier: "gadget", line: 1 },
115
+ canonical: null,
116
+ },
117
+ ]);
118
+ });
119
+
120
+ it("un commentaire d'un fichier gouverné est jugé lui aussi (il est identique partout)", () => {
121
+ const violations = auditProductVocabulary({
122
+ vocabulary: VOCABULARY,
123
+ sourceFiles: [],
124
+ governedFiles: [{ path: "tsconfig.base.json", content: "// tuned for the gadget pipeline" }],
125
+ });
126
+ expect(violations.map((violation) => violation.match.root)).toEqual(["gadget"]);
127
+ });
128
+
129
+ it("un ALIAS interdit dans un fichier gouverné ne déclenche RIEN (v2)", () => {
130
+ // An alias is by definition a word the product does NOT use, so finding it
131
+ // in a generic file proves no contamination — while the framework's own
132
+ // neutral domain and plain computing English produce such collisions in
133
+ // bulk (eight false positives out of eighteen on the first real migration).
134
+ expect(
135
+ auditProductVocabulary({
136
+ vocabulary: VOCABULARY,
137
+ sourceFiles: [],
138
+ governedFiles: [
139
+ { path: "AGENTS.md", content: "const widgetId = 1; // gizmo everywhere" },
140
+ ],
141
+ }),
142
+ ).toEqual([]);
143
+ });
144
+
145
+ it("un fichier gouverné strictement générique passe", () => {
146
+ expect(
147
+ auditProductVocabulary({
148
+ vocabulary: VOCABULARY,
149
+ sourceFiles: [],
150
+ governedFiles: [{ path: "AGENTS.md", content: "Une feature = un dossier autonome." }],
151
+ }),
152
+ ).toEqual([]);
153
+ });
154
+ });
155
+
156
+ describe("dérivations du bloc vocabulary", () => {
157
+ it("vocabularyRootsOf rend les canoniques ET les alias", () => {
158
+ expect(vocabularyRootsOf(VOCABULARY)).toEqual([
159
+ "gadget",
160
+ "widget",
161
+ "gizmo",
162
+ "gadgetRegistry",
163
+ "gadget-catalog",
164
+ ]);
165
+ });
166
+
167
+ it("canonicalByAlias donne la correction à suggérer pour chaque alias", () => {
168
+ expect([...canonicalByAlias(VOCABULARY).entries()]).toEqual([
169
+ ["widget", "gadget"],
170
+ ["gizmo", "gadget"],
171
+ ["gadget-catalog", "gadgetRegistry"],
172
+ ]);
173
+ });
174
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @intent The JUDGEMENT of the vocabulary gate (spec product-vocabulary-gate
3
+ * B3/B4). `@ogygie/core/vocabulary` answers "does this text carry a forbidden
4
+ * root?"; this module answers "given what the manifest declares, which files
5
+ * are in violation, and what should the agent write instead?".
6
+ *
7
+ * Two rules, and they do not read the same text:
8
+ * - PRODUCT SOURCE (B3): only forbidden ALIASES are hunted, and only in the
9
+ * machine identifiers — comments are blanked out first. The business prose
10
+ * of the project keeps its own words; the code has one term per concept.
11
+ * - GOVERNED FILES (B4): the CANONICAL terms are hunted in the RAW text,
12
+ * comments and all. A governed file is identical in every Ogygie project, so
13
+ * it may know no product's domain at all, not even in a comment. That is the
14
+ * framework/product boundary made mechanical.
15
+ * Forbidden aliases are deliberately NOT hunted there (spec v2): an alias is
16
+ * by definition a word the product does NOT use, so finding one in a generic
17
+ * file proves no contamination — while the framework's own neutral domain
18
+ * (`orders`, `customerId`) and plain computing English (`level`, `register`)
19
+ * collide with it in bulk. Eight false positives out of eighteen on the first
20
+ * real migration, and a gate whose verdicts one learns to ignore protects
21
+ * nothing.
22
+ *
23
+ * The filesystem is injected (files come in as text), so the whole judgement
24
+ * is a pure function proven in memory with its mutations.
25
+ */
26
+ import type { ProductVocabulary } from "./manifest.schema.js";
27
+ import { findProductVocabulary, stripComments, type VocabularyMatch } from "./vocabulary.js";
28
+
29
+ /** One file handed to the audit, already read by the caller. */
30
+ export interface ScannedFile {
31
+ /** Path as it should be reported, relative to the project root. */
32
+ readonly path: string;
33
+ readonly content: string;
34
+ }
35
+
36
+ export type VocabularyViolationKind =
37
+ /** Product source code carrying a forbidden alias (B3). */
38
+ | "FORBIDDEN_ALIAS"
39
+ /** A governed file naming any product term at all (B4). */
40
+ | "GOVERNED_FILE";
41
+
42
+ export interface VocabularyViolation {
43
+ readonly kind: VocabularyViolationKind;
44
+ readonly filePath: string;
45
+ readonly match: VocabularyMatch;
46
+ /** The term to use instead — null when the fix is to remove, not rename. */
47
+ readonly canonical: string | null;
48
+ }
49
+
50
+ export interface VocabularyAuditInput {
51
+ /** The manifest's `vocabulary` block — absent means the gate stays silent. */
52
+ readonly vocabulary: ProductVocabulary | undefined;
53
+ /** Code files of the product's apps and packages (B3). */
54
+ readonly sourceFiles: readonly ScannedFile[];
55
+ /** Framework-governed files as they exist in the project (B4). */
56
+ readonly governedFiles: readonly ScannedFile[];
57
+ }
58
+
59
+ /**
60
+ * Every root the vocabulary declares: canonical terms AND forbidden aliases.
61
+ * Kept for callers that need the full list; the governed-file rule uses the
62
+ * canonical terms alone (see above).
63
+ */
64
+ export function vocabularyRootsOf(vocabulary: ProductVocabulary): readonly string[] {
65
+ return vocabulary.entries.flatMap((entry) => [entry.canonical, ...entry.forbiddenAliases]);
66
+ }
67
+
68
+ /** alias → the canonical term to use instead. */
69
+ export function canonicalByAlias(vocabulary: ProductVocabulary): ReadonlyMap<string, string> {
70
+ const byAlias = new Map<string, string>();
71
+ for (const entry of vocabulary.entries) {
72
+ for (const alias of entry.forbiddenAliases) byAlias.set(alias, entry.canonical);
73
+ }
74
+ return byAlias;
75
+ }
76
+
77
+ export function auditProductVocabulary(
78
+ input: VocabularyAuditInput,
79
+ ): readonly VocabularyViolation[] {
80
+ const { vocabulary } = input;
81
+ if (vocabulary === undefined || vocabulary.entries.length === 0) return [];
82
+
83
+ const canonicalOfAlias = canonicalByAlias(vocabulary);
84
+ const forbiddenAliases = [...canonicalOfAlias.keys()];
85
+ const canonicalTerms = vocabulary.entries.map((entry) => entry.canonical);
86
+ const violations: VocabularyViolation[] = [];
87
+
88
+ for (const file of input.sourceFiles) {
89
+ for (const match of findProductVocabulary(stripComments(file.content), forbiddenAliases)) {
90
+ violations.push({
91
+ kind: "FORBIDDEN_ALIAS",
92
+ filePath: file.path,
93
+ match,
94
+ canonical: canonicalOfAlias.get(match.root) ?? null,
95
+ });
96
+ }
97
+ }
98
+
99
+ for (const file of input.governedFiles) {
100
+ for (const match of findProductVocabulary(file.content, canonicalTerms)) {
101
+ violations.push({
102
+ kind: "GOVERNED_FILE",
103
+ filePath: file.path,
104
+ match,
105
+ canonical: null,
106
+ });
107
+ }
108
+ }
109
+
110
+ return violations;
111
+ }
@@ -1,46 +1,110 @@
1
1
  /**
2
- * @intent Mutation tests of the vocabulary CHECKER itself (D22): the matching
3
- * semantics — word start, case-insensitivity, hyphens, regex escaping — are
4
- * what the whole gate rests on, so each one is proven with INVENTED stems.
5
- * The real forbidden list, and the scan of the core's files against it, live
6
- * in product-vocabulary.scan.test.ts (the only file allowed to write the
7
- * forbidden words).
2
+ * @intent Mutation tests of the vocabulary CHECKER itself: the matching
3
+ * semantics — segment cutting, prefix on the last segment, case-insensitivity,
4
+ * multi-segment roots — are what the whole gate rests on, so each one is
5
+ * proven with an INVENTED fixture vocabulary. No real product word appears
6
+ * here, or anywhere else in this package: that is the rule the gate enforces,
7
+ * and a test file ships in the published package like any other source.
8
8
  */
9
9
  import { describe, expect, it } from "vitest";
10
- import { findProductVocabulary } from "./vocabulary.js";
10
+ import {
11
+ findProductVocabulary,
12
+ segmentsOfIdentifier,
13
+ stripComments,
14
+ } from "./vocabulary.js";
11
15
 
12
- describe("findProductVocabulary — sémantique du matching", () => {
13
- it("détecte un stem en début de mot, jamais en plein milieu", () => {
16
+ describe("findProductVocabulary — semantique de l'appariement", () => {
17
+ it("apparie une racine au DÉBUT d'un segment, jamais au milieu", () => {
14
18
  expect(findProductVocabulary("the embedder policy", ["bed"])).toEqual([]);
15
- expect(findProductVocabulary("const bedCount = 2;", ["bed"])).toEqual(["bed"]);
19
+ expect(findProductVocabulary("const bedCount = 2;", ["bed"])).toEqual([
20
+ { root: "bed", identifier: "bedCount", line: 1 },
21
+ ]);
16
22
  });
17
23
 
18
- it("un stem attrape ses dérivés camelCase et ses pluriels", () => {
19
- expect(findProductVocabulary("relayGadgets(gadgetId)", ["gadget"])).toEqual(["gadget"]);
24
+ it("une racine attrape ses dérivés camelCase et ses pluriels", () => {
25
+ expect(findProductVocabulary("relayGadgets(gadgetId)", ["gadget"])).toEqual([
26
+ { root: "gadget", identifier: "relayGadgets", line: 1 },
27
+ { root: "gadget", identifier: "gadgetId", line: 1 },
28
+ ]);
29
+ });
30
+
31
+ it("snake_case et kebab-case sont découpés comme camelCase", () => {
32
+ expect(findProductVocabulary("widget_code, widget-code", ["widget"])).toEqual([
33
+ { root: "widget", identifier: "widget_code", line: 1 },
34
+ { root: "widget", identifier: "widget-code", line: 1 },
35
+ ]);
20
36
  });
21
37
 
22
- it("insensible à la casse", () => {
23
- expect(findProductVocabulary("import { Gadget } from x", ["gadget"])).toEqual(["gadget"]);
38
+ it("insensible à la casse, y compris pour un type PascalCase", () => {
39
+ expect(findProductVocabulary("import { GadgetTable } from x", ["gadget"])).toEqual([
40
+ { root: "gadget", identifier: "GadgetTable", line: 1 },
41
+ ]);
42
+ });
43
+
44
+ it("une racine multi-segments exige la SÉQUENCE complète dans un même token", () => {
45
+ expect(
46
+ findProductVocabulary("the gadget-registry feature", ["gadget-registry", "registry"]),
47
+ ).toEqual([
48
+ { root: "gadget-registry", identifier: "gadget-registry", line: 1 },
49
+ { root: "registry", identifier: "gadget-registry", line: 1 },
50
+ ]);
51
+ expect(findProductVocabulary("widgetRegistry", ["gadget-registry"])).toEqual([]);
24
52
  });
25
53
 
26
- it("un tiret est un début de mot : un id kebab-case déclenche chacun de ses stems", () => {
27
- expect(findProductVocabulary("the gadget-registry feature", ["gadget-registry", "registry"])).toEqual([
28
- "gadget-registry",
29
- "registry",
54
+ it("rend la LIGNE de chaque trouvaille et dédoublonne par (racine, identifiant)", () => {
55
+ const source = "a gadget here\nanother gadget there\nand a gadgetId";
56
+ expect(findProductVocabulary(source, ["gadget"])).toEqual([
57
+ { root: "gadget", identifier: "gadget", line: 1 },
58
+ { root: "gadget", identifier: "gadgetId", line: 3 },
30
59
  ]);
31
60
  });
32
61
 
33
- it("rend les stems dans l'ordre de la liste, une seule fois chacun", () => {
34
- const source = "a gadget, another gadget, a widget";
35
- expect(findProductVocabulary(source, ["widget", "gadget"])).toEqual(["widget", "gadget"]);
62
+ it("une racine qui n'est pas un identifiant machine n'apparie RIEN", () => {
63
+ // No regex escaping to get wrong: matching compares segments, so a root
64
+ // carrying punctuation simply matches no segment. This is why the manifest
65
+ // schema constrains the shape of a declared alias (B2) instead of relying
66
+ // on the checker to make sense of it.
67
+ expect(findProductVocabulary("uses c++ everywhere", ["c++"])).toEqual([]);
68
+ expect(findProductVocabulary("a gadget here", ["gadget."])).toEqual([]);
36
69
  });
37
70
 
38
- it("échappe les caractères spéciaux de regex dans les stems", () => {
39
- expect(findProductVocabulary("uses c++ everywhere", ["c++"])).toEqual(["c++"]);
40
- expect(findProductVocabulary("plain c here", ["c++"])).toEqual([]);
71
+ it("un vocabulaire strictement générique ne déclenche rien", () => {
72
+ const source = `
73
+ /** @intent Generic relay: manifest routes become typed relays. */
74
+ const embedderPolicy = "cross-origin-embedder-policy";
75
+ export function mountRelay(upstreamBaseUrl: string) {
76
+ return { upstreamBaseUrl, embedderPolicy };
77
+ }
78
+ `;
79
+ expect(findProductVocabulary(source, ["gadget", "widget"])).toEqual([]);
41
80
  });
42
81
 
43
- it("une source sans aucun stem interdit rend une liste vide", () => {
44
- expect(findProductVocabulary("generic relay of manifest routes", ["gadget"])).toEqual([]);
82
+ it("une liste de racines vide ne déclenche rien", () => {
83
+ expect(findProductVocabulary("gadgetId everywhere", [])).toEqual([]);
84
+ });
85
+ });
86
+
87
+ describe("segmentsOfIdentifier — découpage des identifiants machine", () => {
88
+ it("coupe camelCase, PascalCase, snake_case et kebab-case", () => {
89
+ expect(segmentsOfIdentifier("gadgetId")).toEqual(["gadget", "id"]);
90
+ expect(segmentsOfIdentifier("GadgetTable")).toEqual(["gadget", "table"]);
91
+ expect(segmentsOfIdentifier("gadget_code")).toEqual(["gadget", "code"]);
92
+ expect(segmentsOfIdentifier("gadget-registry")).toEqual(["gadget", "registry"]);
93
+ });
94
+
95
+ it("garde un acronyme collé au mot qui le suit comme deux segments", () => {
96
+ expect(segmentsOfIdentifier("HTTPGadget")).toEqual(["http", "gadget"]);
97
+ });
98
+ });
99
+
100
+ describe("stripComments — la prose des commentaires est libre", () => {
101
+ it("efface // et /* */ SANS déplacer les numéros de ligne", () => {
102
+ const source = ["// gadget in a comment", "const widgetId = 1; // gadget again", "/* gadget", "still gadget */", "const gadgetCount = 2;"].join("\n");
103
+ const stripped = stripComments(source);
104
+ expect(stripped.split("\n")).toHaveLength(5);
105
+ expect(findProductVocabulary(stripped, ["gadget", "widget"])).toEqual([
106
+ { root: "widget", identifier: "widgetId", line: 2 },
107
+ { root: "gadget", identifier: "gadgetCount", line: 5 },
108
+ ]);
45
109
  });
46
110
  });
package/src/vocabulary.ts CHANGED
@@ -1,36 +1,123 @@
1
1
  /**
2
- * @intent Testable heart of the vocabulary gate (DECISIONS.md D10 rule 1: a
3
- * framework module names NO product concept — no domain word, no product name,
4
- * no feature id). Pure function — source text and stems in, findings out — so
5
- * the scan test and the mutation tests required by D22 feed it directly.
6
- * Extracted on its THIRD occurrence (rule of three, AGENTS.md §Workflow 5):
7
- * the first two copies lived in the vocabulary tests of the manifest-derived
8
- * gateway and client. The stems themselves are product configuration and do
9
- * NOT live here: the caller owns its forbidden list.
2
+ * @intent Testable heart of the vocabulary gate: a product declares its
3
+ * canonical terms and their forbidden aliases in `ogygie.manifest.json`, and
4
+ * the gates enforce them. Pure functions — text and roots in, matches out —
5
+ * so every rule is proven in memory, with its mutations, and the filesystem
6
+ * stays in the callers (the CLI command, the framework's own CI gate).
7
+ *
8
+ * The roots themselves are product configuration and NEVER live here: this
9
+ * module ships inside `@ogygie/core`, which knows no word of any product
10
+ * domain. A framework module naming one would be the very leak this gate
11
+ * exists to catch.
12
+ *
13
+ * Matching is done on machine IDENTIFIERS, not on raw substrings: a token is
14
+ * cut into segments (camelCase, PascalCase, snake_case, kebab-case) and a root
15
+ * matches a segment by PREFIX — so `gadget` catches `gadgets` and `gadgetId`,
16
+ * while `bed` never trips on `embedder`. A multi-segment root (`gadget-registry`)
17
+ * requires its whole consecutive sequence inside one token.
10
18
  */
11
19
 
20
+ /** One forbidden root, found once, with the identifier that carries it. */
21
+ export interface VocabularyMatch {
22
+ /** The declared root that matched, exactly as declared. */
23
+ readonly root: string;
24
+ /** The whole machine token carrying it, in its original casing. */
25
+ readonly identifier: string;
26
+ /** 1-based line number in the scanned text. */
27
+ readonly line: number;
28
+ }
29
+
12
30
  /**
13
- * Every forbidden stem the source names, in the order of the given list —
14
- * one entry per stem, however many times it appears.
15
- *
16
- * A stem matches case-insensitively at a WORD START (`(?<![\p{L}\p{N}])`),
17
- * not as a bare substring: "embedder" must not trip on a stem "bed", but a
18
- * camel-case usage like "tenantId" must trip on a stem "tenant". Hyphens are
19
- * word starts too, so a kebab-case id is caught both as a full id and through
20
- * every stem it contains.
31
+ * A machine token: a letter-initial run, possibly joined by `-`, `_` or `$`.
32
+ * Unicode letters on purpose — a French domain word is a root a product may
33
+ * forbid, and ASCII-only tokenizing would silently miss its accented forms.
21
34
  */
22
- export function findProductVocabulary(
23
- source: string,
24
- forbiddenStems: readonly string[],
25
- ): string[] {
26
- const found: string[] = [];
27
- for (const stem of forbiddenStems) {
28
- // No "-" in this class: in unicode mode an escaped hyphen is itself an
29
- // invalid escape, and a hyphen needs no escaping outside a class.
30
- const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
31
- if (new RegExp(`(?<![\\p{L}\\p{N}])${escaped}`, "iu").test(source)) {
32
- found.push(stem);
35
+ const TOKEN_PATTERN = /\p{L}[\p{L}\p{N}]*(?:[-_$][\p{L}\p{N}]+)*/gu;
36
+
37
+ /** Cuts a machine token into its lowercase segments. */
38
+ export function segmentsOfIdentifier(token: string): string[] {
39
+ return token
40
+ .split(/[-_$]+/)
41
+ .flatMap((part) =>
42
+ part
43
+ // camelCase (and digit-to-upper) boundary, then the ACRONYMWord one.
44
+ .replace(/(?<=\p{Ll}|\p{N})(?=\p{Lu})/gu, " ")
45
+ .replace(/(?<=\p{Lu})(?=\p{Lu}\p{Ll})/gu, " ")
46
+ .split(" "),
47
+ )
48
+ .filter((segment) => segment.length > 0)
49
+ .map((segment) => segment.toLowerCase());
50
+ }
51
+
52
+ /** Does this token carry that root: segment sequence, prefix on the last one? */
53
+ function tokenCarriesRoot(
54
+ tokenSegments: readonly string[],
55
+ rootSegments: readonly string[],
56
+ ): boolean {
57
+ const lastIndex = rootSegments.length - 1;
58
+ for (let start = 0; start + rootSegments.length <= tokenSegments.length; start += 1) {
59
+ let leadingSegmentsMatch = true;
60
+ for (let offset = 0; offset < lastIndex; offset += 1) {
61
+ if (tokenSegments[start + offset] !== rootSegments[offset]) {
62
+ leadingSegmentsMatch = false;
63
+ break;
64
+ }
65
+ }
66
+ const lastTokenSegment = tokenSegments[start + lastIndex];
67
+ if (
68
+ leadingSegmentsMatch &&
69
+ lastTokenSegment !== undefined &&
70
+ lastTokenSegment.startsWith(rootSegments[lastIndex] ?? "")
71
+ ) {
72
+ return true;
33
73
  }
34
74
  }
35
- return found;
75
+ return false;
76
+ }
77
+
78
+ /**
79
+ * Every forbidden root the text names, in order of appearance, deduplicated by
80
+ * (root, identifier): one finding per distinct faulty identifier, however many
81
+ * times it is repeated — a gate printing the same identifier two hundred times
82
+ * is a gate nobody reads.
83
+ */
84
+ export function findProductVocabulary(
85
+ sourceText: string,
86
+ vocabularyRoots: ReadonlyArray<string>,
87
+ ): ReadonlyArray<VocabularyMatch> {
88
+ const roots = vocabularyRoots.map((root) => ({
89
+ root,
90
+ segments: segmentsOfIdentifier(root),
91
+ }));
92
+ const matches: VocabularyMatch[] = [];
93
+ const alreadyReported = new Set<string>();
94
+ sourceText.split("\n").forEach((lineText, lineIndex) => {
95
+ for (const token of lineText.match(TOKEN_PATTERN) ?? []) {
96
+ const tokenSegments = segmentsOfIdentifier(token);
97
+ for (const { root, segments } of roots) {
98
+ if (segments.length === 0) continue;
99
+ if (!tokenCarriesRoot(tokenSegments, segments)) continue;
100
+ const reportKey = `${root} ${token}`;
101
+ if (alreadyReported.has(reportKey)) continue;
102
+ alreadyReported.add(reportKey);
103
+ matches.push({ root, identifier: token, line: lineIndex + 1 });
104
+ }
105
+ }
106
+ });
107
+ return matches;
108
+ }
109
+
110
+ /**
111
+ * Blanks out comments while KEEPING line numbers and line count — the prose of
112
+ * a comment is free (the domain may speak its own language there), only
113
+ * machine identifiers are judged. Deliberately lexical and dependency-free: a
114
+ * comment marker inside a string literal blanks a little more than it should,
115
+ * which can only ever HIDE a violation in that line's remainder, never invent
116
+ * one.
117
+ */
118
+ export function stripComments(sourceText: string): string {
119
+ const blankKeepingNewlines = (text: string): string => text.replace(/[^\n]/g, " ");
120
+ return sourceText
121
+ .replace(/\/\*[\s\S]*?\*\//g, blankKeepingNewlines)
122
+ .replace(/\/\/[^\n]*/g, blankKeepingNewlines);
36
123
  }
@@ -1,142 +0,0 @@
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
- });