@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,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';
|