@expressivecss/mcp-server 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +63 -0
- package/README.md +2 -2
- package/capability-roadmap.json +1927 -345
- package/component-decisions.json +3229 -731
- package/component-guides.json +179 -75
- package/contract.json +6 -4
- package/lint.mjs +8 -4
- package/package.json +4 -2
- package/scripts/bounded-file.mjs +73 -0
- package/scripts/resolve-version.mjs +17 -60
- package/scripts/semantics-rules.mjs +184 -0
- package/semantics-data.json +775 -33
- package/server.js +242 -196
- package/smoke.mjs +210 -23
package/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,
|
|
3
|
+
import { lstat, readFile, realpath, stat } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { fileURLToPath
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
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
|
|
830
|
+
const hasher = createHash('sha256');
|
|
712
831
|
aggregateBytes = 0;
|
|
713
|
-
for (const { source, sourcePath
|
|
714
|
-
const
|
|
715
|
-
|
|
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
|
-
|
|
719
|
-
|
|
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 =
|
|
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
|
-
|
|
1091
|
-
|
|
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
|
|
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
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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
|
|
1638
|
-
const
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
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 === '
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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;
|