@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
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Mutation tests of the shared-table gates (DECISIONS.md D22: every
|
|
3
|
+
* gate is born with its negative test). The shared-table exception is a hole
|
|
4
|
+
* in the multi-tenant rail — each way of abusing it is fed here as a KNOWN
|
|
5
|
+
* VIOLATION and must be DETECTED, never silently tolerated.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import type { FeatureManifest } from "./manifest.schema.js";
|
|
9
|
+
import { FeatureManifestSchema } from "./manifest.schema.js";
|
|
10
|
+
import {
|
|
11
|
+
dbTableNamesOf,
|
|
12
|
+
findTenantColumnsInSharedTables,
|
|
13
|
+
mayUseRawDb,
|
|
14
|
+
ownsOnlySharedTables,
|
|
15
|
+
sharedTableNamesOf,
|
|
16
|
+
splitDbSchemaByTable,
|
|
17
|
+
tenantTableNamesOf,
|
|
18
|
+
} from "./shared-tables.js";
|
|
19
|
+
|
|
20
|
+
const SOCLE = new Set(["accounts"]);
|
|
21
|
+
|
|
22
|
+
function feature(overrides: Partial<FeatureManifest>): FeatureManifest {
|
|
23
|
+
return {
|
|
24
|
+
name: "labels",
|
|
25
|
+
intent: "A feature used as a fixture for the shared-table gate tests.",
|
|
26
|
+
folder: "src/features/labels",
|
|
27
|
+
spec: null,
|
|
28
|
+
routes: [],
|
|
29
|
+
dbTables: [],
|
|
30
|
+
dependsOn: [],
|
|
31
|
+
status: "delivered",
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("déclaration des tables au manifeste", () => {
|
|
37
|
+
it("accepte la forme chaîne (tenant) et la forme objet { name, shared: true }", () => {
|
|
38
|
+
const parsed = FeatureManifestSchema.safeParse(
|
|
39
|
+
feature({ dbTables: ["orders", { name: "labels", shared: true }] }),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(parsed.success).toBe(true);
|
|
43
|
+
const target = feature({ dbTables: ["orders", { name: "labels", shared: true }] });
|
|
44
|
+
expect(dbTableNamesOf(target)).toEqual(["orders", "labels"]);
|
|
45
|
+
expect(sharedTableNamesOf(target)).toEqual(["labels"]);
|
|
46
|
+
expect(tenantTableNamesOf(target)).toEqual(["orders"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("MUTATION — `shared: false` est REFUSÉ : une table est partagée et le dit, ou reste une chaîne", () => {
|
|
50
|
+
// Fed as raw JSON: the manifest arrives from disk, never from typed code —
|
|
51
|
+
// and TypeScript alone would not protect the file on disk.
|
|
52
|
+
const rawFeature: unknown = JSON.parse(
|
|
53
|
+
JSON.stringify({ ...feature({}), dbTables: [{ name: "labels", shared: false }] }),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
expect(FeatureManifestSchema.safeParse(rawFeature).success).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("politique d'accès brut à la base (raw db)", () => {
|
|
61
|
+
it("une feature ne possédant QUE des tables partagées peut toucher la base directement", () => {
|
|
62
|
+
const shared = feature({
|
|
63
|
+
dbTables: [
|
|
64
|
+
{ name: "labels", shared: true },
|
|
65
|
+
{ name: "label_names", shared: true },
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(ownsOnlySharedTables(shared)).toBe(true);
|
|
70
|
+
expect(mayUseRawDb(shared, SOCLE)).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("MUTATION — une seule table TENANT glissée parmi des tables partagées retire le droit d'accès brut", () => {
|
|
74
|
+
const mixed = feature({
|
|
75
|
+
dbTables: [{ name: "labels", shared: true }, "orders"],
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(ownsOnlySharedTables(mixed)).toBe(false);
|
|
79
|
+
expect(mayUseRawDb(mixed, SOCLE)).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("MUTATION — une feature SANS aucune table ne gagne pas le droit par vacuité", () => {
|
|
83
|
+
const tableless = feature({ name: "pure-compute", dbTables: [] });
|
|
84
|
+
|
|
85
|
+
expect(ownsOnlySharedTables(tableless)).toBe(false);
|
|
86
|
+
expect(mayUseRawDb(tableless, SOCLE)).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("le socle (liste fermée) conserve son droit historique, les autres features tenant ne l'ont pas", () => {
|
|
90
|
+
expect(mayUseRawDb(feature({ name: "accounts", dbTables: ["tenants"] }), SOCLE)).toBe(true);
|
|
91
|
+
expect(mayUseRawDb(feature({ name: "orders", dbTables: ["orders"] }), SOCLE)).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const DB_SCHEMA_WITH_TWO_TABLES = `
|
|
96
|
+
export const labels = pgTable("labels", {
|
|
97
|
+
labelCode: text("label_code").primaryKey(),
|
|
98
|
+
canonicalName: text("canonical_name").notNull(),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export const orders = pgTable("orders", {
|
|
102
|
+
id: uuid("id").primaryKey(),
|
|
103
|
+
orgId: uuid("org_id").notNull(),
|
|
104
|
+
});
|
|
105
|
+
`;
|
|
106
|
+
|
|
107
|
+
describe("forme d'une table partagée", () => {
|
|
108
|
+
it("découpe le fichier db.schema.ts par table", () => {
|
|
109
|
+
const blocks = splitDbSchemaByTable(DB_SCHEMA_WITH_TWO_TABLES);
|
|
110
|
+
|
|
111
|
+
expect([...blocks.keys()]).toEqual(["labels", "orders"]);
|
|
112
|
+
expect(blocks.get("labels")).toContain("canonical_name");
|
|
113
|
+
// The labels block stops before the orders one — no bleed-through, or the
|
|
114
|
+
// gate below would accuse an innocent table of its neighbour's column.
|
|
115
|
+
expect(blocks.get("labels")).not.toContain("org_id");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("une table partagée sans colonne tenant ne déclenche aucun finding", () => {
|
|
119
|
+
expect(
|
|
120
|
+
findTenantColumnsInSharedTables(DB_SCHEMA_WITH_TWO_TABLES, ["labels"], ["org_id"]),
|
|
121
|
+
).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("MUTATION — une table déclarée partagée qui porte encore la colonne tenant est DÉTECTÉE", () => {
|
|
125
|
+
const contradictory = `
|
|
126
|
+
export const labels = pgTable("labels", {
|
|
127
|
+
labelCode: text("label_code").primaryKey(),
|
|
128
|
+
orgId: uuid("org_id").notNull(),
|
|
129
|
+
});
|
|
130
|
+
`;
|
|
131
|
+
|
|
132
|
+
expect(
|
|
133
|
+
findTenantColumnsInSharedTables(contradictory, ["labels"], ["org_id"]),
|
|
134
|
+
).toEqual([{ table: "labels", tenantColumn: "org_id" }]);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Testable heart of the gates that guard the SHARED-TABLE exception
|
|
3
|
+
* (AGENTS.md §Multi-tenant rule 4). Shared referential tables are the only
|
|
4
|
+
* sanctioned way to hold rows no tenant owns; they are also a hole in the
|
|
5
|
+
* product's strongest rail, so every consequence of declaring one is checked
|
|
6
|
+
* by a gate rather than trusted:
|
|
7
|
+
*
|
|
8
|
+
* 1. a shared table carries NO tenant column (nothing to filter on, so
|
|
9
|
+
* `tenantDb` would throw TENANT_COLUMN_MISSING at runtime anyway);
|
|
10
|
+
* 2. a feature owning ONLY shared tables is the sole kind of feature allowed
|
|
11
|
+
* to touch the raw database — it has no tenant context to route through;
|
|
12
|
+
* 3. conversely a feature owning even ONE tenant table must never touch the
|
|
13
|
+
* raw db, whatever else it owns: mixing the two in one feature would make
|
|
14
|
+
* the raw-db ban unenforceable by inspection.
|
|
15
|
+
*
|
|
16
|
+
* These functions are pure (source text in, findings out) so that the negative
|
|
17
|
+
* tests required by DECISIONS.md D22 can feed them known violations directly —
|
|
18
|
+
* scripts/lint-interdits.ts and scripts/verify-manifest.ts only report what
|
|
19
|
+
* they return. The module is GENERIC infrastructure (D10 amended): it speaks
|
|
20
|
+
* "tenant", never a word of the product's domain.
|
|
21
|
+
*/
|
|
22
|
+
import type { DbTableDeclaration, FeatureManifest } from "./manifest.schema.js";
|
|
23
|
+
|
|
24
|
+
/** Name of a table however it was declared (bare string or object form). */
|
|
25
|
+
export function dbTableNameOf(declaration: DbTableDeclaration): string {
|
|
26
|
+
return typeof declaration === "string" ? declaration : declaration.name;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function dbTableNamesOf(feature: FeatureManifest): string[] {
|
|
30
|
+
return feature.dbTables.map(dbTableNameOf);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function sharedTableNamesOf(feature: FeatureManifest): string[] {
|
|
34
|
+
return feature.dbTables
|
|
35
|
+
.filter((declaration) => typeof declaration !== "string")
|
|
36
|
+
.map((declaration) => declaration.name);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function tenantTableNamesOf(feature: FeatureManifest): string[] {
|
|
40
|
+
return feature.dbTables
|
|
41
|
+
.filter((declaration) => typeof declaration === "string")
|
|
42
|
+
.map((declaration) => declaration);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A feature may bypass the tenant facade ONLY when every table it owns is
|
|
47
|
+
* shared — and it must own at least one, so that "owns nothing" (a front, a
|
|
48
|
+
* gateway, a pure-compute feature) never becomes an accidental licence.
|
|
49
|
+
*/
|
|
50
|
+
export function ownsOnlySharedTables(feature: FeatureManifest): boolean {
|
|
51
|
+
return feature.dbTables.length > 0 && tenantTableNamesOf(feature).length === 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Full raw-db policy for one feature: the closed socle allowlist (the tenancy
|
|
56
|
+
* feature builds the contexts, so it predates them) OR a shared-only feature.
|
|
57
|
+
*/
|
|
58
|
+
export function mayUseRawDb(
|
|
59
|
+
feature: FeatureManifest,
|
|
60
|
+
socleAllowlist: ReadonlySet<string>,
|
|
61
|
+
): boolean {
|
|
62
|
+
return socleAllowlist.has(feature.name) || ownsOnlySharedTables(feature);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Splits a db.schema.ts source into one text block per `pgTable("name", ...)`
|
|
67
|
+
* definition. Deliberately textual — the same reading verify-manifest already
|
|
68
|
+
* does — so the gate stays dependency-free and cannot be fooled by a table
|
|
69
|
+
* defined in a file it does not read.
|
|
70
|
+
*/
|
|
71
|
+
export function splitDbSchemaByTable(source: string): Map<string, string> {
|
|
72
|
+
const blocks = new Map<string, string>();
|
|
73
|
+
const matches = [...source.matchAll(/pgTable\(\s*"([^"]+)"/g)];
|
|
74
|
+
matches.forEach((match, index) => {
|
|
75
|
+
const tableName = match[1];
|
|
76
|
+
if (tableName === undefined || match.index === undefined) return;
|
|
77
|
+
const nextMatch = matches[index + 1];
|
|
78
|
+
const end = nextMatch?.index ?? source.length;
|
|
79
|
+
blocks.set(tableName, source.slice(match.index, end));
|
|
80
|
+
});
|
|
81
|
+
return blocks;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface SharedTableFinding {
|
|
85
|
+
table: string;
|
|
86
|
+
/** Name of the tenant-looking column found in the table's definition. */
|
|
87
|
+
tenantColumn: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Reports every SHARED table whose Drizzle definition still declares a tenant
|
|
92
|
+
* column. Such a table is a contradiction: declared shared (so exempt from RLS
|
|
93
|
+
* and from tenantDb) yet shaped like a tenant table — exactly the ambiguity
|
|
94
|
+
* that turns an audited exception back into a leak.
|
|
95
|
+
*/
|
|
96
|
+
export function findTenantColumnsInSharedTables(
|
|
97
|
+
dbSchemaSource: string,
|
|
98
|
+
sharedTableNames: readonly string[],
|
|
99
|
+
tenantColumnNames: readonly string[],
|
|
100
|
+
): SharedTableFinding[] {
|
|
101
|
+
const blocks = splitDbSchemaByTable(dbSchemaSource);
|
|
102
|
+
const findings: SharedTableFinding[] = [];
|
|
103
|
+
for (const table of sharedTableNames) {
|
|
104
|
+
const block = blocks.get(table);
|
|
105
|
+
if (block === undefined) continue; // absence is MANIFEST_TABLE_NOT_FOUND's job
|
|
106
|
+
for (const column of tenantColumnNames) {
|
|
107
|
+
if (block.includes(`"${column}"`)) {
|
|
108
|
+
findings.push({ table, tenantColumn: column });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return findings;
|
|
113
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Mutation tests of the spec-frontmatter format (DECISIONS.md D22:
|
|
3
|
+
* every gate is born — and evolves — with its negative test). Each test feeds
|
|
4
|
+
* the parser a KNOWN VIOLATION and requires DETECTION, never a silent
|
|
5
|
+
* default: the three traps consigned in FAILURES.md (2026-07-30, session 7)
|
|
6
|
+
* each get their mutation, plus the nominal path and the schema-level
|
|
7
|
+
* rejection of a non-kebab-case blockedBy target.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import { SpecFrontmatterSchema, parseSpecFrontmatter } from "./spec.schema.js";
|
|
11
|
+
|
|
12
|
+
const NOMINAL_FRONTMATTER = `
|
|
13
|
+
id: order-flow
|
|
14
|
+
status: validated
|
|
15
|
+
depends_on: ["billing", "accounts"]
|
|
16
|
+
priority: P0
|
|
17
|
+
refs: ["modele-source tables Orders (250)"]
|
|
18
|
+
deferred: ["QUANTITY_COMPUTATIONS (F1-F4) blockedBy:computations"]
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
describe("parseSpecFrontmatter — format restreint D13", () => {
|
|
22
|
+
it("lit le chemin nominal : scalaires + tableaux inline, validés par SpecFrontmatterSchema", () => {
|
|
23
|
+
const parsed = parseSpecFrontmatter(NOMINAL_FRONTMATTER);
|
|
24
|
+
|
|
25
|
+
expect(parsed.problems).toEqual([]);
|
|
26
|
+
const validated = SpecFrontmatterSchema.safeParse(parsed.value);
|
|
27
|
+
expect(validated.success).toBe(true);
|
|
28
|
+
expect(validated.success && validated.data.deferred).toEqual([
|
|
29
|
+
"QUANTITY_COMPUTATIONS (F1-F4) blockedBy:computations",
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("lit un tableau MULTILIGNE en entier (le format lisible des specs à différés nombreux)", () => {
|
|
34
|
+
const parsed = parseSpecFrontmatter(`
|
|
35
|
+
id: order-flow
|
|
36
|
+
status: validated
|
|
37
|
+
depends_on: []
|
|
38
|
+
priority: P0
|
|
39
|
+
deferred: [
|
|
40
|
+
"QUANTITY_COMPUTATIONS blockedBy:computations",
|
|
41
|
+
"PERENNIAL_ROLLOVER blockedBy:retour-experts-q2"
|
|
42
|
+
]
|
|
43
|
+
`);
|
|
44
|
+
|
|
45
|
+
expect(parsed.problems).toEqual([]);
|
|
46
|
+
expect(parsed.value["deferred"]).toEqual([
|
|
47
|
+
"QUANTITY_COMPUTATIONS blockedBy:computations",
|
|
48
|
+
"PERENNIAL_ROLLOVER blockedBy:retour-experts-q2",
|
|
49
|
+
]);
|
|
50
|
+
// No junk key leaks from the consumed lines, and the whole spec validates.
|
|
51
|
+
expect(Object.keys(parsed.value).sort()).toEqual([
|
|
52
|
+
"deferred",
|
|
53
|
+
"depends_on",
|
|
54
|
+
"id",
|
|
55
|
+
"priority",
|
|
56
|
+
"status",
|
|
57
|
+
]);
|
|
58
|
+
expect(SpecFrontmatterSchema.safeParse(parsed.value).success).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("MUTATION — un tableau OUVERT ET JAMAIS FERMÉ est REFUSÉ au lieu d'être lu deferred=[] en silence (le piège order-flow)", () => {
|
|
62
|
+
const parsed = parseSpecFrontmatter(`
|
|
63
|
+
id: order-flow
|
|
64
|
+
status: validated
|
|
65
|
+
depends_on: []
|
|
66
|
+
priority: P0
|
|
67
|
+
deferred: [
|
|
68
|
+
"QUANTITY_COMPUTATIONS blockedBy:computations",
|
|
69
|
+
"PERENNIAL_ROLLOVER blockedBy:retour-experts-q2"
|
|
70
|
+
`);
|
|
71
|
+
|
|
72
|
+
// The old parser produced deferred=[] and junk keys — no problem reported,
|
|
73
|
+
// the SPEC_IMPLEMENTED_WITH_DEFERRED gate went inert. Never again:
|
|
74
|
+
expect(parsed.problems.length).toBeGreaterThan(0);
|
|
75
|
+
expect(parsed.problems.join(" ; ")).toContain('key "deferred"');
|
|
76
|
+
expect(parsed.problems.join(" ; ")).toContain("never closed");
|
|
77
|
+
// And the lie itself is gone: no silently-empty deferred survives.
|
|
78
|
+
expect(parsed.value["deferred"]).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("MUTATION — une ligne d'élément ORPHELINE (hors de tout tableau) reste refusée", () => {
|
|
82
|
+
const parsed = parseSpecFrontmatter(`
|
|
83
|
+
id: order-flow
|
|
84
|
+
status: validated
|
|
85
|
+
"PERENNIAL_ROLLOVER blockedBy:retour-experts-q2"
|
|
86
|
+
`);
|
|
87
|
+
|
|
88
|
+
expect(
|
|
89
|
+
parsed.problems.some((problem) => problem.includes("unparseable line")),
|
|
90
|
+
).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("MUTATION — une virgule INTERNE à une entrée citée ne casse plus l'entrée en fragments (split conscient des guillemets)", () => {
|
|
94
|
+
const parsed = parseSpecFrontmatter(
|
|
95
|
+
'deferred: ["INTERVENTIONS_AND_TIMESHEET (D17, imputation MO/machines) blockedBy:interventions"]',
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(parsed.problems).toEqual([]);
|
|
99
|
+
// ONE element, comma preserved — the old split produced two broken shards.
|
|
100
|
+
expect(parsed.value["deferred"]).toEqual([
|
|
101
|
+
"INTERVENTIONS_AND_TIMESHEET (D17, imputation MO/machines) blockedBy:interventions",
|
|
102
|
+
]);
|
|
103
|
+
// …and that whole entry satisfies the DeferredBehaviorSchema regex.
|
|
104
|
+
const validated = SpecFrontmatterSchema.safeParse({
|
|
105
|
+
id: "order-flow",
|
|
106
|
+
status: "validated",
|
|
107
|
+
depends_on: [],
|
|
108
|
+
priority: "P0",
|
|
109
|
+
deferred: parsed.value["deferred"],
|
|
110
|
+
});
|
|
111
|
+
expect(validated.success).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("MUTATION — des guillemets non appariés (dans un tableau ou un scalaire) sont REFUSÉS au lieu de produire des éléments corrompus", () => {
|
|
115
|
+
const inArray = parseSpecFrontmatter('deferred: ["FOO blockedBy:bar, "BAZ blockedBy:qux"]');
|
|
116
|
+
expect(inArray.problems.length).toBeGreaterThan(0);
|
|
117
|
+
expect(inArray.value["deferred"]).toBeUndefined();
|
|
118
|
+
|
|
119
|
+
const inScalar = parseSpecFrontmatter('id: "order-flow');
|
|
120
|
+
expect(inScalar.problems.length).toBeGreaterThan(0);
|
|
121
|
+
expect(inScalar.value["id"]).toBeUndefined();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("MUTATION — un blockedBy hors kebab-case minuscule (blockedBy:retour-experts-Q2) est rejeté par SpecFrontmatterSchema, champ identifié", () => {
|
|
125
|
+
const parsed = parseSpecFrontmatter(
|
|
126
|
+
'deferred: ["PERENNIAL_ROLLOVER blockedBy:retour-experts-Q2"]',
|
|
127
|
+
);
|
|
128
|
+
expect(parsed.problems).toEqual([]); // format OK — the SCHEMA is the gate here
|
|
129
|
+
|
|
130
|
+
const validated = SpecFrontmatterSchema.safeParse({
|
|
131
|
+
id: "order-flow",
|
|
132
|
+
status: "validated",
|
|
133
|
+
depends_on: [],
|
|
134
|
+
priority: "P0",
|
|
135
|
+
deferred: parsed.value["deferred"],
|
|
136
|
+
});
|
|
137
|
+
expect(validated.success).toBe(false);
|
|
138
|
+
expect(
|
|
139
|
+
!validated.success &&
|
|
140
|
+
validated.error.issues.some((issue) => issue.path[0] === "deferred"),
|
|
141
|
+
).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Zod schema AND format parser of the YAML frontmatter of
|
|
3
|
+
* `specs/<feature>.spec.md` files. A spec is a contract, not a document:
|
|
4
|
+
* machine-readable frontmatter + mandatory canonical sections. The CI
|
|
5
|
+
* validator (scripts/verify-specs.ts) checks the frontmatter AND the sections;
|
|
6
|
+
* this module owns the restricted frontmatter format — scalars and arrays of
|
|
7
|
+
* scalars, inline or multiline (D13) — through parseSpecFrontmatter, which REFUSES what it
|
|
8
|
+
* cannot read instead of silently swallowing it (a multiline array once read
|
|
9
|
+
* as `deferred: []` without a sound — FAILURES.md 2026-07-30, DECISIONS.md
|
|
10
|
+
* D22: every gate is born with its mutation test, see spec.schema.test.ts).
|
|
11
|
+
* Section titles stay FRENCH on purpose: specs are human-facing documents
|
|
12
|
+
* (DECISIONS.md D11 — humans of the project read and validate them in French).
|
|
13
|
+
*/
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { FeatureNameSchema } from "./manifest.schema.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One deferred behavior (DECISIONS.md D13): a behavior the spec describes but
|
|
19
|
+
* whose implementation is blocked by a feature that does not exist yet.
|
|
20
|
+
* Format: "<stable error code or short label> blockedBy:<feature-name>".
|
|
21
|
+
* The frontmatter parser only reads arrays of STRINGS (inline or multiline),
|
|
22
|
+
* hence this compact encoding instead of a nested object.
|
|
23
|
+
*/
|
|
24
|
+
export const DeferredBehaviorSchema = z
|
|
25
|
+
.string()
|
|
26
|
+
.regex(
|
|
27
|
+
/^.+ blockedBy:[a-z][a-z0-9]*(-[a-z0-9]+)*$/,
|
|
28
|
+
'Expected "<behavior> blockedBy:<feature-name>" (feature name in kebab-case)',
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
export const SpecFrontmatterSchema = z.object({
|
|
32
|
+
id: FeatureNameSchema.describe("Identical to the feature name in the manifest"),
|
|
33
|
+
status: z.enum(["draft", "validated", "implemented"]),
|
|
34
|
+
depends_on: z.array(FeatureNameSchema),
|
|
35
|
+
priority: z.enum(["P0", "P1", "P2"]),
|
|
36
|
+
refs: z
|
|
37
|
+
.array(z.string())
|
|
38
|
+
.default([])
|
|
39
|
+
.describe("References to the domain model, e.g. 'modele-gally §4.2'"),
|
|
40
|
+
deferred: z
|
|
41
|
+
.array(DeferredBehaviorSchema)
|
|
42
|
+
.default([])
|
|
43
|
+
.describe("Behaviors deferred to a later feature (DECISIONS.md D13)"),
|
|
44
|
+
});
|
|
45
|
+
export type SpecFrontmatter = z.infer<typeof SpecFrontmatterSchema>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Mandatory Markdown sections in the spec body, in this order.
|
|
49
|
+
* The CI validator fails when a section is missing (code: SPEC_SECTION_MISSING).
|
|
50
|
+
*/
|
|
51
|
+
export const SECTIONS_SPEC_OBLIGATOIRES = [
|
|
52
|
+
"# @intent",
|
|
53
|
+
"# Comportements",
|
|
54
|
+
"# Contrats de données",
|
|
55
|
+
"# États UI",
|
|
56
|
+
"# Non-objectifs",
|
|
57
|
+
"# Budget",
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Lifecycle rule (checked in CI):
|
|
62
|
+
* - status: draft → no implementation may exist
|
|
63
|
+
* - status: validated → implementation is allowed
|
|
64
|
+
* - status: implemented → the feature exists in the manifest with status "delivered"
|
|
65
|
+
* AND `deferred` is empty (D13 — nothing left pending)
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
// ----------------------------------------------------------- format parser
|
|
69
|
+
export interface ParsedSpecFrontmatter {
|
|
70
|
+
/** Parsed keys — feed this to SpecFrontmatterSchema. */
|
|
71
|
+
value: Record<string, unknown>;
|
|
72
|
+
/** Format violations; non-empty ⇒ the gate reports SPEC_FRONTMATTER_INVALID. */
|
|
73
|
+
problems: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Splits inline-array elements on commas OUTSIDE quotes — a comma inside a
|
|
77
|
+
* quoted entry is content, not a separator (it used to shatter the entry). */
|
|
78
|
+
function splitInlineArrayElements(inner: string): {
|
|
79
|
+
elements: string[];
|
|
80
|
+
unbalancedQuote: boolean;
|
|
81
|
+
} {
|
|
82
|
+
const elements: string[] = [];
|
|
83
|
+
let current = "";
|
|
84
|
+
let openQuote: '"' | "'" | null = null;
|
|
85
|
+
for (const character of inner) {
|
|
86
|
+
if (openQuote === null && (character === '"' || character === "'")) {
|
|
87
|
+
openQuote = character;
|
|
88
|
+
current += character;
|
|
89
|
+
} else if (character === openQuote) {
|
|
90
|
+
openQuote = null;
|
|
91
|
+
current += character;
|
|
92
|
+
} else if (openQuote === null && character === ",") {
|
|
93
|
+
elements.push(current);
|
|
94
|
+
current = "";
|
|
95
|
+
} else {
|
|
96
|
+
current += character;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
elements.push(current);
|
|
100
|
+
return { elements, unbalancedQuote: openQuote !== null };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Unquotes a scalar; both-ends-or-nothing — a dangling quote is a refusal. */
|
|
104
|
+
function unquote(raw: string, location: string, problems: string[]): string | null {
|
|
105
|
+
const trimmed = raw.trim();
|
|
106
|
+
const first = trimmed[0];
|
|
107
|
+
if (first === '"' || first === "'") {
|
|
108
|
+
if (trimmed.length < 2 || !trimmed.endsWith(first)) {
|
|
109
|
+
problems.push(`${location}: unbalanced quotes in ${JSON.stringify(trimmed)}`);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return trimmed.slice(1, -1);
|
|
113
|
+
}
|
|
114
|
+
return trimmed;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parses the RESTRICTED frontmatter format: one `key: value` per line, values
|
|
119
|
+
* being scalars or arrays of scalars — nothing nested, no object. An array may
|
|
120
|
+
* be written INLINE (`[a, b]`) or SPREAD over several lines, the closing `]`
|
|
121
|
+
* ending it; a long `deferred` list is unreadable on one line, and readability
|
|
122
|
+
* is the whole point of a spec (3rd friction, PR #15 review).
|
|
123
|
+
* Everything it cannot read still becomes a problem instead of a silent
|
|
124
|
+
* default: an array never closed before the end of the frontmatter, a spilled
|
|
125
|
+
* element line OUTSIDE any array, an unbalanced quote — each once made a spec
|
|
126
|
+
* lie by omission (FAILURES.md 2026-07-30, D22).
|
|
127
|
+
*/
|
|
128
|
+
export function parseSpecFrontmatter(text: string): ParsedSpecFrontmatter {
|
|
129
|
+
const value: Record<string, unknown> = {};
|
|
130
|
+
const problems: string[] = [];
|
|
131
|
+
const lines = text.split("\n");
|
|
132
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
133
|
+
const trimmed = (lines[index] ?? "").trim();
|
|
134
|
+
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
135
|
+
const colonIndex = trimmed.indexOf(":");
|
|
136
|
+
if (colonIndex === -1 || trimmed.startsWith('"') || trimmed.startsWith("'")) {
|
|
137
|
+
problems.push(
|
|
138
|
+
`unparseable line ${JSON.stringify(trimmed)}: expected \`key: value\` — a spilled array element outside any array? (scalars and arrays of scalars only, D13)`,
|
|
139
|
+
);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const key = trimmed.slice(0, colonIndex).trim();
|
|
143
|
+
let rawValue = trimmed.slice(colonIndex + 1).trim();
|
|
144
|
+
if (rawValue.startsWith("[")) {
|
|
145
|
+
// Multiline array: keep consuming lines until the one closing the
|
|
146
|
+
// bracket. Reaching the end of the frontmatter without it is a REFUSAL,
|
|
147
|
+
// never an empty array — that silence is the bug this gate exists for.
|
|
148
|
+
let closed = rawValue.endsWith("]");
|
|
149
|
+
while (!closed && index + 1 < lines.length) {
|
|
150
|
+
index += 1;
|
|
151
|
+
rawValue = `${rawValue} ${(lines[index] ?? "").trim()}`;
|
|
152
|
+
closed = rawValue.endsWith("]");
|
|
153
|
+
}
|
|
154
|
+
if (!closed) {
|
|
155
|
+
problems.push(
|
|
156
|
+
`key "${key}": array opened with "[" and never closed with "]" before the end of the frontmatter — a truncated array would be silently read as empty, hence refused (D13)`,
|
|
157
|
+
);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const { elements, unbalancedQuote } = splitInlineArrayElements(
|
|
161
|
+
rawValue.slice(1, -1),
|
|
162
|
+
);
|
|
163
|
+
if (unbalancedQuote) {
|
|
164
|
+
problems.push(`key "${key}": unbalanced quotes inside the array`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const parsedElements: string[] = [];
|
|
168
|
+
let broken = false;
|
|
169
|
+
for (const element of elements) {
|
|
170
|
+
if (element.trim() === "") continue; // trailing comma tolerated
|
|
171
|
+
const unquoted = unquote(element, `key "${key}"`, problems);
|
|
172
|
+
if (unquoted === null) broken = true;
|
|
173
|
+
else parsedElements.push(unquoted);
|
|
174
|
+
}
|
|
175
|
+
if (!broken) value[key] = parsedElements;
|
|
176
|
+
} else {
|
|
177
|
+
const unquoted = unquote(rawValue, `key "${key}"`, problems);
|
|
178
|
+
if (unquoted !== null) value[key] = unquoted;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { value, problems };
|
|
182
|
+
}
|