@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/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');
@@ -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 mkdir(path.join(fixtureDir, 'scripts'));
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 mkdir(path.join(fixtureDir, 'scripts'));
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, /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]]) {
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' ? 2 : 1 });
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, 'partial');
1736
- 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');
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 mkdir(path.join(optionsFixtureDir, 'scripts'));
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 => createHash('sha256').update(bytes).digest('hex');
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')"` },