@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/server.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, closeSync, constants as fsConstants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from 'node:fs';
3
- import { lstat, open, readdir, readFile, realpath, stat } from 'node:fs/promises';
3
+ import { lstat, readFile, realpath, stat } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
- import { fileURLToPath, pathToFileURL } from 'node:url';
6
- import { createHash, randomUUID } from 'node:crypto';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { createHash, randomUUID, hash } from 'node:crypto';
7
7
  import { spawn } from 'node:child_process';
8
8
  import { createRequire } from 'node:module';
9
9
  import * as z from 'zod/v4';
@@ -11,6 +11,8 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mc
11
11
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
12
  import { McpError, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
13
13
  import { resolveExpressiveVersion } from './scripts/resolve-version.mjs';
14
+ import { readBoundedRegularFile } from './scripts/bounded-file.mjs';
15
+ import { authoredName, enforcedRules, expandedSelector, ruleBreach } from './scripts/semantics-rules.mjs';
14
16
 
15
17
  const SERVER_DIR = path.dirname(fileURLToPath(import.meta.url));
16
18
  const COMPONENT_DECISIONS = JSON.parse(readFileSync(path.join(SERVER_DIR, 'component-decisions.json'), 'utf8'));
@@ -33,6 +35,9 @@ const DEFAULT_QA_MAX_TOTAL_MB = 16;
33
35
  const MAX_STATIC_ISSUES = 200;
34
36
  const MAX_STATIC_ISSUES_PER_REQUEST = 1_000;
35
37
  const MAX_STATIC_MARKUP_DELIMITERS = 4_000;
38
+ // `:has()` selectors visit every descendant of every candidate, so deep nesting is quadratic
39
+ // (1,024 levels take over a second), and `outerHTML` overflows the stack past about 2,200.
40
+ const MAX_STATIC_MARKUP_DEPTH = 256;
36
41
  // Source locations make parse5 copy a parent's children for each text run, so the cost grows with
37
42
  // characters times tags. Past this product, semantics findings report 1:1 to stay within the budget.
38
43
  const MAX_LOCATED_MARKUP_WORK = 100_000_000;
@@ -83,7 +88,7 @@ const SETTINGS = {
83
88
  commandTimeoutMs: Number(process.env.EXPRESSIVECSS_MCP_COMMAND_TIMEOUT_MS || DEFAULT_COMMAND_TIMEOUT_MS),
84
89
  allowedScripts: configuredScripts(process.env.EXPRESSIVECSS_MCP_ALLOWED_SCRIPTS),
85
90
  allowedCommandRoots: configuredCommandRoots(process.env.EXPRESSIVECSS_MCP_ALLOWED_COMMAND_ROOTS),
86
- // Unset keeps every root open. A set but empty or invalid list denies every client-supplied root.
91
+ // Unset allows only the default root. A set but empty or invalid list denies every client-supplied root.
87
92
  allowedProjectRoots: process.env[PROJECT_ROOTS_SETTING] === undefined ? null : configuredCommandRoots(process.env[PROJECT_ROOTS_SETTING]),
88
93
  };
89
94
 
@@ -96,12 +101,24 @@ const SKIP_FLAGS = {
96
101
  qualityInspector: 'SKIP_QUALITY_INSPECTOR',
97
102
  };
98
103
 
104
+ // Class-attribute matching for the legacy rules. CLASS_OPEN is the start of a
105
+ // class attribute's value in HTML or JSX: class="…", className="…",
106
+ // className={"…"} or className={`…`}. CLASS_CHARS runs to its end. A class
107
+ // token is a whole name: not part of a longer one, and not a Tailwind-style
108
+ // `name:` prefix.
109
+ const CLASS_OPEN = String.raw`(?<![-\w])class(?:Name)?\s*=\s*(?:\{\s*)?["'\`]`;
110
+ const CLASS_CHARS = String.raw`[^"'\`]*`;
111
+ const classToken = (names) => String.raw`(?<![-\w:])(?:${names})(?![-\w:])`;
112
+ const inClass = (names) => CLASS_OPEN + CLASS_CHARS + classToken(names);
113
+
99
114
  const LegacyPatternList = [
100
115
  {
101
116
  id: 'legacy-btn-class',
102
117
  severity: 'high',
103
118
  description: 'Replace `.btn` with the ExpressiveCSS button contract (`<button>`, `.button`, or component-specific button classes).',
104
- pattern: /\bclass(?:Name)?\s*=\s*(?:"[^"]*\bbtn\b[^"]*"|'[^']*\bbtn\b[^']*'|`[^`]*\bbtn\b[^`]*`|\{\s*(?:"[^"]*\bbtn\b[^"]*"|'[^']*\bbtn\b[^']*'|`[^`]*\bbtn\b[^`]*`)\s*\}|btn(?=[\s>]))/gu,
119
+ // The match ends at `btn`: requiring the closing quote too backtracks once per
120
+ // `btn` in an unclosed value, which is quadratic.
121
+ pattern: /\bclass(?:Name)?\s*=\s*(?:\{\s*)?(?:"[^"]*\bbtn\b|'[^']*\bbtn\b|`[^`]*\bbtn\b|btn(?=[\s>]))/gu,
105
122
  },
106
123
  {
107
124
  id: 'legacy-card-content',
@@ -151,6 +168,108 @@ const LegacyPatternList = [
151
168
  description: '`.materialize-textarea` is retired; use `.expressive-textarea`.',
152
169
  pattern: /\bmaterialize-textarea\b/g,
153
170
  },
171
+ // Names removed in 0.16.0. Each description gives the replacement; the
172
+ // framework CHANGELOG's 0.16.0 migration notes list them together. Class names
173
+ // are matched as whole tokens inside a class attribute (see inClass), so an
174
+ // id or a word in prose that shares a name is not flagged. Tag patterns
175
+ // stop at `<` so unclosed markup cannot make them backtrack.
176
+ {
177
+ id: 'legacy-material-icons',
178
+ severity: 'high',
179
+ description: '`.material-icons` was removed in 0.16.0; use `.material-symbols` (or `-outlined`, `-rounded`, `-sharp`).',
180
+ pattern: /(?<![-\w])material-icons(?![-\w])/g,
181
+ },
182
+ {
183
+ id: 'legacy-i-icon',
184
+ severity: 'medium',
185
+ description: 'A bare `<i>` holding an icon name is italic text in 0.16.0, not an icon; write `<span class="material-symbols" aria-hidden="true">`. Only an `<i>` that opens a button, link, list item, label or similar host is checked; if it is italic prose, ignore this.',
186
+ // The host tag is part of the match, not a lookbehind: Node 22's V8 runs a
187
+ // variable-length lookbehind at every position, which is quadratic.
188
+ pattern: /<(?:a|button|label|li|span|div|nav|header|footer|menu|summary)\b[^<>]*>\s*<i(?:\s(?![^<>]*material-symbols)[^<>]*)?>\s*[a-z][a-z0-9]*(?:_[a-z0-9]+)*\s*<\/i>/g,
189
+ },
190
+ {
191
+ id: 'legacy-visibility-alias',
192
+ severity: 'high',
193
+ description: 'The small/med/xxl visibility aliases were removed in 0.16.0; use `.hide-on-compact-only`, `.hide-on-medium-only`, `.hide-on-extra-large-only`, `.show-on-compact` or `.show-on-extra-large`.',
194
+ pattern: /(?<![-\w])(?:hide-on-(?:small-only|small-and-down|med-only|xxl-only)|show-on-(?:small|xxl))(?![-\w])/g,
195
+ },
196
+ {
197
+ id: 'legacy-helper-class',
198
+ severity: 'medium',
199
+ description: '`.no-padding`, `.center-block` and `.no-select` were removed in 0.16.0; use `.p-0`, `.mx-auto` with `display: block`, or `user-select: none` in your own CSS.',
200
+ pattern: /(?<![-\w])(?:no-padding|center-block|no-select)(?![-\w])/g,
201
+ },
202
+ {
203
+ id: 'legacy-renamed-component',
204
+ severity: 'high',
205
+ description: '`.preloader`, `.searchbar` and the `.range` / `.range-field` slider hosts were removed in 0.16.0; use `.progress.circular`, `.search-bar` and `.slider`.',
206
+ // A `.range` host is only flagged where it wraps a range input: the name is
207
+ // too common to flag on its own. The gap to `>` is bounded: unbounded, an
208
+ // unclosed tag full of `.range` hosts rescanned to the end from each one.
209
+ pattern: new RegExp(`${inClass('preloader|searchbar|range-field')}|${CLASS_OPEN}(?=${CLASS_CHARS}${classToken('range')})${CLASS_CHARS}["'\`][^<>]{0,2000}>\\s*<input\\b[^<>]*\\btype\\s*=\\s*["']?range(?![-\\w])`, 'g'),
210
+ },
211
+ {
212
+ id: 'legacy-picker-root',
213
+ severity: 'high',
214
+ description: 'AutoInit starts `.date-picker` and `.time-picker` in 0.16.0; rename `class="datepicker"` and `class="timepicker"`.',
215
+ pattern: new RegExp(inClass('datepicker|timepicker'), 'g'),
216
+ },
217
+ {
218
+ id: 'legacy-sheet-class',
219
+ severity: 'high',
220
+ description: 'Sheet aliases were removed in 0.16.0; use `dialog.bottom-sheet` with `.drag-handle`, and `dialog.side-sheet` (add `.start` for the start edge).',
221
+ pattern: new RegExp(`<dialog\\b[^<>]*${inClass('bottom|right|left|right-sheet|left-sheet')}|<dialog\\b(?=[^<>]*${classToken('bottom-sheet')})[^<>]*>(?:(?!<\\/dialog>|<\\/DIALOG>)[^]){0,4000}?${inClass('handle')}`, 'g'),
222
+ },
223
+ {
224
+ id: 'legacy-variant-alias',
225
+ severity: 'high',
226
+ description: 'Variant aliases were removed in 0.16.0: use `.tooltip.rich` for `.tooltip.max`, `.toolbar.docked` for a `.max` bar and `.toolbar.vibrant` for a `.filled` bar, and `.selected` for a chip\'s `.active`.',
227
+ pattern: new RegExp(`${CLASS_OPEN}(?=${CLASS_CHARS}${classToken('tooltip|toolbar|chip')})(?=${CLASS_CHARS}${classToken('max|filled|active')})${CLASS_CHARS}["'\`]`, 'g'),
228
+ },
229
+ {
230
+ id: 'legacy-markup-path',
231
+ severity: 'high',
232
+ description: 'Pre-1.0 markup paths were removed in 0.16.0: put tab links directly in `nav.tabs` (no `li.tab`), put `.switch` on the `<label>` that wraps the checkbox, use `.medium` for a 56dp common button, and a plain `.button` for a navigation rail FAB (`.extra` only marks `circle extra` FABs).',
233
+ pattern: new RegExp([
234
+ `<(?!label\\b)[a-z][\\w-]*(?![\\w-])[^<>]*${inClass('switch')}`,
235
+ `<li\\b[^<>]*${inClass('tab')}`,
236
+ // `.extra` sized a common button: a <button> or <a>, or anything with `.button`.
237
+ `<(?:a|button)\\b[^<>]*${CLASS_OPEN}(?!${CLASS_CHARS}${classToken('circle')})${CLASS_CHARS}${classToken('extra')}`,
238
+ `${CLASS_OPEN}(?!${CLASS_CHARS}${classToken('circle')})(?=${CLASS_CHARS}${classToken('button')})${CLASS_CHARS}${classToken('extra')}`,
239
+ ].join('|'), 'g'),
240
+ },
241
+ {
242
+ id: 'legacy-carousel-flat',
243
+ severity: 'medium',
244
+ description: 'Carousel `.flat`, the `fullWidth` option and `.carousel-fixed-item` were removed in 0.16.0; choose an M3 layout such as `.hero` or `.uncontained`.',
245
+ pattern: new RegExp(`${CLASS_OPEN}(?=${CLASS_CHARS}${classToken('carousel')})${CLASS_CHARS}${classToken('flat')}|\\bfullWidth\\s*:\\s*true\\b|(?<![-\\w])carousel-fixed-item(?![-\\w])`, 'g'),
246
+ },
247
+ {
248
+ id: 'legacy-color-role',
249
+ severity: 'high',
250
+ description: 'The legacy color roles were removed in 0.16.0, with their `-light`/`-dark` pairs and classes; use `surface` for `background`, `on-surface` for `on-background`, `surface-container-highest` for `surface-variant` and `primary` for `surface-tint`.',
251
+ // A bare `background` class is a common name of a page's own, so only the
252
+ // role's `-text` class is flagged.
253
+ pattern: new RegExp(`--md-sys-color-(?:background|on-background|surface-variant|surface-tint)(?:-light|-dark)?(?![-\\w])|${inClass('background-text|(?:on-background|surface-variant|surface-tint)(?:-text)?')}`, 'g'),
254
+ },
255
+ {
256
+ id: 'legacy-selector',
257
+ severity: 'high',
258
+ description: 'A CSS selector or query names a class 0.16.0 removed: `.preloader`, `.searchbar`, `.range-field`, `.datepicker` / `.timepicker` (now `.date-picker` / `.time-picker`), `.toolbar.max` / `.toolbar.filled`, `.tooltip.max`, `.chip.active`, `.carousel.flat` or a legacy color role class. See the replacements in the other `legacy-*` rules.',
259
+ pattern: /(?<![\w$)\]-])\.(?:preloader|searchbar|range-field|(?:date|time)picker|(?:toolbar|tooltip)\.(?:max|filled)|chip\.active|carousel\.flat|(?:on-background|surface-variant|surface-tint)(?:-text)?|background-text)(?![-\w])/g,
260
+ },
261
+ {
262
+ id: 'legacy-datepicker-range',
263
+ severity: 'high',
264
+ description: 'Datepicker `startRange` and `endRange` were removed in 0.16.0; use `isDateRange` for range selection.',
265
+ pattern: /[{,]\s*(["']?)\b(?:startRange|endRange)\1\s*:/g,
266
+ },
267
+ {
268
+ id: 'legacy-range-export',
269
+ severity: 'high',
270
+ description: '`Expressive.Range` was removed in 0.16.0; use `Expressive.Slider`.',
271
+ pattern: /\bExpressive\.Range\b/g,
272
+ },
154
273
  {
155
274
  id: 'raw-color-in-component-style',
156
275
  severity: 'medium',
@@ -322,16 +441,32 @@ function resolveProjectRoot(projectRoot) {
322
441
  return projectRoot ? authorizedProjectRoot(projectRoot) : parseCliProjectRoot();
323
442
  }
324
443
 
325
- // The operator's default root is trusted. When configured, a client-supplied root must sit inside an allowed root.
444
+ // Unconfigured, the only allowed root is the default one.
445
+ function projectRootPolicy() {
446
+ return SETTINGS.allowedProjectRoots
447
+ ? { roots: SETTINGS.allowedProjectRoots, denial: `projectRoot is not inside a root allowed by ${PROJECT_ROOTS_SETTING}.` }
448
+ : { roots: [parseCliProjectRoot()], denial: `projectRoot is missing or outside the server's root; list other roots in ${PROJECT_ROOTS_SETTING}.` };
449
+ }
450
+
451
+ // The operator's default root is trusted. A client-supplied root must sit inside an allowed root.
326
452
  function authorizedProjectRoot(projectRoot) {
327
- const resolved = path.resolve(projectRoot);
328
- if (!SETTINGS.allowedProjectRoots) return resolved;
329
- let canonical = null;
330
- try { canonical = realpathSync(resolved); } catch {}
331
- if (!canonical || !matchingRoot(SETTINGS.allowedProjectRoots, canonical)) {
332
- throw new Error(`projectRoot is not inside a root allowed by ${PROJECT_ROOTS_SETTING}.`);
333
- }
334
- return resolved;
453
+ const policy = projectRootPolicy();
454
+ const canonical = canonicalPath(path.resolve(projectRoot), policy.roots);
455
+ if (!canonical || !matchingRoot(policy.roots, canonical)) throw new Error(policy.denial);
456
+ // The checked path, so a symlink swapped after the check cannot move later reads.
457
+ return canonical;
458
+ }
459
+
460
+ // Resolving a Windows network path (\\host\share) contacts that host, so only
461
+ // one under a root the operator listed is resolved.
462
+ function isUnlistedNetworkPath(resolved, roots) {
463
+ return process.platform === 'win32' && /^[\\/]{2}/u.test(resolved)
464
+ && !roots.some((root) => isPathInside(path.resolve(root), resolved));
465
+ }
466
+
467
+ function canonicalPath(resolved, roots) {
468
+ if (isUnlistedNetworkPath(resolved, roots)) return null;
469
+ try { return realpathSync(resolved); } catch { return null; }
335
470
  }
336
471
 
337
472
  function matchingRoot(configuredRoots, candidate) {
@@ -633,22 +768,6 @@ function sameFileIdentity(left, right) {
633
768
  return left.dev === right.dev && left.ino === right.ino;
634
769
  }
635
770
 
636
- async function readHandleBounded(handle, maxBytes) {
637
- const chunks = [];
638
- let totalBytes = 0;
639
- while (totalBytes <= maxBytes) {
640
- const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, (maxBytes + 1) - totalBytes));
641
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
642
- if (bytesRead === 0) break;
643
- chunks.push(buffer.subarray(0, bytesRead));
644
- totalBytes += bytesRead;
645
- }
646
- return {
647
- content: totalBytes > maxBytes ? null : Buffer.concat(chunks, totalBytes),
648
- totalBytes,
649
- };
650
- }
651
-
652
771
  async function verifyLocalContractProvenance(projectRoot, contract, canonicalSources) {
653
772
  if (!contract) {
654
773
  return { status: 'missing', expectedHash: null, computedHash: null, missingSources: [] };
@@ -702,60 +821,24 @@ async function verifyLocalContractProvenance(projectRoot, contract, canonicalSou
702
821
  if (aggregateBytes > MAX_CONTRACT_TOTAL_BYTES) {
703
822
  return { status: 'invalid', expectedHash: contract.sourceHash, computedHash: null, missingSources };
704
823
  }
705
- validatedSources.push({ source, sourcePath: resolvedSourcePath, sourceStat });
824
+ validatedSources.push({ source, sourcePath: resolvedSourcePath });
706
825
  }
707
826
  if (missingSources.length) {
708
827
  return { status: 'missing', expectedHash: contract.sourceHash, computedHash: null, missingSources };
709
828
  }
710
829
 
711
- const hash = createHash('sha256');
830
+ const hasher = createHash('sha256');
712
831
  aggregateBytes = 0;
713
- for (const { source, sourcePath, sourceStat } of validatedSources) {
714
- const handle = await open(sourcePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW).catch(() => null);
715
- if (!handle) {
832
+ for (const { source, sourcePath } of validatedSources) {
833
+ const limit = Math.min(MAX_CONTRACT_SOURCE_BYTES, Math.max(0, MAX_CONTRACT_TOTAL_BYTES - aggregateBytes));
834
+ const content = await readBoundedRegularFile(sourcePath, limit, 'contract source', root, null).catch(() => null);
835
+ if (!content) {
716
836
  return { status: 'invalid', expectedHash: contract.sourceHash, computedHash: null, missingSources };
717
837
  }
718
- try {
719
- const openedStat = await handle.stat();
720
- const currentPathStat = await lstat(sourcePath).catch(() => null);
721
- const currentResolvedPath = await realpath(sourcePath).catch(() => null);
722
- const openedResolvedPath = await realpath(`/proc/self/fd/${handle.fd}`).catch(() => currentResolvedPath);
723
- if (
724
- !openedStat.isFile()
725
- || !currentPathStat?.isFile()
726
- || currentPathStat.isSymbolicLink()
727
- || !currentResolvedPath
728
- || !openedResolvedPath
729
- || !isPathInside(root, currentResolvedPath)
730
- || currentResolvedPath !== sourcePath
731
- || openedResolvedPath !== sourcePath
732
- || !sameFileIdentity(sourceStat, openedStat)
733
- || !sameFileIdentity(openedStat, currentPathStat)
734
- || openedStat.size > MAX_CONTRACT_SOURCE_BYTES
735
- ) {
736
- return { status: 'invalid', expectedHash: contract.sourceHash, computedHash: null, missingSources };
737
- }
738
-
739
- const remainingAggregateBytes = Math.max(0, MAX_CONTRACT_TOTAL_BYTES - aggregateBytes);
740
- const bounded = await readHandleBounded(handle, Math.min(MAX_CONTRACT_SOURCE_BYTES, remainingAggregateBytes));
741
- aggregateBytes += bounded.totalBytes;
742
- const finalStat = await handle.stat();
743
- if (
744
- !bounded.content
745
- || aggregateBytes > MAX_CONTRACT_TOTAL_BYTES
746
- || !sameFileIdentity(openedStat, finalStat)
747
- || openedStat.size !== finalStat.size
748
- || openedStat.mtimeMs !== finalStat.mtimeMs
749
- || openedStat.ctimeMs !== finalStat.ctimeMs
750
- ) {
751
- return { status: 'invalid', expectedHash: contract.sourceHash, computedHash: null, missingSources };
752
- }
753
- hash.update(`${source}\0${bounded.content.toString('utf8')}\0`);
754
- } finally {
755
- await handle.close();
756
- }
838
+ aggregateBytes += content.length;
839
+ hasher.update(`${source}\0${content.toString('utf8')}\0`);
757
840
  }
758
- const computedHash = hash.digest('hex');
841
+ const computedHash = hasher.digest('hex');
759
842
  return {
760
843
  status: computedHash === contract.sourceHash ? 'verified' : 'stale',
761
844
  expectedHash: contract.sourceHash,
@@ -1023,45 +1106,6 @@ function loadSemanticsContract() {
1023
1106
  return semanticsCache;
1024
1107
  }
1025
1108
 
1026
- function enforcedSemanticRules(data) {
1027
- return Object.entries(data.rows)
1028
- .filter(([, component]) => component.status === 'enforced')
1029
- .flatMap(([component, definition]) => definition.rules.map((rule) => ({ ...rule, component })));
1030
- }
1031
-
1032
- function expandedSemanticSelector(rule, compositeRoles) {
1033
- if (rule.kind !== 'forbid-composite-roles') {
1034
- return rule.selector;
1035
- }
1036
- return compositeRoles.map((role) => `${rule.selector}[role="${role}"]`).join(', ');
1037
- }
1038
-
1039
- function authoredName(element, document) {
1040
- const label = element.getAttribute('aria-label');
1041
- if (label?.trim()) {
1042
- return label.trim();
1043
- }
1044
- const labelledBy = element.getAttribute('aria-labelledby');
1045
- if (!labelledBy) {
1046
- return '';
1047
- }
1048
- return labelledBy
1049
- .split(/\s+/u)
1050
- .map((id) => document.getElementById(id)?.textContent ?? '')
1051
- .join(' ')
1052
- .trim();
1053
- }
1054
-
1055
- function accessibleName(element, document) {
1056
- const authored = authoredName(element, document);
1057
- if (authored) {
1058
- return authored;
1059
- }
1060
- const clone = element.cloneNode(true);
1061
- clone.querySelectorAll('[aria-hidden="true"]').forEach((node) => node.remove());
1062
- return clone.textContent.trim();
1063
- }
1064
-
1065
1109
  function markInspectionTruncated(issues, reason) {
1066
1110
  Object.defineProperty(issues, 'truncatedReason', { value: reason, enumerable: false });
1067
1111
  return issues;
@@ -1081,15 +1125,41 @@ function markupStructureExceedsLimit(snippet) {
1081
1125
  return false;
1082
1126
  }
1083
1127
 
1128
+ // An HTML template's parsed markup lives in its content fragment, which outerHTML still
1129
+ // serializes. A `template` inside SVG or MathML (Vue's v-for) is an ordinary element.
1130
+ function childElements(element) {
1131
+ return element.localName === 'template' && element.content
1132
+ ? [...element.children, ...element.content.children]
1133
+ : element.children;
1134
+ }
1135
+
1136
+ function markupNestingExceedsLimit(root) {
1137
+ const stack = [[root, 0]];
1138
+ while (stack.length) {
1139
+ const [element, depth] = stack.pop();
1140
+ if (depth > MAX_STATIC_MARKUP_DEPTH) return true;
1141
+ for (const child of childElements(element)) stack.push([child, depth + 1]);
1142
+ }
1143
+ return false;
1144
+ }
1145
+
1146
+ // Parses a snippet, or names the limit that stops it: tag count before parsing, depth after.
1147
+ function parseInspectableMarkup(snippet, prefix, includeNodeLocations) {
1148
+ if (markupStructureExceedsLimit(snippet)) return { reason: 'markup structure limit reached' };
1149
+ // Loaded on first use: jsdom takes ~0.3 s to load, and most server starts never inspect markup.
1150
+ const { JSDOM } = createRequire(import.meta.url)('jsdom');
1151
+ const dom = new JSDOM(`${prefix}${snippet}</body>`, { includeNodeLocations });
1152
+ if (markupNestingExceedsLimit(dom.window.document.body)) return { reason: 'markup nesting limit reached' };
1153
+ return { dom };
1154
+ }
1155
+
1084
1156
  function inspectSemanticRules(snippet, maxIssues = MAX_STATIC_ISSUES, deadline = Infinity) {
1085
1157
  const { semantics, frameworkVersion } = loadSemanticsContract();
1086
1158
  if (maxIssues <= 0) return markInspectionTruncated([], 'issue limit reached');
1087
- if (markupStructureExceedsLimit(snippet)) return markInspectionTruncated([], 'markup structure limit reached');
1088
1159
  const prefix = '<!doctype html><body>';
1089
1160
  const includeNodeLocations = snippet.length * (snippet.split('<').length - 1) <= MAX_LOCATED_MARKUP_WORK;
1090
- // Loaded on first use: jsdom takes ~0.3 s to load, and most server starts never inspect markup.
1091
- const { JSDOM } = createRequire(import.meta.url)('jsdom');
1092
- const dom = new JSDOM(`${prefix}${snippet}</body>`, { includeNodeLocations });
1161
+ const { dom, reason } = parseInspectableMarkup(snippet, prefix, includeNodeLocations);
1162
+ if (reason) return markInspectionTruncated([], reason);
1093
1163
  const { document } = dom.window;
1094
1164
  // Elements the parser creates without a start tag have no source offset, so they report the start.
1095
1165
  const locate = (element) => {
@@ -1098,34 +1168,14 @@ function inspectSemanticRules(snippet, maxIssues = MAX_STATIC_ISSUES, deadline =
1098
1168
  };
1099
1169
  const issues = [];
1100
1170
 
1101
- for (const rule of enforcedSemanticRules(semantics)) {
1171
+ for (const rule of enforcedRules(semantics)) {
1102
1172
  const beforeRule = inspectionStopReason(issues, maxIssues, deadline);
1103
1173
  if (beforeRule) return markInspectionTruncated(issues, beforeRule);
1104
- const hits = document.querySelectorAll(expandedSemanticSelector(rule, semantics.compositeRoles));
1105
- if (rule.kind === 'forbid' || rule.kind === 'forbid-composite-roles') {
1106
- for (const element of hits) {
1107
- const stop = inspectionStopReason(issues, maxIssues, deadline);
1108
- if (stop) return markInspectionTruncated(issues, stop);
1109
- issues.push(semanticIssue(rule, element, frameworkVersion, locate(element)));
1110
- }
1111
- } else if (rule.kind === 'require-attr') {
1112
- for (const element of hits) {
1113
- const stop = inspectionStopReason(issues, maxIssues, deadline);
1114
- if (stop) return markInspectionTruncated(issues, stop);
1115
- const value = element.getAttribute(rule.attr);
1116
- const valid = rule.equals ? value === rule.equals : value !== null && value !== '';
1117
- if (!valid) {
1118
- issues.push(semanticIssue(rule, element, frameworkVersion, locate(element)));
1119
- }
1120
- }
1121
- } else if (rule.kind === 'require-accessible-name') {
1122
- for (const element of hits) {
1123
- const stop = inspectionStopReason(issues, maxIssues, deadline);
1124
- if (stop) return markInspectionTruncated(issues, stop);
1125
- if (!accessibleName(element, document)) {
1126
- issues.push(semanticIssue(rule, element, frameworkVersion, locate(element)));
1127
- }
1128
- }
1174
+ const breaks = ruleBreach(rule);
1175
+ for (const element of document.querySelectorAll(expandedSelector(rule, semantics.compositeRoles))) {
1176
+ const stop = inspectionStopReason(issues, maxIssues, deadline);
1177
+ if (stop) return markInspectionTruncated(issues, stop);
1178
+ if (breaks(element, document)) issues.push(semanticIssue(rule, element, frameworkVersion, locate(element)));
1129
1179
  }
1130
1180
  }
1131
1181
 
@@ -1173,6 +1223,9 @@ function lineForMatch(text, index) {
1173
1223
  return { line, column: col };
1174
1224
  }
1175
1225
 
1226
+ // The legacy and retired-markup rules, for tests that check each one alone.
1227
+ export const authoringRules = LegacyPatternList;
1228
+
1176
1229
  export function inspectAuthoringRules(
1177
1230
  snippet,
1178
1231
  maxIssues = MAX_STATIC_ISSUES,
@@ -1511,10 +1564,15 @@ function summarizeGuide(guide, { sections, detail, includeCapabilities }) {
1511
1564
 
1512
1565
  function summarizeProjectFiles(files, projectRoot) {
1513
1566
  const resolvedRoot = realpathSync(path.resolve(projectRoot));
1567
+ const roots = [resolvedRoot, path.resolve(projectRoot)];
1514
1568
  const absoluteFiles = files.map((filePath) => {
1515
1569
  const requestedPath = path.isAbsolute(filePath)
1516
1570
  ? path.resolve(filePath)
1517
1571
  : path.resolve(resolvedRoot, filePath);
1572
+ // Touch nothing outside the root, so the reply cannot say whether a host path exists.
1573
+ if (!roots.some((root) => isPathInside(root, requestedPath))) {
1574
+ return { requested: filePath, absolute: requestedPath, exists: false, insideProject: false };
1575
+ }
1518
1576
  const exists = existsSync(requestedPath);
1519
1577
  const absolute = exists ? realpathSync(requestedPath) : requestedPath;
1520
1578
  return {
@@ -1533,14 +1591,14 @@ function summarizeProjectFiles(files, projectRoot) {
1533
1591
  skipped.push({ ...item, reason: 'file limit reached' });
1534
1592
  continue;
1535
1593
  }
1536
- if (!item.exists) {
1537
- skipped.push({ ...item, reason: 'file does not exist' });
1538
- continue;
1539
- }
1540
1594
  if (!item.insideProject) {
1541
1595
  skipped.push({ ...item, reason: 'file is outside projectRoot' });
1542
1596
  continue;
1543
1597
  }
1598
+ if (!item.exists) {
1599
+ skipped.push({ ...item, reason: 'file does not exist' });
1600
+ continue;
1601
+ }
1544
1602
  if (path.relative(resolvedRoot, item.absolute).split(path.sep).includes('node_modules')) {
1545
1603
  skipped.push({ ...item, reason: 'node_modules is excluded' });
1546
1604
  continue;
@@ -1594,6 +1652,10 @@ function commandEnvironment(projectRoot) {
1594
1652
  return env;
1595
1653
  }
1596
1654
 
1655
+ // Commands run in their own process group, so a signal to the server does not reach them.
1656
+ // startServer kills these on shutdown.
1657
+ const liveCommands = new Set();
1658
+
1597
1659
  function stopProcessTree(proc, signal) {
1598
1660
  if (!proc.pid) return;
1599
1661
  if (process.platform === 'win32') {
@@ -1633,48 +1695,14 @@ export function redactSensitiveText(value) {
1633
1695
  .replace(/[A-Z]:\\+Users\\+[^\\\s]+(?:\\+[^\s]*)?/giu, '[LOCAL_PATH]');
1634
1696
  }
1635
1697
 
1698
+ // Directories above the file may be links (callers pass realpaths beside lexical roots);
1699
+ // the file itself may not, and it must resolve inside projectRoot.
1636
1700
  export async function readInspectionFile(filePath, projectRoot, byteLimit) {
1637
- const resolvedRoot = await realpath(projectRoot);
1638
- const noFollow = Number.isInteger(fsConstants.O_NOFOLLOW) ? fsConstants.O_NOFOLLOW : 0;
1639
- const handle = await open(filePath, fsConstants.O_RDONLY | noFollow);
1640
- try {
1641
- const before = await handle.stat({ bigint: true });
1642
- const pathBefore = await lstat(filePath, { bigint: true });
1643
- if (pathBefore.isSymbolicLink()) throw new Error('path is a symbolic link');
1644
- if (!before.isFile() || !pathBefore.isFile()) throw new Error('path is not a regular file');
1645
- if (before.dev !== pathBefore.dev || before.ino !== pathBefore.ino) throw new Error('file identity changed before reading');
1646
- const openedPath = await realpath(`/proc/self/fd/${handle.fd}`).catch(() => realpath(filePath));
1647
- if (!isPathInside(resolvedRoot, openedPath)) throw new Error('opened file is outside projectRoot');
1648
- if (before.size > BigInt(byteLimit)) {
1649
- const error = new Error(`file exceeds ${byteLimit} byte read limit`);
1650
- error.code = 'INSPECTION_FILE_TOO_LARGE';
1651
- throw error;
1652
- }
1653
-
1654
- // Size the buffer to the file, not the limit. The spare byte exposes growth
1655
- // and files such as /proc entries that report a smaller size than they hold.
1656
- const capacity = Number(before.size) + 1;
1657
- const bytes = Buffer.allocUnsafe(capacity);
1658
- let total = 0;
1659
- while (total < capacity) {
1660
- const chunk = await handle.read(bytes, total, capacity - total, total);
1661
- if (chunk.bytesRead === 0) break;
1662
- total += chunk.bytesRead;
1663
- }
1664
- if (total > before.size) throw new Error('file changed while reading');
1665
-
1666
- const after = await handle.stat({ bigint: true });
1667
- const pathAfter = await lstat(filePath, { bigint: true });
1668
- if (pathAfter.isSymbolicLink() || !pathAfter.isFile()
1669
- || before.dev !== after.dev || before.ino !== after.ino
1670
- || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs
1671
- || after.dev !== pathAfter.dev || after.ino !== pathAfter.ino) {
1672
- throw new Error('file changed while reading');
1673
- }
1674
- return { text: bytes.subarray(0, total).toString('utf8'), bytes: total, sha256: createHash('sha256').update(bytes.subarray(0, total)).digest('hex') };
1675
- } finally {
1676
- await handle.close();
1677
- }
1701
+ const root = await realpath(projectRoot);
1702
+ const target = path.join(await realpath(path.dirname(path.resolve(filePath))), path.basename(filePath));
1703
+ if (!isPathInside(root, target)) throw new Error('opened file is outside projectRoot');
1704
+ const bytes = await readBoundedRegularFile(target, byteLimit, 'inspected', root, null);
1705
+ return { text: bytes.toString('utf8'), bytes: bytes.length, sha256: hash('sha256', bytes) };
1678
1706
  }
1679
1707
 
1680
1708
  async function findFileViolations(fileInfoList, projectRoot) {
@@ -1728,7 +1756,7 @@ async function findFileViolations(fileInfoList, projectRoot) {
1728
1756
  inspected.push(file.requested);
1729
1757
  }
1730
1758
  } catch (error) {
1731
- const aggregateLimit = error.code === 'INSPECTION_FILE_TOO_LARGE' && maxTotalBytes - totalBytes < maxBytes;
1759
+ const aggregateLimit = error.code === 'oversized' && maxTotalBytes - totalBytes < maxBytes;
1732
1760
  uninspected.push({
1733
1761
  file: file.requested,
1734
1762
  reason: aggregateLimit
@@ -1748,7 +1776,7 @@ async function findFileViolations(fileInfoList, projectRoot) {
1748
1776
  return { findings, inspected, uninspected, sourcePins };
1749
1777
  }
1750
1778
 
1751
- async function runCommandInProject(projectRoot, manager, script, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS) {
1779
+ async function runCommandInProject(projectRoot, manager, script, timeoutMs, signal) {
1752
1780
  const args = ['run', script];
1753
1781
  const command = `${manager} ${args.join(' ')}`;
1754
1782
  const configuredTimeout = Number.isFinite(SETTINGS.commandTimeoutMs) && SETTINGS.commandTimeoutMs > 0
@@ -1792,6 +1820,9 @@ async function runCommandInProject(projectRoot, manager, script, timeoutMs = DEF
1792
1820
  env: commandEnvironment(projectRoot),
1793
1821
  });
1794
1822
  closeSync(directoryFd);
1823
+ liveCommands.add(proc);
1824
+ const abort = () => stopProcessTree(proc, 'SIGKILL');
1825
+ signal.addEventListener('abort', abort, { once: true });
1795
1826
 
1796
1827
  let stdout = '';
1797
1828
  let stderr = '';
@@ -1802,6 +1833,7 @@ async function runCommandInProject(projectRoot, manager, script, timeoutMs = DEF
1802
1833
  if (settled) return;
1803
1834
  settled = true;
1804
1835
  clearTimeout(timer);
1836
+ signal.removeEventListener('abort', abort);
1805
1837
  if (!timedOut) clearTimeout(forceKillTimer);
1806
1838
  resolve(result);
1807
1839
  };
@@ -1838,6 +1870,7 @@ async function runCommandInProject(projectRoot, manager, script, timeoutMs = DEF
1838
1870
 
1839
1871
  proc.on('close', (code) => {
1840
1872
  stopProcessTree(proc, 'SIGKILL'); // Stop descendants before checking the resulting source state.
1873
+ liveCommands.delete(proc);
1841
1874
  const output = redactSensitiveText(`${stdout}\n${stderr}`);
1842
1875
  finish({
1843
1876
  manager,
@@ -1852,6 +1885,7 @@ async function runCommandInProject(projectRoot, manager, script, timeoutMs = DEF
1852
1885
  });
1853
1886
 
1854
1887
  proc.on('error', (error) => {
1888
+ liveCommands.delete(proc);
1855
1889
  finish({
1856
1890
  manager,
1857
1891
  command,
@@ -1881,13 +1915,17 @@ async function pinsUnchanged(projectRoot, pins) {
1881
1915
  return true;
1882
1916
  }
1883
1917
 
1884
- async function runQualityCommands(projectRoot, commands, packageManager, pins) {
1918
+ // Exported for the smoke test, which cancels a run while the source pins are being checked.
1919
+ export async function runQualityCommands(projectRoot, commands, packageManager, pins, signal) {
1885
1920
  const results = [];
1886
1921
  let unchanged = true;
1887
1922
  for (const script of commands) {
1888
1923
  unchanged = await pinsUnchanged(projectRoot, pins);
1924
+ // Checked after the await, with nothing awaited before the spawn: an abort listener added
1925
+ // to a signal that is already aborted never fires. The SDK drops a cancelled call's result.
1926
+ signal.throwIfAborted();
1889
1927
  if (!unchanged) break;
1890
- const result = await runCommandInProject(projectRoot, packageManager, script, script === 'typecheck' ? 180_000 : 360_000);
1928
+ const result = await runCommandInProject(projectRoot, packageManager, script, script === 'typecheck' ? 180_000 : 360_000, signal);
1891
1929
  results.push(result);
1892
1930
  unchanged = await pinsUnchanged(projectRoot, pins);
1893
1931
  if (!unchanged || !result.completed || result.exitStatus !== 0) break;
@@ -2434,7 +2472,7 @@ function buildCatalogPayload(catalog, version, parsed) {
2434
2472
  }, parsed.workflowId);
2435
2473
  }
2436
2474
 
2437
- async function qualityInspectorHandler(args) {
2475
+ async function qualityInspectorHandler(args, extra) {
2438
2476
  if (skipTool('qualityInspector')) {
2439
2477
  return skippedStage('quality_inspector', 'SKIP_QUALITY_INSPECTOR', args?.workflowId);
2440
2478
  }
@@ -2476,7 +2514,7 @@ async function qualityInspectorHandler(args) {
2476
2514
  }
2477
2515
  let inputsUnchanged = await pinsUnchanged(projectRoot, pins);
2478
2516
  if (commandsRequested && !commandRootBlocked && !commandScopeBlocked && !packageManagerBlocked && !manifestBlocked && expectedMatched !== false && inputsUnchanged) {
2479
- const run = await runQualityCommands(executionPolicy.projectRoot, commands, version.packageManager, pins);
2517
+ const run = await runQualityCommands(executionPolicy.projectRoot, commands, version.packageManager, pins, extra.signal);
2480
2518
  commandChecks.push(...run.results);
2481
2519
  inputsUnchanged = run.unchanged;
2482
2520
  }
@@ -2840,11 +2878,19 @@ async function startServer() {
2840
2878
  const close = transport.close.bind(transport);
2841
2879
  transport.close = async () => { try { await close(); } finally { scopedRequests.clear(); } };
2842
2880
  await server.connect(transport);
2881
+ // The client closing stdin or signalling the server ends the session; take running commands with it.
2882
+ const shutdown = () => {
2883
+ for (const proc of liveCommands) stopProcessTree(proc, 'SIGKILL');
2884
+ process.exit();
2885
+ };
2886
+ process.once('SIGINT', shutdown);
2887
+ process.once('SIGTERM', shutdown);
2888
+ process.stdin.once('end', shutdown);
2843
2889
  console.error('ExpressiveCSS MCP server running on stdio transport.');
2844
2890
  }
2845
2891
 
2846
2892
  // Only run as the entry point; lint.mjs imports the static checks without starting the server.
2847
- if (process.argv[1] && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url) {
2893
+ if (import.meta.main) {
2848
2894
  startServer().catch((error) => {
2849
2895
  console.error('ExpressiveCSS MCP server failed to start:', error);
2850
2896
  process.exitCode = 1;