@urbicon-ui/design-engine 6.1.8

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,111 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { resolveValidTokenCores, VALID_TOKEN_CORES } from './tokens.js';
6
+
7
+ /**
8
+ * Drift guard: the hardcoded {@link VALID_TOKEN_CORES} is the design-engine's
9
+ * standalone copy of the design tokens. When the blocks CSS source is present
10
+ * (i.e. running inside the monorepo) we re-derive the token cores from it and
11
+ * assert the two agree exactly. Any token added to / removed from the CSS that
12
+ * is not mirrored here will fail this test — keeping the hallucination detector
13
+ * honest. The design-engine ships without the CSS, so this can only run in-repo.
14
+ */
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const styleDir = resolve(__dirname, '..', '..', '..', 'blocks', 'src', 'lib', 'style');
18
+ const foundation = resolve(styleDir, 'foundation.css');
19
+ const semantic = resolve(styleDir, 'semantic.css');
20
+ const cssAvailable = existsSync(foundation) && existsSync(semantic);
21
+
22
+ /** Token cores that intentionally live in CSS but are NOT colour-utility cores. */
23
+ const EXCLUDED_PREFIXES = ['shadow-']; // shadows are used via `shadow-[var(--blocks-shadow-*)]`, not `bg-shadow-*`
24
+
25
+ function deriveCoresFromCss(): Set<string> {
26
+ const cores = new Set<string>();
27
+ for (const file of [foundation, semantic]) {
28
+ const css = readFileSync(file, 'utf-8');
29
+ for (const m of css.matchAll(/--color-([a-z0-9-]+)\s*:/g)) {
30
+ const core = m[1]!;
31
+ if (EXCLUDED_PREFIXES.some((p) => core.startsWith(p))) continue;
32
+ cores.add(core);
33
+ }
34
+ }
35
+ return cores;
36
+ }
37
+
38
+ describe.skipIf(!cssAvailable)('token whitelist drift guard', () => {
39
+ it('recognises every colour token defined in the CSS (no forgotten additions)', () => {
40
+ const cssCores = deriveCoresFromCss();
41
+ const missing = [...cssCores].filter((c) => !VALID_TOKEN_CORES.has(c)).sort();
42
+ expect(missing, `CSS tokens missing from VALID_TOKEN_CORES: ${missing.join(', ')}`).toEqual([]);
43
+ });
44
+
45
+ it('contains no phantom tokens absent from the CSS (no invented entries)', () => {
46
+ const cssCores = deriveCoresFromCss();
47
+ const phantom = [...VALID_TOKEN_CORES].filter((c) => !cssCores.has(c)).sort();
48
+ expect(
49
+ phantom,
50
+ `Entries in VALID_TOKEN_CORES with no matching --color-* in CSS: ${phantom.join(', ')}`
51
+ ).toEqual([]);
52
+ });
53
+
54
+ it('each EXCLUDED_PREFIX still matches a real CSS token (exclusion not stale)', () => {
55
+ const css = readFileSync(foundation, 'utf-8') + readFileSync(semantic, 'utf-8');
56
+ for (const prefix of EXCLUDED_PREFIXES) {
57
+ expect(
58
+ css,
59
+ `EXCLUDED_PREFIX "${prefix}" no longer matches any --color-* token — may be stale`
60
+ ).toMatch(new RegExp(`--color-${prefix}`));
61
+ }
62
+ });
63
+ });
64
+
65
+ describe('token whitelist shape', () => {
66
+ it('includes representative tokens from each family', () => {
67
+ for (const core of [
68
+ 'surface-base',
69
+ 'text-primary',
70
+ 'border-subtle',
71
+ 'primary',
72
+ 'primary-500',
73
+ 'feedback-success-subtle',
74
+ 'interactive-hover',
75
+ 'chart-1'
76
+ ]) {
77
+ expect(VALID_TOKEN_CORES.has(core), core).toBe(true);
78
+ }
79
+ });
80
+ });
81
+
82
+ describe('resolveValidTokenCores', () => {
83
+ it('returns the built-in set by reference when there are no extras (no allocation)', () => {
84
+ expect(resolveValidTokenCores()).toBe(VALID_TOKEN_CORES);
85
+ expect(resolveValidTokenCores([])).toBe(VALID_TOKEN_CORES);
86
+ });
87
+
88
+ it('merges extra cores on top of the built-in whitelist', () => {
89
+ const resolved = resolveValidTokenCores(['surface-brand', 'primary-vivid']);
90
+ expect(resolved.has('surface-brand')).toBe(true);
91
+ expect(resolved.has('primary-vivid')).toBe(true);
92
+ expect(resolved.has('surface-base'), 'built-ins still present').toBe(true);
93
+ });
94
+
95
+ it('normalises input — trims surrounding whitespace and drops blanks', () => {
96
+ const resolved = resolveValidTokenCores([' surface-brand ', '', ' ']);
97
+ expect(resolved.has('surface-brand')).toBe(true);
98
+ expect(resolved.has('')).toBe(false);
99
+ });
100
+
101
+ it('treats an all-blank list as no extras (built-in set by reference)', () => {
102
+ expect(resolveValidTokenCores(['', ' '])).toBe(VALID_TOKEN_CORES);
103
+ });
104
+
105
+ it('never mutates the shared built-in set', () => {
106
+ const before = VALID_TOKEN_CORES.size;
107
+ resolveValidTokenCores(['surface-brand']);
108
+ expect(VALID_TOKEN_CORES.size).toBe(before);
109
+ expect(VALID_TOKEN_CORES.has('surface-brand')).toBe(false);
110
+ });
111
+ });
@@ -0,0 +1,230 @@
1
+ /**
2
+ * The authoritative set of Urbicon UI semantic design tokens, expressed as the
3
+ * "core" of a Tailwind colour utility (the part after `bg-`/`text-`/`border-`/…).
4
+ *
5
+ * SOURCE OF TRUTH: `packages/blocks/src/lib/style/{foundation,semantic}.css`
6
+ * (the `--color-*` custom properties). This list is hand-maintained but guarded
7
+ * against drift by `tokens.test.ts`, which re-derives the set from the CSS when
8
+ * run inside the monorepo and fails on any mismatch. The design-engine ships
9
+ * standalone (no access to the blocks source at runtime), so the data must live
10
+ * here, not be read from disk.
11
+ *
12
+ * Why a whitelist: token hallucination is the single biggest weakness of
13
+ * design-quality guidance — the model invents plausible names like
14
+ * `bg-status-danger` or `text-success-fg`
15
+ * that do not exist. A deterministic whitelist turns that from a guess into a
16
+ * fact: a utility whose core looks semantic but is absent here is flagged.
17
+ */
18
+
19
+ /** Surface background tokens → `bg-surface-*`. */
20
+ const SURFACE_CORES = [
21
+ 'surface-base',
22
+ 'surface-quiet',
23
+ 'surface-subtle',
24
+ 'surface-elevated',
25
+ 'surface-overlay',
26
+ 'surface-interactive',
27
+ 'surface-hover',
28
+ 'surface-active',
29
+ 'surface-disabled',
30
+ 'surface-selected',
31
+ 'surface-inverted'
32
+ ] as const;
33
+
34
+ /** Text colour tokens → `text-text-*` (the `text-` namespace, used after a colour prefix). */
35
+ const TEXT_CORES = [
36
+ 'text-primary',
37
+ 'text-secondary',
38
+ 'text-tertiary',
39
+ 'text-quaternary',
40
+ 'text-disabled',
41
+ 'text-inverted',
42
+ 'text-on-primary',
43
+ 'text-on-dark',
44
+ 'text-on-surface'
45
+ ] as const;
46
+
47
+ /** Border colour tokens → `border-border-*`. */
48
+ const BORDER_CORES = [
49
+ 'border-subtle',
50
+ 'border-default',
51
+ 'border-emphasis',
52
+ 'border-strong',
53
+ 'border-hairline'
54
+ ] as const;
55
+
56
+ /** Intent palettes. Each has a bare token plus the four interaction variants. */
57
+ const INTENT_NAMES = [
58
+ 'primary',
59
+ 'secondary',
60
+ 'success',
61
+ 'warning',
62
+ 'danger',
63
+ 'info',
64
+ 'neutral'
65
+ ] as const;
66
+
67
+ const INTENT_VARIANTS = ['hover', 'active', 'subtle', 'emphasis'] as const;
68
+
69
+ /** Numbered foundation steps present on every intent scale. */
70
+ const SCALE_STEPS = [
71
+ '50',
72
+ '100',
73
+ '200',
74
+ '300',
75
+ '400',
76
+ '500',
77
+ '600',
78
+ '700',
79
+ '800',
80
+ '900',
81
+ '950'
82
+ ] as const;
83
+
84
+ /**
85
+ * `neutral` carries an extended ramp beyond the standard scale (finer steps for
86
+ * the surface ladder). Omitting these would make the hallucination rule flag a
87
+ * real token like `bg-neutral-650` — the drift guard in tokens.test.ts caught
88
+ * exactly that.
89
+ */
90
+ const NEUTRAL_EXTRA_STEPS = ['0', '25', '650', '750', '850'] as const;
91
+
92
+ /** Warm-neutral foundation scale (used by the Organic/Warm paradigm). Scale-only — no semantic variants. */
93
+ const WARM_NEUTRAL_STEPS = SCALE_STEPS;
94
+
95
+ /** Feedback tokens for status messaging → `bg-feedback-*` / `text-feedback-*`. */
96
+ const FEEDBACK_CORES = [
97
+ 'feedback-info',
98
+ 'feedback-info-subtle',
99
+ 'feedback-success',
100
+ 'feedback-success-subtle',
101
+ 'feedback-warning',
102
+ 'feedback-warning-subtle',
103
+ 'feedback-error',
104
+ 'feedback-error-subtle'
105
+ ] as const;
106
+
107
+ /** Interactive overlay tokens. */
108
+ const INTERACTIVE_CORES = [
109
+ 'interactive-hover',
110
+ 'interactive-active',
111
+ 'interactive-focus',
112
+ 'interactive-disabled'
113
+ ] as const;
114
+
115
+ /** Chart series tokens → `text-chart-1` … `text-chart-6`. */
116
+ const CHART_CORES = ['chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5', 'chart-6'] as const;
117
+
118
+ function buildIntentCores(): string[] {
119
+ const cores: string[] = [];
120
+ for (const intent of INTENT_NAMES) {
121
+ cores.push(intent); // bare, e.g. `bg-primary`
122
+ for (const variant of INTENT_VARIANTS) cores.push(`${intent}-${variant}`); // `bg-primary-subtle`
123
+ for (const step of SCALE_STEPS) cores.push(`${intent}-${step}`); // `bg-primary-500`
124
+ }
125
+ return cores;
126
+ }
127
+
128
+ /**
129
+ * Every valid semantic colour-utility core, as a Set for O(1) membership checks.
130
+ * `surface-base`, `text-primary`, `primary`, `primary-500`, `feedback-success`, …
131
+ */
132
+ export const VALID_TOKEN_CORES: ReadonlySet<string> = new Set([
133
+ ...SURFACE_CORES,
134
+ ...TEXT_CORES,
135
+ ...BORDER_CORES,
136
+ ...buildIntentCores(),
137
+ ...NEUTRAL_EXTRA_STEPS.map((s) => `neutral-${s}`),
138
+ ...WARM_NEUTRAL_STEPS.map((s) => `warm-neutral-${s}`),
139
+ ...FEEDBACK_CORES,
140
+ ...INTERACTIVE_CORES,
141
+ ...CHART_CORES
142
+ ]);
143
+
144
+ /**
145
+ * Normalise raw per-call extra-token input: trim each, drop blanks. These are
146
+ * token *cores* (e.g. `surface-brand`), matching {@link VALID_TOKEN_CORES} — not
147
+ * full utilities (`bg-surface-brand`) and not CSS variables. Deliberately tolerant
148
+ * on read (trim/drop) but it never rewrites a value, so the whitelist contract
149
+ * stays predictable and a caller's `surface-brand` means exactly that.
150
+ */
151
+ function normalizeExtraTokens(extra: readonly string[]): string[] {
152
+ return extra.map((token) => token.trim()).filter((token) => token.length > 0);
153
+ }
154
+
155
+ /**
156
+ * The effective valid-core set for one lint run: the built-in
157
+ * {@link VALID_TOKEN_CORES} plus any project-specific cores passed per call. The
158
+ * base set is hot and never mutated — so when there are no usable extras this
159
+ * returns it by reference (no allocation), and otherwise returns a fresh merged
160
+ * Set. Powers the `extraTokens` "context as parameter" path (see LintOptions).
161
+ */
162
+ export function resolveValidTokenCores(extra?: readonly string[]): ReadonlySet<string> {
163
+ if (!extra || extra.length === 0) return VALID_TOKEN_CORES;
164
+ const normalized = normalizeExtraTokens(extra);
165
+ if (normalized.length === 0) return VALID_TOKEN_CORES;
166
+ const merged = new Set(VALID_TOKEN_CORES);
167
+ for (const core of normalized) merged.add(core);
168
+ return merged;
169
+ }
170
+
171
+ /**
172
+ * Namespaces that mark a utility core as "intended to be semantic". A core that
173
+ * starts with one of these but is NOT in {@link VALID_TOKEN_CORES} is a
174
+ * hallucinated token. Kept deliberately narrow so we never flag genuine Tailwind
175
+ * utilities (`bg-transparent`, `bg-cover`, arbitrary `bg-[#fff]`).
176
+ */
177
+ export const SEMANTIC_NAMESPACES = [
178
+ 'surface-',
179
+ 'text-',
180
+ 'border-',
181
+ 'feedback-',
182
+ 'interactive-',
183
+ 'chart-'
184
+ ] as const;
185
+
186
+ /** Intent prefixes (`primary-…`, `success-…`) that mark a core as semantic-intent. */
187
+ export const INTENT_PREFIXES: readonly string[] = INTENT_NAMES;
188
+
189
+ /**
190
+ * The shadcn/ui (and Radix) token vocabulary. This is the single most common
191
+ * hallucination source: with no explicit whitelist, models default to the
192
+ * dominant design-system convention in their training data — `text-foreground`,
193
+ * `bg-accent`, `text-muted-foreground`, `bg-card`, … — none of which exist in
194
+ * Urbicon UI. Discovered empirically during design-quality evaluation.
195
+ * These bare cores slip past the namespace/intent heuristics, so they are
196
+ * matched explicitly. (Suffix `-foreground` and the `fg`/`fg-` family are caught
197
+ * by rule in rules.ts.)
198
+ */
199
+ export const KNOWN_FOREIGN_CORES: ReadonlySet<string> = new Set([
200
+ 'foreground',
201
+ 'background',
202
+ 'muted',
203
+ 'accent',
204
+ 'card',
205
+ 'popover',
206
+ 'input',
207
+ 'destructive',
208
+ 'surface', // bare — the real page surface is `surface-base`
209
+ 'muted-foreground',
210
+ 'accent-foreground',
211
+ 'card-foreground',
212
+ 'popover-foreground',
213
+ 'primary-foreground',
214
+ 'secondary-foreground',
215
+ 'destructive-foreground'
216
+ ]);
217
+
218
+ /**
219
+ * Cores that are commonly hallucinated and have a known correct replacement.
220
+ * Drives a precise fix hint instead of a generic "unknown token".
221
+ */
222
+ export const KNOWN_BAD_NAMESPACES: Record<string, string> = {
223
+ // `status-*` is the most frequent invention observed. Map to feedback/intents.
224
+ 'status-':
225
+ 'Use a `feedback-*` token (feedback-success, feedback-error, …) or a bare intent (`success`, `danger`).',
226
+ // `-fg` / `-foreground` suffixes are invented; the system uses `text-on-primary` etc.
227
+ '-fg': 'Use `text-on-primary` / `text-on-surface` for foreground-on-intent text.'
228
+ };
229
+
230
+ export { INTENT_NAMES, INTENT_VARIANTS, SCALE_STEPS };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Types for the Urbicon UI design linter.
3
+ *
4
+ * The linter turns the prose Anti-Patterns and Design-Quality guidance (served
5
+ * today by `get_design_principles`, `suggest_implementation`,
6
+ * `get_implementation_checklist`) into executable checks. Deterministic rules
7
+ * produce `error`/`warning` findings; distribution heuristics produce `info`
8
+ * findings. See the design-loop context in docs/DESIGN-MCP.md.
9
+ */
10
+
11
+ /** How serious a finding is. Drives the score deduction and the report grouping. */
12
+ export type Severity = 'error' | 'warning' | 'info';
13
+
14
+ /**
15
+ * Whether a finding comes from a deterministic rule (regex/string match — a fact
16
+ * about the code) or a statistical heuristic (a distribution judgement that can
17
+ * have false positives). Surfaced in the report so consumers can weight them.
18
+ */
19
+ export type FindingKind = 'deterministic' | 'heuristic';
20
+
21
+ /** A single linter finding, anchored to a location when one exists. */
22
+ export interface Finding {
23
+ /** Stable rule identifier, e.g. `raw-tailwind-color`. Used for tests and suppression. */
24
+ ruleId: string;
25
+ severity: Severity;
26
+ kind: FindingKind;
27
+ /** Human-readable description of what is wrong. */
28
+ message: string;
29
+ /** Concrete fix hint — what to do instead. */
30
+ fix: string;
31
+ /** 1-based line number, when the finding is anchored to one. */
32
+ line?: number;
33
+ /** The offending snippet (e.g. the class token), when applicable. */
34
+ match?: string;
35
+ }
36
+
37
+ /**
38
+ * Per-run context threaded into every {@link Rule.check}. Holds values that
39
+ * depend on the call options rather than the source under lint — today only the
40
+ * effective token whitelist (the built-in cores merged with any
41
+ * {@link LintOptions.extraTokens}). This is the seam future per-call rule inputs
42
+ * (e.g. project-tuned thresholds) hang off, so rules never reach for module-global
43
+ * state that a caller cannot influence.
44
+ */
45
+ export interface LintContext {
46
+ /**
47
+ * The valid semantic token cores for this run: the built-in whitelist plus any
48
+ * project-specific cores supplied via {@link LintOptions.extraTokens}.
49
+ */
50
+ validTokenCores: ReadonlySet<string>;
51
+ }
52
+
53
+ /** A deterministic rule: scans the source and emits findings. */
54
+ export interface Rule {
55
+ id: string;
56
+ severity: Severity;
57
+ /** One-line description of what the rule enforces, shown in `validate_design` rule listings. */
58
+ description: string;
59
+ /**
60
+ * Run the rule over the already-prepared source lines.
61
+ * @param lines source split by `\n`, with comments masked (see linter.ts)
62
+ * @param raw the original source (for rules that need cross-line context)
63
+ * @param ctx per-run context (e.g. the effective token whitelist). Always supplied
64
+ * by {@link lintDesign}; optional so a rule stays callable standalone, in which
65
+ * case it falls back to its own built-in defaults.
66
+ */
67
+ check(lines: string[], raw: string, ctx?: LintContext): Finding[];
68
+ }
69
+
70
+ /**
71
+ * Two-axis design score (DESIGN-MCP-V2 §6: "two tracks, never mixed"). Each axis
72
+ * is an independent 0–100 so a correctness defect never hides behind a clean slop
73
+ * axis, and a generic-looking page never passes just because its tokens are valid.
74
+ */
75
+ export interface LintScores {
76
+ /**
77
+ * Stage 1 — "is it correct Urbicon?". Deducts the deterministic-rule findings
78
+ * (the `error`/`warning` defects: raw colours, `dark:`/`focus:`, hardcoded
79
+ * z-index, broken dynamic classes, hallucinated tokens). Counted per occurrence —
80
+ * every defect is its own fix.
81
+ */
82
+ correctness: number;
83
+ /**
84
+ * Stage 2 — "does it look generic?". Deducts the system-agnostic slop-floor
85
+ * heuristics (the `heuristic`-kind findings). Each heuristic is one holistic
86
+ * judgement about the page, so it costs a flat weight once, regardless of how
87
+ * many times the pattern repeats.
88
+ */
89
+ slop: number;
90
+ }
91
+
92
+ /** Aggregate result of linting one code unit. */
93
+ export interface LintReport {
94
+ findings: Finding[];
95
+ /** Two-axis 0–100 score; 100/100 = no findings on that axis. See {@link LintScores}. */
96
+ scores: LintScores;
97
+ counts: { error: number; warning: number; info: number };
98
+ /** Optional label (e.g. filename) echoed back in the report. */
99
+ filename?: string;
100
+ }
101
+
102
+ /** Options for a lint run. */
103
+ export interface LintOptions {
104
+ filename?: string;
105
+ /** Skip the distribution heuristics (the `info`-level checks). Default: false. */
106
+ skipHeuristics?: boolean;
107
+ /**
108
+ * Project-specific semantic token cores to treat as valid for this run, merged
109
+ * into the built-in whitelist so the `token-hallucination` rule does not flag
110
+ * them (the "context as parameter" trick — lets a consumer on a customised or
111
+ * newer token set avoid false positives without the engine reading their CSS).
112
+ *
113
+ * A "core" is the part after the utility prefix, matching {@link VALID_TOKEN_CORES}:
114
+ * pass `surface-brand` (for `bg-surface-brand`), not `bg-surface-brand` and not the
115
+ * `--color-surface-brand` CSS variable. Only affects cores that already look
116
+ * semantic; raw-palette and `dark:`/`focus:` gates are unaffected.
117
+ */
118
+ extraTokens?: readonly string[];
119
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseHistory, serializeHistoryEntry } from './history.js';
3
+ import type { ValidationHistoryEntry } from './types.js';
4
+
5
+ const entry = (over: Partial<ValidationHistoryEntry> = {}): ValidationHistoryEntry => ({
6
+ date: '2026-06-21T10:00:00.000Z',
7
+ files: 3,
8
+ errors: 0,
9
+ warnings: 1,
10
+ infos: 2,
11
+ correctness: 95,
12
+ slop: 60,
13
+ ...over
14
+ });
15
+
16
+ describe('history serialize ⇆ parse', () => {
17
+ it('round-trips one entry through an ndjson line', () => {
18
+ const e = entry();
19
+ const parsed = parseHistory(serializeHistoryEntry(e));
20
+ expect(parsed).toEqual([e]);
21
+ });
22
+
23
+ it('serialises to a single line (no embedded newline)', () => {
24
+ expect(serializeHistoryEntry(entry())).not.toContain('\n');
25
+ });
26
+
27
+ it('parses multiple lines newest-last, preserving order', () => {
28
+ const a = entry({ date: '2026-06-19T00:00:00.000Z', slop: 40 });
29
+ const b = entry({ date: '2026-06-20T00:00:00.000Z', slop: 50 });
30
+ const blob = `${serializeHistoryEntry(a)}\n${serializeHistoryEntry(b)}\n`;
31
+ expect(parseHistory(blob).map((e) => e.slop)).toEqual([40, 50]);
32
+ });
33
+ });
34
+
35
+ describe('history parse tolerance', () => {
36
+ it('skips blank lines and a malformed/truncated tail without throwing', () => {
37
+ const good = serializeHistoryEntry(entry());
38
+ const blob = `\n${good}\n{"date":"2026-06-21T11:00:00Z","corre`; // half-written CI tail
39
+ const parsed = parseHistory(blob);
40
+ expect(parsed).toHaveLength(1);
41
+ expect(parsed[0]!.correctness).toBe(95);
42
+ });
43
+
44
+ it('rejects JSON that lacks the entry shape', () => {
45
+ expect(parseHistory('{"foo":1}\n[1,2,3]\n"a string"\nnull')).toEqual([]);
46
+ });
47
+
48
+ it('skips an entry whose numeric fields are malformed (not rendered as NaN)', () => {
49
+ const bad = JSON.stringify({
50
+ date: '2026-06-21T00:00:00.000Z',
51
+ files: 'NaN',
52
+ errors: 0,
53
+ warnings: 0,
54
+ infos: 0,
55
+ correctness: 100,
56
+ slop: 50
57
+ });
58
+ expect(parseHistory(bad)).toEqual([]);
59
+ });
60
+
61
+ it('returns [] for an empty blob', () => {
62
+ expect(parseHistory('')).toEqual([]);
63
+ expect(parseHistory(' \n\n')).toEqual([]);
64
+ });
65
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The validation-history sidecar: an append-only ndjson log of `urbicon validate
3
+ * --record` runs, so design drift is measurable over time (DESIGN-MCP-V2 §7).
4
+ *
5
+ * Pure string ⇆ object conversion only — the file I/O (resolving the sidecar path,
6
+ * appending a line) lives in the CLI's `manifest-io`, mirroring the rest of the
7
+ * manifest module: the engine is dependency-free string logic, the CLI owns the
8
+ * filesystem. ndjson is parsed tolerantly (a malformed or truncated line is
9
+ * skipped, never thrown) — a half-written tail from an interrupted CI run must not
10
+ * make the whole history unreadable.
11
+ */
12
+
13
+ import type { ValidationHistoryEntry } from './types.js';
14
+
15
+ /** Serialise one entry to a single ndjson line (no trailing newline — the writer adds it). */
16
+ export function serializeHistoryEntry(entry: ValidationHistoryEntry): string {
17
+ return JSON.stringify(entry);
18
+ }
19
+
20
+ /**
21
+ * True when a parsed value has the full shape of a history entry. Strict on the
22
+ * numeric fields (not just date/correctness/slop) so a hand-corrupted line —
23
+ * `files: "NaN"` — is skipped rather than rendered verbatim into the drift summary;
24
+ * the sidecar is machine-written, so a real entry always carries every field.
25
+ */
26
+ function isEntry(value: unknown): value is ValidationHistoryEntry {
27
+ if (typeof value !== 'object' || value === null) return false;
28
+ const e = value as Record<string, unknown>;
29
+ return (
30
+ typeof e.date === 'string' &&
31
+ typeof e.files === 'number' &&
32
+ typeof e.errors === 'number' &&
33
+ typeof e.warnings === 'number' &&
34
+ typeof e.infos === 'number' &&
35
+ typeof e.correctness === 'number' &&
36
+ typeof e.slop === 'number'
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Parse an ndjson history blob into entries, newest last (file order preserved).
42
+ * Blank and malformed lines are skipped so a corrupt tail never throws.
43
+ */
44
+ export function parseHistory(ndjson: string): ValidationHistoryEntry[] {
45
+ const entries: ValidationHistoryEntry[] = [];
46
+ for (const line of ndjson.split('\n')) {
47
+ const trimmed = line.trim();
48
+ if (!trimmed) continue;
49
+ try {
50
+ const parsed: unknown = JSON.parse(trimmed);
51
+ if (isEntry(parsed)) entries.push(parsed);
52
+ } catch {
53
+ // malformed/partial line — read tolerant, skip it
54
+ }
55
+ }
56
+ return entries;
57
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Public API of the design-manifest module — the persistent design-intent layer
3
+ * (docs/DESIGN-MCP.md, Option C). Consumed by the `urbicon` CLI
4
+ * (context / record-decision / sync-manifest) in `@urbicon-ui/design`.
5
+ */
6
+
7
+ export { parseHistory, serializeHistoryEntry } from './history.js';
8
+ export {
9
+ appendDecision,
10
+ createManifestTemplate,
11
+ DECISIONS_HEADING,
12
+ emptyManifest,
13
+ formatContext,
14
+ parseFrontmatter,
15
+ parseManifest,
16
+ renderDecision,
17
+ USAGES_HEADING,
18
+ upsertUsagesSection
19
+ } from './manifest.js';
20
+ export { scanMarkers } from './scan.js';
21
+ export type {
22
+ DesignDecision,
23
+ DesignManifest,
24
+ PatternUsage,
25
+ ProductIntent,
26
+ ValidationHistoryEntry
27
+ } from './types.js';