@expressivecss/mcp-server 0.4.0 → 0.5.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 +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +63 -0
- package/README.md +2 -2
- package/capability-roadmap.json +1927 -345
- package/component-decisions.json +3229 -731
- package/component-guides.json +179 -75
- package/contract.json +6 -4
- package/lint.mjs +8 -4
- package/package.json +4 -2
- package/scripts/bounded-file.mjs +73 -0
- package/scripts/resolve-version.mjs +17 -60
- package/scripts/semantics-rules.mjs +184 -0
- package/semantics-data.json +775 -33
- package/server.js +242 -196
- package/smoke.mjs +210 -23
package/smoke.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { access, copyFile, mkdir, mkdtemp, readFile, rm, symlink, truncate, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { access, copyFile, cp, mkdir, mkdtemp, readFile, realpath, rm, symlink, truncate, writeFile } from 'node:fs/promises';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
6
|
-
import { createHash } from 'node:crypto';
|
|
6
|
+
import { createHash, hash } from 'node:crypto';
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
@@ -12,6 +12,8 @@ import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv
|
|
|
12
12
|
import { ReadResourceResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
13
13
|
|
|
14
14
|
const packageDir = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
// Fixtures live in temp directories outside the server's default root, which is all an unset allowlist admits.
|
|
16
|
+
process.env.EXPRESSIVECSS_MCP_ALLOWED_PROJECT_ROOTS ??= JSON.stringify([packageDir, os.tmpdir()]);
|
|
15
17
|
const require = createRequire(import.meta.url);
|
|
16
18
|
const packageManifest = JSON.parse(await readFile(path.join(packageDir, 'package.json'), 'utf8'));
|
|
17
19
|
const jsdomManifest = JSON.parse(await readFile(require.resolve('jsdom/package.json'), 'utf8'));
|
|
@@ -23,7 +25,7 @@ assert.equal(packageManifest.scripts.test, 'node smoke.mjs');
|
|
|
23
25
|
assert.equal(packageManifest.scripts.prepack, 'npm test');
|
|
24
26
|
assert.ok(Object.values(packageManifest.scripts).every((command) => !command.includes('../..')));
|
|
25
27
|
assert.equal(packageManifest.files.includes('scripts/sync-guides.mjs'), false);
|
|
26
|
-
assert.equal(packageManifest.overrides.qs, '6.16.
|
|
28
|
+
assert.equal(packageManifest.overrides.qs, '6.16.1');
|
|
27
29
|
assert.equal(packageManifest.engines.node, jsdomManifest.engines.node);
|
|
28
30
|
for (const [name, data] of Object.entries({ guideData, semanticsData, decisionsData, contractData })) {
|
|
29
31
|
assert.equal(typeof data.generatedBy, 'string', `${name} has no generatedBy marker`);
|
|
@@ -47,13 +49,13 @@ async function writeLocalSourceFixture(projectRoot, { stale = false, contract =
|
|
|
47
49
|
assert.ok(buttonGuide, 'bundled buttons guide fixture missing');
|
|
48
50
|
await writeFile(path.join(projectRoot, 'skills', 'expressivecss', 'components', buttonGuide.file), buttonGuide.content);
|
|
49
51
|
|
|
50
|
-
const
|
|
51
|
-
for (const source of contractData.sources)
|
|
52
|
+
const hasher = createHash('sha256');
|
|
53
|
+
for (const source of contractData.sources) hasher.update(`${source}\0${sourceContents.get(source)}\0`);
|
|
52
54
|
if (contract) {
|
|
53
55
|
await writeFile(path.join(projectRoot, 'skills', 'expressivecss', 'references', 'contract.json'), JSON.stringify({
|
|
54
56
|
...contractData,
|
|
55
57
|
frameworkVersion,
|
|
56
|
-
sourceHash:
|
|
58
|
+
sourceHash: hasher.digest('hex'),
|
|
57
59
|
}));
|
|
58
60
|
}
|
|
59
61
|
if (stale) await writeFile(path.join(projectRoot, 'llm.md'), '# stale local contract source\n');
|
|
@@ -299,10 +301,13 @@ async function verifyResponseBudgets(matchingDir, versionedDir) {
|
|
|
299
301
|
assert.equal(result.structuredContent.skipped, true);
|
|
300
302
|
assert.equal(result.structuredContent.contractCompatibility, 'unknown');
|
|
301
303
|
}
|
|
302
|
-
await assert.rejects(peer.callTool(request('component_syntax_expert', { components: Array(400).fill(1) })), (error) => error.code === -32001);
|
|
303
304
|
}, { env: { SKIP_SETUP_EXPERT: 'true', SKIP_CREATIVE_DIRECTOR: 'true', SKIP_PAGE_ARCHITECT: 'true', SKIP_COMPONENT_SYNTAX_EXPERT: 'true' } });
|
|
304
305
|
await withServer(1, async ({ peer }) => {
|
|
305
306
|
await assert.rejects(peer.callTool(request('component_syntax_expert', { components: ['cards'] })), (error) => error.code === -32001);
|
|
307
|
+
// A validation error is budgeted like any result. Its size is zod's to
|
|
308
|
+
// decide (4.6.6 stops at the array's maxItems), so only a 1-byte budget
|
|
309
|
+
// overflows it on every version.
|
|
310
|
+
await assert.rejects(peer.callTool(request('component_syntax_expert', { components: Array(400).fill(1) })), (error) => error.code === -32001);
|
|
306
311
|
}, { env: { SKIP_COMPONENT_SYNTAX_EXPERT: 'true' } });
|
|
307
312
|
for (const invalid of ['', '0', '-1', '+1', '1.5', 'NaN', 'Infinity', '1e4', ' 64', '9007199254740992']) {
|
|
308
313
|
const child = spawnSync(process.execPath, [path.join(packageDir, 'server.js')], { env: { ...process.env, [setting]: invalid }, encoding: 'utf8', timeout: 10_000 });
|
|
@@ -313,8 +318,7 @@ async function verifyResponseBudgets(matchingDir, versionedDir) {
|
|
|
313
318
|
const fixtureDir = await mkdtemp(path.join(packageDir, '.budget-fixture-'));
|
|
314
319
|
try {
|
|
315
320
|
for (const file of ['server.js', 'package.json', 'component-decisions.json', 'capability-roadmap.json', 'contract.json', 'semantics-data.json']) await copyFile(path.join(packageDir, file), path.join(fixtureDir, file));
|
|
316
|
-
await
|
|
317
|
-
await copyFile(path.join(packageDir, 'scripts', 'resolve-version.mjs'), path.join(fixtureDir, 'scripts', 'resolve-version.mjs'));
|
|
321
|
+
await cp(path.join(packageDir, 'scripts'), path.join(fixtureDir, 'scripts'), { recursive: true });
|
|
318
322
|
const code = 'const text = "界🌍\\\\\\\"";\n'.repeat(500).trim();
|
|
319
323
|
const markdown = '完整 Options 🌍 with an escaped \\" value.\n'.repeat(5_000).trim();
|
|
320
324
|
const rules = ['`whole-first`: Preserve this full record.', '`huge-rule`: ' + '界🌍'.repeat(20_000)];
|
|
@@ -468,8 +472,7 @@ async function verifyCatalogueResources(expectedEntries, toolPayload, consumerRo
|
|
|
468
472
|
const fixtureDir = await mkdtemp(path.join(packageDir, '.resource-fixture-'));
|
|
469
473
|
try {
|
|
470
474
|
for (const file of ['server.js', 'package.json', 'component-guides.json', 'component-decisions.json', 'capability-roadmap.json', 'contract.json', 'semantics-data.json']) await copyFile(path.join(packageDir, file), path.join(fixtureDir, file));
|
|
471
|
-
await
|
|
472
|
-
await copyFile(path.join(packageDir, 'scripts', 'resolve-version.mjs'), path.join(fixtureDir, 'scripts', 'resolve-version.mjs'));
|
|
475
|
+
await cp(path.join(packageDir, 'scripts'), path.join(fixtureDir, 'scripts'), { recursive: true });
|
|
473
476
|
const decisions = structuredClone(decisionsData);
|
|
474
477
|
const unicode = '界🌍 "escaped" \\ value\n'.repeat(2_000);
|
|
475
478
|
decisions.components.find((row) => row.slug === expectedEntries[0].slug).useWhen = [unicode];
|
|
@@ -735,8 +738,8 @@ async function verifyComponentResources(catalogueToolPayload, consumerRoots) {
|
|
|
735
738
|
const assertCompletion = async (peer) => {
|
|
736
739
|
for (const template of [guideTemplate, sectionTemplate]) {
|
|
737
740
|
assert.deepEqual(await complete(peer, template, 'slug', ''), completed(slugs), `${template} lists every canonical slug`);
|
|
738
|
-
assert.deepEqual(await complete(peer, template, 'slug', 'ca'), completed(['cards', 'carousel']));
|
|
739
|
-
assert.deepEqual(await complete(peer, template, 'slug', 'CA'), completed(['cards', 'carousel']), 'slug prefixes ignore case');
|
|
741
|
+
assert.deepEqual(await complete(peer, template, 'slug', 'ca'), completed(['calendar', 'cards', 'carousel', 'cascader']));
|
|
742
|
+
assert.deepEqual(await complete(peer, template, 'slug', 'CA'), completed(['calendar', 'cards', 'carousel', 'cascader']), 'slug prefixes ignore case');
|
|
740
743
|
assert.deepEqual(await complete(peer, template, 'slug', 's'), completed(slugs.filter((slug) => slug.startsWith('s'))));
|
|
741
744
|
assert.deepEqual(await complete(peer, template, 'slug', 'date'), completed(['date-picker']));
|
|
742
745
|
for (const slug of slugs) assert.deepEqual((await complete(peer, template, 'slug', slug)).completion.values[0], slug);
|
|
@@ -762,7 +765,7 @@ async function verifyComponentResources(catalogueToolPayload, consumerRoots) {
|
|
|
762
765
|
}
|
|
763
766
|
assert.deepEqual(await complete(peer, catalogueUri, 'slug', ''), noArgument, 'fixed catalogue URI has no arguments');
|
|
764
767
|
await assert.rejects(peer.complete({ ref: { type: 'ref/prompt', name: 'component_guide' }, argument: { name: 'slug', value: '' } }), (error) => error.code === -32602);
|
|
765
|
-
assert.deepEqual(await complete(peer, guideTemplate, 'slug', 'ca'), completed(['cards', 'carousel']), 'failed completions do not affect later requests');
|
|
768
|
+
assert.deepEqual(await complete(peer, guideTemplate, 'slug', 'ca'), completed(['calendar', 'cards', 'carousel', 'cascader']), 'failed completions do not affect later requests');
|
|
766
769
|
// Every completed name reads; completion never makes aliases or case variants readable.
|
|
767
770
|
for (const slug of (await complete(peer, guideTemplate, 'slug', '')).completion.values) {
|
|
768
771
|
await read(`${base}/${slug}`);
|
|
@@ -975,12 +978,122 @@ const transport = new StdioClientTransport({
|
|
|
975
978
|
const cardContent = lint([cardContentMarkup]);
|
|
976
979
|
assert.equal(cardContent.stdout.match(/legacy-card-content/gu)?.length, 1, cardContent.stdout);
|
|
977
980
|
assert.match(cardContent.stdout, /card-content\.html:3:\d+ legacy-card-content/u);
|
|
981
|
+
// The rules for names 0.16.0 removed, one by one and in process: the lint CLI
|
|
982
|
+
// rewrites className= to class= before inspecting, so it cannot test JSX.
|
|
983
|
+
// Each rule lists markup it must flag and current markup that only shares a
|
|
984
|
+
// name with it, and every legacy rule must have such a list.
|
|
985
|
+
const { inspectAuthoringRules, authoringRules } = await import(path.join(packageDir, 'server.js'));
|
|
986
|
+
inspectAuthoringRules('<p></p>'); // loads jsdom before anything below is timed
|
|
987
|
+
const legacyCases = {
|
|
988
|
+
'legacy-material-icons': {
|
|
989
|
+
flag: ['<span class="material-icons">add</span>', '.material-icons { font-size: 20px }'],
|
|
990
|
+
pass: ['<span class="material-symbols" aria-hidden="true">add</span>'],
|
|
991
|
+
},
|
|
992
|
+
'legacy-i-icon': {
|
|
993
|
+
flag: ['<button><i>add</i></button>', '<li>\n <i>home</i> Home</li>'],
|
|
994
|
+
pass: ['<p><i>sic</i> as written</p>', '<td><i>pending</i></td>', '<button><i class="material-symbols">add</i></button>'],
|
|
995
|
+
},
|
|
996
|
+
'legacy-visibility-alias': {
|
|
997
|
+
flag: ['<div class="hide-on-small-only"></div>', '<div class="show-on-xxl"></div>'],
|
|
998
|
+
pass: ['<div class="hide-on-med-and-down show-on-compact"></div>'],
|
|
999
|
+
},
|
|
1000
|
+
'legacy-helper-class': {
|
|
1001
|
+
flag: ['<div class="no-padding"></div>', '<div class="center-block"></div>', '<p class="no-select"></p>'],
|
|
1002
|
+
pass: ['<div class="p-0 mx-auto"></div>'],
|
|
1003
|
+
},
|
|
1004
|
+
'legacy-renamed-component': {
|
|
1005
|
+
flag: ['<div class="preloader"></div>', '<search class="searchbar"></search>', '<div class="range-field"></div>', '<div class="range">\n <input type="range"></div>', '<div className="range"><input type=range></div>'],
|
|
1006
|
+
pass: ['<input id="searchbar" type="search">', '<span class="range">Jan 1 to Jan 5</span>', '<div class="slider"><input type="range"></div>', '<div class="progress circular"></div>'],
|
|
1007
|
+
},
|
|
1008
|
+
'legacy-picker-root': {
|
|
1009
|
+
flag: ['<input class="datepicker">', "<input class='timepicker validate'>", '<input className="datepicker">'],
|
|
1010
|
+
pass: ['<input class="date-picker">', '<div class="datepicker-container"></div>'],
|
|
1011
|
+
},
|
|
1012
|
+
'legacy-sheet-class': {
|
|
1013
|
+
flag: ['<dialog class="bottom"></dialog>', '<dialog class="right-sheet"></dialog>', '<dialog className="left"></dialog>', '<dialog class="bottom-sheet"><span class="handle"></span></dialog>'],
|
|
1014
|
+
pass: ['<dialog class="bottom-sheet"><span class="drag-handle"></span></dialog>', '<dialog class="side-sheet start"></dialog>', '<dialog data-class="left" class="side-sheet"></dialog>', '<ul class="sortable"><li><span class="handle"></span></li></ul>', '<dialog class="no-bottom-sheet"><span class="handle"></span></dialog>'],
|
|
1015
|
+
},
|
|
1016
|
+
'legacy-variant-alias': {
|
|
1017
|
+
flag: ['<div class="tooltip max"></div>', '<div class="toolbar filled"></div>', '<span class="chip active"></span>', '<div class="max toolbar"></div>', '<div className={"tooltip max"}></div>', '<div className={`chip active ${x}`}></div>'],
|
|
1018
|
+
pass: ['<div class="tooltip rich"></div>', '<div class="toolbar docked vibrant"><button class="filled">Go</button><span class="max"></span></div>', '<span class="chip selected"></span>', '<button class="chip active:scale-95"></button>', '<nav class="tabs max"></nav>', '<div class="toolbar-group active"></div>'],
|
|
1019
|
+
},
|
|
1020
|
+
'legacy-markup-path': {
|
|
1021
|
+
flag: ['<ul class="tabs"><li class="tab"><a href="#a">A</a></li></ul>', '<div class="switch"><label><input type="checkbox">Wifi</label></div>', '<button class="extra">Compose</button>', '<a class="button extra" href="/c">C</a>', '<span class="button extra">C</span>', '<button className="extra">C</button>'],
|
|
1022
|
+
pass: ['<label class="switch"><input type="checkbox">Wifi</label>', '<button class="circle extra">+</button>', '<button class="extra circle large">+</button>', '<div class="extra">Notes</div>', '<td class="extra">1</td>'],
|
|
1023
|
+
},
|
|
1024
|
+
'legacy-carousel-flat': {
|
|
1025
|
+
flag: ['<div class="carousel flat"></div>', 'Carousel.init(el, { fullWidth: true });', '<div class="carousel-fixed-item"></div>', '<div className={"carousel flat"}></div>'],
|
|
1026
|
+
pass: ['<div class="carousel hero"></div>', '<div class="flat"></div>', 'Carousel.init(el, { fullWidth: false });'],
|
|
1027
|
+
},
|
|
1028
|
+
'legacy-color-role': {
|
|
1029
|
+
flag: ['<p style="color: var(--md-sys-color-surface-variant)"></p>', ':root { --md-sys-color-background-dark: #000; }', '<p class="on-background-text"></p>', '<div class="surface-tint"></div>', '<p class="background-text"></p>'],
|
|
1030
|
+
pass: ['<img class="background" src="hero.jpg" alt="">', '<p style="background: var(--md-sys-color-surface)"></p>', '<p class="on-surface-variant-text"></p>'],
|
|
1031
|
+
},
|
|
1032
|
+
'legacy-selector': {
|
|
1033
|
+
flag: ['.preloader { color: red }', '.toolbar.max .item {}', '.chip.active {}', 'document.querySelector(".datepicker");', '.on-background-text {}'],
|
|
1034
|
+
pass: ['this.preloader = loader;', 'import "./preloader.css";', '.datepicker-container {}', '.toolbar.docked {}', '.date-picker {}'],
|
|
1035
|
+
},
|
|
1036
|
+
'legacy-datepicker-range': {
|
|
1037
|
+
flag: ['Datepicker.init(el, { startRange: start });', 'const options = { "endRange": end };', "init(el, { format: 'iso',\n 'startRange': start });"],
|
|
1038
|
+
pass: ['const edge = isStart ? startRange : endRange;', 'selection.startRange'],
|
|
1039
|
+
},
|
|
1040
|
+
'legacy-range-export': {
|
|
1041
|
+
flag: ['Expressive.Range.init(el);'],
|
|
1042
|
+
pass: ['Expressive.Slider.init(el);', 'const range = new Range();'],
|
|
1043
|
+
},
|
|
1044
|
+
};
|
|
1045
|
+
const legacyIds = authoringRules.map((rule) => rule.id).filter((id) => /^legacy-/.test(id) && !['legacy-btn-class', 'legacy-card-content', 'legacy-nav-wrapper', 'legacy-brand-logo', 'legacy-lever', 'legacy-filled-in', 'legacy-materialized-name', 'legacy-input-field', 'legacy-materialize-textarea'].includes(id));
|
|
1046
|
+
assert.deepEqual(Object.keys(legacyCases).sort(), [...legacyIds].sort(), 'every 0.16.0 legacy rule needs cases, and every case a rule');
|
|
1047
|
+
const idsFor = (snippet) => inspectAuthoringRules(snippet).map((issue) => issue.id);
|
|
1048
|
+
for (const [id, { flag, pass }] of Object.entries(legacyCases)) {
|
|
1049
|
+
for (const snippet of flag) assert.ok(idsFor(snippet).includes(id), `${id} misses ${JSON.stringify(snippet)}`);
|
|
1050
|
+
for (const snippet of pass) assert.ok(!idsFor(snippet).includes(id), `${id} flags ${JSON.stringify(snippet)}`);
|
|
1051
|
+
}
|
|
1052
|
+
// Each pattern alone, against inputs built to make it backtrack. Every shape
|
|
1053
|
+
// here once took seconds against some version of these patterns.
|
|
1054
|
+
const fill = (text) => text.repeat(Math.ceil(200_000 / text.length));
|
|
1055
|
+
const adversarial = [
|
|
1056
|
+
fill('<input type="radio" '), fill('<i '), fill('<li '), fill('<dialog class="x '), fill('<dialog class="bottom-sheet">'),
|
|
1057
|
+
'<dialog ' + fill('bottom-sheet '), '<a' + fill('-a'), fill('<a-'), '<div class="' + fill('a '), fill('<div class="'),
|
|
1058
|
+
'<span>' + fill(' ') + '<i>add</i>', fill('<button><i>'), 'className={`' + fill('chip '), fill('<a class="button extra '),
|
|
1059
|
+
'<div class="range">' + fill(' ') + '<input', fill(', startRange '), '<dialog class="bottom-sheet">' + fill('<span '), fill('.chip'),
|
|
1060
|
+
// An attribute with no opening quote: two optional whitespace runs that
|
|
1061
|
+
// can split the same spaces between them are quadratic.
|
|
1062
|
+
'class=' + fill(' '), 'className={' + fill(' '), '{' + fill(' '),
|
|
1063
|
+
// An unclosed class value: requiring the closing quote retried it once per `btn`.
|
|
1064
|
+
'class="' + fill('btn '),
|
|
1065
|
+
// A `.range` host with no `>` after it: an unbounded gap rescanned to the end from every host.
|
|
1066
|
+
fill('class="range '),
|
|
1067
|
+
];
|
|
1068
|
+
for (const rule of authoringRules.filter((entry) => legacyIds.includes(entry.id) || entry.id === 'legacy-btn-class')) {
|
|
1069
|
+
const regex = new RegExp(rule.pattern.source, rule.pattern.flags);
|
|
1070
|
+
for (const input of adversarial) {
|
|
1071
|
+
regex.lastIndex = 0;
|
|
1072
|
+
const start = performance.now();
|
|
1073
|
+
while (regex.exec(input) !== null) if (regex.lastIndex === 0) break;
|
|
1074
|
+
assert.ok(performance.now() - start < 250, `${rule.id} backtracks on ${JSON.stringify(input.slice(0, 30))}`);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
// Deep nesting makes `:has()` rules quadratic and overflows `outerHTML`; it stops early instead.
|
|
1078
|
+
for (const deep of ['<div aria-hidden="true">'.repeat(3_990) + '<a href=x>', '<nav aria-label=x>'.repeat(3_990), `<nav><template>${'<div>'.repeat(3_500)}</template></nav>`]) {
|
|
1079
|
+
const start = performance.now();
|
|
1080
|
+
assert.equal(inspectAuthoringRules(deep).truncatedReason, 'markup nesting limit reached');
|
|
1081
|
+
// Parsing 4,000 nested tags takes about a second on CI runners; the rules alone took 20 s before the cap.
|
|
1082
|
+
assert.ok(performance.now() - start < 3_000, `deep markup took ${Math.round(performance.now() - start)} ms`);
|
|
1083
|
+
}
|
|
1084
|
+
// Vue puts `template` inside SVG; there it is a foreign element with no content fragment.
|
|
1085
|
+
assert.equal(inspectAuthoringRules('<svg><template v-for="item in items"><circle /></template></svg>').truncatedReason, undefined);
|
|
1086
|
+
const deepMarkup = path.join(outsideDir, 'deep.html');
|
|
1087
|
+
await writeFile(deepMarkup, '<nav aria-label=x>'.repeat(3_990));
|
|
1088
|
+
const deepHook = lint(['--hook'], JSON.stringify({ tool_input: { file_path: deepMarkup } }));
|
|
1089
|
+
assert.equal(deepHook.status, 2, deepHook.stderr);
|
|
1090
|
+
assert.match(deepHook.stderr, /inspection-truncated/u);
|
|
978
1091
|
// Files the bounded reader refuses, and inspections that stop early, fail instead of passing silently.
|
|
979
1092
|
const linkedMarkup = path.join(outsideDir, 'linked.html');
|
|
980
1093
|
const crowdedMarkup = path.join(outsideDir, 'crowded.html');
|
|
981
1094
|
await symlink(invalidMarkupFile, linkedMarkup);
|
|
982
1095
|
await writeFile(crowdedMarkup, '<i></i>'.repeat(2_100));
|
|
983
|
-
for (const [file, reason] of [[oversizedFile, /
|
|
1096
|
+
for (const [file, reason] of [[oversizedFile, /exceeds \d+ bytes/u], [linkedMarkup, /symbolic link/u], [path.join(packageDir, 'README.md'), /outside projectRoot/u], [crowdedMarkup, /inspection-truncated/u]]) {
|
|
984
1097
|
const refused = lint([file]);
|
|
985
1098
|
assert.equal(refused.status, 1, `${file} passed`);
|
|
986
1099
|
assert.match(refused.stdout, reason);
|
|
@@ -1009,6 +1122,14 @@ const transport = new StdioClientTransport({
|
|
|
1009
1122
|
// procfs reports size 0 for files with content. A size-bound read must fail, not return a truncated file.
|
|
1010
1123
|
await assert.rejects(readInspectionFile('/proc/self/status', '/proc', 1024 * 1024), /changed while reading/u);
|
|
1011
1124
|
}
|
|
1125
|
+
if (process.platform !== 'win32') {
|
|
1126
|
+
// Opening a FIFO with no writer blocks; the reader must refuse it instead.
|
|
1127
|
+
const fifo = path.join(outsideDir, 'pipe.html');
|
|
1128
|
+
execFileSync('mkfifo', [fifo]);
|
|
1129
|
+
const hung = new Promise((_, reject) => setTimeout(() => reject(new Error('FIFO read hung')), 2_000).unref());
|
|
1130
|
+
await assert.rejects(Promise.race([readInspectionFile(fifo, outsideDir, 1024), hung]), /not a regular file/u);
|
|
1131
|
+
await rm(fifo);
|
|
1132
|
+
}
|
|
1012
1133
|
}
|
|
1013
1134
|
|
|
1014
1135
|
const client = new Client({ name: 'expressivecss-mcp-smoke', version: '0.1.0' });
|
|
@@ -1087,7 +1208,7 @@ try {
|
|
|
1087
1208
|
for (const query of ['app-bar', ' APP BAR ', 'navbar', 'screen-level actions']) {
|
|
1088
1209
|
const result = await client.callTool({ name: 'component_catalog', arguments: { query, limit: 1 } });
|
|
1089
1210
|
const matchType = query.includes('actions') ? 'heuristic' : query === 'navbar' ? 'exact-alias' : 'exact-name';
|
|
1090
|
-
assertCatalogSearch(result, { query, limit: 1, entries: [{ ...appBarEntry, matchType }], totalMatches: matchType === 'exact-name' ?
|
|
1211
|
+
assertCatalogSearch(result, { query, limit: 1, entries: [{ ...appBarEntry, matchType }], totalMatches: matchType === 'exact-name' ? 3 : 1 });
|
|
1091
1212
|
}
|
|
1092
1213
|
const recoveredSlugs = new Set();
|
|
1093
1214
|
for (const entry of expectedCatalog) {
|
|
@@ -1163,7 +1284,6 @@ try {
|
|
|
1163
1284
|
[tamperedGuideDir, 'match', 'divergent'],
|
|
1164
1285
|
[symlinkSourceDir, 'unresolved', 'invalid'],
|
|
1165
1286
|
[oversizedSourceDir, 'unresolved', 'invalid'],
|
|
1166
|
-
[path.join(outsideDir, 'nonexistent-project'), 'unresolved', 'bundled-verified'],
|
|
1167
1287
|
]) {
|
|
1168
1288
|
const result = await client.callTool({ name: 'component_catalog', arguments: { projectRoot } });
|
|
1169
1289
|
assertScopedResult(result, 'target component_catalog');
|
|
@@ -1729,8 +1849,8 @@ try {
|
|
|
1729
1849
|
assert.equal(matchingSyntax.structuredContent.contractCompatibility, 'match');
|
|
1730
1850
|
assert.equal(matchingSyntax.structuredContent.status, 'available');
|
|
1731
1851
|
const buttonCapability = matchingSyntax.structuredContent.found[0].capability;
|
|
1732
|
-
assert.equal(buttonCapability.lastReviewedSupport, '
|
|
1733
|
-
assert.equal(buttonCapability.support, buttonCapability.sourceReview === 'source-reviewed' ? '
|
|
1852
|
+
assert.equal(buttonCapability.lastReviewedSupport, 'implemented');
|
|
1853
|
+
assert.equal(buttonCapability.support, buttonCapability.sourceReview === 'source-reviewed' ? 'implemented' : 'unassessed');
|
|
1734
1854
|
assert.match(matchingSyntax.structuredContent.capabilityEvidence.basis, /not proof of published package/);
|
|
1735
1855
|
|
|
1736
1856
|
const fallbackRules = [
|
|
@@ -1868,8 +1988,7 @@ try {
|
|
|
1868
1988
|
for (const file of ['server.js', 'package.json', 'component-decisions.json', 'capability-roadmap.json', 'contract.json', 'semantics-data.json']) {
|
|
1869
1989
|
await copyFile(path.join(packageDir, file), path.join(optionsFixtureDir, file));
|
|
1870
1990
|
}
|
|
1871
|
-
await
|
|
1872
|
-
await copyFile(path.join(packageDir, 'scripts', 'resolve-version.mjs'), path.join(optionsFixtureDir, 'scripts', 'resolve-version.mjs'));
|
|
1991
|
+
await cp(path.join(packageDir, 'scripts'), path.join(optionsFixtureDir, 'scripts'), { recursive: true });
|
|
1873
1992
|
const markdown = [
|
|
1874
1993
|
'| Name | Description |', '| --- | --- |', '| example | Complete table |',
|
|
1875
1994
|
'##### Nested guidance', '[Reference](https://example.test/options)',
|
|
@@ -2170,14 +2289,18 @@ try {
|
|
|
2170
2289
|
name: 'quality_inspector',
|
|
2171
2290
|
arguments: {
|
|
2172
2291
|
projectRoot: packageDir,
|
|
2173
|
-
files: [outsideFile],
|
|
2292
|
+
files: [outsideFile, path.join(outsideDir, 'missing.txt')],
|
|
2174
2293
|
runType: 'quick',
|
|
2175
2294
|
workflowId,
|
|
2176
2295
|
},
|
|
2177
2296
|
});
|
|
2297
|
+
// A missing outside path gets the same reason, so the reply is no existence oracle.
|
|
2178
2298
|
assert.deepEqual(confined.structuredContent.filesSkipped, [{
|
|
2179
2299
|
file: outsideFile,
|
|
2180
2300
|
reason: 'file is outside projectRoot',
|
|
2301
|
+
}, {
|
|
2302
|
+
file: path.join(outsideDir, 'missing.txt'),
|
|
2303
|
+
reason: 'file is outside projectRoot',
|
|
2181
2304
|
}]);
|
|
2182
2305
|
assert.equal(confined.structuredContent.status, 'blocked');
|
|
2183
2306
|
|
|
@@ -2306,6 +2429,52 @@ try {
|
|
|
2306
2429
|
await timeoutClient.close();
|
|
2307
2430
|
}
|
|
2308
2431
|
|
|
2432
|
+
// Commands run in their own process group, so neither a cancelled call nor a closed
|
|
2433
|
+
// session may leave one running.
|
|
2434
|
+
for (const stop of ['cancel', 'close']) {
|
|
2435
|
+
const lingerDir = path.join(outsideDir, `linger-${stop}`);
|
|
2436
|
+
await mkdir(lingerDir);
|
|
2437
|
+
await writeFile(path.join(lingerDir, 'package-lock.json'), '{}');
|
|
2438
|
+
await writeFile(path.join(lingerDir, 'package.json'), JSON.stringify({
|
|
2439
|
+
scripts: { typecheck: "node -e \"const fs = require('node:fs'); fs.writeFileSync('started.txt', ''); setTimeout(() => fs.writeFileSync('survived.txt', 'yes'), 1500);\"" },
|
|
2440
|
+
}));
|
|
2441
|
+
const lingerClient = new Client({ name: `expressivecss-mcp-${stop}-smoke`, version: '0.1.0' });
|
|
2442
|
+
await lingerClient.connect(new StdioClientTransport({
|
|
2443
|
+
command: process.execPath,
|
|
2444
|
+
args: [path.join(packageDir, 'server.js')],
|
|
2445
|
+
cwd: packageDir,
|
|
2446
|
+
stderr: 'pipe',
|
|
2447
|
+
env: { ...process.env, EXPRESSIVECSS_MCP_ALLOWED_COMMAND_ROOTS: lingerDir },
|
|
2448
|
+
}));
|
|
2449
|
+
const controller = new AbortController();
|
|
2450
|
+
const call = lingerClient.callTool({
|
|
2451
|
+
name: 'quality_inspector',
|
|
2452
|
+
arguments: { projectRoot: lingerDir, files: [], runType: 'standard', runCommands: true },
|
|
2453
|
+
}, undefined, { signal: controller.signal }).catch(() => {});
|
|
2454
|
+
for (let waited = 0; await access(path.join(lingerDir, 'started.txt')).then(() => false, () => true); waited += 50) {
|
|
2455
|
+
assert.ok(waited < 10_000, `${stop}: command never started`);
|
|
2456
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2457
|
+
}
|
|
2458
|
+
if (stop === 'cancel') controller.abort();
|
|
2459
|
+
else await lingerClient.close();
|
|
2460
|
+
await call;
|
|
2461
|
+
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
2462
|
+
await assert.rejects(access(path.join(lingerDir, 'survived.txt')), { code: 'ENOENT' }, `${stop} left the command running`);
|
|
2463
|
+
if (stop === 'cancel') await lingerClient.close();
|
|
2464
|
+
}
|
|
2465
|
+
{
|
|
2466
|
+
// A cancel that lands while the source pins are being checked must stop the command from starting.
|
|
2467
|
+
const { runQualityCommands } = await import('./server.js');
|
|
2468
|
+
const lingerDir = await realpath(path.join(outsideDir, 'linger-cancel'));
|
|
2469
|
+
await rm(path.join(lingerDir, 'started.txt'));
|
|
2470
|
+
const controller = new AbortController();
|
|
2471
|
+
const run = runQualityCommands(lingerDir, ['typecheck'], 'npm', [], controller.signal);
|
|
2472
|
+
controller.abort();
|
|
2473
|
+
await assert.rejects(run, { name: 'AbortError' });
|
|
2474
|
+
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
2475
|
+
await assert.rejects(access(path.join(lingerDir, 'started.txt')), { code: 'ENOENT' }, 'a command started after cancellation');
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2309
2478
|
const rootsClient = new Client({ name: 'expressivecss-mcp-project-roots-smoke', version: '0.1.0' });
|
|
2310
2479
|
try {
|
|
2311
2480
|
await rootsClient.connect(new StdioClientTransport({
|
|
@@ -2352,6 +2521,24 @@ try {
|
|
|
2352
2521
|
}
|
|
2353
2522
|
}
|
|
2354
2523
|
|
|
2524
|
+
// Unset, the allowlist is the default root: a client cannot name `/` and hash files outside it.
|
|
2525
|
+
const unsetEnv = { ...process.env };
|
|
2526
|
+
delete unsetEnv.EXPRESSIVECSS_MCP_ALLOWED_PROJECT_ROOTS;
|
|
2527
|
+
const unsetClient = new Client({ name: 'expressivecss-mcp-unset-roots-smoke', version: '0.1.0' });
|
|
2528
|
+
try {
|
|
2529
|
+
await unsetClient.connect(new StdioClientTransport({ command: process.execPath, args: [path.join(packageDir, 'server.js')], cwd: packageDir, stderr: 'pipe', env: unsetEnv }));
|
|
2530
|
+
for (const projectRoot of [outsideDir, path.parse(outsideDir).root, path.join(packageDir, 'nonexistent-project')]) {
|
|
2531
|
+
const denied = await unsetClient.callTool({ name: 'quality_inspector', arguments: { projectRoot, files: [outsideFile] } });
|
|
2532
|
+
assert.equal(denied.isError, true, `an unset allowlist accepted ${projectRoot}`);
|
|
2533
|
+
assert.match(denied.content[0].text, /EXPRESSIVECSS_MCP_ALLOWED_PROJECT_ROOTS/u);
|
|
2534
|
+
assert.equal(JSON.stringify(denied).includes('sha256'), false);
|
|
2535
|
+
}
|
|
2536
|
+
const inside = await unsetClient.callTool({ name: 'quality_inspector', arguments: { projectRoot: packageDir, files: [] } });
|
|
2537
|
+
assert.notEqual(inside.isError, true, inside.content[0].text);
|
|
2538
|
+
} finally {
|
|
2539
|
+
await unsetClient.close();
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2355
2542
|
const trapTransport = new StdioClientTransport({
|
|
2356
2543
|
command: process.execPath,
|
|
2357
2544
|
args: [path.join(packageDir, 'server.js')],
|
|
@@ -2419,7 +2606,7 @@ try {
|
|
|
2419
2606
|
// Recoverable verification: exact input evidence, no automatic restoration, and no later scripts after failure.
|
|
2420
2607
|
const originalManifest = await readFile(path.join(matchingDir, 'package.json'));
|
|
2421
2608
|
const originalMarkup = await readFile(path.join(matchingDir, 'clean.html'));
|
|
2422
|
-
const digest = bytes =>
|
|
2609
|
+
const digest = bytes => hash('sha256', bytes);
|
|
2423
2610
|
const qualityArgs = { projectRoot: matchingDir, files: ['clean.html'], runType: 'full', runCommands: true };
|
|
2424
2611
|
const setScripts = async typecheck => writeFile(path.join(matchingDir, 'package.json'), JSON.stringify({
|
|
2425
2612
|
...JSON.parse(originalManifest), scripts: { typecheck, test: `node -e "require('fs').writeFileSync('later-script.txt','ran')"` },
|