@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,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
+ }