@expressivecss/mcp-server 0.4.1 → 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 +58 -0
- package/README.md +2 -2
- package/capability-roadmap.json +1812 -289
- package/component-decisions.json +3290 -891
- package/component-guides.json +176 -76
- package/contract.json +5 -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 +746 -33
- package/server.js +242 -196
- package/smoke.mjs +206 -22
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');
|
|
@@ -316,8 +318,7 @@ async function verifyResponseBudgets(matchingDir, versionedDir) {
|
|
|
316
318
|
const fixtureDir = await mkdtemp(path.join(packageDir, '.budget-fixture-'));
|
|
317
319
|
try {
|
|
318
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));
|
|
319
|
-
await
|
|
320
|
-
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 });
|
|
321
322
|
const code = 'const text = "界🌍\\\\\\\"";\n'.repeat(500).trim();
|
|
322
323
|
const markdown = '完整 Options 🌍 with an escaped \\" value.\n'.repeat(5_000).trim();
|
|
323
324
|
const rules = ['`whole-first`: Preserve this full record.', '`huge-rule`: ' + '界🌍'.repeat(20_000)];
|
|
@@ -471,8 +472,7 @@ async function verifyCatalogueResources(expectedEntries, toolPayload, consumerRo
|
|
|
471
472
|
const fixtureDir = await mkdtemp(path.join(packageDir, '.resource-fixture-'));
|
|
472
473
|
try {
|
|
473
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));
|
|
474
|
-
await
|
|
475
|
-
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 });
|
|
476
476
|
const decisions = structuredClone(decisionsData);
|
|
477
477
|
const unicode = '界🌍 "escaped" \\ value\n'.repeat(2_000);
|
|
478
478
|
decisions.components.find((row) => row.slug === expectedEntries[0].slug).useWhen = [unicode];
|
|
@@ -738,8 +738,8 @@ async function verifyComponentResources(catalogueToolPayload, consumerRoots) {
|
|
|
738
738
|
const assertCompletion = async (peer) => {
|
|
739
739
|
for (const template of [guideTemplate, sectionTemplate]) {
|
|
740
740
|
assert.deepEqual(await complete(peer, template, 'slug', ''), completed(slugs), `${template} lists every canonical slug`);
|
|
741
|
-
assert.deepEqual(await complete(peer, template, 'slug', 'ca'), completed(['cards', 'carousel']));
|
|
742
|
-
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');
|
|
743
743
|
assert.deepEqual(await complete(peer, template, 'slug', 's'), completed(slugs.filter((slug) => slug.startsWith('s'))));
|
|
744
744
|
assert.deepEqual(await complete(peer, template, 'slug', 'date'), completed(['date-picker']));
|
|
745
745
|
for (const slug of slugs) assert.deepEqual((await complete(peer, template, 'slug', slug)).completion.values[0], slug);
|
|
@@ -765,7 +765,7 @@ async function verifyComponentResources(catalogueToolPayload, consumerRoots) {
|
|
|
765
765
|
}
|
|
766
766
|
assert.deepEqual(await complete(peer, catalogueUri, 'slug', ''), noArgument, 'fixed catalogue URI has no arguments');
|
|
767
767
|
await assert.rejects(peer.complete({ ref: { type: 'ref/prompt', name: 'component_guide' }, argument: { name: 'slug', value: '' } }), (error) => error.code === -32602);
|
|
768
|
-
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');
|
|
769
769
|
// Every completed name reads; completion never makes aliases or case variants readable.
|
|
770
770
|
for (const slug of (await complete(peer, guideTemplate, 'slug', '')).completion.values) {
|
|
771
771
|
await read(`${base}/${slug}`);
|
|
@@ -978,12 +978,122 @@ const transport = new StdioClientTransport({
|
|
|
978
978
|
const cardContent = lint([cardContentMarkup]);
|
|
979
979
|
assert.equal(cardContent.stdout.match(/legacy-card-content/gu)?.length, 1, cardContent.stdout);
|
|
980
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);
|
|
981
1091
|
// Files the bounded reader refuses, and inspections that stop early, fail instead of passing silently.
|
|
982
1092
|
const linkedMarkup = path.join(outsideDir, 'linked.html');
|
|
983
1093
|
const crowdedMarkup = path.join(outsideDir, 'crowded.html');
|
|
984
1094
|
await symlink(invalidMarkupFile, linkedMarkup);
|
|
985
1095
|
await writeFile(crowdedMarkup, '<i></i>'.repeat(2_100));
|
|
986
|
-
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]]) {
|
|
987
1097
|
const refused = lint([file]);
|
|
988
1098
|
assert.equal(refused.status, 1, `${file} passed`);
|
|
989
1099
|
assert.match(refused.stdout, reason);
|
|
@@ -1012,6 +1122,14 @@ const transport = new StdioClientTransport({
|
|
|
1012
1122
|
// procfs reports size 0 for files with content. A size-bound read must fail, not return a truncated file.
|
|
1013
1123
|
await assert.rejects(readInspectionFile('/proc/self/status', '/proc', 1024 * 1024), /changed while reading/u);
|
|
1014
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
|
+
}
|
|
1015
1133
|
}
|
|
1016
1134
|
|
|
1017
1135
|
const client = new Client({ name: 'expressivecss-mcp-smoke', version: '0.1.0' });
|
|
@@ -1090,7 +1208,7 @@ try {
|
|
|
1090
1208
|
for (const query of ['app-bar', ' APP BAR ', 'navbar', 'screen-level actions']) {
|
|
1091
1209
|
const result = await client.callTool({ name: 'component_catalog', arguments: { query, limit: 1 } });
|
|
1092
1210
|
const matchType = query.includes('actions') ? 'heuristic' : query === 'navbar' ? 'exact-alias' : 'exact-name';
|
|
1093
|
-
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 });
|
|
1094
1212
|
}
|
|
1095
1213
|
const recoveredSlugs = new Set();
|
|
1096
1214
|
for (const entry of expectedCatalog) {
|
|
@@ -1166,7 +1284,6 @@ try {
|
|
|
1166
1284
|
[tamperedGuideDir, 'match', 'divergent'],
|
|
1167
1285
|
[symlinkSourceDir, 'unresolved', 'invalid'],
|
|
1168
1286
|
[oversizedSourceDir, 'unresolved', 'invalid'],
|
|
1169
|
-
[path.join(outsideDir, 'nonexistent-project'), 'unresolved', 'bundled-verified'],
|
|
1170
1287
|
]) {
|
|
1171
1288
|
const result = await client.callTool({ name: 'component_catalog', arguments: { projectRoot } });
|
|
1172
1289
|
assertScopedResult(result, 'target component_catalog');
|
|
@@ -1732,8 +1849,8 @@ try {
|
|
|
1732
1849
|
assert.equal(matchingSyntax.structuredContent.contractCompatibility, 'match');
|
|
1733
1850
|
assert.equal(matchingSyntax.structuredContent.status, 'available');
|
|
1734
1851
|
const buttonCapability = matchingSyntax.structuredContent.found[0].capability;
|
|
1735
|
-
assert.equal(buttonCapability.lastReviewedSupport, '
|
|
1736
|
-
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');
|
|
1737
1854
|
assert.match(matchingSyntax.structuredContent.capabilityEvidence.basis, /not proof of published package/);
|
|
1738
1855
|
|
|
1739
1856
|
const fallbackRules = [
|
|
@@ -1871,8 +1988,7 @@ try {
|
|
|
1871
1988
|
for (const file of ['server.js', 'package.json', 'component-decisions.json', 'capability-roadmap.json', 'contract.json', 'semantics-data.json']) {
|
|
1872
1989
|
await copyFile(path.join(packageDir, file), path.join(optionsFixtureDir, file));
|
|
1873
1990
|
}
|
|
1874
|
-
await
|
|
1875
|
-
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 });
|
|
1876
1992
|
const markdown = [
|
|
1877
1993
|
'| Name | Description |', '| --- | --- |', '| example | Complete table |',
|
|
1878
1994
|
'##### Nested guidance', '[Reference](https://example.test/options)',
|
|
@@ -2173,14 +2289,18 @@ try {
|
|
|
2173
2289
|
name: 'quality_inspector',
|
|
2174
2290
|
arguments: {
|
|
2175
2291
|
projectRoot: packageDir,
|
|
2176
|
-
files: [outsideFile],
|
|
2292
|
+
files: [outsideFile, path.join(outsideDir, 'missing.txt')],
|
|
2177
2293
|
runType: 'quick',
|
|
2178
2294
|
workflowId,
|
|
2179
2295
|
},
|
|
2180
2296
|
});
|
|
2297
|
+
// A missing outside path gets the same reason, so the reply is no existence oracle.
|
|
2181
2298
|
assert.deepEqual(confined.structuredContent.filesSkipped, [{
|
|
2182
2299
|
file: outsideFile,
|
|
2183
2300
|
reason: 'file is outside projectRoot',
|
|
2301
|
+
}, {
|
|
2302
|
+
file: path.join(outsideDir, 'missing.txt'),
|
|
2303
|
+
reason: 'file is outside projectRoot',
|
|
2184
2304
|
}]);
|
|
2185
2305
|
assert.equal(confined.structuredContent.status, 'blocked');
|
|
2186
2306
|
|
|
@@ -2309,6 +2429,52 @@ try {
|
|
|
2309
2429
|
await timeoutClient.close();
|
|
2310
2430
|
}
|
|
2311
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
|
+
|
|
2312
2478
|
const rootsClient = new Client({ name: 'expressivecss-mcp-project-roots-smoke', version: '0.1.0' });
|
|
2313
2479
|
try {
|
|
2314
2480
|
await rootsClient.connect(new StdioClientTransport({
|
|
@@ -2355,6 +2521,24 @@ try {
|
|
|
2355
2521
|
}
|
|
2356
2522
|
}
|
|
2357
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
|
+
|
|
2358
2542
|
const trapTransport = new StdioClientTransport({
|
|
2359
2543
|
command: process.execPath,
|
|
2360
2544
|
args: [path.join(packageDir, 'server.js')],
|
|
@@ -2422,7 +2606,7 @@ try {
|
|
|
2422
2606
|
// Recoverable verification: exact input evidence, no automatic restoration, and no later scripts after failure.
|
|
2423
2607
|
const originalManifest = await readFile(path.join(matchingDir, 'package.json'));
|
|
2424
2608
|
const originalMarkup = await readFile(path.join(matchingDir, 'clean.html'));
|
|
2425
|
-
const digest = bytes =>
|
|
2609
|
+
const digest = bytes => hash('sha256', bytes);
|
|
2426
2610
|
const qualityArgs = { projectRoot: matchingDir, files: ['clean.html'], runType: 'full', runCommands: true };
|
|
2427
2611
|
const setScripts = async typecheck => writeFile(path.join(matchingDir, 'package.json'), JSON.stringify({
|
|
2428
2612
|
...JSON.parse(originalManifest), scripts: { typecheck, test: `node -e "require('fs').writeFileSync('later-script.txt','ran')"` },
|