@urbicon-ui/mcp-server 6.1.4
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 +161 -0
- package/dist/data/catalog-loader.d.ts +37 -0
- package/dist/data/catalog-loader.d.ts.map +1 -0
- package/dist/data/catalog-loader.js +15 -0
- package/dist/data/catalog-loader.js.map +1 -0
- package/dist/data/component-loader.d.ts +2 -0
- package/dist/data/component-loader.d.ts.map +1 -0
- package/dist/data/component-loader.js +17 -0
- package/dist/data/component-loader.js.map +1 -0
- package/dist/data/recipe-loader.d.ts +4 -0
- package/dist/data/recipe-loader.d.ts.map +1 -0
- package/dist/data/recipe-loader.js +102 -0
- package/dist/data/recipe-loader.js.map +1 -0
- package/dist/data/template-loader.d.ts +8 -0
- package/dist/data/template-loader.d.ts.map +1 -0
- package/dist/data/template-loader.js +33 -0
- package/dist/data/template-loader.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +57 -0
- package/dist/index.js.map +1 -0
- package/dist/resources/catalog.d.ts +3 -0
- package/dist/resources/catalog.d.ts.map +1 -0
- package/dist/resources/catalog.js +20 -0
- package/dist/resources/catalog.js.map +1 -0
- package/dist/resources/component.d.ts +3 -0
- package/dist/resources/component.d.ts.map +1 -0
- package/dist/resources/component.js +29 -0
- package/dist/resources/component.js.map +1 -0
- package/dist/resources/guides.d.ts +3 -0
- package/dist/resources/guides.d.ts.map +1 -0
- package/dist/resources/guides.js +36 -0
- package/dist/resources/guides.js.map +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/find-components.d.ts +3 -0
- package/dist/tools/find-components.d.ts.map +1 -0
- package/dist/tools/find-components.js +21 -0
- package/dist/tools/find-components.js.map +1 -0
- package/dist/tools/get-recipe.d.ts +3 -0
- package/dist/tools/get-recipe.d.ts.map +1 -0
- package/dist/tools/get-recipe.js +48 -0
- package/dist/tools/get-recipe.js.map +1 -0
- package/dist/tools/suggest-implementation.d.ts +3 -0
- package/dist/tools/suggest-implementation.d.ts.map +1 -0
- package/dist/tools/suggest-implementation.js +178 -0
- package/dist/tools/suggest-implementation.js.map +1 -0
- package/dist/transports/http.d.ts +2 -0
- package/dist/transports/http.d.ts.map +1 -0
- package/dist/transports/http.js +77 -0
- package/dist/transports/http.js.map +1 -0
- package/dist/transports/stdio.d.ts +3 -0
- package/dist/transports/stdio.d.ts.map +1 -0
- package/dist/transports/stdio.js +6 -0
- package/dist/transports/stdio.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/utils/format-catalog.d.ts +7 -0
- package/dist/utils/format-catalog.d.ts.map +1 -0
- package/dist/utils/format-catalog.js +93 -0
- package/dist/utils/format-catalog.js.map +1 -0
- package/dist/utils/paths.d.ts +7 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/dist/utils/paths.js +23 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/search.d.ts +3 -0
- package/dist/utils/search.d.ts.map +1 -0
- package/dist/utils/search.js +44 -0
- package/dist/utils/search.js.map +1 -0
- package/package.json +42 -0
- package/src/data/catalog-loader.test.ts +42 -0
- package/src/data/catalog-loader.ts +78 -0
- package/src/data/component-loader.ts +68 -0
- package/src/data/design-system-loader.test.ts +82 -0
- package/src/data/design-system-loader.ts +125 -0
- package/src/data/icon-loader.test.ts +85 -0
- package/src/data/icon-loader.ts +90 -0
- package/src/data/recipe-loader.test.ts +49 -0
- package/src/data/recipe-loader.ts +131 -0
- package/src/data/template-loader.ts +55 -0
- package/src/design-linter/heuristics.ts +162 -0
- package/src/design-linter/index.ts +14 -0
- package/src/design-linter/linter.test.ts +257 -0
- package/src/design-linter/linter.ts +62 -0
- package/src/design-linter/rules.ts +348 -0
- package/src/design-linter/tokens.test.ts +80 -0
- package/src/design-linter/tokens.ts +203 -0
- package/src/design-linter/types.ts +66 -0
- package/src/design-manifest/index.ts +20 -0
- package/src/design-manifest/manifest.test.ts +175 -0
- package/src/design-manifest/manifest.ts +250 -0
- package/src/design-manifest/scan.test.ts +51 -0
- package/src/design-manifest/scan.ts +74 -0
- package/src/design-manifest/types.ts +40 -0
- package/src/design-rubric/rubric.test.ts +43 -0
- package/src/design-rubric/rubric.ts +140 -0
- package/src/eval/briefs.ts +104 -0
- package/src/eval/eval.test.ts +99 -0
- package/src/eval/index.ts +11 -0
- package/src/eval/score.ts +112 -0
- package/src/index.ts +75 -0
- package/src/prompts/design-prompts.test.ts +51 -0
- package/src/prompts/design-prompts.ts +127 -0
- package/src/resources/catalog.ts +23 -0
- package/src/resources/guides.ts +60 -0
- package/src/server.test.ts +69 -0
- package/src/server.ts +48 -0
- package/src/tools/find-components.ts +83 -0
- package/src/tools/find-icons.ts +77 -0
- package/src/tools/get-checklist.ts +139 -0
- package/src/tools/get-component.ts +204 -0
- package/src/tools/get-css-reference.ts +446 -0
- package/src/tools/get-design-context.ts +43 -0
- package/src/tools/get-design-principles.ts +72 -0
- package/src/tools/get-pattern.ts +69 -0
- package/src/tools/get-recipe.ts +80 -0
- package/src/tools/record-design-decision.ts +99 -0
- package/src/tools/suggest-implementation.ts +251 -0
- package/src/tools/sync-design-manifest.ts +92 -0
- package/src/tools/validate-design.ts +84 -0
- package/src/transports/http.ts +79 -0
- package/src/transports/stdio.ts +7 -0
- package/src/utils/format-catalog.test.ts +144 -0
- package/src/utils/format-catalog.ts +130 -0
- package/src/utils/paths.test.ts +101 -0
- package/src/utils/paths.ts +78 -0
- package/src/utils/search.test.ts +141 -0
- package/src/utils/search.ts +106 -0
- package/tsconfig.json +27 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,20 @@
|
|
|
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';
|
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse and edit a `design.manifest.md`. Deliberately dependency-free (no YAML
|
|
3
|
+
* lib — consistent with the zero-dep ethos): frontmatter is flat `key: value`,
|
|
4
|
+
* and edits are surgical (replace one marked block / insert one ADR) so any
|
|
5
|
+
* hand-written prose, ordering, and formatting survive a round-trip.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { DesignDecision, DesignManifest, PatternUsage } from './types.js';
|
|
9
|
+
|
|
10
|
+
const USAGES_HEADING = '## Pattern Usages';
|
|
11
|
+
const DECISIONS_HEADING = '## Design Decisions';
|
|
12
|
+
const USAGES_START =
|
|
13
|
+
'<!-- AUTO-GENERATED pattern usages — managed by sync_design_manifest; do not edit by hand -->';
|
|
14
|
+
const USAGES_END = '<!-- END pattern usages -->';
|
|
15
|
+
|
|
16
|
+
/** Split leading `--- … ---` frontmatter from the body. Returns flat key→value pairs. */
|
|
17
|
+
export function parseFrontmatter(content: string): { data: Record<string, string>; body: string } {
|
|
18
|
+
const data: Record<string, string> = {};
|
|
19
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
20
|
+
if (!match) return { data, body: content };
|
|
21
|
+
|
|
22
|
+
for (const line of match[1]!.split(/\r?\n/)) {
|
|
23
|
+
const kv = line.match(/^([a-zA-Z][\w-]*)\s*:\s*(.*)$/);
|
|
24
|
+
if (kv) {
|
|
25
|
+
const value = kv[2]!.trim().replace(/^["']|["']$/g, '');
|
|
26
|
+
if (value) data[kv[1]!] = value;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { data, body: content.slice(match[0].length) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Extract the `## …` section body for a given heading (until the next `## ` or EOF). */
|
|
33
|
+
function extractSection(body: string, heading: string): string | null {
|
|
34
|
+
const lines = body.split('\n');
|
|
35
|
+
const start = lines.findIndex((l) => l.trim() === heading);
|
|
36
|
+
if (start === -1) return null;
|
|
37
|
+
let end = lines.length;
|
|
38
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
39
|
+
if (/^## /.test(lines[i]!)) {
|
|
40
|
+
end = i;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return lines.slice(start + 1, end).join('\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseUsages(body: string): PatternUsage[] {
|
|
48
|
+
const section = extractSection(body, USAGES_HEADING);
|
|
49
|
+
if (!section) return [];
|
|
50
|
+
const usages: PatternUsage[] = [];
|
|
51
|
+
for (const m of section.matchAll(/^- `([a-z0-9-]+)`\s+—\s+(.+)$/gm)) {
|
|
52
|
+
usages.push({ pattern: m[1]!, file: m[2]!.trim() });
|
|
53
|
+
}
|
|
54
|
+
return usages;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseDecisions(body: string): DesignDecision[] {
|
|
58
|
+
const section = extractSection(body, DECISIONS_HEADING);
|
|
59
|
+
if (!section) return [];
|
|
60
|
+
const decisions: DesignDecision[] = [];
|
|
61
|
+
// Each decision is a `### <date> — <title>` block.
|
|
62
|
+
const blocks = section.split(/^### /m).slice(1);
|
|
63
|
+
for (const block of blocks) {
|
|
64
|
+
const headerLine = block.split('\n', 1)[0]!;
|
|
65
|
+
const header = headerLine.match(/^(\d{4}-\d{2}-\d{2})\s+—\s+(.+)$/);
|
|
66
|
+
if (!header) continue;
|
|
67
|
+
const field = (name: string): string | undefined =>
|
|
68
|
+
block.match(new RegExp(`\\*\\*${name}:\\*\\*\\s*(.+)`))?.[1]?.trim();
|
|
69
|
+
decisions.push({
|
|
70
|
+
date: header[1]!,
|
|
71
|
+
title: header[2]!.trim(),
|
|
72
|
+
status: field('Status') ?? 'accepted',
|
|
73
|
+
decision: field('Decision') ?? '',
|
|
74
|
+
rationale: field('Rationale')
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return decisions;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Parse a manifest file into structured form. */
|
|
81
|
+
export function parseManifest(content: string, exists = true): DesignManifest {
|
|
82
|
+
const { data, body } = parseFrontmatter(content);
|
|
83
|
+
return {
|
|
84
|
+
frontmatter: data,
|
|
85
|
+
usages: parseUsages(body),
|
|
86
|
+
decisions: parseDecisions(body),
|
|
87
|
+
exists
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The empty-manifest sentinel returned when no file exists. */
|
|
92
|
+
export function emptyManifest(): DesignManifest {
|
|
93
|
+
return { frontmatter: {}, usages: [], decisions: [], exists: false };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function renderUsagesBlock(usages: PatternUsage[]): string {
|
|
97
|
+
const lines = [USAGES_START];
|
|
98
|
+
if (usages.length === 0) {
|
|
99
|
+
lines.push('', '_No `data-design-pattern` markers found yet._', '');
|
|
100
|
+
} else {
|
|
101
|
+
lines.push('');
|
|
102
|
+
const sorted = [...usages].sort(
|
|
103
|
+
(a, b) => a.pattern.localeCompare(b.pattern) || a.file.localeCompare(b.file)
|
|
104
|
+
);
|
|
105
|
+
for (const u of sorted) lines.push(`- \`${u.pattern}\` — ${u.file}`);
|
|
106
|
+
lines.push('');
|
|
107
|
+
}
|
|
108
|
+
lines.push(USAGES_END);
|
|
109
|
+
return lines.join('\n');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Replace the auto-generated usages block (or insert the section) — everything else is untouched. */
|
|
113
|
+
export function upsertUsagesSection(content: string, usages: PatternUsage[]): string {
|
|
114
|
+
const block = renderUsagesBlock(usages);
|
|
115
|
+
const startIdx = content.indexOf(USAGES_START);
|
|
116
|
+
if (startIdx !== -1) {
|
|
117
|
+
const endIdx = content.indexOf(USAGES_END, startIdx);
|
|
118
|
+
if (endIdx !== -1) {
|
|
119
|
+
const after = endIdx + USAGES_END.length;
|
|
120
|
+
return content.slice(0, startIdx) + block + content.slice(after);
|
|
121
|
+
}
|
|
122
|
+
// Start marker present but end marker lost (hand-edit / merge): replace from the
|
|
123
|
+
// start marker to the next section heading so no orphaned block is left to double-count.
|
|
124
|
+
const nextSection = content.indexOf('\n## ', startIdx);
|
|
125
|
+
const truncateAt = nextSection !== -1 ? nextSection : content.length;
|
|
126
|
+
return content.slice(0, startIdx) + block + content.slice(truncateAt);
|
|
127
|
+
}
|
|
128
|
+
// No marker block yet — insert after the heading, or append a fresh section.
|
|
129
|
+
// NB: a function replacer, not a string, so `$`-sequences in a file path can't
|
|
130
|
+
// be interpreted as replacement patterns (`$'`, `$&`, …).
|
|
131
|
+
if (content.includes(`\n${USAGES_HEADING}`) || content.startsWith(USAGES_HEADING)) {
|
|
132
|
+
return content.replace(
|
|
133
|
+
new RegExp(`(${USAGES_HEADING}\\n)`),
|
|
134
|
+
(_m, heading: string) => `${heading}\n${block}\n`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const sep = content.endsWith('\n') ? '\n' : '\n\n';
|
|
138
|
+
return `${content}${sep}${USAGES_HEADING}\n\n${block}\n`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Collapse newlines to spaces — every ADR field is single-line in the Markdown format. */
|
|
142
|
+
function oneLine(s: string): string {
|
|
143
|
+
return s.replace(/[\r\n]+/g, ' ').trim();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Render one ADR block. */
|
|
147
|
+
export function renderDecision(d: DesignDecision): string {
|
|
148
|
+
const lines = [
|
|
149
|
+
`### ${d.date} — ${oneLine(d.title)}`,
|
|
150
|
+
'',
|
|
151
|
+
`**Status:** ${oneLine(d.status)}`,
|
|
152
|
+
'',
|
|
153
|
+
`**Decision:** ${oneLine(d.decision)}`
|
|
154
|
+
];
|
|
155
|
+
if (d.rationale) lines.push('', `**Rationale:** ${oneLine(d.rationale)}`);
|
|
156
|
+
return `${lines.join('\n')}\n`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Insert a new ADR at the top of the Design Decisions section (newest first). Creates the section if absent. */
|
|
160
|
+
export function appendDecision(content: string, decision: DesignDecision): string {
|
|
161
|
+
const block = renderDecision(decision);
|
|
162
|
+
const m = content.match(/(?:^|\n)## Design Decisions[^\n]*\n/);
|
|
163
|
+
if (m && m.index !== undefined) {
|
|
164
|
+
let pos = m.index + m[0].length; // just past the heading line's newline
|
|
165
|
+
let prefix = '';
|
|
166
|
+
if (content[pos] === '\n')
|
|
167
|
+
pos += 1; // keep an existing blank line, insert after it
|
|
168
|
+
else prefix = '\n'; // no blank line below the heading — add one
|
|
169
|
+
return `${content.slice(0, pos) + prefix + block}\n${content.slice(pos)}`;
|
|
170
|
+
}
|
|
171
|
+
const sep = content.endsWith('\n') ? '\n' : '\n\n';
|
|
172
|
+
return `${content}${sep}${DECISIONS_HEADING}\n\n${block}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A starter manifest for a project that has none. */
|
|
176
|
+
export function createManifestTemplate(opts: {
|
|
177
|
+
paradigm?: string;
|
|
178
|
+
theme?: string;
|
|
179
|
+
density?: string;
|
|
180
|
+
projectName?: string;
|
|
181
|
+
}): string {
|
|
182
|
+
const fm = [
|
|
183
|
+
'---',
|
|
184
|
+
`paradigm: ${opts.paradigm ?? 'minimal'}`,
|
|
185
|
+
`theme: ${opts.theme ?? 'default'}`,
|
|
186
|
+
`density: ${opts.density ?? 'comfortable'}`,
|
|
187
|
+
'---'
|
|
188
|
+
].join('\n');
|
|
189
|
+
return [
|
|
190
|
+
fm,
|
|
191
|
+
'',
|
|
192
|
+
`# Design Manifest${opts.projectName ? ` — ${opts.projectName}` : ''}`,
|
|
193
|
+
'',
|
|
194
|
+
'The persistent design intent for this project. Frontmatter records the enforced intake',
|
|
195
|
+
'decisions (paradigm, theme, density). `## Pattern Usages` is regenerated from',
|
|
196
|
+
'`data-design-pattern` markers by `sync_design_manifest`. `## Design Decisions` is an',
|
|
197
|
+
'append-only ADR log written by `record_design_decision`.',
|
|
198
|
+
'',
|
|
199
|
+
USAGES_HEADING,
|
|
200
|
+
'',
|
|
201
|
+
renderUsagesBlock([]),
|
|
202
|
+
'',
|
|
203
|
+
DECISIONS_HEADING,
|
|
204
|
+
''
|
|
205
|
+
].join('\n');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Human-readable context summary for `get_design_context`. */
|
|
209
|
+
export function formatContext(manifest: DesignManifest): string {
|
|
210
|
+
let md = '# Design Context\n\n';
|
|
211
|
+
|
|
212
|
+
const fm = manifest.frontmatter;
|
|
213
|
+
const keys = Object.keys(fm);
|
|
214
|
+
if (keys.length > 0) {
|
|
215
|
+
md += '## Intake\n\n';
|
|
216
|
+
for (const k of keys) md += `- **${k}:** ${fm[k]}\n`;
|
|
217
|
+
md += '\n';
|
|
218
|
+
if (fm.paradigm) {
|
|
219
|
+
md += `> Stay within the **${fm.paradigm}** paradigm. Call \`get_design_principles(topic="theming")\` for its token profile.\n\n`;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
md += '## Pattern Usages\n\n';
|
|
224
|
+
if (manifest.usages.length === 0) {
|
|
225
|
+
md +=
|
|
226
|
+
'_None recorded._ Add `data-design-pattern="<name>"` to page roots, then run `sync_design_manifest`.\n\n';
|
|
227
|
+
} else {
|
|
228
|
+
const byPattern = new Map<string, string[]>();
|
|
229
|
+
for (const u of manifest.usages) {
|
|
230
|
+
(byPattern.get(u.pattern) ?? byPattern.set(u.pattern, []).get(u.pattern)!).push(u.file);
|
|
231
|
+
}
|
|
232
|
+
for (const [pattern, files] of [...byPattern].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
233
|
+
md += `- \`${pattern}\` (${files.length}): ${files.join(', ')}\n`;
|
|
234
|
+
}
|
|
235
|
+
md += '\n> To change a pattern across the app, migrate every file listed under it.\n\n';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
md += '## Design Decisions\n\n';
|
|
239
|
+
if (manifest.decisions.length === 0) {
|
|
240
|
+
md +=
|
|
241
|
+
'_None recorded._ Use `record_design_decision` when you deviate from a pattern or principle.\n';
|
|
242
|
+
} else {
|
|
243
|
+
for (const d of manifest.decisions) {
|
|
244
|
+
md += `- **${d.date} — ${d.title}** (${d.status}): ${d.decision}\n`;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return md;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export { DECISIONS_HEADING, USAGES_END, USAGES_HEADING, USAGES_START };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
5
|
+
import { scanMarkers } from './scan.js';
|
|
6
|
+
|
|
7
|
+
let root: string;
|
|
8
|
+
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
root = await mkdtemp(join(tmpdir(), 'uib-scan-'));
|
|
11
|
+
const write = async (rel: string, body: string) => {
|
|
12
|
+
const path = join(root, rel);
|
|
13
|
+
await mkdir(join(path, '..'), { recursive: true });
|
|
14
|
+
await writeFile(path, body, 'utf-8');
|
|
15
|
+
};
|
|
16
|
+
await write('src/routes/dashboard/+page.svelte', '<div data-design-pattern="dashboard">…</div>');
|
|
17
|
+
await write('src/routes/signup/+page.svelte', "<main data-design-pattern='form-page'>…</main>");
|
|
18
|
+
await write('src/lib/Plain.svelte', '<div class="bg-surface-base">no marker</div>');
|
|
19
|
+
// Must be skipped:
|
|
20
|
+
await write('src/node_modules/pkg/Comp.svelte', '<div data-design-pattern="should-skip">…</div>');
|
|
21
|
+
await write('dist/built.svelte', '<div data-design-pattern="should-skip">…</div>');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterAll(async () => {
|
|
25
|
+
await rm(root, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('scanMarkers', () => {
|
|
29
|
+
it('finds markers and reports project-relative paths', async () => {
|
|
30
|
+
const usages = await scanMarkers(join(root, 'src'), root);
|
|
31
|
+
const patterns = usages.map((u) => u.pattern).sort();
|
|
32
|
+
expect(patterns).toEqual(['dashboard', 'form-page']);
|
|
33
|
+
expect(usages.find((u) => u.pattern === 'dashboard')?.file).toBe(
|
|
34
|
+
'src/routes/dashboard/+page.svelte'
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('skips node_modules and build output', async () => {
|
|
39
|
+
const usages = await scanMarkers(root, root);
|
|
40
|
+
expect(usages.some((u) => u.pattern === 'should-skip')).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('handles both single- and double-quoted markers', async () => {
|
|
44
|
+
const usages = await scanMarkers(join(root, 'src'), root);
|
|
45
|
+
expect(usages.some((u) => u.pattern === 'form-page')).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns empty for a non-existent directory', async () => {
|
|
49
|
+
expect(await scanMarkers(join(root, 'does-not-exist'))).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan a source tree for `data-design-pattern="…"` markers. This is the
|
|
3
|
+
* convention (analogous to the `data-guide` namespace) that makes pattern usage
|
|
4
|
+
* greppable instead of guessable — the answer to DESIGN-SYSTEM-INTELLIGENCE.md's
|
|
5
|
+
* "how does the LLM reliably find which pages follow a pattern?".
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
9
|
+
import { join, relative, sep } from 'node:path';
|
|
10
|
+
import type { PatternUsage } from './types.js';
|
|
11
|
+
|
|
12
|
+
const SCANNED_EXT = /\.(svelte|html|tsx|jsx|astro|vue)$/;
|
|
13
|
+
const SKIP_DIRS = new Set([
|
|
14
|
+
'node_modules',
|
|
15
|
+
'.svelte-kit',
|
|
16
|
+
'.git',
|
|
17
|
+
'dist',
|
|
18
|
+
'build',
|
|
19
|
+
'.next',
|
|
20
|
+
'.turbo',
|
|
21
|
+
'coverage'
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const MARKER_RE = /data-design-pattern\s*=\s*["'`]([a-z0-9-]+)["'`]/g;
|
|
25
|
+
|
|
26
|
+
/** Recursion cap — guards against symlink loops; real source trees are far shallower. */
|
|
27
|
+
const MAX_DEPTH = 24;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Recursively scan `dir` for marker usages. Files in the returned list are
|
|
31
|
+
* relative to `baseDir` (default `dir`) so the manifest stays portable.
|
|
32
|
+
*/
|
|
33
|
+
export async function scanMarkers(
|
|
34
|
+
dir: string,
|
|
35
|
+
baseDir: string = dir,
|
|
36
|
+
depth = 0
|
|
37
|
+
): Promise<PatternUsage[]> {
|
|
38
|
+
const usages: PatternUsage[] = [];
|
|
39
|
+
if (depth > MAX_DEPTH) return usages;
|
|
40
|
+
|
|
41
|
+
let names: string[];
|
|
42
|
+
try {
|
|
43
|
+
names = await readdir(dir);
|
|
44
|
+
} catch {
|
|
45
|
+
return usages;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
const full = join(dir, name);
|
|
50
|
+
const info = await stat(full).catch(() => null);
|
|
51
|
+
if (!info) continue;
|
|
52
|
+
|
|
53
|
+
if (info.isDirectory()) {
|
|
54
|
+
if (SKIP_DIRS.has(name) || name.startsWith('.')) continue;
|
|
55
|
+
usages.push(...(await scanMarkers(full, baseDir, depth + 1)));
|
|
56
|
+
} else if (info.isFile() && SCANNED_EXT.test(name)) {
|
|
57
|
+
let content: string;
|
|
58
|
+
try {
|
|
59
|
+
content = await readFile(full, 'utf-8');
|
|
60
|
+
} catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const seen = new Set<string>();
|
|
64
|
+
for (const m of content.matchAll(MARKER_RE)) {
|
|
65
|
+
const pattern = m[1]!;
|
|
66
|
+
if (seen.has(pattern)) continue; // one entry per (pattern, file)
|
|
67
|
+
seen.add(pattern);
|
|
68
|
+
usages.push({ pattern, file: relative(baseDir, full).split(sep).join('/') });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return usages;
|
|
74
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the design manifest — the per-consumer-project memory of design
|
|
3
|
+
* intent (docs/DESIGN-MCP.md, Option C). The manifest is a Markdown file
|
|
4
|
+
* (`design.manifest.md`) at the consumer's project root with three parts:
|
|
5
|
+
*
|
|
6
|
+
* 1. Frontmatter — the enforced intake decisions (paradigm, theme, density).
|
|
7
|
+
* 2. Pattern Usages — an auto-generated index of `data-design-pattern` markers
|
|
8
|
+
* found in the source (so "which pages follow pattern X" is a grep, not a
|
|
9
|
+
* guess — answering the open question from DESIGN-SYSTEM-INTELLIGENCE.md).
|
|
10
|
+
* 3. Design Decisions — append-only ADRs recording deliberate deviations.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** One `data-design-pattern="…"` marker found in the source tree. */
|
|
14
|
+
export interface PatternUsage {
|
|
15
|
+
/** The pattern name, e.g. "dashboard". */
|
|
16
|
+
pattern: string;
|
|
17
|
+
/** Source file, relative to the project root. */
|
|
18
|
+
file: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A recorded design decision (ADR). */
|
|
22
|
+
export interface DesignDecision {
|
|
23
|
+
/** ISO date (YYYY-MM-DD). */
|
|
24
|
+
date: string;
|
|
25
|
+
title: string;
|
|
26
|
+
/** accepted | proposed | superseded — free text, defaults to "accepted". */
|
|
27
|
+
status: string;
|
|
28
|
+
decision: string;
|
|
29
|
+
rationale?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Parsed view of a manifest file. */
|
|
33
|
+
export interface DesignManifest {
|
|
34
|
+
/** Flat key→value frontmatter (paradigm, theme, density, …). */
|
|
35
|
+
frontmatter: Record<string, string>;
|
|
36
|
+
usages: PatternUsage[];
|
|
37
|
+
decisions: DesignDecision[];
|
|
38
|
+
/** Whether a manifest file actually existed (false → defaults returned). */
|
|
39
|
+
exists: boolean;
|
|
40
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { MAX_RUBRIC_SCORE, RUBRIC_CRITERIA, renderRubric } from './rubric.js';
|
|
3
|
+
|
|
4
|
+
describe('rubric criteria', () => {
|
|
5
|
+
it('keeps the eight A/B-test criteria', () => {
|
|
6
|
+
expect(RUBRIC_CRITERIA).toHaveLength(8);
|
|
7
|
+
expect(MAX_RUBRIC_SCORE).toBe(40);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('has unique ids and complete anchors', () => {
|
|
11
|
+
const ids = new Set(RUBRIC_CRITERIA.map((c) => c.id));
|
|
12
|
+
expect(ids.size).toBe(RUBRIC_CRITERIA.length);
|
|
13
|
+
for (const c of RUBRIC_CRITERIA) {
|
|
14
|
+
expect(c.name).toBeTruthy();
|
|
15
|
+
expect(c.measures).toBeTruthy();
|
|
16
|
+
for (const score of [1, 3, 5] as const) {
|
|
17
|
+
expect(c.anchors[score], `${c.id} anchor ${score}`).toBeTruthy();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('anchors technical correctness on validate_design', () => {
|
|
23
|
+
const correctness = RUBRIC_CRITERIA.find((c) => c.id === 'correctness');
|
|
24
|
+
expect(correctness?.anchors[5]).toContain('validate_design');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('renderRubric', () => {
|
|
29
|
+
const md = renderRubric();
|
|
30
|
+
|
|
31
|
+
it('renders every criterion and the total', () => {
|
|
32
|
+
for (const c of RUBRIC_CRITERIA) expect(md).toContain(c.name);
|
|
33
|
+
expect(md).toContain(`/${MAX_RUBRIC_SCORE}`);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('tells the judge to run validate_design first', () => {
|
|
37
|
+
expect(md).toContain('validate_design');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('describes the panel-of-lenses approach for variant selection', () => {
|
|
41
|
+
expect(md.toLowerCase()).toContain('lens');
|
|
42
|
+
});
|
|
43
|
+
});
|