openuispec 0.2.19 → 0.2.21
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/dist/check/audit.js +392 -0
- package/dist/check/index.js +216 -0
- package/dist/cli/configure-target.js +391 -0
- package/dist/cli/index.js +510 -0
- package/dist/cli/init.js +964 -0
- package/dist/drift/index.js +903 -0
- package/dist/mcp-server/index.js +888 -0
- package/dist/mcp-server/preview-render.js +1761 -0
- package/dist/mcp-server/preview.js +229 -0
- package/dist/mcp-server/screenshot-android.js +458 -0
- package/dist/mcp-server/screenshot-ios.js +639 -0
- package/dist/mcp-server/screenshot-shared.js +185 -0
- package/dist/mcp-server/screenshot.js +469 -0
- package/dist/prepare/index.js +1216 -0
- package/dist/runtime/package-paths.js +33 -0
- package/dist/schema/semantic-lint.js +564 -0
- package/dist/schema/validate.js +689 -0
- package/dist/status/index.js +194 -0
- package/package.json +13 -14
- package/check/audit.ts +0 -426
- package/check/index.ts +0 -320
- package/cli/configure-target.ts +0 -523
- package/cli/index.ts +0 -537
- package/cli/init.ts +0 -1253
- package/drift/index.ts +0 -1165
- package/mcp-server/index.ts +0 -1041
- package/mcp-server/preview-render.ts +0 -1922
- package/mcp-server/preview.ts +0 -292
- package/mcp-server/screenshot-android.ts +0 -621
- package/mcp-server/screenshot-ios.ts +0 -753
- package/mcp-server/screenshot-shared.ts +0 -237
- package/mcp-server/screenshot.ts +0 -563
- package/prepare/index.ts +0 -1530
- package/schema/semantic-lint.ts +0 -692
- package/schema/validate.ts +0 -870
- package/scripts/regenerate-previews.ts +0 -136
- package/scripts/take-all-screenshots.ts +0 -507
- package/status/index.ts +0 -275
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Cross-target status summary for OpenUISpec projects.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* openuispec status # summarize every target
|
|
7
|
+
* openuispec status --json # machine-readable output
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { computeDrift, computeSharedDrift, discoverTargets, explainDrift, findProjectDir, formatBaseline, hasDriftChanges, readOutputDirs, readProjectName, readSharedLayers, readSharedLayerState, resolveOutputDir, stateFilePath, } from "../drift/index.js";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
function configuredTargets(projectDir) {
|
|
13
|
+
try {
|
|
14
|
+
const manifest = readOutputDirs(projectDir);
|
|
15
|
+
const keys = Object.keys(manifest);
|
|
16
|
+
if (keys.length > 0)
|
|
17
|
+
return keys.sort();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// ignore
|
|
21
|
+
}
|
|
22
|
+
// Fallback to common targets when manifest output_dir is absent.
|
|
23
|
+
return ["android", "ios", "web"];
|
|
24
|
+
}
|
|
25
|
+
function allTargets(projectDir, projectName) {
|
|
26
|
+
const seen = new Set(configuredTargets(projectDir));
|
|
27
|
+
for (const target of discoverTargets(projectDir, projectName)) {
|
|
28
|
+
seen.add(target);
|
|
29
|
+
}
|
|
30
|
+
return Array.from(seen).sort();
|
|
31
|
+
}
|
|
32
|
+
function readState(statePath) {
|
|
33
|
+
return JSON.parse(readFileSync(statePath, "utf-8"));
|
|
34
|
+
}
|
|
35
|
+
function buildTargetStatus(cwd, projectDir, projectName, target) {
|
|
36
|
+
const outputDir = resolveOutputDir(projectDir, projectName, target);
|
|
37
|
+
const outputExists = existsSync(outputDir);
|
|
38
|
+
const path = stateFilePath(projectDir, projectName, target);
|
|
39
|
+
if (!existsSync(path)) {
|
|
40
|
+
return {
|
|
41
|
+
target,
|
|
42
|
+
output_dir: outputDir,
|
|
43
|
+
output_exists: outputExists,
|
|
44
|
+
snapshot: false,
|
|
45
|
+
snapshot_at: null,
|
|
46
|
+
baseline: {
|
|
47
|
+
kind: null,
|
|
48
|
+
commit: null,
|
|
49
|
+
branch: null,
|
|
50
|
+
label: null,
|
|
51
|
+
},
|
|
52
|
+
changed: 0,
|
|
53
|
+
added: 0,
|
|
54
|
+
removed: 0,
|
|
55
|
+
behind: false,
|
|
56
|
+
explain_available: false,
|
|
57
|
+
status: outputExists ? "needs baseline" : "needs generation",
|
|
58
|
+
recommended_next_step: outputExists
|
|
59
|
+
? `Review the generated output, then run \`openuispec drift --snapshot --target ${target}\` to create the baseline.`
|
|
60
|
+
: `Run code generation for "${target}", then \`openuispec prepare --target ${target}\` to build the target work bundle.`,
|
|
61
|
+
note: outputExists
|
|
62
|
+
? "Baseline pending — generated code exists but user has not yet confirmed it with a snapshot."
|
|
63
|
+
: `Output directory not found. Run code generation for "${target}" first.`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const state = readState(path);
|
|
67
|
+
const result = computeDrift(projectDir, state, false);
|
|
68
|
+
const explanation = explainDrift(projectDir, result);
|
|
69
|
+
const changed = result.drift.changed.length;
|
|
70
|
+
const added = result.drift.added.length;
|
|
71
|
+
const removed = result.drift.removed.length;
|
|
72
|
+
return {
|
|
73
|
+
target,
|
|
74
|
+
output_dir: outputDir,
|
|
75
|
+
output_exists: outputExists,
|
|
76
|
+
snapshot: true,
|
|
77
|
+
snapshot_at: state.snapshot_at,
|
|
78
|
+
baseline: {
|
|
79
|
+
kind: state.baseline?.kind ?? null,
|
|
80
|
+
commit: state.baseline?.commit ?? null,
|
|
81
|
+
branch: state.baseline?.branch ?? null,
|
|
82
|
+
label: formatBaseline(state.baseline),
|
|
83
|
+
},
|
|
84
|
+
changed,
|
|
85
|
+
added,
|
|
86
|
+
removed,
|
|
87
|
+
behind: changed + added + removed > 0,
|
|
88
|
+
explain_available: explanation.available,
|
|
89
|
+
status: changed + added + removed > 0 ? "behind" : "up to date",
|
|
90
|
+
recommended_next_step: changed + added + removed > 0
|
|
91
|
+
? `Run \`openuispec prepare --target ${target}\` to build the target work bundle for the pending spec changes.`
|
|
92
|
+
: `No immediate action required for "${target}". Re-run \`openuispec status\` after spec changes or after re-baselining.`,
|
|
93
|
+
note: explanation.available ? undefined : explanation.note,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function buildSharedLayerStatus(projectDir, layer) {
|
|
97
|
+
const state = readSharedLayerState(layer);
|
|
98
|
+
if (!state) {
|
|
99
|
+
return {
|
|
100
|
+
name: layer.name,
|
|
101
|
+
platforms: layer.platforms,
|
|
102
|
+
root: layer.root,
|
|
103
|
+
snapshot: false,
|
|
104
|
+
snapshot_at: null,
|
|
105
|
+
generated_by_target: null,
|
|
106
|
+
has_drift: false,
|
|
107
|
+
status: "needs generation",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
let hasDrift = false;
|
|
111
|
+
if (layer.tracks.length > 0) {
|
|
112
|
+
hasDrift = hasDriftChanges(computeSharedDrift(projectDir, layer).drift);
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
name: layer.name,
|
|
116
|
+
platforms: layer.platforms,
|
|
117
|
+
root: layer.root,
|
|
118
|
+
snapshot: true,
|
|
119
|
+
snapshot_at: state.snapshot_at,
|
|
120
|
+
generated_by_target: state.generated_by_target,
|
|
121
|
+
has_drift: hasDrift,
|
|
122
|
+
status: hasDrift ? "behind" : "up to date",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
export function buildStatusResult(cwd = process.cwd()) {
|
|
126
|
+
const projectDir = findProjectDir(cwd);
|
|
127
|
+
const projectName = readProjectName(projectDir);
|
|
128
|
+
const targets = allTargets(projectDir, projectName).map((target) => buildTargetStatus(cwd, projectDir, projectName, target));
|
|
129
|
+
const sharedLayers = readSharedLayers(projectDir);
|
|
130
|
+
const sharedLayerStatuses = sharedLayers.length > 0
|
|
131
|
+
? sharedLayers.map((layer) => buildSharedLayerStatus(projectDir, layer))
|
|
132
|
+
: undefined;
|
|
133
|
+
return {
|
|
134
|
+
project: projectName,
|
|
135
|
+
targets,
|
|
136
|
+
...(sharedLayerStatuses ? { shared_layers: sharedLayerStatuses } : {}),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function printReport(result) {
|
|
140
|
+
console.log("OpenUISpec Status");
|
|
141
|
+
console.log("=================");
|
|
142
|
+
console.log(`Project: ${result.project}`);
|
|
143
|
+
console.log("");
|
|
144
|
+
for (const target of result.targets) {
|
|
145
|
+
const summary = target.snapshot
|
|
146
|
+
? `${target.changed} changed, ${target.added} added, ${target.removed} removed`
|
|
147
|
+
: target.output_exists
|
|
148
|
+
? "no snapshot"
|
|
149
|
+
: "output missing";
|
|
150
|
+
console.log(`${target.target}`);
|
|
151
|
+
console.log(` output: ${target.output_dir}`);
|
|
152
|
+
console.log(` output exists: ${target.output_exists ? "yes" : "no"}`);
|
|
153
|
+
console.log(` snapshot: ${target.snapshot ? target.snapshot_at : "missing"}`);
|
|
154
|
+
if (target.baseline.label) {
|
|
155
|
+
console.log(` baseline: ${target.baseline.label}`);
|
|
156
|
+
}
|
|
157
|
+
console.log(` drift: ${summary}`);
|
|
158
|
+
console.log(` status: ${target.status}`);
|
|
159
|
+
console.log(` explain: ${target.explain_available ? "available" : "unavailable"}`);
|
|
160
|
+
if (target.note) {
|
|
161
|
+
console.log(` note: ${target.note}`);
|
|
162
|
+
}
|
|
163
|
+
console.log(` next: ${target.recommended_next_step}`);
|
|
164
|
+
console.log("");
|
|
165
|
+
}
|
|
166
|
+
if (result.shared_layers && result.shared_layers.length > 0) {
|
|
167
|
+
console.log("Shared Layers");
|
|
168
|
+
console.log("─────────────");
|
|
169
|
+
for (const layer of result.shared_layers) {
|
|
170
|
+
console.log(`${layer.name} (${layer.platforms.join(", ")})`);
|
|
171
|
+
console.log(` root: ${layer.root}`);
|
|
172
|
+
console.log(` snapshot: ${layer.snapshot ? layer.snapshot_at : "missing"}`);
|
|
173
|
+
if (layer.generated_by_target) {
|
|
174
|
+
console.log(` generated by: ${layer.generated_by_target}`);
|
|
175
|
+
}
|
|
176
|
+
console.log(` status: ${layer.status}`);
|
|
177
|
+
console.log("");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
export function runStatus(argv) {
|
|
182
|
+
const isJson = argv.includes("--json");
|
|
183
|
+
const result = buildStatusResult(process.cwd());
|
|
184
|
+
if (isJson) {
|
|
185
|
+
console.log(JSON.stringify(result, null, 2));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
printReport(result);
|
|
189
|
+
}
|
|
190
|
+
const isDirectRun = process.argv[1]?.endsWith("status/index.ts") ||
|
|
191
|
+
process.argv[1]?.endsWith("status/index.js");
|
|
192
|
+
if (isDirectRun) {
|
|
193
|
+
runStatus(process.argv.slice(2));
|
|
194
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openuispec",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.21",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A semantic UI specification format for AI-native, platform-native app development",
|
|
@@ -10,27 +10,23 @@
|
|
|
10
10
|
"url": "https://github.com/rsktash/openuispec.git"
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"mcp-server/",
|
|
17
|
-
"prepare/",
|
|
18
|
-
"status/",
|
|
19
|
-
"schema/",
|
|
13
|
+
"dist/",
|
|
14
|
+
"cli/target-presets.json",
|
|
15
|
+
"schema/**/*.json",
|
|
20
16
|
"spec/",
|
|
21
17
|
"docs/",
|
|
22
18
|
"examples/*/openuispec/**",
|
|
23
19
|
"examples/*/openuispec.yaml",
|
|
24
20
|
"examples/*/README.md",
|
|
25
|
-
"scripts/",
|
|
26
21
|
"README.md",
|
|
27
22
|
"LICENSE"
|
|
28
23
|
],
|
|
29
24
|
"bin": {
|
|
30
|
-
"openuispec": "./cli/index.
|
|
31
|
-
"openuispec-mcp": "./mcp-server/index.
|
|
25
|
+
"openuispec": "./dist/cli/index.js",
|
|
26
|
+
"openuispec-mcp": "./dist/mcp-server/index.js"
|
|
32
27
|
},
|
|
33
28
|
"scripts": {
|
|
29
|
+
"build": "tsc -p tsconfig.build.json && node scripts/fix-dist-bins.mjs",
|
|
34
30
|
"test": "node --import tsx --test tests/check.test.ts tests/configure-target.test.ts tests/drift-prepare.test.ts tests/init.test.ts tests/mcp-tools.test.ts tests/semantic-lint.test.ts tests/status.test.ts",
|
|
35
31
|
"test:screenshot": "node --import tsx --test tests/mcp-screenshot.test.ts",
|
|
36
32
|
"test:all": "node --import tsx --test tests/*.test.ts",
|
|
@@ -45,6 +41,8 @@
|
|
|
45
41
|
"drift:snapshot": "tsx drift/index.ts --snapshot --target",
|
|
46
42
|
"prepare:target": "tsx prepare/index.ts",
|
|
47
43
|
"status": "tsx status/index.ts",
|
|
44
|
+
"prepare": "npm run build",
|
|
45
|
+
"prepack": "npm run build",
|
|
48
46
|
"postinstall": "echo \"\\n ✓ openuispec installed — if upgrading, run: openuispec update-rules\\n\"",
|
|
49
47
|
"cloc": "cloc --exclude-dir=$(tr '\n' ',' < .cloc-ignore) ."
|
|
50
48
|
},
|
|
@@ -52,14 +50,15 @@
|
|
|
52
50
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
53
51
|
"ajv": "^8.17.1",
|
|
54
52
|
"ajv-formats": "^3.0.1",
|
|
55
|
-
"
|
|
56
|
-
"
|
|
53
|
+
"yaml": "^2.7.1",
|
|
54
|
+
"zod": "^3.25.76"
|
|
57
55
|
},
|
|
58
56
|
"optionalDependencies": {
|
|
59
|
-
"
|
|
57
|
+
"playwright": "^1.52.0"
|
|
60
58
|
},
|
|
61
59
|
"devDependencies": {
|
|
62
60
|
"@types/node": "^25.5.0",
|
|
61
|
+
"tsx": "^4.19.4",
|
|
63
62
|
"typescript": "^5.8.3"
|
|
64
63
|
}
|
|
65
64
|
}
|
package/check/audit.ts
DELETED
|
@@ -1,426 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Design quality audit for OpenUISpec projects.
|
|
3
|
-
*
|
|
4
|
-
* Checks token patterns and contract completeness to produce a numeric quality score.
|
|
5
|
-
* Score formula: max(0, 100 - errors × 10 - warnings × 3)
|
|
6
|
-
*
|
|
7
|
-
* Usage:
|
|
8
|
-
* openuispec check --target web --audit
|
|
9
|
-
* openuispec check --target ios --audit --min-score 70
|
|
10
|
-
* openuispec check --target web --audit --format json
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
14
|
-
import { join, resolve } from "node:path";
|
|
15
|
-
import YAML from "yaml";
|
|
16
|
-
|
|
17
|
-
export interface AuditFinding {
|
|
18
|
-
domain: string;
|
|
19
|
-
rule: string;
|
|
20
|
-
severity: "error" | "warning";
|
|
21
|
-
message: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface AuditResult {
|
|
25
|
-
score: number;
|
|
26
|
-
errors: number;
|
|
27
|
-
warnings: number;
|
|
28
|
-
findings: AuditFinding[];
|
|
29
|
-
passed: boolean;
|
|
30
|
-
threshold: number;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const AI_DEFAULT_FONTS = new Set(["Inter", "Roboto", "Arial", "Open Sans"]);
|
|
34
|
-
const REQUIRED_TOKEN_FILES = [
|
|
35
|
-
"color.yaml",
|
|
36
|
-
"typography.yaml",
|
|
37
|
-
"spacing.yaml",
|
|
38
|
-
"elevation.yaml",
|
|
39
|
-
"motion.yaml",
|
|
40
|
-
"layout.yaml",
|
|
41
|
-
"themes.yaml",
|
|
42
|
-
"icons.yaml",
|
|
43
|
-
];
|
|
44
|
-
|
|
45
|
-
function readYaml(path: string): any {
|
|
46
|
-
try {
|
|
47
|
-
return YAML.parse(readFileSync(path, "utf-8"));
|
|
48
|
-
} catch {
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function readYamlForAudit(path: string, domain: string, findings: AuditFinding[]): any {
|
|
54
|
-
try {
|
|
55
|
-
return YAML.parse(readFileSync(path, "utf-8"));
|
|
56
|
-
} catch (err: any) {
|
|
57
|
-
if (err?.code === "ENOENT") return null;
|
|
58
|
-
findings.push({
|
|
59
|
-
domain,
|
|
60
|
-
rule: "unreadable_file",
|
|
61
|
-
severity: "error",
|
|
62
|
-
message: `Could not parse "${path}". Fix malformed YAML before relying on this audit.`,
|
|
63
|
-
});
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function checkRequiredTokenFiles(tokensDir: string, findings: AuditFinding[]): void {
|
|
69
|
-
for (const filename of REQUIRED_TOKEN_FILES) {
|
|
70
|
-
if (!existsSync(join(tokensDir, filename))) {
|
|
71
|
-
findings.push({
|
|
72
|
-
domain: "tokens",
|
|
73
|
-
rule: "missing_file",
|
|
74
|
-
severity: "error",
|
|
75
|
-
message: `Required token file "${filename}" is missing.`,
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function checkTypography(tokensDir: string, findings: AuditFinding[]): void {
|
|
82
|
-
const doc = readYamlForAudit(join(tokensDir, "typography.yaml"), "typography", findings);
|
|
83
|
-
if (!doc?.typography) return;
|
|
84
|
-
|
|
85
|
-
// Font diversity: primary must NOT be a common AI default
|
|
86
|
-
const primaryFont = doc.typography.font_family?.primary?.value;
|
|
87
|
-
if (typeof primaryFont === "string" && AI_DEFAULT_FONTS.has(primaryFont)) {
|
|
88
|
-
findings.push({
|
|
89
|
-
domain: "typography",
|
|
90
|
-
rule: "font_diversity",
|
|
91
|
-
severity: "error",
|
|
92
|
-
message: `Primary font "${primaryFont}" is an AI-default choice. Use a distinctive brand font.`,
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Scale usage: at least 4 distinct scale levels defined
|
|
97
|
-
const scaleKeys = Object.keys(doc.typography.scale ?? {});
|
|
98
|
-
if (scaleKeys.length > 0 && scaleKeys.length < 4) {
|
|
99
|
-
findings.push({
|
|
100
|
-
domain: "typography",
|
|
101
|
-
rule: "scale_usage",
|
|
102
|
-
severity: "warning",
|
|
103
|
-
message: `Only ${scaleKeys.length} type scale level(s) defined. Use ≥4 distinct levels for clear hierarchy.`,
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Weight hierarchy: at least 2 distinct weights
|
|
108
|
-
if (doc.typography.scale) {
|
|
109
|
-
const weights = new Set<number>();
|
|
110
|
-
for (const level of Object.values(doc.typography.scale)) {
|
|
111
|
-
if (typeof (level as any)?.weight === "number") {
|
|
112
|
-
weights.add((level as any).weight);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
if (weights.size > 0 && weights.size < 2) {
|
|
116
|
-
findings.push({
|
|
117
|
-
domain: "typography",
|
|
118
|
-
rule: "weight_hierarchy",
|
|
119
|
-
severity: "warning",
|
|
120
|
-
message: "Only 1 distinct font weight used across the type scale. Use ≥2 weights (e.g. 400 + 700) for clear hierarchy.",
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function checkColor(tokensDir: string, findings: AuditFinding[]): void {
|
|
127
|
-
const doc = readYamlForAudit(join(tokensDir, "color.yaml"), "color", findings);
|
|
128
|
-
if (!doc?.color) return;
|
|
129
|
-
|
|
130
|
-
// Pure black/white check
|
|
131
|
-
function scanForPure(obj: any, path: string): void {
|
|
132
|
-
if (typeof obj !== "object" || obj === null) return;
|
|
133
|
-
if (typeof obj.reference === "string") {
|
|
134
|
-
const ref = obj.reference.toUpperCase();
|
|
135
|
-
if (ref === "#000000" || ref === "#000") {
|
|
136
|
-
findings.push({
|
|
137
|
-
domain: "color",
|
|
138
|
-
rule: "pure_black",
|
|
139
|
-
severity: "error",
|
|
140
|
-
message: `Token at ${path} uses pure black (#000000). Use a near-black with hue instead.`,
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
if (ref === "#FFFFFF" || ref === "#FFF") {
|
|
144
|
-
findings.push({
|
|
145
|
-
domain: "color",
|
|
146
|
-
rule: "pure_white",
|
|
147
|
-
severity: "error",
|
|
148
|
-
message: `Token at ${path} uses pure white (#FFFFFF). Use a slightly tinted white instead.`,
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
153
|
-
if (key !== "reference" && typeof value === "object") {
|
|
154
|
-
scanForPure(value, `${path}.${key}`);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
scanForPure(doc.color, "color");
|
|
159
|
-
|
|
160
|
-
// Semantic color completeness: success, warning, danger, info
|
|
161
|
-
if (doc.color.semantic) {
|
|
162
|
-
const required = ["success", "warning", "danger", "info"];
|
|
163
|
-
const defined = Object.keys(doc.color.semantic);
|
|
164
|
-
for (const name of required) {
|
|
165
|
-
if (!defined.includes(name)) {
|
|
166
|
-
findings.push({
|
|
167
|
-
domain: "color",
|
|
168
|
-
rule: "semantic_completeness",
|
|
169
|
-
severity: "warning",
|
|
170
|
-
message: `Semantic color "${name}" is missing. Define all four (success, warning, danger, info) for complete state coverage.`,
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Theme coverage: check themes.yaml for both light + dark
|
|
177
|
-
{
|
|
178
|
-
const themes = readYamlForAudit(join(tokensDir, "themes.yaml"), "color", findings);
|
|
179
|
-
if (!themes?.themes) return;
|
|
180
|
-
const themeKeys = Object.keys(themes.themes);
|
|
181
|
-
const hasLight = themeKeys.some((k) => k.includes("light"));
|
|
182
|
-
const hasDark = themeKeys.some((k) => k.includes("dark"));
|
|
183
|
-
if (!hasLight || !hasDark) {
|
|
184
|
-
findings.push({
|
|
185
|
-
domain: "color",
|
|
186
|
-
rule: "theme_coverage",
|
|
187
|
-
severity: "warning",
|
|
188
|
-
message: "Both light and dark themes should be defined in tokens/themes.yaml.",
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function checkSpacing(tokensDir: string, findings: AuditFinding[]): void {
|
|
195
|
-
const doc = readYamlForAudit(join(tokensDir, "spacing.yaml"), "spacing", findings);
|
|
196
|
-
if (!doc?.spacing) return;
|
|
197
|
-
|
|
198
|
-
// Scale usage: at least 4 distinct values
|
|
199
|
-
const scale = doc.spacing.scale ?? {};
|
|
200
|
-
const scaleCount = Object.keys(scale).length;
|
|
201
|
-
if (scaleCount > 0 && scaleCount < 4) {
|
|
202
|
-
findings.push({
|
|
203
|
-
domain: "spacing",
|
|
204
|
-
rule: "scale_usage",
|
|
205
|
-
severity: "warning",
|
|
206
|
-
message: `Only ${scaleCount} spacing scale value(s) defined. Define ≥4 for meaningful spatial rhythm.`,
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Alias usage: page_margin and card_padding should exist
|
|
211
|
-
const aliases = doc.spacing.aliases ?? {};
|
|
212
|
-
const aliasKeys = Object.keys(aliases).map((k) => k.toLowerCase());
|
|
213
|
-
if (!aliasKeys.some((k) => k.includes("page_margin") || k.includes("page"))) {
|
|
214
|
-
findings.push({
|
|
215
|
-
domain: "spacing",
|
|
216
|
-
rule: "alias_page_margin",
|
|
217
|
-
severity: "warning",
|
|
218
|
-
message: "No page_margin alias found in spacing tokens. Define it for consistent screen padding.",
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
if (!aliasKeys.some((k) => k.includes("card_padding") || k.includes("card"))) {
|
|
222
|
-
findings.push({
|
|
223
|
-
domain: "spacing",
|
|
224
|
-
rule: "alias_card_padding",
|
|
225
|
-
severity: "warning",
|
|
226
|
-
message: "No card_padding alias found in spacing tokens. Define it for consistent card spacing.",
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function checkMotion(tokensDir: string, findings: AuditFinding[]): void {
|
|
232
|
-
const doc = readYamlForAudit(join(tokensDir, "motion.yaml"), "motion", findings);
|
|
233
|
-
if (!doc?.motion) return;
|
|
234
|
-
|
|
235
|
-
// Duration variety: at least 2 distinct durations
|
|
236
|
-
const durations = doc.motion.duration ?? {};
|
|
237
|
-
const distinctDurations = new Set(Object.values(durations));
|
|
238
|
-
if (Object.keys(durations).length > 0 && distinctDurations.size < 2) {
|
|
239
|
-
findings.push({
|
|
240
|
-
domain: "motion",
|
|
241
|
-
rule: "duration_variety",
|
|
242
|
-
severity: "warning",
|
|
243
|
-
message: "Only 1 distinct duration value found. Use ≥2 distinct durations (e.g. quick + normal).",
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// Reduced motion: must be defined
|
|
248
|
-
if (!doc.motion.reduced_motion) {
|
|
249
|
-
findings.push({
|
|
250
|
-
domain: "motion",
|
|
251
|
-
rule: "reduced_motion",
|
|
252
|
-
severity: "error",
|
|
253
|
-
message: "motion.reduced_motion is not defined. Must specify policy for prefers-reduced-motion.",
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Easing quality: enter + exit curves, at least one cubic-bezier
|
|
258
|
-
if (doc.motion.easing) {
|
|
259
|
-
const easings = doc.motion.easing;
|
|
260
|
-
const keys = Object.keys(easings);
|
|
261
|
-
if (!keys.includes("enter") || !keys.includes("exit")) {
|
|
262
|
-
findings.push({
|
|
263
|
-
domain: "motion",
|
|
264
|
-
rule: "easing_quality",
|
|
265
|
-
severity: "warning",
|
|
266
|
-
message: "Motion easing should define at least 'enter' and 'exit' curves for asymmetric transitions.",
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
const hasCubicBezier = Object.values(easings).some(
|
|
270
|
-
(v) => typeof v === "string" && v.includes("cubic-bezier"),
|
|
271
|
-
);
|
|
272
|
-
if (!hasCubicBezier) {
|
|
273
|
-
findings.push({
|
|
274
|
-
domain: "motion",
|
|
275
|
-
rule: "easing_quality",
|
|
276
|
-
severity: "warning",
|
|
277
|
-
message: "All easing curves are generic keywords. Use at least one cubic-bezier() for nuanced motion.",
|
|
278
|
-
});
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function checkElevationProgression(tokensDir: string, findings: AuditFinding[]): void {
|
|
284
|
-
const doc = readYamlForAudit(join(tokensDir, "elevation.yaml"), "elevation", findings);
|
|
285
|
-
if (!doc?.elevation) return;
|
|
286
|
-
const levels = Object.keys(doc.elevation).filter((k) => k !== "none");
|
|
287
|
-
if (levels.length < 2) {
|
|
288
|
-
findings.push({
|
|
289
|
-
domain: "elevation",
|
|
290
|
-
rule: "level_count",
|
|
291
|
-
severity: "warning",
|
|
292
|
-
message: `Only ${levels.length} non-none elevation level(s) defined. Define ≥2 (e.g. sm, md, lg) for meaningful depth hierarchy.`,
|
|
293
|
-
});
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
const androidValues: number[] = [];
|
|
297
|
-
for (const level of levels) {
|
|
298
|
-
const val = doc.elevation[level]?.platform?.android?.elevation;
|
|
299
|
-
if (typeof val === "number") androidValues.push(val);
|
|
300
|
-
}
|
|
301
|
-
if (androidValues.length >= 2) {
|
|
302
|
-
for (let i = 1; i < androidValues.length; i++) {
|
|
303
|
-
if (androidValues[i] <= androidValues[i - 1]) {
|
|
304
|
-
findings.push({
|
|
305
|
-
domain: "elevation",
|
|
306
|
-
rule: "progression",
|
|
307
|
-
severity: "warning",
|
|
308
|
-
message: "Elevation levels do not increase monotonically. Each level should cast a deeper shadow than the previous.",
|
|
309
|
-
});
|
|
310
|
-
break;
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function checkLayoutSizeClasses(tokensDir: string, findings: AuditFinding[]): void {
|
|
317
|
-
const doc = readYamlForAudit(join(tokensDir, "layout.yaml"), "layout", findings);
|
|
318
|
-
if (!doc?.layout?.size_classes) return;
|
|
319
|
-
const classes = Object.keys(doc.layout.size_classes);
|
|
320
|
-
if (classes.length < 2) {
|
|
321
|
-
findings.push({
|
|
322
|
-
domain: "layout",
|
|
323
|
-
rule: "size_class_coverage",
|
|
324
|
-
severity: "warning",
|
|
325
|
-
message: `Only ${classes.length} size class(es) defined. Define at least compact + regular for responsive layouts.`,
|
|
326
|
-
});
|
|
327
|
-
} else if (!classes.includes("compact")) {
|
|
328
|
-
findings.push({
|
|
329
|
-
domain: "layout",
|
|
330
|
-
rule: "size_class_coverage",
|
|
331
|
-
severity: "warning",
|
|
332
|
-
message: "No 'compact' size class defined. Mobile-first layouts require a compact breakpoint.",
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function checkContractStateCoverage(contractsDir: string, findings: AuditFinding[]): void {
|
|
338
|
-
if (!existsSync(contractsDir)) return;
|
|
339
|
-
for (const file of readdirSync(contractsDir).filter((f) => f.endsWith(".yaml") && !f.startsWith("x_"))) {
|
|
340
|
-
const doc = readYamlForAudit(join(contractsDir, file), "contracts", findings);
|
|
341
|
-
if (!doc) continue;
|
|
342
|
-
const contractName = Object.keys(doc)[0];
|
|
343
|
-
const contract = doc[contractName];
|
|
344
|
-
const mustHandle: string[] = contract?.generation?.must_handle ?? [];
|
|
345
|
-
if (mustHandle.length === 0) {
|
|
346
|
-
findings.push({
|
|
347
|
-
domain: "contracts",
|
|
348
|
-
rule: "state_coverage",
|
|
349
|
-
severity: "warning",
|
|
350
|
-
message: `Contract "${contractName}" has no generation.must_handle entries. Define required states for AI compliance.`,
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
function checkContracts(contractsDir: string, findings: AuditFinding[]): void {
|
|
357
|
-
// All collections have empty_state in must_handle or variants
|
|
358
|
-
{
|
|
359
|
-
const doc = readYamlForAudit(join(contractsDir, "collection.yaml"), "contracts", findings);
|
|
360
|
-
const collection = doc ? doc[Object.keys(doc)[0]] : null;
|
|
361
|
-
if (collection) {
|
|
362
|
-
const mustHandle: string[] = collection.generation?.must_handle ?? [];
|
|
363
|
-
const hasEmptyState = mustHandle.some((s: string) => s.toLowerCase().includes("empty"));
|
|
364
|
-
if (!hasEmptyState) {
|
|
365
|
-
findings.push({
|
|
366
|
-
domain: "contracts",
|
|
367
|
-
rule: "collection_empty_state",
|
|
368
|
-
severity: "warning",
|
|
369
|
-
message: "collection contract does not list empty_state handling in generation.must_handle.",
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
export function buildAuditResult(projectDir: string, threshold: number = 0): AuditResult {
|
|
377
|
-
const manifest = readYaml(join(projectDir, "openuispec.yaml"));
|
|
378
|
-
const tokensDir = resolve(projectDir, manifest?.includes?.tokens ?? "./tokens/");
|
|
379
|
-
const contractsDir = resolve(projectDir, manifest?.includes?.contracts ?? "./contracts/");
|
|
380
|
-
|
|
381
|
-
// Use audit_threshold from manifest if no CLI override
|
|
382
|
-
const effectiveThreshold = threshold > 0 ? threshold : (manifest?.generation_guidance?.audit_threshold ?? 0);
|
|
383
|
-
|
|
384
|
-
const findings: AuditFinding[] = [];
|
|
385
|
-
checkRequiredTokenFiles(tokensDir, findings);
|
|
386
|
-
checkTypography(tokensDir, findings);
|
|
387
|
-
checkColor(tokensDir, findings);
|
|
388
|
-
checkSpacing(tokensDir, findings);
|
|
389
|
-
checkMotion(tokensDir, findings);
|
|
390
|
-
checkElevationProgression(tokensDir, findings);
|
|
391
|
-
checkLayoutSizeClasses(tokensDir, findings);
|
|
392
|
-
checkContracts(contractsDir, findings);
|
|
393
|
-
checkContractStateCoverage(contractsDir, findings);
|
|
394
|
-
|
|
395
|
-
const errors = findings.filter((f) => f.severity === "error").length;
|
|
396
|
-
const warnings = findings.filter((f) => f.severity === "warning").length;
|
|
397
|
-
const score = Math.max(0, 100 - errors * 10 - warnings * 3);
|
|
398
|
-
|
|
399
|
-
return {
|
|
400
|
-
score,
|
|
401
|
-
errors,
|
|
402
|
-
warnings,
|
|
403
|
-
findings,
|
|
404
|
-
passed: score >= effectiveThreshold,
|
|
405
|
-
threshold: effectiveThreshold,
|
|
406
|
-
};
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
export function formatAuditResult(result: AuditResult): string {
|
|
410
|
-
const lines: string[] = [
|
|
411
|
-
`Design Quality Score: ${result.score}/100`,
|
|
412
|
-
`Errors: ${result.errors} Warnings: ${result.warnings}`,
|
|
413
|
-
result.threshold > 0 ? `Threshold: ${result.threshold} — ${result.passed ? "PASS" : "FAIL"}` : "",
|
|
414
|
-
"",
|
|
415
|
-
].filter((l) => l !== "" || lines?.length === 0);
|
|
416
|
-
|
|
417
|
-
if (result.findings.length === 0) {
|
|
418
|
-
lines.push("No issues found.");
|
|
419
|
-
} else {
|
|
420
|
-
for (const f of result.findings) {
|
|
421
|
-
lines.push(`[${f.severity.toUpperCase()}] [${f.domain}] ${f.message}`);
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
return lines.join("\n");
|
|
426
|
-
}
|