@expressivecss/mcp-server 0.2.0
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/.claude-plugin/plugin.json +14 -0
- package/.cursor-plugin/plugin.json +25 -0
- package/CHANGELOG.md +19 -0
- package/LICENSE +101 -0
- package/README.md +240 -0
- package/capability-roadmap.json +3109 -0
- package/component-decisions.json +6950 -0
- package/component-guides.json +171 -0
- package/contract.json +27 -0
- package/lint.mjs +68 -0
- package/mcp.json +14 -0
- package/package.json +49 -0
- package/sample-hermes-config.yaml +13 -0
- package/scripts/resolve-version.mjs +736 -0
- package/semantics-data.json +1166 -0
- package/server.js +2380 -0
- package/smoke.mjs +1237 -0
package/server.js
ADDED
|
@@ -0,0 +1,2380 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, closeSync, constants as fsConstants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { lstat, open, readdir, readFile, realpath, stat } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { JSDOM } from 'jsdom';
|
|
9
|
+
import * as z from 'zod/v4';
|
|
10
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
11
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
12
|
+
import { resolveExpressiveVersion } from './scripts/resolve-version.mjs';
|
|
13
|
+
|
|
14
|
+
const SERVER_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const COMPONENT_DECISIONS = JSON.parse(readFileSync(path.join(SERVER_DIR, 'component-decisions.json'), 'utf8'));
|
|
16
|
+
const CAPABILITY_ROADMAP = JSON.parse(readFileSync(path.join(SERVER_DIR, 'capability-roadmap.json'), 'utf8'));
|
|
17
|
+
const SERVER_VERSION = JSON.parse(readFileSync(path.join(SERVER_DIR, 'package.json'), 'utf8')).version;
|
|
18
|
+
const CAPABILITIES_BY_SLUG = new Map(CAPABILITY_ROADMAP.entries.map((entry) => [entry.slug, entry]));
|
|
19
|
+
const COMPONENT_DECISIONS_BY_SLUG = new Map(COMPONENT_DECISIONS.components.map((entry) => [entry.slug, entry]));
|
|
20
|
+
const DEFAULT_MAX_COMPONENT_RESPONSE_CHARS = 24_000;
|
|
21
|
+
const DEFAULT_QA_MAX_FILES = 300;
|
|
22
|
+
const DEFAULT_QA_MAX_MB = 2;
|
|
23
|
+
const DEFAULT_QA_MAX_TOTAL_MB = 16;
|
|
24
|
+
const MAX_STATIC_ISSUES = 200;
|
|
25
|
+
const MAX_STATIC_ISSUES_PER_REQUEST = 1_000;
|
|
26
|
+
const MAX_STATIC_MARKUP_DELIMITERS = 4_000;
|
|
27
|
+
const STATIC_INSPECTION_TIMEOUT_MS = 5_000;
|
|
28
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
|
|
29
|
+
const MAX_PROJECT_ROOT_CHARS = 4_096;
|
|
30
|
+
const MAX_WORKFLOW_ID_CHARS = 256;
|
|
31
|
+
const MAX_SNIPPET_CHARS = 500_000;
|
|
32
|
+
const MAX_PROMPT_CHARS = 20_000;
|
|
33
|
+
const MAX_COMPONENT_NAME_CHARS = 120;
|
|
34
|
+
const MAX_FILE_PATH_CHARS = 4_096;
|
|
35
|
+
const MAX_CONTRACT_SOURCE_BYTES = 2 * 1024 * 1024;
|
|
36
|
+
const MAX_CONTRACT_TOTAL_BYTES = 8 * 1024 * 1024;
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
function configuredCommandRoots(value) {
|
|
40
|
+
if (!value?.trim()) return [];
|
|
41
|
+
const trimmed = value.trim();
|
|
42
|
+
if (trimmed.startsWith('[')) {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(trimmed);
|
|
45
|
+
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === 'string' && entry.trim()) : [];
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return trimmed.split(path.delimiter).map((entry) => entry.trim()).filter(Boolean);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const QUALITY_SCRIPTS = ['typecheck', 'test', 'verify:expressivecss'];
|
|
54
|
+
// Trusted server configuration can only narrow the built-in list. Invalid input denies all.
|
|
55
|
+
function configuredScripts(value) {
|
|
56
|
+
if (value === undefined) return QUALITY_SCRIPTS;
|
|
57
|
+
try {
|
|
58
|
+
const names = JSON.parse(value);
|
|
59
|
+
return Array.isArray(names) && names.every(name => QUALITY_SCRIPTS.includes(name)) ? [...new Set(names)] : [];
|
|
60
|
+
} catch { return []; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const SETTINGS = {
|
|
64
|
+
maxComponentResponseChars: Number(process.env.EXPRESSIVECSS_MCP_MAX_COMPONENT_RESPONSE_CHARS || DEFAULT_MAX_COMPONENT_RESPONSE_CHARS),
|
|
65
|
+
maxComponentSkips: Number(process.env.EXPRESSIVECSS_MCP_MAX_COMPONENT_SKIPS || 7),
|
|
66
|
+
qaMaxFiles: Number(process.env.EXPRESSIVECSS_MCP_QA_MAX_FILES || DEFAULT_QA_MAX_FILES),
|
|
67
|
+
qaMaxMb: Number(process.env.EXPRESSIVECSS_MCP_QA_MAX_MB || DEFAULT_QA_MAX_MB),
|
|
68
|
+
qaMaxTotalMb: Number(process.env.EXPRESSIVECSS_MCP_QA_MAX_TOTAL_MB || DEFAULT_QA_MAX_TOTAL_MB),
|
|
69
|
+
commandTimeoutMs: Number(process.env.EXPRESSIVECSS_MCP_COMMAND_TIMEOUT_MS || DEFAULT_COMMAND_TIMEOUT_MS),
|
|
70
|
+
allowedScripts: configuredScripts(process.env.EXPRESSIVECSS_MCP_ALLOWED_SCRIPTS),
|
|
71
|
+
allowedCommandRoots: configuredCommandRoots(process.env.EXPRESSIVECSS_MCP_ALLOWED_COMMAND_ROOTS),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const SKIP_FLAGS = {
|
|
75
|
+
setupExpert: 'SKIP_SETUP_EXPERT',
|
|
76
|
+
rulesEnforcer: 'SKIP_RULES_ENFORCER',
|
|
77
|
+
creativeDirector: 'SKIP_CREATIVE_DIRECTOR',
|
|
78
|
+
pageArchitect: 'SKIP_PAGE_ARCHITECT',
|
|
79
|
+
componentSyntaxExpert: 'SKIP_COMPONENT_SYNTAX_EXPERT',
|
|
80
|
+
qualityInspector: 'SKIP_QUALITY_INSPECTOR',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const LegacyPatternList = [
|
|
84
|
+
{
|
|
85
|
+
id: 'legacy-btn-class',
|
|
86
|
+
severity: 'high',
|
|
87
|
+
description: 'Replace `.btn` with the ExpressiveCSS button contract (`<button>`, `.button`, or component-specific button classes).',
|
|
88
|
+
pattern: /\bclass(?:Name)?\s*=\s*(?:"[^"]*\bbtn\b[^"]*"|'[^']*\bbtn\b[^']*'|`[^`]*\bbtn\b[^`]*`|\{\s*(?:"[^"]*\bbtn\b[^"]*"|'[^']*\bbtn\b[^']*'|`[^`]*\bbtn\b[^`]*`)\s*\}|btn(?=[\s>]))/gu,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: 'legacy-card-content',
|
|
92
|
+
severity: 'high',
|
|
93
|
+
description: '`.card-content` was removed; use the component’s documented child structure.',
|
|
94
|
+
pattern: /\bcard-content\b/g,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: 'legacy-nav-wrapper',
|
|
98
|
+
severity: 'high',
|
|
99
|
+
description: '`.nav-wrapper` is legacy Materialize structure; use ExpressiveCSS nav components directly.',
|
|
100
|
+
pattern: /\bnav-wrapper\b/g,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'legacy-brand-logo',
|
|
104
|
+
severity: 'medium',
|
|
105
|
+
description: '`.brand-logo` is Materialize-era naming; prefer native layout semantics in ExpressiveCSS pages.',
|
|
106
|
+
pattern: /\bbrand-logo\b/g,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'legacy-lever',
|
|
110
|
+
severity: 'medium',
|
|
111
|
+
description: '`.lever` is a Materialize switch token, replace with Expressive switches per component guide.',
|
|
112
|
+
pattern: /\blever\b/g,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: 'legacy-filled-in',
|
|
116
|
+
severity: 'medium',
|
|
117
|
+
description: '`.filled-in` is legacy checkbox styling; use ExpressiveCSS checkbox component markup and classes.',
|
|
118
|
+
pattern: /\bfilled-in\b/g,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'legacy-materialized-name',
|
|
122
|
+
severity: 'medium',
|
|
123
|
+
description: 'Avoid `el["M_"]` / `window.M` instance patterns. Use `el["Expressive_<Component>"]` names.',
|
|
124
|
+
pattern: /\bel\[['"]M_[A-Za-z_]+['"]\]|\bwindow\.M\b/g,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: 'legacy-input-field',
|
|
128
|
+
severity: 'high',
|
|
129
|
+
description: '`.input-field` is retired; use the ExpressiveCSS `.field` contract.',
|
|
130
|
+
pattern: /\binput-field\b/g,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: 'legacy-materialize-textarea',
|
|
134
|
+
severity: 'high',
|
|
135
|
+
description: '`.materialize-textarea` is retired; use `.expressive-textarea`.',
|
|
136
|
+
pattern: /\bmaterialize-textarea\b/g,
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: 'raw-color-in-component-style',
|
|
140
|
+
severity: 'medium',
|
|
141
|
+
description: 'Use a Material semantic color role instead of a raw color in component declarations. Theme seed and token definitions are separate concerns.',
|
|
142
|
+
pattern: /(?<![-\w])(?:color|background(?:-color)?|border(?:-(?:top|right|bottom|left))?(?:-color)?|outline-color|fill|stroke)\s*:\s*#[0-9a-fA-F]{3,8}\b/g,
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
const TOOL_DESCRIPTIONS = {
|
|
148
|
+
setup_expert: {
|
|
149
|
+
stage: 'Setup Expert',
|
|
150
|
+
description: 'Onboard a project and verify ExpressiveCSS baseline requirements.',
|
|
151
|
+
},
|
|
152
|
+
rules_enforcer: {
|
|
153
|
+
stage: 'Rules Enforcer',
|
|
154
|
+
description: 'Validate markup and authoring invariants before moving to design and implementation.',
|
|
155
|
+
},
|
|
156
|
+
creative_director: {
|
|
157
|
+
stage: 'Creative Director',
|
|
158
|
+
description: 'Choose components and patterns that best match stated UX goals.',
|
|
159
|
+
},
|
|
160
|
+
page_architect: {
|
|
161
|
+
stage: 'Page Architect',
|
|
162
|
+
description: 'Propose landmarked page architecture, layout, and content flow.',
|
|
163
|
+
},
|
|
164
|
+
page_arcjitect: {
|
|
165
|
+
stage: 'Page Arcjitect',
|
|
166
|
+
description: 'Page Arcjitect spelling used by the requested workflow; equivalent to Page Architect.',
|
|
167
|
+
},
|
|
168
|
+
component_syntax_expert: {
|
|
169
|
+
stage: 'Component Syntax Expert',
|
|
170
|
+
description: 'Return component syntax and constraints, plus scoped Material capability evidence. Request typography, shape, or motion through foundations.',
|
|
171
|
+
},
|
|
172
|
+
quality_inspector: {
|
|
173
|
+
stage: 'Quality Inspector',
|
|
174
|
+
description: 'Inspect selected files and optionally execute configured project scripts. Scripts may write files or access services; results are scoped evidence, not design approval.',
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const setupSchema = {
|
|
179
|
+
projectRoot: z.string().max(MAX_PROJECT_ROOT_CHARS).optional(),
|
|
180
|
+
themes: z.boolean().default(false),
|
|
181
|
+
colors: z.boolean().default(false),
|
|
182
|
+
installHint: z.boolean().default(false),
|
|
183
|
+
workflowId: z.string().max(MAX_WORKFLOW_ID_CHARS).optional(),
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const rulesSchema = {
|
|
187
|
+
projectRoot: z.string().max(MAX_PROJECT_ROOT_CHARS).optional(),
|
|
188
|
+
snippet: z.string().max(MAX_SNIPPET_CHARS),
|
|
189
|
+
targetComponents: z.array(z.string().max(MAX_COMPONENT_NAME_CHARS)).max(12).optional(),
|
|
190
|
+
workflowId: z.string().max(MAX_WORKFLOW_ID_CHARS).optional(),
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const creativeSchema = {
|
|
194
|
+
projectRoot: z.string().max(MAX_PROJECT_ROOT_CHARS).optional(),
|
|
195
|
+
goal: z.string().min(10).max(MAX_PROMPT_CHARS),
|
|
196
|
+
constraints: z.string().max(MAX_PROMPT_CHARS).optional(),
|
|
197
|
+
maxSuggestions: z.number().int().min(1).max(12).default(7),
|
|
198
|
+
workflowId: z.string().max(MAX_WORKFLOW_ID_CHARS).optional(),
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const pageArchitectSchema = {
|
|
202
|
+
projectRoot: z.string().max(MAX_PROJECT_ROOT_CHARS).optional(),
|
|
203
|
+
pageGoal: z.string().min(8).max(MAX_PROMPT_CHARS),
|
|
204
|
+
components: z.array(z.string().max(MAX_COMPONENT_NAME_CHARS)).max(12).default([]),
|
|
205
|
+
viewportTarget: z.enum(['compact', 'medium', 'expanded', 'large', 'extra-large', 'responsive']).default('responsive'),
|
|
206
|
+
includeAccessibility: z.boolean().default(true),
|
|
207
|
+
workflowId: z.string().max(MAX_WORKFLOW_ID_CHARS).optional(),
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const syntaxSchema = {
|
|
211
|
+
projectRoot: z.string().max(MAX_PROJECT_ROOT_CHARS).optional(),
|
|
212
|
+
components: z.array(z.string().max(MAX_COMPONENT_NAME_CHARS)).max(12).default([]),
|
|
213
|
+
foundations: z.array(z.enum(['typography', 'shape', 'motion'])).max(3).default([]),
|
|
214
|
+
workflowId: z.string().max(MAX_WORKFLOW_ID_CHARS).optional(),
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
|
218
|
+
const inspectionEvidenceSchema = z.object({
|
|
219
|
+
algorithm: z.literal('sha256'),
|
|
220
|
+
files: z.array(z.object({ file: z.string(), sha256: digestSchema, bytes: z.number().int().nonnegative() })),
|
|
221
|
+
expectedMatched: z.boolean().nullable(),
|
|
222
|
+
inputsUnchanged: z.boolean(),
|
|
223
|
+
commandManifestSha256: digestSchema.nullable(),
|
|
224
|
+
scope: z.string(),
|
|
225
|
+
});
|
|
226
|
+
// Preserve tool-specific fields while validating the shared evidence envelope.
|
|
227
|
+
const stageOutputSchema = z.looseObject({
|
|
228
|
+
workflowId: z.string(), stage: z.string(),
|
|
229
|
+
checksPerformed: z.array(z.string()), evidenceSources: z.array(z.string()),
|
|
230
|
+
uncheckedAreas: z.array(z.string()), blockedChecks: z.array(z.string()),
|
|
231
|
+
contractCompatibility: z.string(), contractProvenance: z.string(), coverageStatus: z.string(),
|
|
232
|
+
});
|
|
233
|
+
const qualityOutputSchema = stageOutputSchema.extend({
|
|
234
|
+
status: z.enum(['pass', 'warn', 'blocked', 'needs_fix']),
|
|
235
|
+
inspectionEvidence: inspectionEvidenceSchema.optional(), // Disabled tools return the shared blocked envelope.
|
|
236
|
+
});
|
|
237
|
+
const readAnnotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
238
|
+
|
|
239
|
+
const inspectSchema = {
|
|
240
|
+
projectRoot: z.string().max(MAX_PROJECT_ROOT_CHARS).optional(),
|
|
241
|
+
files: z.array(z.string().max(MAX_FILE_PATH_CHARS)).max(DEFAULT_QA_MAX_FILES).default([]),
|
|
242
|
+
runType: z.enum(['quick', 'standard', 'full', 'consumer']).default('quick'),
|
|
243
|
+
runCommands: z.boolean().default(false),
|
|
244
|
+
expectedSourceHashes: z.record(z.string().min(1).max(MAX_FILE_PATH_CHARS), digestSchema).refine(value => Object.keys(value).length <= DEFAULT_QA_MAX_FILES, 'Too many source pins').optional(),
|
|
245
|
+
workflowId: z.string().max(MAX_WORKFLOW_ID_CHARS).optional(),
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
function parseCliProjectRoot() {
|
|
249
|
+
const args = process.argv.slice(2);
|
|
250
|
+
const explicit = args.find((arg) => arg.startsWith('--project-root='));
|
|
251
|
+
if (explicit) {
|
|
252
|
+
return path.resolve(explicit.split('=')[1] || process.cwd());
|
|
253
|
+
}
|
|
254
|
+
return resolveRepoRoot(process.cwd());
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function resolveProjectRoot(projectRoot) {
|
|
258
|
+
return projectRoot ? path.resolve(projectRoot) : parseCliProjectRoot();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function resolveRepoRoot(startDir) {
|
|
262
|
+
let current = path.resolve(startDir);
|
|
263
|
+
for (let i = 0; i < 8; i++) {
|
|
264
|
+
const pkgPath = path.join(current, 'package.json');
|
|
265
|
+
const skillPath = path.join(current, 'skills', 'expressivecss', 'components');
|
|
266
|
+
const navPath = path.join(current, 'docs', 'src', 'data', 'nav.ts');
|
|
267
|
+
|
|
268
|
+
if (existsSync(pkgPath) && existsSync(skillPath) && existsSync(navPath)) {
|
|
269
|
+
return current;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const parent = path.dirname(current);
|
|
273
|
+
if (parent === current) {
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
current = parent;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return path.resolve(startDir);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function normalizeForMatch(value) {
|
|
283
|
+
return (value || '').toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-+|-+$/gu, '');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function extractSection(text, heading) {
|
|
287
|
+
const marker = `#### ${heading}`;
|
|
288
|
+
const start = text.indexOf(marker);
|
|
289
|
+
if (start === -1) {
|
|
290
|
+
return '';
|
|
291
|
+
}
|
|
292
|
+
const body = text.slice(start + marker.length);
|
|
293
|
+
const next = body.indexOf('\n#### ');
|
|
294
|
+
return (next === -1 ? body : body.slice(0, next)).trim();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function extractTitle(text) {
|
|
298
|
+
const match = text.match(/^###\s*(.+)$/m);
|
|
299
|
+
return match ? match[1].trim() : 'Component';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function extractFirstCodeBlock(text) {
|
|
303
|
+
const fenced = text.match(/```(?:[a-z0-9+.-]+)?\n([\s\S]*?)```/i);
|
|
304
|
+
if (!fenced) return '';
|
|
305
|
+
return fenced[1].trim();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function extractRules(text) {
|
|
309
|
+
if (!text) {
|
|
310
|
+
return [];
|
|
311
|
+
}
|
|
312
|
+
const lines = text.split('\n');
|
|
313
|
+
const rules = [];
|
|
314
|
+
for (const line of lines) {
|
|
315
|
+
const trimmed = line.trim();
|
|
316
|
+
if (trimmed.startsWith('- ')) {
|
|
317
|
+
rules.push(trimmed.replace(/^-\s*/, '').trim());
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return rules;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function clampText(input, maxChars) {
|
|
324
|
+
const text = input?.toString() ?? '';
|
|
325
|
+
if (text.length <= maxChars) {
|
|
326
|
+
return text;
|
|
327
|
+
}
|
|
328
|
+
return `${text.slice(0, Math.max(0, maxChars - 40))}…(truncated)`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function toToolResult(payload) {
|
|
332
|
+
return {
|
|
333
|
+
content: [{
|
|
334
|
+
type: 'text',
|
|
335
|
+
text: JSON.stringify(payload, null, 2),
|
|
336
|
+
}],
|
|
337
|
+
structuredContent: payload,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function buildStagePayload(tool, result, workflowId) {
|
|
342
|
+
return {
|
|
343
|
+
workflowId: workflowId || randomUUID().slice(0, 12),
|
|
344
|
+
stage: tool,
|
|
345
|
+
...result,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function skipTool(toolName) {
|
|
350
|
+
const envKey = SKIP_FLAGS[toolName];
|
|
351
|
+
if (!envKey) {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
return process.env[envKey] === 'true';
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function skippedStage(tool, envKey, workflowId) {
|
|
358
|
+
return toToolResult(buildStagePayload(
|
|
359
|
+
tool,
|
|
360
|
+
{
|
|
361
|
+
skipped: true,
|
|
362
|
+
status: 'blocked',
|
|
363
|
+
message: `${TOOL_DESCRIPTIONS[tool].stage} is disabled by ${envKey}.`,
|
|
364
|
+
checksPerformed: [],
|
|
365
|
+
evidenceSources: [],
|
|
366
|
+
uncheckedAreas: ['all requested checks'],
|
|
367
|
+
contractCompatibility: 'unknown',
|
|
368
|
+
contractProvenance: 'unknown',
|
|
369
|
+
coverageStatus: 'skipped',
|
|
370
|
+
blockedChecks: [`${tool} disabled`],
|
|
371
|
+
},
|
|
372
|
+
workflowId,
|
|
373
|
+
));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function tokenize(value) {
|
|
377
|
+
return value
|
|
378
|
+
.toLowerCase()
|
|
379
|
+
.split(/[^a-z0-9]+/gu)
|
|
380
|
+
.map((token) => token.trim())
|
|
381
|
+
.filter((token) => token.length > 2);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function resolveGuideDirectory(projectRoot) {
|
|
385
|
+
let packageJson = null;
|
|
386
|
+
try {
|
|
387
|
+
const manifest = await readInspectionFile(path.join(projectRoot, 'package.json'), projectRoot, 1 * 1024 * 1024);
|
|
388
|
+
packageJson = JSON.parse(manifest.text);
|
|
389
|
+
} catch {}
|
|
390
|
+
if (packageJson?.name !== '@expressivecss/expressive') {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
const candidate = path.join(projectRoot, 'skills', 'expressivecss', 'components');
|
|
394
|
+
return existsSync(candidate) ? candidate : null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function parseGuide(file, content) {
|
|
398
|
+
const slug = file.replace(/\.md$/u, '');
|
|
399
|
+
const title = extractTitle(content);
|
|
400
|
+
const contract = extractSection(content, 'Contract');
|
|
401
|
+
const syntax = extractSection(content, 'Syntax');
|
|
402
|
+
const rules = extractRules(extractSection(content, 'Rules'));
|
|
403
|
+
const syntaxCode = extractFirstCodeBlock(syntax);
|
|
404
|
+
const syntaxLangMatch = syntax.match(/```\s*([a-z0-9+.-]+)/i);
|
|
405
|
+
const syntaxLanguage = syntaxLangMatch ? syntaxLangMatch[1].toLowerCase() : 'html';
|
|
406
|
+
const docsMatch = content.match(/\[Component documentation\]\(([^\)]+)\)/);
|
|
407
|
+
const repoMatch = content.match(/https:\/\/github\.com\/BaezFJ\/ExpressiveCSS\/blob\/master\/docs\/src\/pages\/([^\)\s]+)\.astro/);
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
file,
|
|
411
|
+
slug,
|
|
412
|
+
title,
|
|
413
|
+
contract: clampText(contract, 1_000),
|
|
414
|
+
syntax: {
|
|
415
|
+
language: syntaxLanguage,
|
|
416
|
+
code: clampText(syntaxCode, Math.max(1_200, Math.floor(SETTINGS.maxComponentResponseChars / 2))),
|
|
417
|
+
},
|
|
418
|
+
rules: rules.length ? rules : [
|
|
419
|
+
'Follow the component contract in the full documentation before adding optional attributes.',
|
|
420
|
+
'Keep runtime-owned state in framework initialization, not in static markup values.',
|
|
421
|
+
],
|
|
422
|
+
sourceUrl: docsMatch ? docsMatch[1] : null,
|
|
423
|
+
astroSource: repoMatch ? `https://github.com/BaezFJ/ExpressiveCSS/blob/master/docs/src/pages/${repoMatch[1]}.astro` : null,
|
|
424
|
+
text: `${title}\n${contract}\n${rules.join('\n')}`.toLowerCase(),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function sameFileIdentity(left, right) {
|
|
429
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function readHandleBounded(handle, maxBytes) {
|
|
433
|
+
const chunks = [];
|
|
434
|
+
let totalBytes = 0;
|
|
435
|
+
while (totalBytes <= maxBytes) {
|
|
436
|
+
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, (maxBytes + 1) - totalBytes));
|
|
437
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
|
|
438
|
+
if (bytesRead === 0) break;
|
|
439
|
+
chunks.push(buffer.subarray(0, bytesRead));
|
|
440
|
+
totalBytes += bytesRead;
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
content: totalBytes > maxBytes ? null : Buffer.concat(chunks, totalBytes),
|
|
444
|
+
totalBytes,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function verifyLocalContractProvenance(projectRoot, contract, canonicalSources) {
|
|
449
|
+
if (!contract) {
|
|
450
|
+
return { status: 'missing', expectedHash: null, computedHash: null, missingSources: [] };
|
|
451
|
+
}
|
|
452
|
+
if (
|
|
453
|
+
!Array.isArray(canonicalSources)
|
|
454
|
+
|| !Array.isArray(contract.sources)
|
|
455
|
+
|| contract.sources.length !== canonicalSources.length
|
|
456
|
+
|| contract.sources.some((source, index) => source !== canonicalSources[index])
|
|
457
|
+
|| !/^[a-f0-9]{64}$/u.test(contract.sourceHash ?? '')
|
|
458
|
+
) {
|
|
459
|
+
return { status: 'invalid', expectedHash: contract.sourceHash ?? null, computedHash: null, missingSources: [] };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const root = await realpath(path.resolve(projectRoot)).catch(() => null);
|
|
463
|
+
if (!root) {
|
|
464
|
+
return { status: 'invalid', expectedHash: contract.sourceHash, computedHash: null, missingSources: [] };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const missingSources = [];
|
|
468
|
+
const validatedSources = [];
|
|
469
|
+
let aggregateBytes = 0;
|
|
470
|
+
for (const source of contract.sources) {
|
|
471
|
+
const sourcePath = path.resolve(root, source);
|
|
472
|
+
if (!isPathInside(root, sourcePath)) {
|
|
473
|
+
return { status: 'invalid', expectedHash: contract.sourceHash, computedHash: null, missingSources };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
let current = root;
|
|
477
|
+
let sourceStat = null;
|
|
478
|
+
for (const segment of source.split('/')) {
|
|
479
|
+
current = path.join(current, segment);
|
|
480
|
+
sourceStat = await lstat(current).catch(() => null);
|
|
481
|
+
if (!sourceStat) break;
|
|
482
|
+
if (sourceStat.isSymbolicLink()) {
|
|
483
|
+
return { status: 'invalid', expectedHash: contract.sourceHash, computedHash: null, missingSources };
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (!sourceStat) {
|
|
487
|
+
missingSources.push(source);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const resolvedSourcePath = await realpath(sourcePath).catch(() => null);
|
|
491
|
+
if (!resolvedSourcePath || !isPathInside(root, resolvedSourcePath) || !sourceStat.isFile()) {
|
|
492
|
+
return { status: 'invalid', expectedHash: contract.sourceHash, computedHash: null, missingSources };
|
|
493
|
+
}
|
|
494
|
+
if (sourceStat.size > MAX_CONTRACT_SOURCE_BYTES) {
|
|
495
|
+
return { status: 'invalid', expectedHash: contract.sourceHash, computedHash: null, missingSources };
|
|
496
|
+
}
|
|
497
|
+
aggregateBytes += sourceStat.size;
|
|
498
|
+
if (aggregateBytes > MAX_CONTRACT_TOTAL_BYTES) {
|
|
499
|
+
return { status: 'invalid', expectedHash: contract.sourceHash, computedHash: null, missingSources };
|
|
500
|
+
}
|
|
501
|
+
validatedSources.push({ source, sourcePath: resolvedSourcePath, sourceStat });
|
|
502
|
+
}
|
|
503
|
+
if (missingSources.length) {
|
|
504
|
+
return { status: 'missing', expectedHash: contract.sourceHash, computedHash: null, missingSources };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const hash = createHash('sha256');
|
|
508
|
+
aggregateBytes = 0;
|
|
509
|
+
for (const { source, sourcePath, sourceStat } of validatedSources) {
|
|
510
|
+
const handle = await open(sourcePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW).catch(() => null);
|
|
511
|
+
if (!handle) {
|
|
512
|
+
return { status: 'invalid', expectedHash: contract.sourceHash, computedHash: null, missingSources };
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
const openedStat = await handle.stat();
|
|
516
|
+
const currentPathStat = await lstat(sourcePath).catch(() => null);
|
|
517
|
+
const currentResolvedPath = await realpath(sourcePath).catch(() => null);
|
|
518
|
+
const openedResolvedPath = await realpath(`/proc/self/fd/${handle.fd}`).catch(() => currentResolvedPath);
|
|
519
|
+
if (
|
|
520
|
+
!openedStat.isFile()
|
|
521
|
+
|| !currentPathStat?.isFile()
|
|
522
|
+
|| currentPathStat.isSymbolicLink()
|
|
523
|
+
|| !currentResolvedPath
|
|
524
|
+
|| !openedResolvedPath
|
|
525
|
+
|| !isPathInside(root, currentResolvedPath)
|
|
526
|
+
|| currentResolvedPath !== sourcePath
|
|
527
|
+
|| openedResolvedPath !== sourcePath
|
|
528
|
+
|| !sameFileIdentity(sourceStat, openedStat)
|
|
529
|
+
|| !sameFileIdentity(openedStat, currentPathStat)
|
|
530
|
+
|| openedStat.size > MAX_CONTRACT_SOURCE_BYTES
|
|
531
|
+
) {
|
|
532
|
+
return { status: 'invalid', expectedHash: contract.sourceHash, computedHash: null, missingSources };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const remainingAggregateBytes = Math.max(0, MAX_CONTRACT_TOTAL_BYTES - aggregateBytes);
|
|
536
|
+
const bounded = await readHandleBounded(handle, Math.min(MAX_CONTRACT_SOURCE_BYTES, remainingAggregateBytes));
|
|
537
|
+
aggregateBytes += bounded.totalBytes;
|
|
538
|
+
const finalStat = await handle.stat();
|
|
539
|
+
if (
|
|
540
|
+
!bounded.content
|
|
541
|
+
|| aggregateBytes > MAX_CONTRACT_TOTAL_BYTES
|
|
542
|
+
|| !sameFileIdentity(openedStat, finalStat)
|
|
543
|
+
|| openedStat.size !== finalStat.size
|
|
544
|
+
|| openedStat.mtimeMs !== finalStat.mtimeMs
|
|
545
|
+
|| openedStat.ctimeMs !== finalStat.ctimeMs
|
|
546
|
+
) {
|
|
547
|
+
return { status: 'invalid', expectedHash: contract.sourceHash, computedHash: null, missingSources };
|
|
548
|
+
}
|
|
549
|
+
hash.update(`${source}\0${bounded.content.toString('utf8')}\0`);
|
|
550
|
+
} finally {
|
|
551
|
+
await handle.close();
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const computedHash = hash.digest('hex');
|
|
555
|
+
return {
|
|
556
|
+
status: computedHash === contract.sourceHash ? 'verified' : 'stale',
|
|
557
|
+
expectedHash: contract.sourceHash,
|
|
558
|
+
computedHash,
|
|
559
|
+
missingSources,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function provenanceBlockReason(provenanceStatus) {
|
|
564
|
+
if (provenanceStatus === 'verified' || provenanceStatus === 'bundled-verified') return null;
|
|
565
|
+
return `local contract provenance is ${provenanceStatus}`;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function loadGuideCatalog(projectRoot) {
|
|
569
|
+
if (!bundledGuideCache) {
|
|
570
|
+
const bundledPath = path.join(SERVER_DIR, 'component-guides.json');
|
|
571
|
+
const bundled = JSON.parse(await readFile(bundledPath, 'utf8'));
|
|
572
|
+
if (bundled.schemaVersion !== 1 || !bundled.frameworkVersion || !Array.isArray(bundled.guides)) {
|
|
573
|
+
throw new Error(`Bundled ExpressiveCSS component guide data is invalid at ${bundledPath}`);
|
|
574
|
+
}
|
|
575
|
+
const bundledContractRead = await readInspectionFile(
|
|
576
|
+
path.join(SERVER_DIR, 'contract.json'),
|
|
577
|
+
SERVER_DIR,
|
|
578
|
+
MAX_CONTRACT_SOURCE_BYTES,
|
|
579
|
+
);
|
|
580
|
+
const bundledContract = JSON.parse(bundledContractRead.text);
|
|
581
|
+
const components = new Map();
|
|
582
|
+
for (const entry of bundled.guides) {
|
|
583
|
+
const guide = parseGuide(entry.file, entry.content);
|
|
584
|
+
components.set(guide.slug, guide);
|
|
585
|
+
}
|
|
586
|
+
bundledGuideCache = {
|
|
587
|
+
frameworkVersion: bundledContract?.frameworkVersion ?? bundled.frameworkVersion ?? null,
|
|
588
|
+
sourceHash: bundledContract?.sourceHash ?? bundled.sourceHash ?? null,
|
|
589
|
+
contractSources: bundledContract?.sources ?? [],
|
|
590
|
+
count: components.size,
|
|
591
|
+
components,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
let provenance;
|
|
596
|
+
|
|
597
|
+
const guideDir = await resolveGuideDirectory(projectRoot);
|
|
598
|
+
const dirStat = guideDir ? await stat(guideDir).catch(() => null) : null;
|
|
599
|
+
const projectResolution = await resolveExpressiveVersion({
|
|
600
|
+
projectRoot,
|
|
601
|
+
contractVersion: bundledGuideCache.frameworkVersion,
|
|
602
|
+
});
|
|
603
|
+
const isFrameworkSource = projectResolution.resolutionSource === 'framework-source';
|
|
604
|
+
if (dirStat?.isDirectory()) {
|
|
605
|
+
const localContractPath = path.join(projectRoot, 'skills', 'expressivecss', 'references', 'contract.json');
|
|
606
|
+
const localContractRead = await readInspectionFile(
|
|
607
|
+
localContractPath,
|
|
608
|
+
projectRoot,
|
|
609
|
+
MAX_CONTRACT_SOURCE_BYTES,
|
|
610
|
+
).catch(() => null);
|
|
611
|
+
let localContract = null;
|
|
612
|
+
try {
|
|
613
|
+
localContract = localContractRead ? JSON.parse(localContractRead.text) : null;
|
|
614
|
+
} catch {
|
|
615
|
+
localContract = null;
|
|
616
|
+
}
|
|
617
|
+
provenance = await verifyLocalContractProvenance(
|
|
618
|
+
projectRoot,
|
|
619
|
+
localContract,
|
|
620
|
+
bundledGuideCache.contractSources,
|
|
621
|
+
);
|
|
622
|
+
const packageCompatible = localContract?.frameworkVersion === bundledGuideCache.frameworkVersion
|
|
623
|
+
&& localContract?.sourceHash === bundledGuideCache.sourceHash;
|
|
624
|
+
provenance = {
|
|
625
|
+
...provenance,
|
|
626
|
+
packageExpectedVersion: bundledGuideCache.frameworkVersion,
|
|
627
|
+
packageExpectedHash: bundledGuideCache.sourceHash,
|
|
628
|
+
...(provenance.status === 'verified' && !packageCompatible ? { status: 'divergent' } : {}),
|
|
629
|
+
};
|
|
630
|
+
} else if (isFrameworkSource) {
|
|
631
|
+
provenance = {
|
|
632
|
+
status: 'missing',
|
|
633
|
+
expectedHash: bundledGuideCache.sourceHash,
|
|
634
|
+
computedHash: null,
|
|
635
|
+
missingSources: ['skills/expressivecss/components'],
|
|
636
|
+
packageExpectedVersion: bundledGuideCache.frameworkVersion,
|
|
637
|
+
packageExpectedHash: bundledGuideCache.sourceHash,
|
|
638
|
+
};
|
|
639
|
+
} else {
|
|
640
|
+
provenance = {
|
|
641
|
+
status: 'bundled-verified',
|
|
642
|
+
expectedHash: bundledGuideCache.sourceHash,
|
|
643
|
+
computedHash: bundledGuideCache.sourceHash,
|
|
644
|
+
missingSources: [],
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
return {
|
|
649
|
+
generatedAt: new Date().toISOString(),
|
|
650
|
+
projectRoot: path.resolve(projectRoot),
|
|
651
|
+
frameworkVersion: bundledGuideCache.frameworkVersion,
|
|
652
|
+
sourceHash: bundledGuideCache.sourceHash,
|
|
653
|
+
provenance,
|
|
654
|
+
guideSource: 'bundled',
|
|
655
|
+
count: bundledGuideCache.count,
|
|
656
|
+
components: bundledGuideCache.components,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function findGuideByName(catalog, rawName, allowFuzzy = true) {
|
|
661
|
+
if (!rawName) {
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const canonical = normalizeForMatch(rawName);
|
|
666
|
+
const direct = catalog.components.get(canonical);
|
|
667
|
+
if (direct) {
|
|
668
|
+
return direct;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
for (const [slug, guide] of catalog.components.entries()) {
|
|
672
|
+
const titleSlug = normalizeForMatch(guide.title);
|
|
673
|
+
const fileSlug = normalizeForMatch(slug);
|
|
674
|
+
if (titleSlug === canonical || fileSlug === canonical) {
|
|
675
|
+
return guide;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (!allowFuzzy) {
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Fuzzy fallback by token overlap.
|
|
684
|
+
const tokens = new Set(tokenize(canonical));
|
|
685
|
+
const scores = [];
|
|
686
|
+
for (const guide of catalog.components.values()) {
|
|
687
|
+
let score = 0;
|
|
688
|
+
for (const token of tokens) {
|
|
689
|
+
if (guide.text.includes(token)) {
|
|
690
|
+
score += 1;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
if (score > 0) {
|
|
694
|
+
scores.push({score, guide});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (!scores.length) {
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
scores.sort((a, b) => b.score - a.score);
|
|
703
|
+
return scores[0].guide;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function nearestMatches(catalog, token, limit = 5) {
|
|
707
|
+
const normalized = normalizeForMatch(token);
|
|
708
|
+
const scored = [];
|
|
709
|
+
for (const guide of catalog.components.values()) {
|
|
710
|
+
const slug = normalizeForMatch(guide.slug);
|
|
711
|
+
const title = normalizeForMatch(guide.title);
|
|
712
|
+
let score = 0;
|
|
713
|
+
if (slug.includes(normalized) || title.includes(normalized)) {
|
|
714
|
+
score += 3;
|
|
715
|
+
}
|
|
716
|
+
for (const t of tokenize(guide.text)) {
|
|
717
|
+
if (normalized.includes(t) || t.includes(normalized)) {
|
|
718
|
+
score += 1;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
if (score > 0) {
|
|
722
|
+
scored.push({ score, slug: guide.slug, title: guide.title });
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
scored.sort((a, b) => b.score - a.score);
|
|
726
|
+
return scored.slice(0, limit);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
async function projectSummary(projectRoot) {
|
|
730
|
+
const summary = {
|
|
731
|
+
projectRoot,
|
|
732
|
+
isExpressiveProject: false,
|
|
733
|
+
dependency: null,
|
|
734
|
+
packageManager: null,
|
|
735
|
+
installGuide: null,
|
|
736
|
+
foundDocs: false,
|
|
737
|
+
foundSkills: false,
|
|
738
|
+
bundledGuides: existsSync(path.join(SERVER_DIR, 'component-guides.json')),
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
const packagePath = path.join(projectRoot, 'package.json');
|
|
742
|
+
const lockFiles = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'].map((f) => path.join(projectRoot, f));
|
|
743
|
+
|
|
744
|
+
if (lockFiles.some((p) => existsSync(p))) {
|
|
745
|
+
summary.packageManager = lockFiles.find((p) => existsSync(p)).endsWith('package-lock.json')
|
|
746
|
+
? 'npm'
|
|
747
|
+
: lockFiles.find((p) => existsSync(p)).endsWith('yarn.lock')
|
|
748
|
+
? 'yarn'
|
|
749
|
+
: 'pnpm';
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
let packageJson = null;
|
|
753
|
+
try {
|
|
754
|
+
const manifest = await readInspectionFile(packagePath, projectRoot, 1 * 1024 * 1024);
|
|
755
|
+
packageJson = JSON.parse(manifest.text);
|
|
756
|
+
} catch {}
|
|
757
|
+
if (packageJson && typeof packageJson === 'object') {
|
|
758
|
+
if (packageJson.name === '@expressivecss/expressive') {
|
|
759
|
+
summary.isExpressiveProject = true;
|
|
760
|
+
summary.dependency = {
|
|
761
|
+
version: packageJson.version,
|
|
762
|
+
dependencyKind: 'framework source',
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
const deps = {
|
|
766
|
+
...(packageJson.dependencies || {}),
|
|
767
|
+
...(packageJson.devDependencies || {}),
|
|
768
|
+
};
|
|
769
|
+
const expressive = deps['@expressivecss/expressive'];
|
|
770
|
+
if (expressive && !summary.isExpressiveProject) {
|
|
771
|
+
summary.isExpressiveProject = true;
|
|
772
|
+
summary.dependency = {
|
|
773
|
+
version: expressive,
|
|
774
|
+
dependencyKind: (packageJson.dependencies || {})['@expressivecss/expressive'] ? 'dependency' : 'devDependency',
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
summary.foundDocs = existsSync(path.join(projectRoot, 'docs', 'src', 'data', 'nav.ts'));
|
|
780
|
+
summary.foundSkills = existsSync(path.join(projectRoot, 'skills', 'expressivecss', 'SKILL.md'));
|
|
781
|
+
|
|
782
|
+
summary.installGuide = summary.isExpressiveProject
|
|
783
|
+
? 'ExpressiveCSS is already in package.json.'
|
|
784
|
+
: 'Add @expressivecss/expressive and include dist/js/expressive.{mjs,cjs} or npm package import. See install docs.';
|
|
785
|
+
|
|
786
|
+
return summary;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async function resolveAgainstContract(projectRoot, contractVersion) {
|
|
790
|
+
const version = await resolveExpressiveVersion({ projectRoot, contractVersion });
|
|
791
|
+
if (contractVersion) return version;
|
|
792
|
+
return {
|
|
793
|
+
...version,
|
|
794
|
+
status: 'unresolved',
|
|
795
|
+
contractStatus: 'unresolved',
|
|
796
|
+
documentationMode: 'unavailable',
|
|
797
|
+
bundledContractSafe: false,
|
|
798
|
+
currentDocsSafe: false,
|
|
799
|
+
documentationSources: {
|
|
800
|
+
...version.documentationSources,
|
|
801
|
+
bundled: { ...version.documentationSources.bundled, available: false },
|
|
802
|
+
},
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function loadSemanticsContract() {
|
|
807
|
+
if (semanticsCache) {
|
|
808
|
+
return semanticsCache;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const semanticsPath = path.join(SERVER_DIR, 'semantics-data.json');
|
|
812
|
+
const bundled = JSON.parse(readFileSync(semanticsPath, 'utf8'));
|
|
813
|
+
if (bundled.schemaVersion !== 1 || !bundled.frameworkVersion || !bundled.semantics?.rows) {
|
|
814
|
+
throw new Error(`Bundled ExpressiveCSS semantics are invalid at ${semanticsPath}`);
|
|
815
|
+
}
|
|
816
|
+
semanticsCache = bundled;
|
|
817
|
+
return semanticsCache;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function enforcedSemanticRules(data) {
|
|
821
|
+
return Object.entries(data.rows)
|
|
822
|
+
.filter(([, component]) => component.status === 'enforced')
|
|
823
|
+
.flatMap(([component, definition]) => definition.rules.map((rule) => ({ ...rule, component })));
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function expandedSemanticSelector(rule, compositeRoles) {
|
|
827
|
+
if (rule.kind !== 'forbid-composite-roles') {
|
|
828
|
+
return rule.selector;
|
|
829
|
+
}
|
|
830
|
+
return compositeRoles.map((role) => `${rule.selector}[role="${role}"]`).join(', ');
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function authoredName(element, document) {
|
|
834
|
+
const label = element.getAttribute('aria-label');
|
|
835
|
+
if (label?.trim()) {
|
|
836
|
+
return label.trim();
|
|
837
|
+
}
|
|
838
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
839
|
+
if (!labelledBy) {
|
|
840
|
+
return '';
|
|
841
|
+
}
|
|
842
|
+
return labelledBy
|
|
843
|
+
.split(/\s+/u)
|
|
844
|
+
.map((id) => document.getElementById(id)?.textContent ?? '')
|
|
845
|
+
.join(' ')
|
|
846
|
+
.trim();
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function accessibleName(element, document) {
|
|
850
|
+
const authored = authoredName(element, document);
|
|
851
|
+
if (authored) {
|
|
852
|
+
return authored;
|
|
853
|
+
}
|
|
854
|
+
const clone = element.cloneNode(true);
|
|
855
|
+
clone.querySelectorAll('[aria-hidden="true"]').forEach((node) => node.remove());
|
|
856
|
+
return clone.textContent.trim();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function markInspectionTruncated(issues, reason) {
|
|
860
|
+
Object.defineProperty(issues, 'truncatedReason', { value: reason, enumerable: false });
|
|
861
|
+
return issues;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function inspectionStopReason(issues, maxIssues, deadline) {
|
|
865
|
+
if (issues.length >= maxIssues) return 'issue limit reached';
|
|
866
|
+
if (Date.now() >= deadline) return 'time limit reached';
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function markupStructureExceedsLimit(snippet) {
|
|
871
|
+
let delimiters = 0;
|
|
872
|
+
for (let index = 0; index < snippet.length; index += 1) {
|
|
873
|
+
if (snippet.charCodeAt(index) === 60 && ++delimiters > MAX_STATIC_MARKUP_DELIMITERS) return true;
|
|
874
|
+
}
|
|
875
|
+
return false;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function inspectSemanticRules(snippet, maxIssues = MAX_STATIC_ISSUES, deadline = Infinity) {
|
|
879
|
+
const { semantics, frameworkVersion } = loadSemanticsContract();
|
|
880
|
+
if (maxIssues <= 0) return markInspectionTruncated([], 'issue limit reached');
|
|
881
|
+
if (markupStructureExceedsLimit(snippet)) return markInspectionTruncated([], 'markup structure limit reached');
|
|
882
|
+
const { document } = new JSDOM(`<!doctype html><body>${snippet}</body>`).window;
|
|
883
|
+
const issues = [];
|
|
884
|
+
|
|
885
|
+
for (const rule of enforcedSemanticRules(semantics)) {
|
|
886
|
+
const beforeRule = inspectionStopReason(issues, maxIssues, deadline);
|
|
887
|
+
if (beforeRule) return markInspectionTruncated(issues, beforeRule);
|
|
888
|
+
const hits = document.querySelectorAll(expandedSemanticSelector(rule, semantics.compositeRoles));
|
|
889
|
+
if (rule.kind === 'forbid' || rule.kind === 'forbid-composite-roles') {
|
|
890
|
+
for (const element of hits) {
|
|
891
|
+
const stop = inspectionStopReason(issues, maxIssues, deadline);
|
|
892
|
+
if (stop) return markInspectionTruncated(issues, stop);
|
|
893
|
+
issues.push(semanticIssue(rule, element, frameworkVersion));
|
|
894
|
+
}
|
|
895
|
+
} else if (rule.kind === 'require-attr') {
|
|
896
|
+
for (const element of hits) {
|
|
897
|
+
const stop = inspectionStopReason(issues, maxIssues, deadline);
|
|
898
|
+
if (stop) return markInspectionTruncated(issues, stop);
|
|
899
|
+
const value = element.getAttribute(rule.attr);
|
|
900
|
+
const valid = rule.equals ? value === rule.equals : value !== null && value !== '';
|
|
901
|
+
if (!valid) {
|
|
902
|
+
issues.push(semanticIssue(rule, element, frameworkVersion));
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
} else if (rule.kind === 'require-accessible-name') {
|
|
906
|
+
for (const element of hits) {
|
|
907
|
+
const stop = inspectionStopReason(issues, maxIssues, deadline);
|
|
908
|
+
if (stop) return markInspectionTruncated(issues, stop);
|
|
909
|
+
if (!accessibleName(element, document)) {
|
|
910
|
+
issues.push(semanticIssue(rule, element, frameworkVersion));
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const seenLandmarkNames = new Set();
|
|
917
|
+
for (const nav of document.querySelectorAll('nav')) {
|
|
918
|
+
const stop = inspectionStopReason(issues, maxIssues, deadline);
|
|
919
|
+
if (stop) return markInspectionTruncated(issues, stop);
|
|
920
|
+
const name = authoredName(nav, document);
|
|
921
|
+
if (!name) {
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
if (seenLandmarkNames.has(name)) {
|
|
925
|
+
issues.push({
|
|
926
|
+
id: 'duplicate-navigation-landmark-name',
|
|
927
|
+
severity: 'high',
|
|
928
|
+
component: 'landmarks',
|
|
929
|
+
frameworkVersion,
|
|
930
|
+
rule: `Navigation landmarks on one page need distinct names; "${name}" is repeated.`,
|
|
931
|
+
location: { line: 1, column: 1 },
|
|
932
|
+
snippet: nav.outerHTML.slice(0, 140),
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
seenLandmarkNames.add(name);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
return issues;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function semanticIssue(rule, element, frameworkVersion) {
|
|
942
|
+
return {
|
|
943
|
+
id: rule.id,
|
|
944
|
+
severity: 'high',
|
|
945
|
+
component: rule.component,
|
|
946
|
+
frameworkVersion,
|
|
947
|
+
rule: rule.message,
|
|
948
|
+
location: { line: 1, column: 1 },
|
|
949
|
+
snippet: element.outerHTML.slice(0, 140),
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function lineForMatch(text, index) {
|
|
954
|
+
const prefix = text.slice(0, index);
|
|
955
|
+
const line = prefix.split('\n').length;
|
|
956
|
+
const col = prefix.length - prefix.lastIndexOf('\n');
|
|
957
|
+
return { line, column: col };
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
export function inspectAuthoringRules(
|
|
961
|
+
snippet,
|
|
962
|
+
maxIssues = MAX_STATIC_ISSUES,
|
|
963
|
+
deadline = Date.now() + STATIC_INSPECTION_TIMEOUT_MS,
|
|
964
|
+
) {
|
|
965
|
+
const issues = [];
|
|
966
|
+
|
|
967
|
+
for (const rule of LegacyPatternList) {
|
|
968
|
+
const beforeRule = inspectionStopReason(issues, maxIssues, deadline);
|
|
969
|
+
if (beforeRule) return markInspectionTruncated(issues, beforeRule);
|
|
970
|
+
const regex = new RegExp(rule.pattern.source, rule.pattern.flags.includes('g') ? rule.pattern.flags : `${rule.pattern.flags}g`);
|
|
971
|
+
let match;
|
|
972
|
+
while ((match = regex.exec(snippet)) !== null) {
|
|
973
|
+
const stop = inspectionStopReason(issues, maxIssues, deadline);
|
|
974
|
+
if (stop) return markInspectionTruncated(issues, stop);
|
|
975
|
+
const loc = lineForMatch(snippet, match.index);
|
|
976
|
+
issues.push({
|
|
977
|
+
id: rule.id,
|
|
978
|
+
severity: rule.severity,
|
|
979
|
+
rule: rule.description,
|
|
980
|
+
location: { line: loc.line, column: loc.column },
|
|
981
|
+
snippet: match[0].slice(0, 120),
|
|
982
|
+
});
|
|
983
|
+
if (match.index === regex.lastIndex) {
|
|
984
|
+
regex.lastIndex++;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const autoInit = /(?:Expressive\.)?AutoInit\s*\(/u.test(snippet);
|
|
990
|
+
const manualInitPattern = /(?:(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*)?(?:Expressive\.)?[A-Z][A-Za-z0-9]*\.init\s*\(/gu;
|
|
991
|
+
let manualInit;
|
|
992
|
+
while ((manualInit = manualInitPattern.exec(snippet)) !== null) {
|
|
993
|
+
const stop = inspectionStopReason(issues, maxIssues, deadline);
|
|
994
|
+
if (stop) return markInspectionTruncated(issues, stop);
|
|
995
|
+
if (autoInit) {
|
|
996
|
+
const loc = lineForMatch(snippet, manualInit.index);
|
|
997
|
+
issues.push({
|
|
998
|
+
id: 'possible-duplicate-initialization',
|
|
999
|
+
severity: 'medium',
|
|
1000
|
+
rule: 'Auto Init and manual component initialization appear together. Prove that manual targets use no-autoinit.',
|
|
1001
|
+
location: { line: loc.line, column: loc.column },
|
|
1002
|
+
snippet: manualInit[0],
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
const initializedBinding = manualInit[1] ?? null;
|
|
1006
|
+
const escapedBinding = initializedBinding?.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&') ?? null;
|
|
1007
|
+
const hasMatchingTeardown = escapedBinding
|
|
1008
|
+
? new RegExp(`(?:^|[^A-Za-z0-9_$])${escapedBinding}\\.destroy\\s*\\(`, 'u').test(snippet)
|
|
1009
|
+
: false;
|
|
1010
|
+
if (!hasMatchingTeardown) {
|
|
1011
|
+
const loc = lineForMatch(snippet, manualInit.index);
|
|
1012
|
+
issues.push({
|
|
1013
|
+
id: 'manual-init-without-teardown',
|
|
1014
|
+
severity: 'medium',
|
|
1015
|
+
rule: 'Manual initialization needs an owned teardown path. Provide destroy() evidence or document why the owner is process-lifetime.',
|
|
1016
|
+
location: { line: loc.line, column: loc.column },
|
|
1017
|
+
snippet: manualInit[0],
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const semanticIssues = inspectSemanticRules(snippet, maxIssues - issues.length, deadline);
|
|
1023
|
+
issues.push(...semanticIssues);
|
|
1024
|
+
if (semanticIssues.truncatedReason) return markInspectionTruncated(issues, semanticIssues.truncatedReason);
|
|
1025
|
+
|
|
1026
|
+
return issues;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function buildCreativeCandidates(catalog, goal, maxSuggestions, componentsHint = []) {
|
|
1030
|
+
const ignoredTokens = new Set(['and', 'for', 'from', 'into', 'the', 'this', 'use', 'user', 'with']);
|
|
1031
|
+
const tokens = new Set(tokenize(goal).filter((token) => !ignoredTokens.has(token)));
|
|
1032
|
+
const normalizedGoal = normalizeForMatch(goal);
|
|
1033
|
+
const hinted = new Set(componentsHint.map(normalizeForMatch));
|
|
1034
|
+
const primary = [];
|
|
1035
|
+
const rejected = new Set();
|
|
1036
|
+
const searchableText = (values) => values
|
|
1037
|
+
.flatMap((value) => {
|
|
1038
|
+
if (typeof value === 'string') return [value];
|
|
1039
|
+
if (value && typeof value === 'object') return Object.values(value).filter((item) => typeof item === 'string');
|
|
1040
|
+
return [];
|
|
1041
|
+
})
|
|
1042
|
+
.join(' ')
|
|
1043
|
+
.toLowerCase();
|
|
1044
|
+
|
|
1045
|
+
for (const guide of catalog.components.values()) {
|
|
1046
|
+
const decision = COMPONENT_DECISIONS_BY_SLUG.get(guide.slug);
|
|
1047
|
+
if (!decision) continue;
|
|
1048
|
+
|
|
1049
|
+
const aliases = Array.isArray(decision.aliases) ? decision.aliases : [];
|
|
1050
|
+
const adaptive = decision.adaptive ?? [];
|
|
1051
|
+
const positiveText = searchableText([
|
|
1052
|
+
...(decision.jobs ?? []),
|
|
1053
|
+
...(decision.useWhen ?? []),
|
|
1054
|
+
...aliases,
|
|
1055
|
+
...(decision.alternatives ?? []),
|
|
1056
|
+
...(Array.isArray(adaptive) ? adaptive : [adaptive]),
|
|
1057
|
+
]);
|
|
1058
|
+
const avoidText = searchableText(decision.avoidWhen ?? []);
|
|
1059
|
+
const positiveMatches = [...tokens].filter((token) => positiveText.includes(token));
|
|
1060
|
+
const avoidMatches = [...tokens].filter((token) => avoidText.includes(token));
|
|
1061
|
+
const aliasMatch = aliases.some((alias) => normalizedGoal.includes(normalizeForMatch(alias)));
|
|
1062
|
+
const nameMatch = normalizedGoal.includes(normalizeForMatch(decision.slug))
|
|
1063
|
+
|| normalizedGoal.includes(normalizeForMatch(decision.title));
|
|
1064
|
+
const hintMatch = hinted.has(guide.slug) || aliases.some((alias) => hinted.has(normalizeForMatch(alias)));
|
|
1065
|
+
const score = (positiveMatches.length * 2) + (aliasMatch ? 30 : 0) + (nameMatch ? 12 + tokenize(decision.slug).length : 0) + (hintMatch ? 12 : 0);
|
|
1066
|
+
const avoidScore = avoidMatches.length * 8;
|
|
1067
|
+
|
|
1068
|
+
if (score <= avoidScore || score === 0) {
|
|
1069
|
+
if (avoidMatches.length) rejected.add(guide.slug);
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
primary.push({
|
|
1074
|
+
slug: guide.slug,
|
|
1075
|
+
title: guide.title,
|
|
1076
|
+
score: score - avoidScore,
|
|
1077
|
+
why: positiveMatches.length
|
|
1078
|
+
? `Decision metadata matches: ${positiveMatches.slice(0, 4).join(', ')}`
|
|
1079
|
+
: 'Explicit component name or alias match',
|
|
1080
|
+
docs: guide.sourceUrl,
|
|
1081
|
+
selectionSource: 'decision-catalog',
|
|
1082
|
+
confidence: 'primary',
|
|
1083
|
+
aliases,
|
|
1084
|
+
useWhen: decision.useWhen ?? [],
|
|
1085
|
+
avoidWhen: decision.avoidWhen ?? [],
|
|
1086
|
+
alternatives: decision.alternatives ?? [],
|
|
1087
|
+
adaptive,
|
|
1088
|
+
runtime: decision.runtime ?? null,
|
|
1089
|
+
guideSource: decision.guideSource ?? null,
|
|
1090
|
+
materialGuidance: decision.materialGuidance ?? null,
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
primary.sort((a, b) => b.score - a.score || a.slug.localeCompare(b.slug));
|
|
1095
|
+
const selectedSlugs = new Set(primary.map((item) => item.slug));
|
|
1096
|
+
const fallback = [];
|
|
1097
|
+
for (const guide of catalog.components.values()) {
|
|
1098
|
+
const decision = COMPONENT_DECISIONS_BY_SLUG.get(guide.slug);
|
|
1099
|
+
if (selectedSlugs.has(guide.slug) || rejected.has(guide.slug)) continue;
|
|
1100
|
+
const matches = [...tokens].filter((token) => guide.text.includes(token));
|
|
1101
|
+
if (!matches.length) continue;
|
|
1102
|
+
fallback.push({
|
|
1103
|
+
slug: guide.slug,
|
|
1104
|
+
title: guide.title,
|
|
1105
|
+
score: matches.length,
|
|
1106
|
+
why: `Generated guide fallback matches: ${matches.slice(0, 4).join(', ')}`,
|
|
1107
|
+
docs: guide.sourceUrl,
|
|
1108
|
+
selectionSource: 'fuzzy-fallback',
|
|
1109
|
+
confidence: 'fallback',
|
|
1110
|
+
aliases: decision?.aliases ?? [],
|
|
1111
|
+
useWhen: decision?.useWhen ?? [],
|
|
1112
|
+
avoidWhen: decision?.avoidWhen ?? [],
|
|
1113
|
+
alternatives: decision?.alternatives ?? [],
|
|
1114
|
+
adaptive: decision?.adaptive ?? [],
|
|
1115
|
+
runtime: decision?.runtime ?? null,
|
|
1116
|
+
guideSource: decision?.guideSource ?? null,
|
|
1117
|
+
materialGuidance: decision?.materialGuidance ?? null,
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
fallback.sort((a, b) => b.score - a.score || a.slug.localeCompare(b.slug));
|
|
1121
|
+
|
|
1122
|
+
const nativePatterns = [];
|
|
1123
|
+
if (/\binline (?:feedback|messages?)\b/i.test(goal)
|
|
1124
|
+
|| (/\bnon[- ]?blocking\b/i.test(goal) && /\b(?:persistent|offline|until|remain|stays?)\b/i.test(goal))) {
|
|
1125
|
+
nativePatterns.push({
|
|
1126
|
+
slug: 'inline',
|
|
1127
|
+
title: 'Native inline feedback',
|
|
1128
|
+
why: 'Keep persistent nonblocking feedback and its actions in the document flow.',
|
|
1129
|
+
selectionSource: 'native-pattern',
|
|
1130
|
+
confidence: 'primary',
|
|
1131
|
+
runtime: 'native',
|
|
1132
|
+
useWhen: ['An issue persists while the user can continue working.'],
|
|
1133
|
+
avoidWhen: ['Temporary confirmations need snackbar; blocking decisions need dialog.'],
|
|
1134
|
+
alternatives: ['snackbar', 'dialogs'],
|
|
1135
|
+
guidance: [
|
|
1136
|
+
'Use native text and named buttons near the affected content. No banner classes or framework initialization are needed.',
|
|
1137
|
+
'Static messages need no live region. For dynamic announcements, update an existing status node and keep action buttons outside it.',
|
|
1138
|
+
'The application owns actions, dismissal and logical focus recovery. Allow text and controls to wrap.',
|
|
1139
|
+
],
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
const allCandidates = [...nativePatterns, ...primary, ...fallback];
|
|
1143
|
+
const suggestions = allCandidates.slice(0, maxSuggestions);
|
|
1144
|
+
return {
|
|
1145
|
+
suggestions,
|
|
1146
|
+
truncated: allCandidates.length > suggestions.length,
|
|
1147
|
+
omittedCount: Math.max(0, allCandidates.length - suggestions.length),
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function buildPageArchitecture(catalog, pageGoal, components = [], viewportTarget = 'responsive', includeAccessibility = true) {
|
|
1152
|
+
const selected = [];
|
|
1153
|
+
const unresolvedComponents = [];
|
|
1154
|
+
|
|
1155
|
+
for (const component of components) {
|
|
1156
|
+
const guide = findGuideByName(catalog, component, false);
|
|
1157
|
+
if (guide) {
|
|
1158
|
+
selected.push(guide.slug);
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
unresolvedComponents.push({
|
|
1162
|
+
requested: component,
|
|
1163
|
+
nearest: nearestMatches(catalog, component, 3).map((match) => match.slug),
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const uniqueSelected = Array.from(new Set(selected));
|
|
1168
|
+
const hasAppBar = uniqueSelected.includes('app-bar');
|
|
1169
|
+
const primaryNavigation = uniqueSelected.find((slug) => ['navigation-bar', 'navigation-rail'].includes(slug));
|
|
1170
|
+
const hasTabs = uniqueSelected.includes('tabs');
|
|
1171
|
+
const hasBreadcrumbs = uniqueSelected.includes('breadcrumbs');
|
|
1172
|
+
const hasFooter = uniqueSelected.includes('footer');
|
|
1173
|
+
const hasFeedback = selected.some((slug) => ['snackbar', 'tooltips'].includes(slug));
|
|
1174
|
+
const structuralComponents = new Set([
|
|
1175
|
+
'app-bar',
|
|
1176
|
+
'navigation-bar',
|
|
1177
|
+
'navigation-rail',
|
|
1178
|
+
'tabs',
|
|
1179
|
+
'breadcrumbs',
|
|
1180
|
+
'footer',
|
|
1181
|
+
]);
|
|
1182
|
+
const contentComponents = uniqueSelected.filter((slug) => !structuralComponents.has(slug));
|
|
1183
|
+
|
|
1184
|
+
const landmarks = [];
|
|
1185
|
+
if (hasAppBar) {
|
|
1186
|
+
landmarks.push({
|
|
1187
|
+
role: 'banner',
|
|
1188
|
+
component: 'app-bar',
|
|
1189
|
+
purpose: 'Page title and global actions; its child nav is labelled.',
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
if (primaryNavigation) {
|
|
1193
|
+
landmarks.push({
|
|
1194
|
+
role: 'navigation',
|
|
1195
|
+
component: primaryNavigation,
|
|
1196
|
+
purpose: 'Primary destinations; the component itself owns the labelled navigation landmark.',
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
landmarks.push({
|
|
1201
|
+
role: 'main',
|
|
1202
|
+
component: contentComponents.includes('panes') ? 'panes' : 'authored content',
|
|
1203
|
+
purpose: 'Primary feature content and interaction surface.',
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
if (hasFooter) {
|
|
1207
|
+
landmarks.push({
|
|
1208
|
+
role: 'contentinfo',
|
|
1209
|
+
component: 'footer',
|
|
1210
|
+
purpose: 'Global helper links and closing information.',
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
const skeleton = ['<body>'];
|
|
1215
|
+
if (hasAppBar) {
|
|
1216
|
+
skeleton.push(
|
|
1217
|
+
' <header>',
|
|
1218
|
+
' <nav aria-label="Main">',
|
|
1219
|
+
' <!-- app bar title and actions -->',
|
|
1220
|
+
' </nav>',
|
|
1221
|
+
' </header>',
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
if (primaryNavigation === 'navigation-bar') {
|
|
1225
|
+
skeleton.push(' <nav class="navigation-bar" aria-label="Primary"><!-- 3–5 destinations; mark one aria-current="page" --></nav>');
|
|
1226
|
+
} else if (primaryNavigation === 'navigation-rail') {
|
|
1227
|
+
skeleton.push(' <nav class="navigation-rail" aria-label="Primary"><!-- destinations; mark one aria-current="page" --></nav>');
|
|
1228
|
+
}
|
|
1229
|
+
skeleton.push(' <main>');
|
|
1230
|
+
if (hasBreadcrumbs) {
|
|
1231
|
+
skeleton.push(' <nav aria-label="Breadcrumb"><ol><!-- ordered path; final link uses aria-current="page" --></ol></nav>');
|
|
1232
|
+
}
|
|
1233
|
+
if (hasTabs) {
|
|
1234
|
+
skeleton.push(' <nav class="tabs" aria-label="Sections"><!-- section links; active link uses aria-current="page" --></nav>');
|
|
1235
|
+
}
|
|
1236
|
+
skeleton.push(' <!-- primary page content -->');
|
|
1237
|
+
skeleton.push(...contentComponents.slice(0, 4).map((slug) => ` <!-- ${slug} -->`));
|
|
1238
|
+
skeleton.push(' </main>');
|
|
1239
|
+
if (hasFeedback) {
|
|
1240
|
+
skeleton.push(' <div aria-live="polite"><!-- feedback component placeholder --></div>');
|
|
1241
|
+
}
|
|
1242
|
+
if (hasFooter) {
|
|
1243
|
+
skeleton.push(' <footer><!-- footer content --></footer>');
|
|
1244
|
+
}
|
|
1245
|
+
skeleton.push('</body>');
|
|
1246
|
+
|
|
1247
|
+
const notes = [];
|
|
1248
|
+
if (viewportTarget === 'responsive') {
|
|
1249
|
+
notes.push('Plan adaptive behavior for compact → expanded → large breakpoints using Material Design 3 size guidance.');
|
|
1250
|
+
} else {
|
|
1251
|
+
notes.push(`Targeted viewport intent: ${viewportTarget}; verify this still degrades to adjacent breakpoints.`);
|
|
1252
|
+
}
|
|
1253
|
+
if (includeAccessibility) {
|
|
1254
|
+
notes.push('Each landmark and control needs an accessible name and keyboard path before final render.');
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
return {
|
|
1258
|
+
unresolvedComponents,
|
|
1259
|
+
objective: pageGoal,
|
|
1260
|
+
architecture: {
|
|
1261
|
+
viewportTarget,
|
|
1262
|
+
landmarkOrder: landmarks,
|
|
1263
|
+
selectedComponents: uniqueSelected,
|
|
1264
|
+
skeleton: skeleton.join('\n'),
|
|
1265
|
+
},
|
|
1266
|
+
rationale: notes,
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function summarizeGuide(guide) {
|
|
1271
|
+
return {
|
|
1272
|
+
file: guide.file,
|
|
1273
|
+
slug: guide.slug,
|
|
1274
|
+
title: guide.title,
|
|
1275
|
+
source: guide.sourceUrl,
|
|
1276
|
+
docs: guide.astroSource,
|
|
1277
|
+
contract: clampText(guide.contract, 900),
|
|
1278
|
+
syntax: {
|
|
1279
|
+
language: guide.syntax.language,
|
|
1280
|
+
example: clampText(guide.syntax.code, 3_000),
|
|
1281
|
+
},
|
|
1282
|
+
rules: guide.rules.slice(0, 8),
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function summarizeProjectFiles(files, projectRoot) {
|
|
1287
|
+
const resolvedRoot = realpathSync(path.resolve(projectRoot));
|
|
1288
|
+
const absoluteFiles = files.map((filePath) => {
|
|
1289
|
+
const requestedPath = path.isAbsolute(filePath)
|
|
1290
|
+
? path.resolve(filePath)
|
|
1291
|
+
: path.resolve(resolvedRoot, filePath);
|
|
1292
|
+
const exists = existsSync(requestedPath);
|
|
1293
|
+
const absolute = exists ? realpathSync(requestedPath) : requestedPath;
|
|
1294
|
+
return {
|
|
1295
|
+
requested: filePath,
|
|
1296
|
+
absolute,
|
|
1297
|
+
exists,
|
|
1298
|
+
insideProject: isPathInside(resolvedRoot, absolute),
|
|
1299
|
+
};
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
const existing = [];
|
|
1303
|
+
const skipped = [];
|
|
1304
|
+
const maxFiles = Number.isFinite(SETTINGS.qaMaxFiles) && SETTINGS.qaMaxFiles > 0 ? SETTINGS.qaMaxFiles : DEFAULT_QA_MAX_FILES;
|
|
1305
|
+
for (const item of absoluteFiles) {
|
|
1306
|
+
if (existing.length >= maxFiles) {
|
|
1307
|
+
skipped.push({ ...item, reason: 'file limit reached' });
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
if (!item.exists) {
|
|
1311
|
+
skipped.push({ ...item, reason: 'file does not exist' });
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
if (!item.insideProject) {
|
|
1315
|
+
skipped.push({ ...item, reason: 'file is outside projectRoot' });
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
if (path.relative(resolvedRoot, item.absolute).split(path.sep).includes('node_modules')) {
|
|
1319
|
+
skipped.push({ ...item, reason: 'node_modules is excluded' });
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
existing.push(item);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
return {
|
|
1326
|
+
existing,
|
|
1327
|
+
skipped,
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function isPathInside(root, candidate) {
|
|
1332
|
+
const relative = path.relative(root, candidate);
|
|
1333
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function commandExecutionPolicy(projectRoot) {
|
|
1337
|
+
const resolvedProjectRoot = realpathSync(path.resolve(projectRoot));
|
|
1338
|
+
const allowedRoots = SETTINGS.allowedCommandRoots.flatMap((configuredRoot) => {
|
|
1339
|
+
try {
|
|
1340
|
+
return [realpathSync(path.resolve(configuredRoot))];
|
|
1341
|
+
} catch {
|
|
1342
|
+
return [];
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
const matchedRoot = allowedRoots.find((allowedRoot) => isPathInside(allowedRoot, resolvedProjectRoot)) ?? null;
|
|
1346
|
+
return {
|
|
1347
|
+
configured: SETTINGS.allowedCommandRoots.length > 0,
|
|
1348
|
+
allowed: Boolean(matchedRoot),
|
|
1349
|
+
matchedRoot,
|
|
1350
|
+
projectRoot: resolvedProjectRoot,
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function commandEnvironment(projectRoot) {
|
|
1355
|
+
const allowedNames = [
|
|
1356
|
+
'PATH',
|
|
1357
|
+
'SystemRoot',
|
|
1358
|
+
'ComSpec',
|
|
1359
|
+
'PATHEXT',
|
|
1360
|
+
'TMPDIR',
|
|
1361
|
+
'TMP',
|
|
1362
|
+
'TEMP',
|
|
1363
|
+
'LANG',
|
|
1364
|
+
'LC_ALL',
|
|
1365
|
+
];
|
|
1366
|
+
const env = {
|
|
1367
|
+
CI: '1',
|
|
1368
|
+
NO_COLOR: '1',
|
|
1369
|
+
HOME: path.resolve(projectRoot),
|
|
1370
|
+
USERPROFILE: path.resolve(projectRoot),
|
|
1371
|
+
};
|
|
1372
|
+
for (const name of allowedNames) {
|
|
1373
|
+
if (typeof process.env[name] === 'string') env[name] = process.env[name];
|
|
1374
|
+
}
|
|
1375
|
+
return env;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function stopProcessTree(proc, signal) {
|
|
1379
|
+
if (!proc.pid) return;
|
|
1380
|
+
if (process.platform === 'win32') {
|
|
1381
|
+
const killer = spawn('taskkill', ['/pid', String(proc.pid), '/t', '/f'], {
|
|
1382
|
+
stdio: 'ignore',
|
|
1383
|
+
windowsHide: true,
|
|
1384
|
+
});
|
|
1385
|
+
killer.unref();
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
try {
|
|
1389
|
+
process.kill(-proc.pid, signal);
|
|
1390
|
+
} catch {
|
|
1391
|
+
proc.kill(signal);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function redactSensitiveText(value) {
|
|
1396
|
+
return String(value)
|
|
1397
|
+
.replace(/(Authorization\s*:\s*Bearer\s+)[^\s,;]+/giu, '$1[REDACTED]')
|
|
1398
|
+
.replace(/\b(Bearer\s+)[A-Za-z0-9._~+\/-]{16,}/giu, '$1[REDACTED]')
|
|
1399
|
+
.replace(/((?:Set-)?Cookie\s*:\s*)[^\r\n]+/giu, '$1[REDACTED]')
|
|
1400
|
+
.replace(/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/gu, '[REDACTED]')
|
|
1401
|
+
.replace(/\bglpat-[A-Za-z0-9_-]{20,}\b/gu, '[REDACTED]')
|
|
1402
|
+
.replace(/\bnpm_[A-Za-z0-9]{20,}\b/gu, '[REDACTED]')
|
|
1403
|
+
.replace(/\bxox[baprs]-[A-Za-z0-9-]{20,}\b/gu, '[REDACTED]')
|
|
1404
|
+
.replace(/\bsk-[A-Za-z0-9_-]{20,}\b/gu, '[REDACTED]')
|
|
1405
|
+
.replace(/\bAKIA[0-9A-Z]{16}\b/gu, '[REDACTED]')
|
|
1406
|
+
.replace(/\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gu, '[REDACTED]')
|
|
1407
|
+
.replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gu, '[REDACTED]')
|
|
1408
|
+
.replace(/(["'])(API[_-]?KEY|TOKEN|PASSWORD|PASSWD|SECRET|CLIENT[_-]?SECRET|CLIENTSECRET|CONNECTION[_-]?STRING|ACCESS_TOKEN|REFRESH_TOKEN|AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY)\1\s*:\s*(["'])[^"'\r\n]*\3/giu, '$1$2$1:$3[REDACTED]$3')
|
|
1409
|
+
.replace(/\b(API[_-]?KEY|TOKEN|PASSWORD|PASSWD|SECRET|CLIENT[_-]?SECRET|CLIENTSECRET|CONNECTION[_-]?STRING|ACCESS_TOKEN|REFRESH_TOKEN|AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY)\s*[:=]\s*[^\s,;]+/giu, '$1=[REDACTED]')
|
|
1410
|
+
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/giu, '$1[REDACTED]@')
|
|
1411
|
+
.replace(/\/(?:home|Users)\/[^/\s"'<>]+(?:\/[^\s"'<>),;\]}]*)?/gu, '[LOCAL_PATH]')
|
|
1412
|
+
.replace(/\/(?:private\/)?tmp\/[^\s"'<>),;\]}]+/gu, '[LOCAL_PATH]')
|
|
1413
|
+
.replace(/[A-Z]:\\+Users\\+[^\\\s]+(?:\\+[^\s]*)?/giu, '[LOCAL_PATH]');
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
export async function readInspectionFile(filePath, projectRoot, byteLimit) {
|
|
1417
|
+
const resolvedRoot = await realpath(projectRoot);
|
|
1418
|
+
const noFollow = Number.isInteger(fsConstants.O_NOFOLLOW) ? fsConstants.O_NOFOLLOW : 0;
|
|
1419
|
+
const handle = await open(filePath, fsConstants.O_RDONLY | noFollow);
|
|
1420
|
+
try {
|
|
1421
|
+
const before = await handle.stat({ bigint: true });
|
|
1422
|
+
const pathBefore = await lstat(filePath, { bigint: true });
|
|
1423
|
+
if (pathBefore.isSymbolicLink()) throw new Error('path is a symbolic link');
|
|
1424
|
+
if (!before.isFile() || !pathBefore.isFile()) throw new Error('path is not a regular file');
|
|
1425
|
+
if (before.dev !== pathBefore.dev || before.ino !== pathBefore.ino) throw new Error('file identity changed before reading');
|
|
1426
|
+
const openedPath = await realpath(`/proc/self/fd/${handle.fd}`).catch(() => realpath(filePath));
|
|
1427
|
+
if (!isPathInside(resolvedRoot, openedPath)) throw new Error('opened file is outside projectRoot');
|
|
1428
|
+
if (before.size > BigInt(byteLimit)) {
|
|
1429
|
+
const error = new Error(`file exceeds ${byteLimit} byte read limit`);
|
|
1430
|
+
error.code = 'INSPECTION_FILE_TOO_LARGE';
|
|
1431
|
+
throw error;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
const bytes = Buffer.allocUnsafe(byteLimit + 1);
|
|
1435
|
+
let total = 0;
|
|
1436
|
+
while (total <= byteLimit) {
|
|
1437
|
+
const chunk = await handle.read(bytes, total, byteLimit + 1 - total, total);
|
|
1438
|
+
if (chunk.bytesRead === 0) break;
|
|
1439
|
+
total += chunk.bytesRead;
|
|
1440
|
+
}
|
|
1441
|
+
if (total > byteLimit) {
|
|
1442
|
+
const error = new Error(`file exceeds ${byteLimit} byte read limit`);
|
|
1443
|
+
error.code = 'INSPECTION_FILE_TOO_LARGE';
|
|
1444
|
+
throw error;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
const after = await handle.stat({ bigint: true });
|
|
1448
|
+
const pathAfter = await lstat(filePath, { bigint: true });
|
|
1449
|
+
if (pathAfter.isSymbolicLink() || !pathAfter.isFile()
|
|
1450
|
+
|| before.dev !== after.dev || before.ino !== after.ino
|
|
1451
|
+
|| before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs
|
|
1452
|
+
|| after.dev !== pathAfter.dev || after.ino !== pathAfter.ino) {
|
|
1453
|
+
throw new Error('file changed while reading');
|
|
1454
|
+
}
|
|
1455
|
+
return { text: bytes.subarray(0, total).toString('utf8'), bytes: total, sha256: createHash('sha256').update(bytes.subarray(0, total)).digest('hex') };
|
|
1456
|
+
} finally {
|
|
1457
|
+
await handle.close();
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
async function findFileViolations(fileInfoList, projectRoot) {
|
|
1462
|
+
const findings = [];
|
|
1463
|
+
const sourcePins = [];
|
|
1464
|
+
const inspected = [];
|
|
1465
|
+
const uninspected = [];
|
|
1466
|
+
const perFileMb = Number.isFinite(SETTINGS.qaMaxMb) && SETTINGS.qaMaxMb > 0
|
|
1467
|
+
? SETTINGS.qaMaxMb
|
|
1468
|
+
: DEFAULT_QA_MAX_MB;
|
|
1469
|
+
const totalMb = Number.isFinite(SETTINGS.qaMaxTotalMb) && SETTINGS.qaMaxTotalMb > 0
|
|
1470
|
+
? SETTINGS.qaMaxTotalMb
|
|
1471
|
+
: DEFAULT_QA_MAX_TOTAL_MB;
|
|
1472
|
+
const maxBytes = Math.max(1, Math.floor(perFileMb * 1024 * 1024));
|
|
1473
|
+
const maxTotalBytes = Math.max(1, Math.floor(totalMb * 1024 * 1024));
|
|
1474
|
+
const deadline = Date.now() + STATIC_INSPECTION_TIMEOUT_MS;
|
|
1475
|
+
let totalBytes = 0;
|
|
1476
|
+
let totalIssues = 0;
|
|
1477
|
+
|
|
1478
|
+
for (let index = 0; index < fileInfoList.length; index += 1) {
|
|
1479
|
+
const file = fileInfoList[index];
|
|
1480
|
+
if (Date.now() >= deadline || totalIssues >= MAX_STATIC_ISSUES_PER_REQUEST || totalBytes >= maxTotalBytes) {
|
|
1481
|
+
const reason = Date.now() >= deadline
|
|
1482
|
+
? 'static inspection time limit reached'
|
|
1483
|
+
: totalIssues >= MAX_STATIC_ISSUES_PER_REQUEST
|
|
1484
|
+
? 'static inspection issue limit reached'
|
|
1485
|
+
: 'aggregate byte limit reached';
|
|
1486
|
+
uninspected.push(...fileInfoList.slice(index).map((entry) => ({ file: entry.requested, reason })));
|
|
1487
|
+
break;
|
|
1488
|
+
}
|
|
1489
|
+
try {
|
|
1490
|
+
const remainingBytes = Math.floor(Math.min(maxBytes, maxTotalBytes - totalBytes));
|
|
1491
|
+
const read = await readInspectionFile(file.absolute, projectRoot, remainingBytes);
|
|
1492
|
+
totalBytes += read.bytes;
|
|
1493
|
+
sourcePins.push({ file: file.requested, sha256: read.sha256, bytes: read.bytes });
|
|
1494
|
+
const issues = inspectAuthoringRules(
|
|
1495
|
+
read.text,
|
|
1496
|
+
Math.min(MAX_STATIC_ISSUES, MAX_STATIC_ISSUES_PER_REQUEST - totalIssues),
|
|
1497
|
+
deadline,
|
|
1498
|
+
);
|
|
1499
|
+
totalIssues += issues.length;
|
|
1500
|
+
if (issues.length > 0) {
|
|
1501
|
+
findings.push({
|
|
1502
|
+
file: file.requested,
|
|
1503
|
+
issues,
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
if (issues.truncatedReason) {
|
|
1507
|
+
uninspected.push({ file: file.requested, reason: `static inspection ${issues.truncatedReason}` });
|
|
1508
|
+
} else {
|
|
1509
|
+
inspected.push(file.requested);
|
|
1510
|
+
}
|
|
1511
|
+
} catch (error) {
|
|
1512
|
+
const aggregateLimit = error.code === 'INSPECTION_FILE_TOO_LARGE' && maxTotalBytes - totalBytes < maxBytes;
|
|
1513
|
+
uninspected.push({
|
|
1514
|
+
file: file.requested,
|
|
1515
|
+
reason: aggregateLimit
|
|
1516
|
+
? 'aggregate byte limit reached'
|
|
1517
|
+
: `file read failed: ${error.message}`,
|
|
1518
|
+
});
|
|
1519
|
+
if (aggregateLimit) {
|
|
1520
|
+
uninspected.push(...fileInfoList.slice(index + 1).map((entry) => ({
|
|
1521
|
+
file: entry.requested,
|
|
1522
|
+
reason: 'aggregate byte limit reached',
|
|
1523
|
+
})));
|
|
1524
|
+
break;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
return { findings, inspected, uninspected, sourcePins };
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
async function runCommandInProject(projectRoot, manager, script, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS) {
|
|
1533
|
+
const args = ['run', script];
|
|
1534
|
+
const command = `${manager} ${args.join(' ')}`;
|
|
1535
|
+
const configuredTimeout = Number.isFinite(SETTINGS.commandTimeoutMs) && SETTINGS.commandTimeoutMs > 0
|
|
1536
|
+
? SETTINGS.commandTimeoutMs
|
|
1537
|
+
: DEFAULT_COMMAND_TIMEOUT_MS;
|
|
1538
|
+
const effectiveTimeout = Math.min(timeoutMs, configuredTimeout);
|
|
1539
|
+
let directoryFd;
|
|
1540
|
+
let pinnedCwd;
|
|
1541
|
+
try {
|
|
1542
|
+
const directoryFlags = fsConstants.O_RDONLY
|
|
1543
|
+
| (fsConstants.O_DIRECTORY ?? 0)
|
|
1544
|
+
| (fsConstants.O_NOFOLLOW ?? 0);
|
|
1545
|
+
directoryFd = openSync(projectRoot, directoryFlags);
|
|
1546
|
+
const descriptorStat = fstatSync(directoryFd, { bigint: true });
|
|
1547
|
+
const pathStat = lstatSync(projectRoot, { bigint: true });
|
|
1548
|
+
const canonicalRoot = realpathSync(projectRoot);
|
|
1549
|
+
if (!descriptorStat.isDirectory() || !pathStat.isDirectory() || pathStat.isSymbolicLink()
|
|
1550
|
+
|| !sameFileIdentity(descriptorStat, pathStat) || canonicalRoot !== projectRoot) {
|
|
1551
|
+
throw new Error('authorized project root changed before command execution');
|
|
1552
|
+
}
|
|
1553
|
+
pinnedCwd = process.platform === 'linux' ? `/proc/self/fd/${directoryFd}` : canonicalRoot;
|
|
1554
|
+
} catch (error) {
|
|
1555
|
+
if (directoryFd !== undefined) closeSync(directoryFd);
|
|
1556
|
+
return {
|
|
1557
|
+
manager,
|
|
1558
|
+
command,
|
|
1559
|
+
exitStatus: null,
|
|
1560
|
+
exitCode: null,
|
|
1561
|
+
completed: false,
|
|
1562
|
+
timedOut: false,
|
|
1563
|
+
spawnError: true,
|
|
1564
|
+
output: redactSensitiveText(`Failed to pin command root: ${error.message}`),
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
return new Promise((resolve) => {
|
|
1568
|
+
const proc = spawn(manager, args, {
|
|
1569
|
+
cwd: pinnedCwd,
|
|
1570
|
+
shell: false,
|
|
1571
|
+
detached: process.platform !== 'win32',
|
|
1572
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1573
|
+
env: commandEnvironment(projectRoot),
|
|
1574
|
+
});
|
|
1575
|
+
closeSync(directoryFd);
|
|
1576
|
+
|
|
1577
|
+
let stdout = '';
|
|
1578
|
+
let stderr = '';
|
|
1579
|
+
let forceKillTimer;
|
|
1580
|
+
let timedOut = false;
|
|
1581
|
+
let settled = false;
|
|
1582
|
+
const finish = (result) => {
|
|
1583
|
+
if (settled) return;
|
|
1584
|
+
settled = true;
|
|
1585
|
+
clearTimeout(timer);
|
|
1586
|
+
if (!timedOut) clearTimeout(forceKillTimer);
|
|
1587
|
+
resolve(result);
|
|
1588
|
+
};
|
|
1589
|
+
const timer = setTimeout(() => {
|
|
1590
|
+
timedOut = true;
|
|
1591
|
+
stopProcessTree(proc, 'SIGTERM');
|
|
1592
|
+
proc.stdout.destroy();
|
|
1593
|
+
proc.stderr.destroy();
|
|
1594
|
+
forceKillTimer = setTimeout(() => stopProcessTree(proc, 'SIGKILL'), 50);
|
|
1595
|
+
forceKillTimer.unref();
|
|
1596
|
+
const output = redactSensitiveText(`${stdout}\n${stderr}`);
|
|
1597
|
+
finish({
|
|
1598
|
+
manager,
|
|
1599
|
+
command,
|
|
1600
|
+
exitStatus: null,
|
|
1601
|
+
exitCode: null,
|
|
1602
|
+
completed: false,
|
|
1603
|
+
timedOut: true,
|
|
1604
|
+
spawnError: false,
|
|
1605
|
+
output: clampText(output, 20_000),
|
|
1606
|
+
});
|
|
1607
|
+
}, effectiveTimeout);
|
|
1608
|
+
|
|
1609
|
+
proc.stdout.on('data', (chunk) => {
|
|
1610
|
+
if (stdout.length < 80_000) {
|
|
1611
|
+
stdout += chunk.toString();
|
|
1612
|
+
}
|
|
1613
|
+
});
|
|
1614
|
+
proc.stderr.on('data', (chunk) => {
|
|
1615
|
+
if (stderr.length < 80_000) {
|
|
1616
|
+
stderr += chunk.toString();
|
|
1617
|
+
}
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1620
|
+
proc.on('close', (code) => {
|
|
1621
|
+
stopProcessTree(proc, 'SIGKILL'); // Stop descendants before checking the resulting source state.
|
|
1622
|
+
const output = redactSensitiveText(`${stdout}\n${stderr}`);
|
|
1623
|
+
finish({
|
|
1624
|
+
manager,
|
|
1625
|
+
command,
|
|
1626
|
+
exitStatus: typeof code === 'number' ? code : null,
|
|
1627
|
+
exitCode: typeof code === 'number' ? code : 1,
|
|
1628
|
+
completed: !timedOut,
|
|
1629
|
+
timedOut,
|
|
1630
|
+
spawnError: false,
|
|
1631
|
+
output: clampText(output, 20_000),
|
|
1632
|
+
});
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
proc.on('error', (error) => {
|
|
1636
|
+
finish({
|
|
1637
|
+
manager,
|
|
1638
|
+
command,
|
|
1639
|
+
exitStatus: null,
|
|
1640
|
+
exitCode: null,
|
|
1641
|
+
completed: false,
|
|
1642
|
+
timedOut: false,
|
|
1643
|
+
spawnError: true,
|
|
1644
|
+
output: redactSensitiveText(`Failed to run command: ${error.message}`),
|
|
1645
|
+
});
|
|
1646
|
+
});
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function qualityCommands(runType) {
|
|
1651
|
+
return runType === 'full' ? ['typecheck', 'test'] : runType === 'standard' ? ['typecheck'] : runType === 'consumer' ? ['verify:expressivecss'] : [];
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// Endpoint comparisons detect stale evidence; they do not lock the checkout or sandbox scripts.
|
|
1655
|
+
async function pinsUnchanged(projectRoot, pins) {
|
|
1656
|
+
for (const pin of pins) {
|
|
1657
|
+
try {
|
|
1658
|
+
const current = await readInspectionFile(path.resolve(projectRoot, pin.file), projectRoot, pin.bytes);
|
|
1659
|
+
if (current.sha256 !== pin.sha256) return false;
|
|
1660
|
+
} catch { return false; }
|
|
1661
|
+
}
|
|
1662
|
+
return true;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
async function runQualityCommands(projectRoot, commands, packageManager, pins) {
|
|
1666
|
+
const results = [];
|
|
1667
|
+
let unchanged = true;
|
|
1668
|
+
for (const script of commands) {
|
|
1669
|
+
unchanged = await pinsUnchanged(projectRoot, pins);
|
|
1670
|
+
if (!unchanged) break;
|
|
1671
|
+
const result = await runCommandInProject(projectRoot, packageManager, script, script === 'typecheck' ? 180_000 : 360_000);
|
|
1672
|
+
results.push(result);
|
|
1673
|
+
unchanged = await pinsUnchanged(projectRoot, pins);
|
|
1674
|
+
if (!unchanged || !result.completed || result.exitStatus !== 0) break;
|
|
1675
|
+
}
|
|
1676
|
+
return { results, unchanged };
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
let bundledGuideCache;
|
|
1680
|
+
let semanticsCache;
|
|
1681
|
+
|
|
1682
|
+
const setupExpertSchema = z.object(setupSchema);
|
|
1683
|
+
const rulesSchemaParsed = z.object(rulesSchema);
|
|
1684
|
+
const creativeSchemaParsed = z.object(creativeSchema);
|
|
1685
|
+
const architectSchemaParsed = z.object(pageArchitectSchema);
|
|
1686
|
+
const syntaxSchemaParsed = z.object(syntaxSchema).refine((value) => value.components.length + value.foundations.length > 0, 'Request at least one component or foundation');
|
|
1687
|
+
const qualitySchemaParsed = z.object(inspectSchema);
|
|
1688
|
+
|
|
1689
|
+
async function setupExpertHandler(args) {
|
|
1690
|
+
if (skipTool('setupExpert')) {
|
|
1691
|
+
return skippedStage('setup_expert', 'SKIP_SETUP_EXPERT', args?.workflowId);
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
const parsed = setupExpertSchema.parse(args);
|
|
1695
|
+
const workflowId = parsed.workflowId || randomUUID();
|
|
1696
|
+
const projectRoot = resolveProjectRoot(parsed.projectRoot);
|
|
1697
|
+
const snapshot = await projectSummary(projectRoot);
|
|
1698
|
+
const catalog = await loadGuideCatalog(projectRoot);
|
|
1699
|
+
const contractVersion = catalog.frameworkVersion;
|
|
1700
|
+
const version = await resolveExpressiveVersion({ projectRoot, contractVersion });
|
|
1701
|
+
const provenanceBlock = provenanceBlockReason(catalog.provenance.status);
|
|
1702
|
+
const checks = [
|
|
1703
|
+
{
|
|
1704
|
+
check: 'project-manifest',
|
|
1705
|
+
passed: snapshot.isExpressiveProject,
|
|
1706
|
+
message: snapshot.isExpressiveProject
|
|
1707
|
+
? `Dependency found: ${snapshot.dependency.version} (${snapshot.dependency.dependencyKind})`
|
|
1708
|
+
: 'ExpressiveCSS dependency not found in package.json. Use installHint=true for installation guidance.',
|
|
1709
|
+
},
|
|
1710
|
+
{
|
|
1711
|
+
check: 'guide-artifacts',
|
|
1712
|
+
passed: snapshot.foundSkills || snapshot.bundledGuides,
|
|
1713
|
+
message: snapshot.foundSkills
|
|
1714
|
+
? 'Local component guide set is present.'
|
|
1715
|
+
: snapshot.bundledGuides
|
|
1716
|
+
? 'Using the component guides bundled with the MCP package.'
|
|
1717
|
+
: 'No local or bundled ExpressiveCSS component guides were found.',
|
|
1718
|
+
},
|
|
1719
|
+
{
|
|
1720
|
+
check: 'docs-catalog',
|
|
1721
|
+
passed: snapshot.foundDocs,
|
|
1722
|
+
message: snapshot.foundDocs
|
|
1723
|
+
? 'Docs catalogue is present.'
|
|
1724
|
+
: 'Project docs catalogue not found at docs/src/data/nav.ts.',
|
|
1725
|
+
},
|
|
1726
|
+
];
|
|
1727
|
+
|
|
1728
|
+
const suggestions = [
|
|
1729
|
+
'Use `projectRoot` in other tool calls when running outside repository root.',
|
|
1730
|
+
'Run Rules Enforcer next once setup checks are satisfied.',
|
|
1731
|
+
];
|
|
1732
|
+
|
|
1733
|
+
if (parsed.installHint && !snapshot.isExpressiveProject) {
|
|
1734
|
+
suggestions.push('Install suggestion: npm i @expressivecss/expressive --save');
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
if (parsed.themes) {
|
|
1738
|
+
checks.push({
|
|
1739
|
+
check: 'theme-surface',
|
|
1740
|
+
passed: snapshot.isExpressiveProject,
|
|
1741
|
+
message: 'Use documented theme extension points and project-level custom properties rather than editing framework internals.',
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
if (parsed.colors) {
|
|
1746
|
+
checks.push({
|
|
1747
|
+
check: 'theme-color-tokens',
|
|
1748
|
+
passed: snapshot.isExpressiveProject,
|
|
1749
|
+
message: 'Prefer runtime theme configuration and MD3 roles (`--md-sys-*`).',
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
const payload = buildStagePayload(
|
|
1754
|
+
'setup_expert',
|
|
1755
|
+
{
|
|
1756
|
+
projectRoot,
|
|
1757
|
+
checks,
|
|
1758
|
+
packageManager: version.packageManager,
|
|
1759
|
+
framework: {
|
|
1760
|
+
detectedVersion: version.resolvedVersion,
|
|
1761
|
+
declaredRange: version.declaredRange,
|
|
1762
|
+
resolvedVersion: version.resolvedVersion,
|
|
1763
|
+
resolutionSource: version.resolutionSource,
|
|
1764
|
+
contractVersion,
|
|
1765
|
+
contractCompatibility: version.status,
|
|
1766
|
+
compatibility: {
|
|
1767
|
+
status: version.status,
|
|
1768
|
+
message: version.status === 'match'
|
|
1769
|
+
? `Rules match ExpressiveCSS ${contractVersion}.`
|
|
1770
|
+
: version.status === 'mismatch'
|
|
1771
|
+
? `Project resolves ExpressiveCSS ${version.resolvedVersion}; this server bundles ${contractVersion} contracts.`
|
|
1772
|
+
: `No exact ExpressiveCSS version was resolved; this server bundles ${contractVersion} contracts.`,
|
|
1773
|
+
},
|
|
1774
|
+
matchingTag: version.matchingTag,
|
|
1775
|
+
documentationMode: version.documentationMode,
|
|
1776
|
+
documentationSources: version.documentationSources,
|
|
1777
|
+
bundledContractSafe: version.bundledContractSafe,
|
|
1778
|
+
currentDocsSafe: version.currentDocsSafe,
|
|
1779
|
+
warnings: version.warnings,
|
|
1780
|
+
diagnostics: version.diagnostics,
|
|
1781
|
+
},
|
|
1782
|
+
checksPerformed: ['project manifest', 'package manager detection', 'ExpressiveCSS version resolution', 'guide artifact discovery'],
|
|
1783
|
+
evidenceSources: [
|
|
1784
|
+
'package.json',
|
|
1785
|
+
version.resolutionSource,
|
|
1786
|
+
snapshot.foundSkills ? 'local skill guides' : 'bundled MCP guides',
|
|
1787
|
+
],
|
|
1788
|
+
uncheckedAreas: ['visual hierarchy', 'rendered responsive behavior', 'keyboard behavior', 'screen-reader announcements'],
|
|
1789
|
+
contractCompatibility: version.status,
|
|
1790
|
+
contractProvenance: catalog.provenance.status,
|
|
1791
|
+
contractProvenanceDetails: catalog.provenance,
|
|
1792
|
+
coverageStatus: 'setup-only',
|
|
1793
|
+
blockedChecks: [
|
|
1794
|
+
...(version.status === 'match' ? [] : ['target-version contract checks']),
|
|
1795
|
+
...(provenanceBlock ? [provenanceBlock] : []),
|
|
1796
|
+
],
|
|
1797
|
+
recommendations: suggestions,
|
|
1798
|
+
installHint: parsed.installHint,
|
|
1799
|
+
skipSettings: {
|
|
1800
|
+
SKIP_SETUP_EXPERT: process.env.SKIP_SETUP_EXPERT || 'false',
|
|
1801
|
+
},
|
|
1802
|
+
},
|
|
1803
|
+
workflowId,
|
|
1804
|
+
);
|
|
1805
|
+
|
|
1806
|
+
return toToolResult(payload);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
async function rulesEnforcerHandler(args) {
|
|
1810
|
+
if (skipTool('rulesEnforcer')) {
|
|
1811
|
+
return skippedStage('rules_enforcer', 'SKIP_RULES_ENFORCER', args?.workflowId);
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
const parsed = rulesSchemaParsed.parse(args);
|
|
1815
|
+
const workflowId = parsed.workflowId || randomUUID();
|
|
1816
|
+
const projectRoot = resolveProjectRoot(parsed.projectRoot);
|
|
1817
|
+
const catalog = await loadGuideCatalog(projectRoot);
|
|
1818
|
+
const version = await resolveAgainstContract(projectRoot, catalog.frameworkVersion);
|
|
1819
|
+
const hasSnippet = parsed.snippet.trim().length > 0;
|
|
1820
|
+
const provenanceBlock = provenanceBlockReason(catalog.provenance.status);
|
|
1821
|
+
|
|
1822
|
+
const componentMatches = (parsed.targetComponents || []).map((item) => ({
|
|
1823
|
+
requested: item,
|
|
1824
|
+
guide: findGuideByName(catalog, item, false),
|
|
1825
|
+
}));
|
|
1826
|
+
|
|
1827
|
+
const issues = hasSnippet ? inspectAuthoringRules(parsed.snippet) : [];
|
|
1828
|
+
const inspectionTruncated = Boolean(issues.truncatedReason);
|
|
1829
|
+
const componentGuidance = [];
|
|
1830
|
+
for (const match of componentMatches) {
|
|
1831
|
+
if (!match.guide) {
|
|
1832
|
+
const near = nearestMatches(catalog, match.requested, 3).map((row) => row.slug);
|
|
1833
|
+
componentGuidance.push({
|
|
1834
|
+
kind: 'guidance',
|
|
1835
|
+
component: match.requested,
|
|
1836
|
+
lookupStatus: 'unknown',
|
|
1837
|
+
suggestions: near,
|
|
1838
|
+
});
|
|
1839
|
+
} else {
|
|
1840
|
+
componentGuidance.push({
|
|
1841
|
+
kind: 'guidance',
|
|
1842
|
+
component: match.guide.slug,
|
|
1843
|
+
lookupStatus: 'known',
|
|
1844
|
+
guideRules: match.guide.rules.slice(0, 6),
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
const blocking = issues.filter((item) => item.severity === 'high');
|
|
1850
|
+
const unknownComponents = componentGuidance.filter((item) => item.lookupStatus === 'unknown');
|
|
1851
|
+
const contractBlocked = version.status !== 'match';
|
|
1852
|
+
const componentValidationBlocked = componentGuidance.length > 0;
|
|
1853
|
+
const staticStatus = blocking.length
|
|
1854
|
+
? 'needs_fix'
|
|
1855
|
+
: inspectionTruncated
|
|
1856
|
+
? 'blocked'
|
|
1857
|
+
: !hasSnippet
|
|
1858
|
+
? 'blocked'
|
|
1859
|
+
: issues.length
|
|
1860
|
+
? 'warn'
|
|
1861
|
+
: 'pass';
|
|
1862
|
+
const reportedStaticStatus = staticStatus === 'pass' ? 'heuristic_pass' : staticStatus;
|
|
1863
|
+
const status = staticStatus === 'needs_fix'
|
|
1864
|
+
? 'needs_fix'
|
|
1865
|
+
: contractBlocked || provenanceBlock || unknownComponents.length || componentValidationBlocked || inspectionTruncated
|
|
1866
|
+
? 'blocked'
|
|
1867
|
+
: staticStatus;
|
|
1868
|
+
|
|
1869
|
+
const payload = buildStagePayload(
|
|
1870
|
+
'rules_enforcer',
|
|
1871
|
+
{
|
|
1872
|
+
projectRoot,
|
|
1873
|
+
status,
|
|
1874
|
+
staticStatus: reportedStaticStatus,
|
|
1875
|
+
scopedStatus: `authored_static_${staticStatus}`,
|
|
1876
|
+
reviewComplete: false,
|
|
1877
|
+
framework: {
|
|
1878
|
+
detectedVersion: version.resolvedVersion,
|
|
1879
|
+
declaredRange: version.declaredRange,
|
|
1880
|
+
resolvedVersion: version.resolvedVersion,
|
|
1881
|
+
resolutionSource: version.resolutionSource,
|
|
1882
|
+
contractVersion: catalog.frameworkVersion,
|
|
1883
|
+
guideSource: catalog.guideSource,
|
|
1884
|
+
contractCompatibility: version.status,
|
|
1885
|
+
documentationMode: version.documentationMode,
|
|
1886
|
+
documentationSources: version.documentationSources,
|
|
1887
|
+
bundledContractSafe: version.bundledContractSafe,
|
|
1888
|
+
currentDocsSafe: version.currentDocsSafe,
|
|
1889
|
+
},
|
|
1890
|
+
issueCount: issues.length,
|
|
1891
|
+
blockingIssueCount: blocking.length,
|
|
1892
|
+
issues,
|
|
1893
|
+
componentGuidance,
|
|
1894
|
+
checksPerformed: [
|
|
1895
|
+
...(hasSnippet ? ['heuristic static authoring rules', 'heuristic authored semantics'] : ['target contract resolution']),
|
|
1896
|
+
...(componentGuidance.length ? ['requested component guide lookup'] : []),
|
|
1897
|
+
],
|
|
1898
|
+
evidenceSources: hasSnippet
|
|
1899
|
+
? ['supplied snippet', catalog.guideSource, 'bundled normative semantics']
|
|
1900
|
+
: [catalog.guideSource],
|
|
1901
|
+
uncheckedAreas: [
|
|
1902
|
+
'rendered appearance',
|
|
1903
|
+
'runtime behavior',
|
|
1904
|
+
'keyboard operation',
|
|
1905
|
+
'focus',
|
|
1906
|
+
'screen-reader announcements',
|
|
1907
|
+
...(componentValidationBlocked ? ['requested component-rule validation'] : []),
|
|
1908
|
+
],
|
|
1909
|
+
contractCompatibility: version.status,
|
|
1910
|
+
contractProvenance: catalog.provenance.status,
|
|
1911
|
+
contractProvenanceDetails: catalog.provenance,
|
|
1912
|
+
coverageStatus: hasSnippet ? 'partial-static-evidence' : 'no-evidence',
|
|
1913
|
+
blockedChecks: [
|
|
1914
|
+
...(contractBlocked ? ['target-version contract checks'] : []),
|
|
1915
|
+
...(provenanceBlock ? [provenanceBlock] : []),
|
|
1916
|
+
...(unknownComponents.length ? ['unknown requested component contracts'] : []),
|
|
1917
|
+
...(componentValidationBlocked ? ['requested component-rule validation'] : []),
|
|
1918
|
+
...(!hasSnippet ? ['empty snippet'] : []),
|
|
1919
|
+
...(inspectionTruncated ? ['static inspection limit reached'] : []),
|
|
1920
|
+
],
|
|
1921
|
+
guidance: {
|
|
1922
|
+
alwaysPrefer: 'Read component guides before adding structure.',
|
|
1923
|
+
compatibility: 'Avoid Materialize-era selectors and prefer source-of-truth component contracts.',
|
|
1924
|
+
},
|
|
1925
|
+
},
|
|
1926
|
+
workflowId,
|
|
1927
|
+
);
|
|
1928
|
+
|
|
1929
|
+
return toToolResult(payload);
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
async function creativeDirectorHandler(args) {
|
|
1933
|
+
if (skipTool('creativeDirector')) {
|
|
1934
|
+
return skippedStage('creative_director', 'SKIP_CREATIVE_DIRECTOR', args?.workflowId);
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
const parsed = creativeSchemaParsed.parse(args);
|
|
1938
|
+
const workflowId = parsed.workflowId || randomUUID();
|
|
1939
|
+
const projectRoot = resolveProjectRoot(parsed.projectRoot);
|
|
1940
|
+
const catalog = await loadGuideCatalog(projectRoot);
|
|
1941
|
+
const version = await resolveAgainstContract(projectRoot, catalog.frameworkVersion);
|
|
1942
|
+
const provenanceBlock = provenanceBlockReason(catalog.provenance.status);
|
|
1943
|
+
const contractSafe = version.status === 'match' && !provenanceBlock;
|
|
1944
|
+
|
|
1945
|
+
const hintTokens = parsed.constraints ? tokenize(parsed.constraints) : [];
|
|
1946
|
+
const candidateResult = contractSafe
|
|
1947
|
+
? buildCreativeCandidates(catalog, `${parsed.goal} ${hintTokens.join(' ')}`, parsed.maxSuggestions)
|
|
1948
|
+
: { suggestions: [], truncated: false, omittedCount: 0 };
|
|
1949
|
+
const { suggestions, truncated, omittedCount } = candidateResult;
|
|
1950
|
+
|
|
1951
|
+
const payload = buildStagePayload(
|
|
1952
|
+
'creative_director',
|
|
1953
|
+
{
|
|
1954
|
+
goal: parsed.goal,
|
|
1955
|
+
constraints: parsed.constraints || null,
|
|
1956
|
+
status: contractSafe ? 'available' : 'blocked',
|
|
1957
|
+
contractVersion: catalog.frameworkVersion,
|
|
1958
|
+
count: suggestions.length,
|
|
1959
|
+
suggestions,
|
|
1960
|
+
truncated,
|
|
1961
|
+
omittedCount,
|
|
1962
|
+
checksPerformed: contractSafe
|
|
1963
|
+
? ['target contract resolution', 'contract provenance validation', 'component decision catalogue ranking', 'native inline-feedback selection']
|
|
1964
|
+
: ['target contract resolution', 'contract provenance validation'],
|
|
1965
|
+
evidenceSources: contractSafe
|
|
1966
|
+
? [version.resolutionSource, 'bundled:component-decisions.json', `${catalog.guideSource}:component-guides`, 'server:native-inline-feedback']
|
|
1967
|
+
: [version.resolutionSource],
|
|
1968
|
+
uncheckedAreas: [
|
|
1969
|
+
'target-version compatibility',
|
|
1970
|
+
'rendered component behavior',
|
|
1971
|
+
'responsive behavior',
|
|
1972
|
+
'keyboard and focus behavior',
|
|
1973
|
+
'screen-reader and accessibility behavior',
|
|
1974
|
+
],
|
|
1975
|
+
contractCompatibility: version.status,
|
|
1976
|
+
contractProvenance: catalog.provenance.status,
|
|
1977
|
+
contractProvenanceDetails: catalog.provenance,
|
|
1978
|
+
coverageStatus: contractSafe ? 'component-selection-only' : 'no-contract-guidance',
|
|
1979
|
+
blockedChecks: [
|
|
1980
|
+
...(version.status === 'match' ? [] : ['target-version contract checks']),
|
|
1981
|
+
...(provenanceBlock ? [provenanceBlock] : []),
|
|
1982
|
+
'rendered validation',
|
|
1983
|
+
'interaction validation',
|
|
1984
|
+
'accessibility validation',
|
|
1985
|
+
],
|
|
1986
|
+
},
|
|
1987
|
+
workflowId,
|
|
1988
|
+
);
|
|
1989
|
+
|
|
1990
|
+
return toToolResult(payload);
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
async function pageArchitectHandler(args, stage = 'page_architect') {
|
|
1994
|
+
if (skipTool('pageArchitect')) {
|
|
1995
|
+
return skippedStage(stage, 'SKIP_PAGE_ARCHITECT', args?.workflowId);
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
const parsed = architectSchemaParsed.parse(args);
|
|
1999
|
+
const workflowId = parsed.workflowId || randomUUID();
|
|
2000
|
+
const projectRoot = resolveProjectRoot(parsed.projectRoot);
|
|
2001
|
+
const catalog = await loadGuideCatalog(projectRoot);
|
|
2002
|
+
const version = await resolveAgainstContract(projectRoot, catalog.frameworkVersion);
|
|
2003
|
+
const provenanceBlock = provenanceBlockReason(catalog.provenance.status);
|
|
2004
|
+
const contractSafe = version.status === 'match' && !provenanceBlock;
|
|
2005
|
+
|
|
2006
|
+
const candidate = contractSafe
|
|
2007
|
+
? buildPageArchitecture(
|
|
2008
|
+
catalog,
|
|
2009
|
+
parsed.pageGoal,
|
|
2010
|
+
parsed.components,
|
|
2011
|
+
parsed.viewportTarget,
|
|
2012
|
+
parsed.includeAccessibility,
|
|
2013
|
+
)
|
|
2014
|
+
: null;
|
|
2015
|
+
const unresolvedComponents = candidate?.unresolvedComponents ?? [];
|
|
2016
|
+
const architectureSafe = contractSafe && unresolvedComponents.length === 0;
|
|
2017
|
+
const architecture = architectureSafe
|
|
2018
|
+
? Object.fromEntries(Object.entries(candidate).filter(([key]) => key !== 'unresolvedComponents'))
|
|
2019
|
+
: null;
|
|
2020
|
+
|
|
2021
|
+
const payload = buildStagePayload(
|
|
2022
|
+
stage,
|
|
2023
|
+
{
|
|
2024
|
+
pageGoal: parsed.pageGoal,
|
|
2025
|
+
viewportTarget: parsed.viewportTarget,
|
|
2026
|
+
accessibilityNotes: parsed.includeAccessibility,
|
|
2027
|
+
status: architectureSafe ? 'available' : 'blocked',
|
|
2028
|
+
architecture,
|
|
2029
|
+
unresolvedComponents,
|
|
2030
|
+
contractVersion: catalog.frameworkVersion,
|
|
2031
|
+
catalogCount: catalog.count,
|
|
2032
|
+
checksPerformed: [
|
|
2033
|
+
'target contract resolution',
|
|
2034
|
+
'contract provenance validation',
|
|
2035
|
+
...(contractSafe ? ['exact requested component lookup'] : []),
|
|
2036
|
+
...(architectureSafe ? ['semantic skeleton generation'] : []),
|
|
2037
|
+
],
|
|
2038
|
+
evidenceSources: contractSafe
|
|
2039
|
+
? [version.resolutionSource, 'bundled:component-decisions.json', `${catalog.guideSource}:component-guides`]
|
|
2040
|
+
: [version.resolutionSource],
|
|
2041
|
+
uncheckedAreas: [
|
|
2042
|
+
'target-version compatibility',
|
|
2043
|
+
'rendered layout',
|
|
2044
|
+
'responsive behavior',
|
|
2045
|
+
'keyboard and focus behavior',
|
|
2046
|
+
'screen-reader and accessibility behavior',
|
|
2047
|
+
],
|
|
2048
|
+
contractCompatibility: version.status,
|
|
2049
|
+
contractProvenance: catalog.provenance.status,
|
|
2050
|
+
contractProvenanceDetails: catalog.provenance,
|
|
2051
|
+
coverageStatus: architectureSafe ? 'architecture-proposal-only' : 'no-architecture-guidance',
|
|
2052
|
+
blockedChecks: [
|
|
2053
|
+
...(version.status === 'match' ? [] : ['target-version contract checks']),
|
|
2054
|
+
...(provenanceBlock ? [provenanceBlock] : []),
|
|
2055
|
+
...(unresolvedComponents.length ? ['exact requested component lookup'] : []),
|
|
2056
|
+
'rendered validation',
|
|
2057
|
+
'interaction validation',
|
|
2058
|
+
'accessibility validation',
|
|
2059
|
+
],
|
|
2060
|
+
},
|
|
2061
|
+
workflowId,
|
|
2062
|
+
);
|
|
2063
|
+
|
|
2064
|
+
return toToolResult(payload);
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
async function componentSyntaxExpertHandler(args) {
|
|
2068
|
+
if (skipTool('componentSyntaxExpert')) {
|
|
2069
|
+
return skippedStage('component_syntax_expert', 'SKIP_COMPONENT_SYNTAX_EXPERT', args?.workflowId);
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
const parsed = syntaxSchemaParsed.parse(args);
|
|
2073
|
+
const workflowId = parsed.workflowId || randomUUID();
|
|
2074
|
+
const projectRoot = resolveProjectRoot(parsed.projectRoot);
|
|
2075
|
+
const catalog = await loadGuideCatalog(projectRoot);
|
|
2076
|
+
const version = await resolveAgainstContract(projectRoot, catalog.frameworkVersion);
|
|
2077
|
+
const provenanceBlock = provenanceBlockReason(catalog.provenance.status);
|
|
2078
|
+
|
|
2079
|
+
const capabilitySafe = version.status === 'match' && !provenanceBlock && CAPABILITY_ROADMAP.frameworkVersion === catalog.frameworkVersion;
|
|
2080
|
+
const requested = parsed.components;
|
|
2081
|
+
const found = [];
|
|
2082
|
+
const missing = [];
|
|
2083
|
+
|
|
2084
|
+
for (const component of requested) {
|
|
2085
|
+
const guide = findGuideByName(catalog, component, false);
|
|
2086
|
+
if (!guide) {
|
|
2087
|
+
const skipLimit = Math.min(Math.max(SETTINGS.maxComponentSkips, 1), 20);
|
|
2088
|
+
missing.push({
|
|
2089
|
+
requested: component,
|
|
2090
|
+
nearest: nearestMatches(catalog, component, skipLimit).map((row) => row.slug),
|
|
2091
|
+
});
|
|
2092
|
+
} else {
|
|
2093
|
+
found.push({ ...summarizeGuide(guide), capability: capabilitySafe ? CAPABILITIES_BY_SLUG.get(guide.slug) ?? null : null });
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
const payload = buildStagePayload(
|
|
2098
|
+
'component_syntax_expert',
|
|
2099
|
+
{
|
|
2100
|
+
foundCount: found.length,
|
|
2101
|
+
foundations: capabilitySafe ? parsed.foundations.map((slug) => CAPABILITIES_BY_SLUG.get(slug)) : [],
|
|
2102
|
+
capabilityEvidence: { status: capabilitySafe ? 'bundled-review-snapshot' : 'blocked', basis: CAPABILITY_ROADMAP.basis, browserRun: capabilitySafe ? CAPABILITY_ROADMAP.browserRun : null },
|
|
2103
|
+
contractVersion: catalog.frameworkVersion,
|
|
2104
|
+
guideSource: catalog.guideSource,
|
|
2105
|
+
found,
|
|
2106
|
+
missing,
|
|
2107
|
+
status: version.status === 'match' && !provenanceBlock && missing.length === 0 && (!parsed.foundations.length || capabilitySafe) ? 'available' : 'blocked',
|
|
2108
|
+
checksPerformed: [...(requested.length ? ['named component contract lookup'] : []), ...(capabilitySafe ? ['bundled capability snapshot lookup'] : [])],
|
|
2109
|
+
evidenceSources: [...found.map((component) => `${catalog.guideSource}:${component.file}`), ...(capabilitySafe ? ['bundled:capability-roadmap.json'] : [])],
|
|
2110
|
+
uncheckedAreas: [
|
|
2111
|
+
'rendered component behavior',
|
|
2112
|
+
'visual hierarchy',
|
|
2113
|
+
'responsive composition',
|
|
2114
|
+
'keyboard and assistive-technology behavior',
|
|
2115
|
+
],
|
|
2116
|
+
contractCompatibility: version.status,
|
|
2117
|
+
contractProvenance: catalog.provenance.status,
|
|
2118
|
+
contractProvenanceDetails: catalog.provenance,
|
|
2119
|
+
coverageStatus: !requested.length ? 'named-foundation-review-snapshot' : missing.length ? 'partial-named-component-contracts' : 'named-component-contracts',
|
|
2120
|
+
blockedChecks: [
|
|
2121
|
+
...(version.status === 'match' ? [] : ['target-version contract checks']),
|
|
2122
|
+
...(provenanceBlock ? [provenanceBlock] : []),
|
|
2123
|
+
...(missing.length ? ['missing requested component contracts'] : []),
|
|
2124
|
+
...(!capabilitySafe && parsed.foundations.length ? ['requested foundation capability evidence'] : []),
|
|
2125
|
+
],
|
|
2126
|
+
maxCharactersPerComponent: SETTINGS.maxComponentResponseChars,
|
|
2127
|
+
notes: [
|
|
2128
|
+
'Component rules are derived from the generated ExpressiveCSS component guides and should be cross-checked against package docs.',
|
|
2129
|
+
'If a component is marked unknown, run Creative Director again with clearer component intent or pass an exact guide slug.',
|
|
2130
|
+
],
|
|
2131
|
+
},
|
|
2132
|
+
workflowId,
|
|
2133
|
+
);
|
|
2134
|
+
|
|
2135
|
+
return toToolResult(payload);
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
async function qualityInspectorHandler(args) {
|
|
2139
|
+
if (skipTool('qualityInspector')) {
|
|
2140
|
+
return skippedStage('quality_inspector', 'SKIP_QUALITY_INSPECTOR', args?.workflowId);
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
const parsed = qualitySchemaParsed.parse(args);
|
|
2144
|
+
const workflowId = parsed.workflowId || randomUUID();
|
|
2145
|
+
const projectRoot = resolveProjectRoot(parsed.projectRoot);
|
|
2146
|
+
const fileSummary = summarizeProjectFiles(parsed.files, projectRoot);
|
|
2147
|
+
const catalog = await loadGuideCatalog(projectRoot);
|
|
2148
|
+
const contractVersion = catalog.frameworkVersion;
|
|
2149
|
+
const version = await resolveExpressiveVersion({ projectRoot, contractVersion });
|
|
2150
|
+
const provenanceBlock = provenanceBlockReason(catalog.provenance.status);
|
|
2151
|
+
|
|
2152
|
+
const fileInspection = await findFileViolations(fileSummary.existing, projectRoot);
|
|
2153
|
+
const staticFindings = fileInspection.findings;
|
|
2154
|
+
const filesUninspected = [
|
|
2155
|
+
...fileSummary.skipped.map((item) => ({ file: item.requested, reason: item.reason })),
|
|
2156
|
+
...fileInspection.uninspected,
|
|
2157
|
+
];
|
|
2158
|
+
const highCount = staticFindings.reduce((count, entry) => count + entry.issues.filter((issue) => issue.severity === 'high').length, 0);
|
|
2159
|
+
|
|
2160
|
+
const commandChecks = [];
|
|
2161
|
+
const commandsRequested = parsed.runCommands && (['standard', 'full', 'consumer'].includes(parsed.runType));
|
|
2162
|
+
const executionPolicy = commandExecutionPolicy(projectRoot);
|
|
2163
|
+
const commandRootBlocked = commandsRequested && !executionPolicy.allowed;
|
|
2164
|
+
const packageManagerBlocked = commandsRequested && !['npm', 'pnpm', 'yarn'].includes(version.packageManager);
|
|
2165
|
+
const commands = commandsRequested ? qualityCommands(parsed.runType) : [];
|
|
2166
|
+
const commandScopeBlocked = commands.some(script => !SETTINGS.allowedScripts.includes(script));
|
|
2167
|
+
const pins = [...fileInspection.sourcePins];
|
|
2168
|
+
const expectedMatched = parsed.expectedSourceHashes === undefined ? null : Object.entries(parsed.expectedSourceHashes)
|
|
2169
|
+
.every(([file, sha256]) => pins.some(pin => pin.file === file && pin.sha256 === sha256));
|
|
2170
|
+
let commandManifestSha256 = null;
|
|
2171
|
+
let manifestBlocked = false;
|
|
2172
|
+
if (commandsRequested && !commandRootBlocked && !commandScopeBlocked && !packageManagerBlocked && expectedMatched !== false) {
|
|
2173
|
+
try {
|
|
2174
|
+
const manifest = await readInspectionFile(path.join(projectRoot, 'package.json'), projectRoot, 1024 * 1024);
|
|
2175
|
+
commandManifestSha256 = manifest.sha256;
|
|
2176
|
+
pins.push({ file: 'package.json', sha256: manifest.sha256, bytes: manifest.bytes });
|
|
2177
|
+
} catch { manifestBlocked = true; }
|
|
2178
|
+
}
|
|
2179
|
+
let inputsUnchanged = await pinsUnchanged(projectRoot, pins);
|
|
2180
|
+
if (commandsRequested && !commandRootBlocked && !commandScopeBlocked && !packageManagerBlocked && !manifestBlocked && expectedMatched !== false && inputsUnchanged) {
|
|
2181
|
+
const run = await runQualityCommands(executionPolicy.projectRoot, commands, version.packageManager, pins);
|
|
2182
|
+
commandChecks.push(...run.results);
|
|
2183
|
+
inputsUnchanged = run.unchanged;
|
|
2184
|
+
}
|
|
2185
|
+
inputsUnchanged = inputsUnchanged && await pinsUnchanged(projectRoot, pins);
|
|
2186
|
+
const commandsNotRun = commands.slice(commandChecks.length);
|
|
2187
|
+
|
|
2188
|
+
const commandBlocked = commandChecks.filter((run) => !run.completed);
|
|
2189
|
+
const commandFailed = commandChecks.some((run) => run.completed && run.exitStatus !== 0);
|
|
2190
|
+
const completedCommands = commandChecks.filter((run) => run.completed);
|
|
2191
|
+
const inspectionPerformed = fileInspection.inspected.length > 0 || completedCommands.length > 0;
|
|
2192
|
+
const evidenceStatus = highCount > 0 || commandFailed
|
|
2193
|
+
? 'needs_fix'
|
|
2194
|
+
: staticFindings.some((entry) => entry.issues.length > 0) || fileSummary.skipped.length > 0 || !inspectionPerformed
|
|
2195
|
+
? 'warn'
|
|
2196
|
+
: 'pass';
|
|
2197
|
+
const staticStatus = fileInspection.inspected.length === 0
|
|
2198
|
+
? 'not_run'
|
|
2199
|
+
: highCount > 0
|
|
2200
|
+
? 'needs_fix'
|
|
2201
|
+
: staticFindings.length > 0
|
|
2202
|
+
? 'warn'
|
|
2203
|
+
: 'heuristic_pass';
|
|
2204
|
+
const hasBlockedChecks = version.status !== 'match'
|
|
2205
|
+
|| provenanceBlock
|
|
2206
|
+
|| filesUninspected.length > 0
|
|
2207
|
+
|| commandRootBlocked
|
|
2208
|
+
|| commandScopeBlocked
|
|
2209
|
+
|| expectedMatched === false
|
|
2210
|
+
|| !inputsUnchanged
|
|
2211
|
+
|| manifestBlocked
|
|
2212
|
+
|| commandsNotRun.length > 0
|
|
2213
|
+
|| packageManagerBlocked
|
|
2214
|
+
|| commandBlocked.length > 0
|
|
2215
|
+
|| !inspectionPerformed;
|
|
2216
|
+
const status = evidenceStatus === 'needs_fix'
|
|
2217
|
+
? 'needs_fix'
|
|
2218
|
+
: hasBlockedChecks
|
|
2219
|
+
? 'blocked'
|
|
2220
|
+
: evidenceStatus;
|
|
2221
|
+
|
|
2222
|
+
const payload = buildStagePayload(
|
|
2223
|
+
'quality_inspector',
|
|
2224
|
+
{
|
|
2225
|
+
projectRoot,
|
|
2226
|
+
runType: parsed.runType,
|
|
2227
|
+
filesRequested: parsed.files.length,
|
|
2228
|
+
coverage: {
|
|
2229
|
+
filesInspected: fileInspection.inspected,
|
|
2230
|
+
filesUninspected,
|
|
2231
|
+
filesInspectedCount: fileInspection.inspected.length,
|
|
2232
|
+
filesUninspectedCount: filesUninspected.length,
|
|
2233
|
+
commandsRun: completedCommands.length,
|
|
2234
|
+
commandsAttempted: commandChecks.length,
|
|
2235
|
+
inspectionPerformed,
|
|
2236
|
+
},
|
|
2237
|
+
filesSkipped: filesUninspected,
|
|
2238
|
+
staticFindings,
|
|
2239
|
+
inspectionEvidence: {
|
|
2240
|
+
algorithm: 'sha256', files: fileInspection.sourcePins, expectedMatched, inputsUnchanged, commandManifestSha256,
|
|
2241
|
+
scope: 'Exact inspected bytes and command package.json at observed endpoints only; no lock, whole-revision proof, script sandbox, or automatic rollback.',
|
|
2242
|
+
},
|
|
2243
|
+
commandChecks: commandChecks.length ? commandChecks : [],
|
|
2244
|
+
commandExecutionPolicy: {
|
|
2245
|
+
requested: commandsRequested,
|
|
2246
|
+
...executionPolicy,
|
|
2247
|
+
allowedScripts: SETTINGS.allowedScripts,
|
|
2248
|
+
commandsNotRun,
|
|
2249
|
+
stopOnFailure: true,
|
|
2250
|
+
},
|
|
2251
|
+
status,
|
|
2252
|
+
staticStatus,
|
|
2253
|
+
scopedStatus: `static_contract_${status}`,
|
|
2254
|
+
reviewComplete: false,
|
|
2255
|
+
checksPerformed: [
|
|
2256
|
+
...(fileInspection.inspected.length ? ['heuristic static authoring and semantics rules'] : []),
|
|
2257
|
+
...completedCommands.map((run) => run.command),
|
|
2258
|
+
],
|
|
2259
|
+
evidenceSources: [
|
|
2260
|
+
...fileInspection.inspected,
|
|
2261
|
+
...completedCommands.map((run) => `command: ${run.command}`),
|
|
2262
|
+
],
|
|
2263
|
+
uncheckedAreas: [
|
|
2264
|
+
'visual hierarchy',
|
|
2265
|
+
'motion behavior',
|
|
2266
|
+
'focus visibility and order',
|
|
2267
|
+
'rendered responsive composition',
|
|
2268
|
+
'screen-reader announcements',
|
|
2269
|
+
'contrast unless separately measured',
|
|
2270
|
+
],
|
|
2271
|
+
contractCompatibility: version.status,
|
|
2272
|
+
contractProvenance: catalog.provenance.status,
|
|
2273
|
+
contractProvenanceDetails: catalog.provenance,
|
|
2274
|
+
coverageStatus: inspectionPerformed ? 'partial-static-evidence' : 'no-evidence',
|
|
2275
|
+
blockedChecks: [
|
|
2276
|
+
...(version.status === 'match' ? [] : ['target-version contract checks']),
|
|
2277
|
+
...(provenanceBlock ? [provenanceBlock] : []),
|
|
2278
|
+
...(!inspectionPerformed ? ['static inspection'] : []),
|
|
2279
|
+
...(filesUninspected.length ? ['some requested files were not inspected'] : []),
|
|
2280
|
+
...(commandRootBlocked ? ['command execution root is not allowlisted'] : []),
|
|
2281
|
+
...(commandScopeBlocked ? ['requested scripts exceed server command scope'] : []),
|
|
2282
|
+
...(expectedMatched === false ? ['expected source hashes do not match inspected files'] : []),
|
|
2283
|
+
...(!inputsUnchanged ? ['inspected inputs changed during verification'] : []),
|
|
2284
|
+
...(manifestBlocked ? ['command manifest could not be pinned'] : []),
|
|
2285
|
+
...commandsNotRun.map(script => `${script} command not run`),
|
|
2286
|
+
...(packageManagerBlocked ? ['package manager could not be detected'] : []),
|
|
2287
|
+
...commandBlocked.map((run) => `${run.command.split(' ').at(-1)} command ${run.timedOut ? 'timed out' : 'could not be launched'}`),
|
|
2288
|
+
],
|
|
2289
|
+
recommendations: [
|
|
2290
|
+
...(parsed.runType === 'consumer' ? ['Consumer commands are project-authored. Inspect operator-collected report.json and captures; exit status or printed claims alone do not establish browser conformance.'] : []),
|
|
2291
|
+
'If status is warn, resolve medium/high-severity issues before shipping.',
|
|
2292
|
+
'Run only authorized checks. On failure, retain evidence and repair within scope; do not widen permissions or reset unrelated work.',
|
|
2293
|
+
],
|
|
2294
|
+
limits: {
|
|
2295
|
+
maxFiles: SETTINGS.qaMaxFiles,
|
|
2296
|
+
maxFileMb: SETTINGS.qaMaxMb,
|
|
2297
|
+
commandTimeoutMs: SETTINGS.commandTimeoutMs,
|
|
2298
|
+
maxCommandOutputCharacters: 20_000,
|
|
2299
|
+
},
|
|
2300
|
+
},
|
|
2301
|
+
workflowId,
|
|
2302
|
+
);
|
|
2303
|
+
|
|
2304
|
+
return toToolResult(payload);
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
async function startServer() {
|
|
2308
|
+
const server = new McpServer(
|
|
2309
|
+
{
|
|
2310
|
+
name: 'ExpressiveCSS MCP',
|
|
2311
|
+
version: SERVER_VERSION,
|
|
2312
|
+
},
|
|
2313
|
+
{
|
|
2314
|
+
capabilities: {
|
|
2315
|
+
tools: {},
|
|
2316
|
+
},
|
|
2317
|
+
},
|
|
2318
|
+
);
|
|
2319
|
+
|
|
2320
|
+
server.registerTool('setup_expert', {
|
|
2321
|
+
description: TOOL_DESCRIPTIONS.setup_expert.description,
|
|
2322
|
+
inputSchema: setupSchema,
|
|
2323
|
+
outputSchema: stageOutputSchema,
|
|
2324
|
+
annotations: readAnnotations,
|
|
2325
|
+
}, setupExpertHandler);
|
|
2326
|
+
|
|
2327
|
+
server.registerTool('rules_enforcer', {
|
|
2328
|
+
description: TOOL_DESCRIPTIONS.rules_enforcer.description,
|
|
2329
|
+
inputSchema: rulesSchema,
|
|
2330
|
+
outputSchema: stageOutputSchema,
|
|
2331
|
+
annotations: readAnnotations,
|
|
2332
|
+
}, rulesEnforcerHandler);
|
|
2333
|
+
|
|
2334
|
+
server.registerTool('creative_director', {
|
|
2335
|
+
description: TOOL_DESCRIPTIONS.creative_director.description,
|
|
2336
|
+
inputSchema: creativeSchema,
|
|
2337
|
+
outputSchema: stageOutputSchema,
|
|
2338
|
+
annotations: readAnnotations,
|
|
2339
|
+
}, creativeDirectorHandler);
|
|
2340
|
+
|
|
2341
|
+
server.registerTool('page_architect', {
|
|
2342
|
+
description: TOOL_DESCRIPTIONS.page_architect.description,
|
|
2343
|
+
inputSchema: pageArchitectSchema,
|
|
2344
|
+
outputSchema: stageOutputSchema,
|
|
2345
|
+
annotations: readAnnotations,
|
|
2346
|
+
}, (args) => pageArchitectHandler(args, 'page_architect'));
|
|
2347
|
+
|
|
2348
|
+
server.registerTool('page_arcjitect', {
|
|
2349
|
+
description: TOOL_DESCRIPTIONS.page_arcjitect.description,
|
|
2350
|
+
inputSchema: pageArchitectSchema,
|
|
2351
|
+
outputSchema: stageOutputSchema,
|
|
2352
|
+
annotations: readAnnotations,
|
|
2353
|
+
}, (args) => pageArchitectHandler(args, 'page_arcjitect'));
|
|
2354
|
+
|
|
2355
|
+
server.registerTool('component_syntax_expert', {
|
|
2356
|
+
description: TOOL_DESCRIPTIONS.component_syntax_expert.description,
|
|
2357
|
+
inputSchema: syntaxSchema,
|
|
2358
|
+
outputSchema: stageOutputSchema,
|
|
2359
|
+
annotations: readAnnotations,
|
|
2360
|
+
}, componentSyntaxExpertHandler);
|
|
2361
|
+
|
|
2362
|
+
server.registerTool('quality_inspector', {
|
|
2363
|
+
description: TOOL_DESCRIPTIONS.quality_inspector.description,
|
|
2364
|
+
inputSchema: inspectSchema,
|
|
2365
|
+
outputSchema: qualityOutputSchema,
|
|
2366
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
2367
|
+
}, qualityInspectorHandler);
|
|
2368
|
+
|
|
2369
|
+
const transport = new StdioServerTransport();
|
|
2370
|
+
await server.connect(transport);
|
|
2371
|
+
console.error('ExpressiveCSS MCP server running on stdio transport.');
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
// Only run as the entry point; lint.mjs imports the static checks without starting the server.
|
|
2375
|
+
if (process.argv[1] && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url) {
|
|
2376
|
+
startServer().catch((error) => {
|
|
2377
|
+
console.error('ExpressiveCSS MCP server failed to start:', error);
|
|
2378
|
+
process.exitCode = 1;
|
|
2379
|
+
});
|
|
2380
|
+
}
|