@urbicon-ui/mcp-server 6.1.5 → 6.1.6
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 +44 -41
- package/package.json +3 -1
- package/src/data/catalog-loader.test.ts +1 -1
- package/src/data/catalog-loader.ts +12 -37
- package/src/data/component-loader.ts +5 -40
- package/src/data/design-system-loader.test.ts +1 -1
- package/src/data/design-system-loader.ts +1 -1
- package/src/data/icon-loader.test.ts +25 -80
- package/src/data/icon-loader.ts +8 -68
- package/src/data/template-loader.ts +1 -1
- package/src/data/verb-loader.ts +29 -0
- package/src/eval/eval.test.ts +16 -9
- package/src/eval/score.ts +26 -10
- package/src/index.ts +7 -14
- package/src/prompts/design-prompts.test.ts +56 -28
- package/src/prompts/design-prompts.ts +135 -104
- package/src/server.test.ts +16 -7
- package/src/server.ts +4 -7
- package/src/tools/find-components.ts +1 -1
- package/src/tools/get-design-principles.ts +1 -1
- package/src/tools/get-recipe.ts +6 -4
- package/src/tools/suggest-implementation.ts +2 -3
- package/src/tools/validate-design.ts +17 -9
- package/src/data/recipe-loader.test.ts +0 -49
- package/src/data/recipe-loader.ts +0 -131
- package/src/design-linter/heuristics.ts +0 -162
- package/src/design-linter/index.ts +0 -14
- package/src/design-linter/linter.test.ts +0 -257
- package/src/design-linter/linter.ts +0 -62
- package/src/design-linter/rules.ts +0 -348
- package/src/design-linter/tokens.test.ts +0 -80
- package/src/design-linter/tokens.ts +0 -203
- package/src/design-linter/types.ts +0 -66
- package/src/design-manifest/index.ts +0 -20
- package/src/design-manifest/manifest.test.ts +0 -175
- package/src/design-manifest/manifest.ts +0 -250
- package/src/design-manifest/scan.test.ts +0 -51
- package/src/design-manifest/scan.ts +0 -74
- package/src/design-manifest/types.ts +0 -40
- package/src/design-rubric/rubric.test.ts +0 -43
- package/src/design-rubric/rubric.ts +0 -140
- package/src/tools/get-design-context.ts +0 -43
- package/src/tools/record-design-decision.ts +0 -99
- package/src/tools/sync-design-manifest.ts +0 -92
- package/src/utils/paths.test.ts +0 -101
- package/src/utils/paths.ts +0 -78
- package/src/utils/search.test.ts +0 -141
- package/src/utils/search.ts +0 -106
|
@@ -1,80 +0,0 @@
|
|
|
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 { VALID_TOKEN_CORES } from './tokens.js';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Drift guard: the hardcoded {@link VALID_TOKEN_CORES} is the mcp-server'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 mcp-server 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
|
-
});
|
|
@@ -1,203 +0,0 @@
|
|
|
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 mcp-server 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
|
-
* Namespaces that mark a utility core as "intended to be semantic". A core that
|
|
146
|
-
* starts with one of these but is NOT in {@link VALID_TOKEN_CORES} is a
|
|
147
|
-
* hallucinated token. Kept deliberately narrow so we never flag genuine Tailwind
|
|
148
|
-
* utilities (`bg-transparent`, `bg-cover`, arbitrary `bg-[#fff]`).
|
|
149
|
-
*/
|
|
150
|
-
export const SEMANTIC_NAMESPACES = [
|
|
151
|
-
'surface-',
|
|
152
|
-
'text-',
|
|
153
|
-
'border-',
|
|
154
|
-
'feedback-',
|
|
155
|
-
'interactive-',
|
|
156
|
-
'chart-'
|
|
157
|
-
] as const;
|
|
158
|
-
|
|
159
|
-
/** Intent prefixes (`primary-…`, `success-…`) that mark a core as semantic-intent. */
|
|
160
|
-
export const INTENT_PREFIXES: readonly string[] = INTENT_NAMES;
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* The shadcn/ui (and Radix) token vocabulary. This is the single most common
|
|
164
|
-
* hallucination source: with no explicit whitelist, models default to the
|
|
165
|
-
* dominant design-system convention in their training data — `text-foreground`,
|
|
166
|
-
* `bg-accent`, `text-muted-foreground`, `bg-card`, … — none of which exist in
|
|
167
|
-
* Urbicon UI. Discovered empirically during design-quality evaluation.
|
|
168
|
-
* These bare cores slip past the namespace/intent heuristics, so they are
|
|
169
|
-
* matched explicitly. (Suffix `-foreground` and the `fg`/`fg-` family are caught
|
|
170
|
-
* by rule in rules.ts.)
|
|
171
|
-
*/
|
|
172
|
-
export const KNOWN_FOREIGN_CORES: ReadonlySet<string> = new Set([
|
|
173
|
-
'foreground',
|
|
174
|
-
'background',
|
|
175
|
-
'muted',
|
|
176
|
-
'accent',
|
|
177
|
-
'card',
|
|
178
|
-
'popover',
|
|
179
|
-
'input',
|
|
180
|
-
'destructive',
|
|
181
|
-
'surface', // bare — the real page surface is `surface-base`
|
|
182
|
-
'muted-foreground',
|
|
183
|
-
'accent-foreground',
|
|
184
|
-
'card-foreground',
|
|
185
|
-
'popover-foreground',
|
|
186
|
-
'primary-foreground',
|
|
187
|
-
'secondary-foreground',
|
|
188
|
-
'destructive-foreground'
|
|
189
|
-
]);
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Cores that are commonly hallucinated and have a known correct replacement.
|
|
193
|
-
* Drives a precise fix hint instead of a generic "unknown token".
|
|
194
|
-
*/
|
|
195
|
-
export const KNOWN_BAD_NAMESPACES: Record<string, string> = {
|
|
196
|
-
// `status-*` is the most frequent invention observed. Map to feedback/intents.
|
|
197
|
-
'status-':
|
|
198
|
-
'Use a `feedback-*` token (feedback-success, feedback-error, …) or a bare intent (`success`, `danger`).',
|
|
199
|
-
// `-fg` / `-foreground` suffixes are invented; the system uses `text-on-primary` etc.
|
|
200
|
-
'-fg': 'Use `text-on-primary` / `text-on-surface` for foreground-on-intent text.'
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
export { INTENT_NAMES, INTENT_VARIANTS, SCALE_STEPS };
|
|
@@ -1,66 +0,0 @@
|
|
|
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 design-linter/README 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
|
-
/** A deterministic rule: scans the source and emits findings. */
|
|
38
|
-
export interface Rule {
|
|
39
|
-
id: string;
|
|
40
|
-
severity: Severity;
|
|
41
|
-
/** One-line description of what the rule enforces, shown in `validate_design` rule listings. */
|
|
42
|
-
description: string;
|
|
43
|
-
/**
|
|
44
|
-
* Run the rule over the already-prepared source lines.
|
|
45
|
-
* @param lines source split by `\n`, with comments masked (see linter.ts)
|
|
46
|
-
* @param raw the original source (for rules that need cross-line context)
|
|
47
|
-
*/
|
|
48
|
-
check(lines: string[], raw: string): Finding[];
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Aggregate result of linting one code unit. */
|
|
52
|
-
export interface LintReport {
|
|
53
|
-
findings: Finding[];
|
|
54
|
-
/** Deterministic 0–100 design-quality score. 100 = no findings. */
|
|
55
|
-
score: number;
|
|
56
|
-
counts: { error: number; warning: number; info: number };
|
|
57
|
-
/** Optional label (e.g. filename) echoed back in the report. */
|
|
58
|
-
filename?: string;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Options for a lint run. */
|
|
62
|
-
export interface LintOptions {
|
|
63
|
-
filename?: string;
|
|
64
|
-
/** Skip the distribution heuristics (the `info`-level checks). Default: false. */
|
|
65
|
-
skipHeuristics?: boolean;
|
|
66
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Public API of the design-manifest module — the persistent design-intent layer
|
|
3
|
-
* (docs/DESIGN-MCP.md, Option C). Consumed by the `get_design_context`,
|
|
4
|
-
* `record_design_decision`, and `sync_design_manifest` MCP tools.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
export {
|
|
8
|
-
appendDecision,
|
|
9
|
-
createManifestTemplate,
|
|
10
|
-
DECISIONS_HEADING,
|
|
11
|
-
emptyManifest,
|
|
12
|
-
formatContext,
|
|
13
|
-
parseFrontmatter,
|
|
14
|
-
parseManifest,
|
|
15
|
-
renderDecision,
|
|
16
|
-
USAGES_HEADING,
|
|
17
|
-
upsertUsagesSection
|
|
18
|
-
} from './manifest.js';
|
|
19
|
-
export { scanMarkers } from './scan.js';
|
|
20
|
-
export type { DesignDecision, DesignManifest, PatternUsage } from './types.js';
|
|
@@ -1,175 +0,0 @@
|
|
|
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 } 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('record_design_decision');
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
describe('review hardening', () => {
|
|
136
|
-
it('does not interpret `$` sequences in a file path as replacement patterns', () => {
|
|
137
|
-
// Heading present, no marker block yet → the String.replace fallback branch.
|
|
138
|
-
const bare = '# M\n\n## Pattern Usages\n';
|
|
139
|
-
const updated = upsertUsagesSection(bare, [
|
|
140
|
-
{ pattern: 'dashboard', file: "src/o'$&-$1/+page.svelte" }
|
|
141
|
-
]);
|
|
142
|
-
expect(updated).toContain("src/o'$&-$1/+page.svelte");
|
|
143
|
-
expect(parseManifest(updated).usages[0]?.file).toBe("src/o'$&-$1/+page.svelte");
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it('replaces an orphaned start marker (lost end marker) without duplicating usages', () => {
|
|
147
|
-
const orphaned =
|
|
148
|
-
'## 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';
|
|
149
|
-
const updated = upsertUsagesSection(orphaned, [{ pattern: 'form-page', file: 'new.svelte' }]);
|
|
150
|
-
const usages = parseManifest(updated).usages;
|
|
151
|
-
expect(usages).toHaveLength(1);
|
|
152
|
-
expect(usages[0]).toEqual({ pattern: 'form-page', file: 'new.svelte' });
|
|
153
|
-
expect(updated).toContain('## Design Decisions'); // following section preserved
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('collapses multi-line decision/rationale text to a single line (no truncation on re-parse)', () => {
|
|
157
|
-
const updated = appendDecision(createManifestTemplate({}), {
|
|
158
|
-
date: '2026-06-13',
|
|
159
|
-
title: 'Cache strategy',
|
|
160
|
-
status: 'accepted',
|
|
161
|
-
decision: 'Use SWR.\nFallback to stale for 30s.',
|
|
162
|
-
rationale: 'Line one.\r\nLine two.'
|
|
163
|
-
});
|
|
164
|
-
const d = parseManifest(updated).decisions[0]!;
|
|
165
|
-
expect(d.decision).toBe('Use SWR. Fallback to stale for 30s.');
|
|
166
|
-
expect(d.rationale).toBe('Line one. Line two.');
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it('tolerates CRLF frontmatter', () => {
|
|
170
|
-
const { data } = parseFrontmatter(
|
|
171
|
-
'---\r\nparadigm: brutalist\r\ntheme: forest\r\n---\r\n# Title'
|
|
172
|
-
);
|
|
173
|
-
expect(data).toEqual({ paradigm: 'brutalist', theme: 'forest' });
|
|
174
|
-
});
|
|
175
|
-
});
|