@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/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.0');
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 hash = createHash('sha256');
51
- for (const source of contractData.sources) hash.update(`${source}\0${sourceContents.get(source)}\0`);
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: hash.digest('hex'),
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 mkdir(path.join(fixtureDir, 'scripts'));
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 mkdir(path.join(fixtureDir, 'scripts'));
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, /read limit/u], [linkedMarkup, /symbolic link/u], [path.join(packageDir, 'README.md'), /outside projectRoot/u], [crowdedMarkup, /inspection-truncated/u]]) {
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' ? 2 : 1 });
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, 'partial');
1733
- assert.equal(buttonCapability.support, buttonCapability.sourceReview === 'source-reviewed' ? 'partial' : 'unassessed');
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 mkdir(path.join(optionsFixtureDir, 'scripts'));
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 => createHash('sha256').update(bytes).digest('hex');
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')"` },