@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.
- package/README.md +36 -0
- package/package.json +47 -0
- package/src/index.ts +23 -0
- package/src/linter/heuristics.ts +609 -0
- package/src/linter/index.ts +23 -0
- package/src/linter/linter.test.ts +509 -0
- package/src/linter/linter.ts +96 -0
- package/src/linter/markup-rules.test.ts +109 -0
- package/src/linter/markup-rules.ts +209 -0
- package/src/linter/markup.test.ts +139 -0
- package/src/linter/markup.ts +274 -0
- package/src/linter/rules.ts +354 -0
- package/src/linter/tokens.test.ts +111 -0
- package/src/linter/tokens.ts +230 -0
- package/src/linter/types.ts +119 -0
- package/src/manifest/history.test.ts +65 -0
- package/src/manifest/history.ts +57 -0
- package/src/manifest/index.ts +27 -0
- package/src/manifest/manifest.test.ts +338 -0
- package/src/manifest/manifest.ts +439 -0
- package/src/manifest/scan.test.ts +51 -0
- package/src/manifest/scan.ts +74 -0
- package/src/manifest/types.ts +98 -0
- package/src/rubric/index.ts +10 -0
- package/src/rubric/rubric.test.ts +43 -0
- package/src/rubric/rubric.ts +140 -0
- package/src/search/index.ts +12 -0
- package/src/search/match.ts +115 -0
- package/src/search/search.test.ts +195 -0
- package/src/search/section.ts +47 -0
- package/src/search/types.ts +44 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
appendDecision,
|
|
4
|
+
createManifestTemplate,
|
|
5
|
+
formatContext,
|
|
6
|
+
parseFrontmatter,
|
|
7
|
+
parseManifest,
|
|
8
|
+
upsertUsagesSection
|
|
9
|
+
} from './manifest.js';
|
|
10
|
+
import type { DesignDecision, ValidationHistoryEntry } from './types.js';
|
|
11
|
+
|
|
12
|
+
describe('parseFrontmatter', () => {
|
|
13
|
+
it('reads flat key:value pairs and strips the block from the body', () => {
|
|
14
|
+
const { data, body } = parseFrontmatter(
|
|
15
|
+
'---\nparadigm: corporate\ntheme: "ocean"\n---\n# Title\nrest'
|
|
16
|
+
);
|
|
17
|
+
expect(data).toEqual({ paradigm: 'corporate', theme: 'ocean' });
|
|
18
|
+
expect(body).toBe('# Title\nrest');
|
|
19
|
+
});
|
|
20
|
+
it('returns empty data when there is no frontmatter', () => {
|
|
21
|
+
const { data, body } = parseFrontmatter('# No frontmatter');
|
|
22
|
+
expect(data).toEqual({});
|
|
23
|
+
expect(body).toBe('# No frontmatter');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('manifest template + parse round-trip', () => {
|
|
28
|
+
const template = createManifestTemplate({
|
|
29
|
+
paradigm: 'corporate',
|
|
30
|
+
theme: 'ocean',
|
|
31
|
+
projectName: 'Acme'
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('parses its own scaffold', () => {
|
|
35
|
+
const m = parseManifest(template);
|
|
36
|
+
expect(m.frontmatter.paradigm).toBe('corporate');
|
|
37
|
+
expect(m.frontmatter.theme).toBe('ocean');
|
|
38
|
+
expect(m.usages).toEqual([]);
|
|
39
|
+
expect(m.decisions).toEqual([]);
|
|
40
|
+
expect(m.exists).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('upsertUsagesSection', () => {
|
|
45
|
+
const template = createManifestTemplate({});
|
|
46
|
+
const usages = [
|
|
47
|
+
{ pattern: 'dashboard', file: 'src/routes/dashboard/+page.svelte' },
|
|
48
|
+
{ pattern: 'form-page', file: 'src/routes/signup/+page.svelte' }
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
it('fills the generated block and is re-parseable', () => {
|
|
52
|
+
const updated = upsertUsagesSection(template, usages);
|
|
53
|
+
const m = parseManifest(updated);
|
|
54
|
+
expect(m.usages).toHaveLength(2);
|
|
55
|
+
expect(m.usages).toContainEqual({
|
|
56
|
+
pattern: 'dashboard',
|
|
57
|
+
file: 'src/routes/dashboard/+page.svelte'
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('is idempotent — replacing the block, not appending', () => {
|
|
62
|
+
const once = upsertUsagesSection(template, usages);
|
|
63
|
+
const twice = upsertUsagesSection(once, usages);
|
|
64
|
+
expect(twice).toBe(once);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('removes stale usages on the next sync', () => {
|
|
68
|
+
const withTwo = upsertUsagesSection(template, usages);
|
|
69
|
+
const withOne = upsertUsagesSection(withTwo, [usages[0]!]);
|
|
70
|
+
expect(parseManifest(withOne).usages).toHaveLength(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('appends a Pattern Usages section when none exists', () => {
|
|
74
|
+
const bare = '# Bare manifest\n\nsome prose\n';
|
|
75
|
+
const updated = upsertUsagesSection(bare, usages);
|
|
76
|
+
expect(updated).toContain('## Pattern Usages');
|
|
77
|
+
expect(parseManifest(updated).usages).toHaveLength(2);
|
|
78
|
+
expect(updated).toContain('some prose'); // original content preserved
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('appendDecision', () => {
|
|
83
|
+
const dec = (title: string, date: string): DesignDecision => ({
|
|
84
|
+
date,
|
|
85
|
+
title,
|
|
86
|
+
status: 'accepted',
|
|
87
|
+
decision: `do ${title}`,
|
|
88
|
+
rationale: `because ${title}`
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('adds a decision into the existing section, newest first', () => {
|
|
92
|
+
const t = createManifestTemplate({});
|
|
93
|
+
const one = appendDecision(t, dec('first', '2026-06-01'));
|
|
94
|
+
const two = appendDecision(one, dec('second', '2026-06-02'));
|
|
95
|
+
const m = parseManifest(two);
|
|
96
|
+
expect(m.decisions.map((d) => d.title)).toEqual(['second', 'first']);
|
|
97
|
+
expect(m.decisions[0]!.rationale).toBe('because second');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('creates the section when absent and preserves frontmatter + usages', () => {
|
|
101
|
+
const withUsage = upsertUsagesSection(createManifestTemplate({ paradigm: 'brutalist' }), [
|
|
102
|
+
{ pattern: 'dashboard', file: 'a.svelte' }
|
|
103
|
+
]);
|
|
104
|
+
const updated = appendDecision(withUsage, dec('x', '2026-06-13'));
|
|
105
|
+
const m = parseManifest(updated);
|
|
106
|
+
expect(m.frontmatter.paradigm).toBe('brutalist');
|
|
107
|
+
expect(m.usages).toHaveLength(1);
|
|
108
|
+
expect(m.decisions).toHaveLength(1);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('formatContext', () => {
|
|
113
|
+
it('summarises intake, usages and decisions', () => {
|
|
114
|
+
let content = createManifestTemplate({ paradigm: 'corporate', theme: 'ocean' });
|
|
115
|
+
content = upsertUsagesSection(content, [{ pattern: 'dashboard', file: 'a.svelte' }]);
|
|
116
|
+
content = appendDecision(content, {
|
|
117
|
+
date: '2026-06-13',
|
|
118
|
+
title: 'Tabs for settings',
|
|
119
|
+
status: 'accepted',
|
|
120
|
+
decision: 'use tabs'
|
|
121
|
+
});
|
|
122
|
+
const out = formatContext(parseManifest(content));
|
|
123
|
+
expect(out).toContain('corporate');
|
|
124
|
+
expect(out).toContain('`dashboard`');
|
|
125
|
+
expect(out).toContain('Tabs for settings');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('guides the user when the manifest is empty', () => {
|
|
129
|
+
const out = formatContext(parseManifest(createManifestTemplate({})));
|
|
130
|
+
expect(out).toContain('data-design-pattern');
|
|
131
|
+
expect(out).toContain('urbicon record-decision');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('parseManifest — product intent', () => {
|
|
136
|
+
it('parses audience, voice, and bulleted references / anti-references', () => {
|
|
137
|
+
const md = [
|
|
138
|
+
'## Product Intent',
|
|
139
|
+
'',
|
|
140
|
+
'**Audience:** Municipal ops staff — non-technical, time-pressured.',
|
|
141
|
+
'',
|
|
142
|
+
'**Voice:** calm, precise, trustworthy',
|
|
143
|
+
'',
|
|
144
|
+
'**References:**',
|
|
145
|
+
'- Linear — focused density',
|
|
146
|
+
'- Stripe Dashboard',
|
|
147
|
+
'',
|
|
148
|
+
'**Anti-references:**',
|
|
149
|
+
'- Bootstrap admin',
|
|
150
|
+
'- rainbow SaaS'
|
|
151
|
+
].join('\n');
|
|
152
|
+
const { intent } = parseManifest(md);
|
|
153
|
+
expect(intent.audience).toBe('Municipal ops staff — non-technical, time-pressured.');
|
|
154
|
+
expect(intent.voice).toEqual(['calm', 'precise', 'trustworthy']);
|
|
155
|
+
expect(intent.references).toEqual(['Linear — focused density', 'Stripe Dashboard']);
|
|
156
|
+
expect(intent.antiReferences).toEqual(['Bootstrap admin', 'rainbow SaaS']);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('tolerates an inline comma list for references (not only bullets)', () => {
|
|
160
|
+
const md = '## Product Intent\n\n**References:** Linear, Stripe, Vercel\n';
|
|
161
|
+
expect(parseManifest(md).intent.references).toEqual(['Linear', 'Stripe', 'Vercel']);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('also parses voice written as bullets, not only the inline comma form', () => {
|
|
165
|
+
const md = '## Product Intent\n\n**Voice:**\n- calm\n- precise\n- trustworthy\n';
|
|
166
|
+
expect(parseManifest(md).intent.voice).toEqual(['calm', 'precise', 'trustworthy']);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('parses bullet lists that put a blank line between the label and the bullets', () => {
|
|
170
|
+
// The Markdown-idiomatic form (and what `prettier` produces) — must parse too.
|
|
171
|
+
const md = '## Product Intent\n\n**References:**\n\n- Linear\n- Stripe\n';
|
|
172
|
+
expect(parseManifest(md).intent.references).toEqual(['Linear', 'Stripe']);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('returns an empty intent when the section is absent', () => {
|
|
176
|
+
const intent = parseManifest('# Bare\n\nsome prose\n').intent;
|
|
177
|
+
expect(intent).toEqual({ voice: [], references: [], antiReferences: [] });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('does not parse the scaffold’s bare-label placeholders as values', () => {
|
|
181
|
+
// Regression: empty `**Audience:**` etc. in the template must read as "not set".
|
|
182
|
+
const intent = parseManifest(createManifestTemplate({})).intent;
|
|
183
|
+
expect(intent.audience).toBeUndefined();
|
|
184
|
+
expect(intent.voice).toEqual([]);
|
|
185
|
+
expect(intent.references).toEqual([]);
|
|
186
|
+
expect(intent.antiReferences).toEqual([]);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('parseManifest — token overrides', () => {
|
|
191
|
+
it('parses backtick cores from the bullet list and ignores trailing notes', () => {
|
|
192
|
+
const md = [
|
|
193
|
+
'## Token Overrides',
|
|
194
|
+
'',
|
|
195
|
+
'- `surface-brand` — the marketing accent surface',
|
|
196
|
+
'- `text-brand`'
|
|
197
|
+
].join('\n');
|
|
198
|
+
expect(parseManifest(md).tokenOverrides).toEqual(['surface-brand', 'text-brand']);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('deduplicates and preserves first-seen order', () => {
|
|
202
|
+
const md = '## Token Overrides\n\n- `a-one`\n- `b-two`\n- `a-one`\n';
|
|
203
|
+
expect(parseManifest(md).tokenOverrides).toEqual(['a-one', 'b-two']);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('returns [] for an absent section and for the scaffold placeholder', () => {
|
|
207
|
+
expect(parseManifest('# Bare\n').tokenOverrides).toEqual([]);
|
|
208
|
+
// The template's explanatory comment mentions `surface-brand` / `bg-surface-brand`
|
|
209
|
+
// but never as a bullet, so nothing is parsed out of the scaffold.
|
|
210
|
+
expect(parseManifest(createManifestTemplate({})).tokenOverrides).toEqual([]);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('template scaffolds the new sections without breaking the round-trip', () => {
|
|
215
|
+
it('still parses frontmatter, usages and decisions; adds empty intent + overrides', () => {
|
|
216
|
+
const m = parseManifest(createManifestTemplate({ paradigm: 'editorial', theme: 'slate' }));
|
|
217
|
+
expect(m.frontmatter.paradigm).toBe('editorial');
|
|
218
|
+
expect(m.usages).toEqual([]);
|
|
219
|
+
expect(m.decisions).toEqual([]);
|
|
220
|
+
expect(m.tokenOverrides).toEqual([]);
|
|
221
|
+
expect(m.intent.voice).toEqual([]);
|
|
222
|
+
expect(m.exists).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('contains all five section headings', () => {
|
|
226
|
+
const t = createManifestTemplate({});
|
|
227
|
+
for (const h of [
|
|
228
|
+
'## Product Intent',
|
|
229
|
+
'## Token Overrides',
|
|
230
|
+
'## Pattern Usages',
|
|
231
|
+
'## Design Decisions'
|
|
232
|
+
]) {
|
|
233
|
+
expect(t).toContain(h);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('formatContext — intent, overrides, drift', () => {
|
|
239
|
+
const history = (over: Partial<ValidationHistoryEntry> = {}): ValidationHistoryEntry => ({
|
|
240
|
+
date: '2026-06-21T10:00:00.000Z',
|
|
241
|
+
files: 4,
|
|
242
|
+
errors: 0,
|
|
243
|
+
warnings: 0,
|
|
244
|
+
infos: 3,
|
|
245
|
+
correctness: 100,
|
|
246
|
+
slop: 70,
|
|
247
|
+
...over
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('renders the intent and token overrides when set', () => {
|
|
251
|
+
let md = '## Product Intent\n\n**Audience:** Ops staff\n\n**Voice:** calm, precise\n';
|
|
252
|
+
md += '\n## Token Overrides\n\n- `surface-brand`\n';
|
|
253
|
+
const out = formatContext(parseManifest(md));
|
|
254
|
+
expect(out).toContain('**Audience:** Ops staff');
|
|
255
|
+
expect(out).toContain('**Voice:** calm, precise');
|
|
256
|
+
expect(out).toContain('`surface-brand`');
|
|
257
|
+
expect(out).toContain('passed as extra tokens');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('nudges when the product intent is empty', () => {
|
|
261
|
+
const out = formatContext(parseManifest(createManifestTemplate({})));
|
|
262
|
+
expect(out).toContain('## Product Intent');
|
|
263
|
+
expect(out).toContain('_Not set._');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('appends a drift block with the score trend when history is supplied', () => {
|
|
267
|
+
const out = formatContext(parseManifest(createManifestTemplate({})), [
|
|
268
|
+
history({ date: '2026-06-19T00:00:00.000Z', slop: 50 }),
|
|
269
|
+
history({ date: '2026-06-20T00:00:00.000Z', slop: 60 }),
|
|
270
|
+
history({ date: '2026-06-21T00:00:00.000Z', slop: 70 })
|
|
271
|
+
]);
|
|
272
|
+
expect(out).toContain('## Validation Drift');
|
|
273
|
+
expect(out).toContain('correctness 100/100 · slop 70/100');
|
|
274
|
+
expect(out).toContain('50 → 60 → 70'); // recent slop trend
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('omits the drift block when no history is supplied', () => {
|
|
278
|
+
expect(formatContext(parseManifest(createManifestTemplate({})))).not.toContain(
|
|
279
|
+
'## Validation Drift'
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe('review hardening', () => {
|
|
285
|
+
it('does not interpret `$` sequences in a file path as replacement patterns', () => {
|
|
286
|
+
// Heading present, no marker block yet → the String.replace fallback branch.
|
|
287
|
+
const bare = '# M\n\n## Pattern Usages\n';
|
|
288
|
+
const updated = upsertUsagesSection(bare, [
|
|
289
|
+
{ pattern: 'dashboard', file: "src/o'$&-$1/+page.svelte" }
|
|
290
|
+
]);
|
|
291
|
+
expect(updated).toContain("src/o'$&-$1/+page.svelte");
|
|
292
|
+
expect(parseManifest(updated).usages[0]?.file).toBe("src/o'$&-$1/+page.svelte");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('replaces an orphaned start marker (lost end marker) without duplicating usages', () => {
|
|
296
|
+
// Intentionally the OLD marker text ("managed by sync_design_manifest") — this
|
|
297
|
+
// doubles as a backward-compat guard: prefix detection must still find a block
|
|
298
|
+
// written by an earlier version whose marker tail differs.
|
|
299
|
+
const orphaned =
|
|
300
|
+
'## Pattern Usages\n\n<!-- AUTO-GENERATED pattern usages — managed by sync_design_manifest; do not edit by hand -->\n\n- `dashboard` — old.svelte\n\n## Design Decisions\n';
|
|
301
|
+
const updated = upsertUsagesSection(orphaned, [{ pattern: 'form-page', file: 'new.svelte' }]);
|
|
302
|
+
const usages = parseManifest(updated).usages;
|
|
303
|
+
expect(usages).toHaveLength(1);
|
|
304
|
+
expect(usages[0]).toEqual({ pattern: 'form-page', file: 'new.svelte' });
|
|
305
|
+
expect(updated).toContain('## Design Decisions'); // following section preserved
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('replaces an intact old-tail usages block (both markers present) — backward-compat', () => {
|
|
309
|
+
// A manifest written by an earlier version: old marker tail, BOTH markers intact.
|
|
310
|
+
// Prefix detection must find and replace it, not append a second block.
|
|
311
|
+
const oldStyle =
|
|
312
|
+
'## Pattern Usages\n\n<!-- AUTO-GENERATED pattern usages — managed by sync_design_manifest; do not edit by hand -->\n\n- `dashboard` — old.svelte\n\n<!-- END pattern usages -->\n\n## Design Decisions\n';
|
|
313
|
+
const updated = upsertUsagesSection(oldStyle, [{ pattern: 'form-page', file: 'new.svelte' }]);
|
|
314
|
+
expect(parseManifest(updated).usages).toEqual([{ pattern: 'form-page', file: 'new.svelte' }]);
|
|
315
|
+
expect(updated).not.toContain('old.svelte'); // stale entry replaced, not duplicated
|
|
316
|
+
expect(updated).toContain('## Design Decisions'); // following section preserved
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('collapses multi-line decision/rationale text to a single line (no truncation on re-parse)', () => {
|
|
320
|
+
const updated = appendDecision(createManifestTemplate({}), {
|
|
321
|
+
date: '2026-06-13',
|
|
322
|
+
title: 'Cache strategy',
|
|
323
|
+
status: 'accepted',
|
|
324
|
+
decision: 'Use SWR.\nFallback to stale for 30s.',
|
|
325
|
+
rationale: 'Line one.\r\nLine two.'
|
|
326
|
+
});
|
|
327
|
+
const d = parseManifest(updated).decisions[0]!;
|
|
328
|
+
expect(d.decision).toBe('Use SWR. Fallback to stale for 30s.');
|
|
329
|
+
expect(d.rationale).toBe('Line one. Line two.');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('tolerates CRLF frontmatter', () => {
|
|
333
|
+
const { data } = parseFrontmatter(
|
|
334
|
+
'---\r\nparadigm: brutalist\r\ntheme: forest\r\n---\r\n# Title'
|
|
335
|
+
);
|
|
336
|
+
expect(data).toEqual({ paradigm: 'brutalist', theme: 'forest' });
|
|
337
|
+
});
|
|
338
|
+
});
|