@master/css-mcp 2.1.2 → 2.1.3
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/README.md +52 -2
- package/dist/contract-schema.js +104 -0
- package/dist/contracts.js +623 -0
- package/dist/output-schema.js +5 -1
- package/dist/server.js +45 -0
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Master CSS MCP Server
|
|
2
2
|
|
|
3
|
-
Model Context Protocol server for Master CSS tooling. It lets AI clients inspect a Master CSS workspace, audit setup, read the active project manifest, inspect CSS-first directives, trace classes through extraction and generated CSS, render or compare CSS output, run lint diagnostics, request syntax suggestions, and apply reviewed fix previews.
|
|
3
|
+
Model Context Protocol server for Master CSS tooling. It lets AI clients inspect a Master CSS workspace, audit setup, read the active project manifest, inspect CSS-first directives, trace classes through extraction and generated CSS, render or compare CSS output, run lint diagnostics, request syntax suggestions, read and check the component contracts of a design system, and apply reviewed fix previews.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -60,6 +60,9 @@ The `preset` context uses the bundled Master CSS defaults, so this check works b
|
|
|
60
60
|
| `mastercss_lint_project` | Run class-list diagnostics for workspace files without writing files. |
|
|
61
61
|
| `mastercss_lint_content` | Run class-list diagnostics on an in-memory source buffer without writing files. |
|
|
62
62
|
| `mastercss_suggest_syntax` | Return language-service completions and hover context. |
|
|
63
|
+
| `mastercss_contract_list` | List the component contracts of the workspace's design-system dependencies, merged with the project's own. Filter by `query`, `tier`, or `category`. |
|
|
64
|
+
| `mastercss_contract_get` | Return one full contract and its source by contract name or by any class it defines. |
|
|
65
|
+
| `mastercss_contract_check` | Check an in-memory source buffer or workspace files against the contracts with the checker a design-system dependency declares, without writing files. |
|
|
63
66
|
| `mastercss_preview_fixes` | Create a diff preview and confirmation token. |
|
|
64
67
|
| `mastercss_preview_directive_format` | Preview Master CSS directive formatting for files, or format in-memory content without writing. |
|
|
65
68
|
| `mastercss_apply_preview` | Apply a preview after token, hash, and workspace checks. |
|
|
@@ -92,6 +95,51 @@ The report covers project-owned CSS reached from the project's Master CSS entrie
|
|
|
92
95
|
|
|
93
96
|
Metadata and policies are analysis inputs: they do not change generated CSS, manifests, or runtime programs. Unrecognized option fields are rejected. Omit `rawValuePolicy` to skip native declaration raw-value checks. Read `project.excluded` and `coverage`: package sources and files outside the project root are excluded, importer links include reference-only context, and the report does not establish DOM usage, cascade winners, computed styles, or browser behavior. `includeSnapshot` remains available in the same call.
|
|
94
97
|
|
|
98
|
+
## Contracts of a design system
|
|
99
|
+
|
|
100
|
+
A design system built on Master CSS can describe each component in a contract: its classes and where they go, its states, public properties and smallest correct markup. The contract tools serve those contracts from the same server, so an agent can read a component's contract before writing its markup and check the result afterward. [`@master/ui`](https://ui.master.co) is the first library that ships contracts this way.
|
|
101
|
+
|
|
102
|
+
A library declares its contracts in its own `package.json`:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"name": "your-design-system",
|
|
107
|
+
"masterCSS": {
|
|
108
|
+
"contracts": "./dist/contracts.json",
|
|
109
|
+
"check": "./check/index.js"
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- `contracts` is a path inside the package to a JSON file holding `{ "contracts": [...] }`. A contract is an object with a string `name` and a `classes` array; every other field belongs to the library.
|
|
115
|
+
- `check` is optional: a path inside the package to an ES module that exports `check(source, contracts, { knows, strict })` and returns diagnostics with `code`, `severity`, `message`, `line`, `column` and, when they apply, `className` and `contract`.
|
|
116
|
+
|
|
117
|
+
The server reads `dependencies`, `devDependencies` and `peerDependencies` from the `package.json` in the workspace root, resolves each package from that root and keeps those that declare `masterCSS.contracts`. Packages that do not resolve or do not declare the field are skipped. When none declares contracts, the server reads the same fields of the monorepo root above: the nearest directory whose `package.json` lists `workspaces` or that holds a `pnpm-workspace.yaml`, without leaving the repository.
|
|
118
|
+
|
|
119
|
+
A project adds contracts of its own with globs in the same field of its root `package.json`, one contract per JSON file:
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{
|
|
123
|
+
"masterCSS": {
|
|
124
|
+
"contracts": ["contracts/*.json"]
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
A project with a `master-ui.json` also contributes the JSON files of its `directory` (`ui` by default) and its `contracts` globs. A project contract replaces a library contract of the same `name`. Every contract in a result carries its `source`: `{ "kind": "package", "package", "version" }`, or `{ "kind": "project", "file" }` with `replaces` naming the library contract it took over.
|
|
130
|
+
|
|
131
|
+
| Call | Result |
|
|
132
|
+
| --- | --- |
|
|
133
|
+
| `mastercss_contract_list` with optional `query`, `tier`, `category` | `libraries` (name, version, contract count, whether it has a checker) and a compact `contracts` list. Nothing declaring contracts is an empty list with a `note`, not an error. |
|
|
134
|
+
| `mastercss_contract_get` with `name` | The full `contract` and its `source`. `name` is a contract name or any class in a contract, so `btn-primary` returns the button contract. An unknown name is an error that lists the closest contract and class names. |
|
|
135
|
+
| `mastercss_contract_check` with `content` and optional `filePath`, or `patterns` | Diagnostics per file, a `summary`, `coverage`, and the `checkers` that ran. Without input it checks the project's markup files. |
|
|
136
|
+
|
|
137
|
+
A library in its own repository can declare `masterCSS.check` in the root `package.json` too; that checker then reads the contracts its root `masterCSS.contracts` globs select.
|
|
138
|
+
|
|
139
|
+
`mastercss_contract_check` runs code from the package that declares the checker. The server imports it only from a path inside that package, runs it in a worker thread, and stops it with `CONTRACT_CHECKER_FAILED` when one file takes longer than 10 seconds or the checker exceeds its memory limit. Each check loads the checker again, so an upgraded library takes effect without a restart.
|
|
140
|
+
|
|
141
|
+
`mastercss_contract_check` passes the checker `knows(className)`, which answers from the project manifest whether a class is a valid Master CSS class or a native class of the project's stylesheets, and `strict: true` only when that manifest loaded; `classValidation` and `strict` in the result say which happened. When contracts exist but no library declares a checker, `coverage.status` is `no-checker` and lookups still work. With several libraries, each checker reads its own library's contracts as the project replaced them, and contracts no checker owns go to the first one.
|
|
142
|
+
|
|
95
143
|
## Prompt templates
|
|
96
144
|
|
|
97
145
|
| Prompt | Use |
|
|
@@ -106,7 +154,9 @@ Every tool returns a `version: 3` envelope with `metadata`, `diagnostics` and `r
|
|
|
106
154
|
|
|
107
155
|
## Safety
|
|
108
156
|
|
|
109
|
-
Read-only tools do not write files.
|
|
157
|
+
Read-only tools do not write files. `mastercss_contract_check` is the one tool that runs code from the workspace: it imports the checker module of a package that is a declared dependency of the workspace root, only from a path inside that package, and runs it in the server process. Project contract files are read as data and never executed. Restart the server after upgrading a library so that its checker is loaded again.
|
|
158
|
+
|
|
159
|
+
File writes use a two-step preview/apply flow, validate workspace containment, and verify original file hashes before writing. Lint fixes, generated CSS output writes, and directive formatting for files all use preview tokens before `mastercss_apply_preview` can write anything.
|
|
110
160
|
|
|
111
161
|
Concurrent applies coordinate across MCP processes for the same OS user through a local lock directory under `~/.cache/mastercss`. Hash validation and writes run one apply at a time across all workspace roots. A waiting apply times out after 30 seconds without taking over a live writer; its token can be retried until it expires. External editors do not participate in this lock, so edits made after hash validation can still race with apply. Multi-file writes are sequential and do not provide rollback or crash atomicity.
|
|
112
162
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { metadata, strings } from "./semantic-schema.js";
|
|
2
|
+
import * as z from "zod/v4";
|
|
3
|
+
// src/contract-schema.ts
|
|
4
|
+
const count = z.number().int().nonnegative();
|
|
5
|
+
const owner = z.object({
|
|
6
|
+
package: z.string(),
|
|
7
|
+
version: z.string().nullable()
|
|
8
|
+
});
|
|
9
|
+
/** Where a contract comes from: the library that ships it, or the project file that defines or replaces it. */
|
|
10
|
+
const contractSource = z.discriminatedUnion("kind", [owner.extend({ kind: z.literal("package") }), z.object({
|
|
11
|
+
kind: z.literal("project"),
|
|
12
|
+
file: z.string(),
|
|
13
|
+
replaces: owner.optional()
|
|
14
|
+
})]);
|
|
15
|
+
const library = z.object({
|
|
16
|
+
name: z.string(),
|
|
17
|
+
version: z.string().nullable(),
|
|
18
|
+
count,
|
|
19
|
+
checker: z.boolean()
|
|
20
|
+
});
|
|
21
|
+
const entry = z.object({
|
|
22
|
+
name: z.string(),
|
|
23
|
+
title: z.string().optional(),
|
|
24
|
+
tier: z.string().optional(),
|
|
25
|
+
category: z.string().optional(),
|
|
26
|
+
description: z.string().optional(),
|
|
27
|
+
use: z.string().optional(),
|
|
28
|
+
script: z.string().optional(),
|
|
29
|
+
source: contractSource
|
|
30
|
+
});
|
|
31
|
+
const contract = z.object({
|
|
32
|
+
name: z.string(),
|
|
33
|
+
classes: z.array(z.json())
|
|
34
|
+
}).passthrough();
|
|
35
|
+
const diagnostic = z.object({
|
|
36
|
+
code: z.string(),
|
|
37
|
+
severity: z.enum([
|
|
38
|
+
"error",
|
|
39
|
+
"warning",
|
|
40
|
+
"info"
|
|
41
|
+
]),
|
|
42
|
+
message: z.string(),
|
|
43
|
+
line: count,
|
|
44
|
+
column: count,
|
|
45
|
+
className: z.string().optional(),
|
|
46
|
+
contract: z.string().optional(),
|
|
47
|
+
source: z.string()
|
|
48
|
+
});
|
|
49
|
+
const contractList = {
|
|
50
|
+
version: z.literal(1),
|
|
51
|
+
root: z.string(),
|
|
52
|
+
libraries: z.array(library),
|
|
53
|
+
contracts: z.array(entry),
|
|
54
|
+
summary: z.object({
|
|
55
|
+
libraries: count,
|
|
56
|
+
contracts: count,
|
|
57
|
+
returned: count
|
|
58
|
+
}),
|
|
59
|
+
note: z.string().optional()
|
|
60
|
+
};
|
|
61
|
+
const contractGet = {
|
|
62
|
+
version: z.literal(1),
|
|
63
|
+
root: z.string(),
|
|
64
|
+
name: z.string(),
|
|
65
|
+
matched: z.enum(["contract", "class"]),
|
|
66
|
+
contract,
|
|
67
|
+
source: contractSource
|
|
68
|
+
};
|
|
69
|
+
const contractCheck = {
|
|
70
|
+
version: z.literal(1),
|
|
71
|
+
root: z.string(),
|
|
72
|
+
manifest: metadata.optional(),
|
|
73
|
+
libraries: z.array(library),
|
|
74
|
+
checkers: z.array(owner.extend({ contracts: count })),
|
|
75
|
+
classValidation: z.enum(["project-manifest", "none"]),
|
|
76
|
+
strict: z.boolean(),
|
|
77
|
+
files: z.array(z.object({
|
|
78
|
+
filePath: z.string(),
|
|
79
|
+
diagnostics: z.array(diagnostic)
|
|
80
|
+
})),
|
|
81
|
+
coverage: z.object({
|
|
82
|
+
status: z.enum([
|
|
83
|
+
"complete",
|
|
84
|
+
"no-inputs",
|
|
85
|
+
"no-checker",
|
|
86
|
+
"no-contracts"
|
|
87
|
+
]),
|
|
88
|
+
checkedFiles: strings,
|
|
89
|
+
skippedFiles: z.array(z.object({
|
|
90
|
+
filePath: z.string(),
|
|
91
|
+
reason: z.string()
|
|
92
|
+
})),
|
|
93
|
+
description: z.string()
|
|
94
|
+
}),
|
|
95
|
+
summary: z.object({
|
|
96
|
+
checkedFiles: count,
|
|
97
|
+
filesWithDiagnostics: count,
|
|
98
|
+
diagnostics: count,
|
|
99
|
+
errors: count,
|
|
100
|
+
warnings: count
|
|
101
|
+
}),
|
|
102
|
+
note: z.string().optional()
|
|
103
|
+
};
|
|
104
|
+
export { contractCheck, contractGet, contractList, contractSource };
|
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import { getErrorMessage } from "./result.js";
|
|
2
|
+
import { loadWorkspaceManifest, manifestMetadata } from "./project.js";
|
|
3
|
+
import { createMCPToolingSession } from "./tooling-session.js";
|
|
4
|
+
import { resolveSourceFiles } from "./scan.js";
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
8
|
+
import fg from "fast-glob";
|
|
9
|
+
import { Worker } from "node:worker_threads";
|
|
10
|
+
// src/contracts.ts
|
|
11
|
+
/**
|
|
12
|
+
* Contracts of a design system built on Master CSS.
|
|
13
|
+
*
|
|
14
|
+
* A package declares them in its package.json:
|
|
15
|
+
* "masterCSS": { "contracts": "./dist/contracts.json", "check": "./check/index.js" }
|
|
16
|
+
* `contracts` names a JSON file holding `{ contracts: [...] }`; the optional `check` names an ES module
|
|
17
|
+
* exporting `check(source, contracts, { knows, strict })`. A project adds or replaces contracts with its own
|
|
18
|
+
* JSON files, and a library checking itself declares its `check` in the workspace's own package.json. This module
|
|
19
|
+
* only reads that protocol: it knows no design system by name.
|
|
20
|
+
*/
|
|
21
|
+
const CONTRACT_REPORT_VERSION = 1;
|
|
22
|
+
const MAX_CONTRACT_FILE_BYTES = 16777216;
|
|
23
|
+
const MAX_PROJECT_CONTRACT_FILES = 2e3;
|
|
24
|
+
const MAX_CHECK_FILES = 5e3;
|
|
25
|
+
const MAX_CHECK_FILE_BYTES = 2097152;
|
|
26
|
+
const MAX_DIAGNOSTICS_PER_FILE = 500;
|
|
27
|
+
const MAX_CHECK_CONTENT_LENGTH = MAX_CHECK_FILE_BYTES;
|
|
28
|
+
const DEFAULT_CHECK_PATTERNS = ["**/*.{html,htm,jsx,tsx,svelte,astro,vue,mdx,php}"];
|
|
29
|
+
const DEFAULT_IGNORE_PATTERNS = ["**/node_modules/**", "node_modules"];
|
|
30
|
+
const PACKAGE_NAME = /^(?:@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*$/i;
|
|
31
|
+
const NO_CONTRACTS_NOTE = "No dependency of this workspace declares \"masterCSS.contracts\" in its package.json, and the project defines no contracts of its own.";
|
|
32
|
+
const DEPENDENCY_FIELDS = [
|
|
33
|
+
"dependencies",
|
|
34
|
+
"devDependencies",
|
|
35
|
+
"peerDependencies"
|
|
36
|
+
];
|
|
37
|
+
/** Limits of one checker call, read at every check. A checker that exceeds them is stopped, not the server. */
|
|
38
|
+
const contractCheckerLimits = {
|
|
39
|
+
timeoutMs: 1e4,
|
|
40
|
+
memoryMb: 512
|
|
41
|
+
};
|
|
42
|
+
const record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
43
|
+
const text = (value) => typeof value === "string" && value ? value : void 0;
|
|
44
|
+
const strings = (value) => (Array.isArray(value) ? value : [value]).filter((item) => typeof item === "string" && item.length > 0);
|
|
45
|
+
const posix = (path) => path.replaceAll("\\", "/");
|
|
46
|
+
function inside(directory, filePath) {
|
|
47
|
+
const path = relative(directory, filePath);
|
|
48
|
+
return Boolean(path) && !path.startsWith("..") && !isAbsolute(path);
|
|
49
|
+
}
|
|
50
|
+
async function readJSON(filePath) {
|
|
51
|
+
try {
|
|
52
|
+
if ((await stat(filePath)).size > MAX_CONTRACT_FILE_BYTES) return {
|
|
53
|
+
error: `larger than ${MAX_CONTRACT_FILE_BYTES} bytes`,
|
|
54
|
+
missing: false
|
|
55
|
+
};
|
|
56
|
+
return { value: JSON.parse(await readFile(filePath, "utf8")) };
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return {
|
|
59
|
+
error: getErrorMessage(error),
|
|
60
|
+
missing: record(error).code === "ENOENT"
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** A file inside a package directory, with links resolved so a path cannot leave the package. */
|
|
65
|
+
async function resolveInside(directory, path) {
|
|
66
|
+
if (isAbsolute(path)) return void 0;
|
|
67
|
+
try {
|
|
68
|
+
const filePath = await realpath(resolve(directory, path));
|
|
69
|
+
return inside(directory, filePath) && (await stat(filePath)).isFile() ? filePath : void 0;
|
|
70
|
+
} catch {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Where Node resolves a bare package name from the root, without requiring an exported package.json. */
|
|
75
|
+
async function findPackageDirectory(root, name) {
|
|
76
|
+
for (let directory = root;; directory = dirname(directory)) {
|
|
77
|
+
const manifest = join(directory, "node_modules", name, "package.json");
|
|
78
|
+
if ((await stat(manifest).catch(() => void 0))?.isFile()) return realpath(dirname(manifest));
|
|
79
|
+
if (dirname(directory) === directory) return void 0;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const exists = (path) => stat(path).then(() => true, () => false);
|
|
83
|
+
/** The nearest ancestor that roots a workspace, without leaving the repository that holds the root. */
|
|
84
|
+
async function findWorkspaceRoot(root) {
|
|
85
|
+
if (await exists(join(root, ".git"))) return void 0;
|
|
86
|
+
for (let directory = dirname(root); directory !== dirname(directory); directory = dirname(directory)) {
|
|
87
|
+
const manifest = await readJSON(join(directory, "package.json"));
|
|
88
|
+
const pkg = record("value" in manifest ? manifest.value : void 0);
|
|
89
|
+
if (pkg.workspaces || await exists(join(directory, "pnpm-workspace.yaml"))) return {
|
|
90
|
+
directory,
|
|
91
|
+
pkg
|
|
92
|
+
};
|
|
93
|
+
if (await exists(join(directory, ".git"))) return void 0;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const isContract = (value) => typeof record(value).name === "string" && record(value).name !== "" && Array.isArray(record(value).classes);
|
|
97
|
+
/** One contract, or the contracts of a `{ contracts: [...] }` file. Anything else holds none. */
|
|
98
|
+
function contractsIn(value) {
|
|
99
|
+
if (isContract(value)) return [value];
|
|
100
|
+
const { contracts } = record(value);
|
|
101
|
+
return Array.isArray(contracts) ? contracts.filter(isContract) : [];
|
|
102
|
+
}
|
|
103
|
+
const classNamesOf = (contract) => contract.classes.flatMap((entry) => text(record(entry).name) ?? []);
|
|
104
|
+
/** The libraries among the dependencies a package.json declares, resolved from its directory. */
|
|
105
|
+
async function readLibraries(root, pkg, libraries, merged, warn) {
|
|
106
|
+
for (const dependency of new Set(DEPENDENCY_FIELDS.flatMap((field) => Object.keys(record(pkg[field]))))) {
|
|
107
|
+
if (!PACKAGE_NAME.test(dependency)) continue;
|
|
108
|
+
const directory = await findPackageDirectory(root, dependency);
|
|
109
|
+
if (!directory) continue;
|
|
110
|
+
const manifest = await readJSON(join(directory, "package.json"));
|
|
111
|
+
if (!("value" in manifest)) continue;
|
|
112
|
+
const pkg = record(manifest.value);
|
|
113
|
+
const declared = record(pkg.masterCSS);
|
|
114
|
+
const contractPaths = strings(declared.contracts);
|
|
115
|
+
if (!contractPaths.length) continue;
|
|
116
|
+
const owner = {
|
|
117
|
+
package: text(pkg.name) ?? dependency,
|
|
118
|
+
version: text(pkg.version) ?? null
|
|
119
|
+
};
|
|
120
|
+
if (libraries.some((library) => library.name === owner.package)) continue;
|
|
121
|
+
const library = {
|
|
122
|
+
name: owner.package,
|
|
123
|
+
version: owner.version,
|
|
124
|
+
count: 0
|
|
125
|
+
};
|
|
126
|
+
for (const path of contractPaths) {
|
|
127
|
+
const filePath = await resolveInside(directory, path);
|
|
128
|
+
const file = filePath ? await readJSON(filePath) : {
|
|
129
|
+
error: "not a file inside the package",
|
|
130
|
+
missing: true
|
|
131
|
+
};
|
|
132
|
+
if (!("value" in file)) {
|
|
133
|
+
warn("CONTRACT_FILE_UNREADABLE", `${owner.package} declares masterCSS.contracts "${path}", which could not be read: ${file.error}`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
for (const contract of contractsIn(file.value)) {
|
|
137
|
+
library.count++;
|
|
138
|
+
const existing = merged.get(contract.name);
|
|
139
|
+
if (existing) {
|
|
140
|
+
const other = existing.source.kind === "package" ? existing.source.package : existing.source.file;
|
|
141
|
+
if (other !== owner.package) warn("CONTRACT_NAME_COLLISION", `${owner.package} and ${other} both declare a contract named "${contract.name}"; ${other} is kept.`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
merged.set(contract.name, {
|
|
145
|
+
contract,
|
|
146
|
+
source: {
|
|
147
|
+
kind: "package",
|
|
148
|
+
...owner
|
|
149
|
+
},
|
|
150
|
+
library: owner.package
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const check = text(declared.check);
|
|
155
|
+
if (check) {
|
|
156
|
+
library.checkPath = await resolveInside(directory, check);
|
|
157
|
+
if (!library.checkPath) warn("CONTRACT_CHECKER_UNRESOLVED", `${owner.package} declares masterCSS.check "${check}", which is not a file inside the package.`);
|
|
158
|
+
}
|
|
159
|
+
libraries.push(library);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** Read every contract that applies to the workspace. Nothing here executes package or project code. */
|
|
163
|
+
async function discoverContracts(context) {
|
|
164
|
+
const warnings = [];
|
|
165
|
+
const warn = (code, message) => {
|
|
166
|
+
warnings.push({
|
|
167
|
+
code,
|
|
168
|
+
phase: "contract-discovery",
|
|
169
|
+
severity: "warning",
|
|
170
|
+
message
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
const rootManifest = await readJSON(join(context.root, "package.json"));
|
|
174
|
+
if (!("value" in rootManifest) && !rootManifest.missing) warn("CONTRACT_PACKAGE_JSON_UNREADABLE", `The workspace package.json could not be read: ${rootManifest.error}`);
|
|
175
|
+
const rootPackage = record("value" in rootManifest ? rootManifest.value : void 0);
|
|
176
|
+
const libraries = [];
|
|
177
|
+
const merged = /* @__PURE__ */ new Map();
|
|
178
|
+
await readLibraries(context.root, rootPackage, libraries, merged, warn);
|
|
179
|
+
if (!libraries.length) {
|
|
180
|
+
const workspace = await findWorkspaceRoot(context.root);
|
|
181
|
+
if (workspace) await readLibraries(workspace.directory, workspace.pkg, libraries, merged, warn);
|
|
182
|
+
}
|
|
183
|
+
const patterns = strings(record(rootPackage.masterCSS).contracts);
|
|
184
|
+
const settingsFile = await readJSON(join(context.root, "master-ui.json"));
|
|
185
|
+
if ("value" in settingsFile) {
|
|
186
|
+
const settings = record(settingsFile.value);
|
|
187
|
+
patterns.push(`${text(settings.directory) ?? "ui"}/*.json`, ...strings(settings.contracts));
|
|
188
|
+
} else if (!settingsFile.missing) warn("CONTRACT_SETTINGS_UNREADABLE", `master-ui.json could not be read: ${settingsFile.error}`);
|
|
189
|
+
const accepted = [];
|
|
190
|
+
for (const pattern of new Set(patterns.map((pattern) => posix(pattern).replace(/^(?:\.\/)+/, "").replace(/\/{2,}/g, "/")))) try {
|
|
191
|
+
context.validateGlobPatterns([pattern]);
|
|
192
|
+
accepted.push(pattern);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
warn("CONTRACT_PATTERN_REJECTED", getErrorMessage(error));
|
|
195
|
+
}
|
|
196
|
+
const files = accepted.length ? (await fg(accepted, {
|
|
197
|
+
cwd: context.root,
|
|
198
|
+
ignore: DEFAULT_IGNORE_PATTERNS,
|
|
199
|
+
onlyFiles: true,
|
|
200
|
+
followSymbolicLinks: false
|
|
201
|
+
})).sort() : [];
|
|
202
|
+
if (files.length > MAX_PROJECT_CONTRACT_FILES) {
|
|
203
|
+
warn("CONTRACT_FILES_TRUNCATED", `The project's contract patterns match ${files.length} files; only the first ${MAX_PROJECT_CONTRACT_FILES} are read.`);
|
|
204
|
+
files.length = MAX_PROJECT_CONTRACT_FILES;
|
|
205
|
+
}
|
|
206
|
+
for (const file of files) {
|
|
207
|
+
const filePath = await context.resolveExistingFile(file).catch(() => void 0);
|
|
208
|
+
const parsed = filePath ? await readJSON(filePath) : void 0;
|
|
209
|
+
if (!parsed || !("value" in parsed)) continue;
|
|
210
|
+
for (const contract of contractsIn(parsed.value)) {
|
|
211
|
+
const previous = merged.get(contract.name);
|
|
212
|
+
const replaces = previous?.source.kind === "package" ? {
|
|
213
|
+
package: previous.source.package,
|
|
214
|
+
version: previous.source.version
|
|
215
|
+
} : previous?.source.replaces;
|
|
216
|
+
merged.set(contract.name, {
|
|
217
|
+
contract,
|
|
218
|
+
source: {
|
|
219
|
+
kind: "project",
|
|
220
|
+
file: posix(file),
|
|
221
|
+
...replaces ? { replaces } : {}
|
|
222
|
+
},
|
|
223
|
+
library: previous?.library
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const ownCheck = text(record(rootPackage.masterCSS).check);
|
|
228
|
+
if (ownCheck) {
|
|
229
|
+
const checkPath = await resolveInside(await realpath(context.root), ownCheck);
|
|
230
|
+
if (checkPath) {
|
|
231
|
+
const name = text(rootPackage.name) ?? "workspace";
|
|
232
|
+
const own = {
|
|
233
|
+
name,
|
|
234
|
+
version: text(rootPackage.version) ?? null,
|
|
235
|
+
count: 0,
|
|
236
|
+
checkPath
|
|
237
|
+
};
|
|
238
|
+
for (const item of merged.values()) if (item.source.kind === "project" && !item.library) {
|
|
239
|
+
item.library = name;
|
|
240
|
+
own.count++;
|
|
241
|
+
}
|
|
242
|
+
libraries.unshift(own);
|
|
243
|
+
} else warn("CONTRACT_CHECKER_UNRESOLVED", `The workspace package.json declares masterCSS.check "${ownCheck}", which is not a file inside the workspace.`);
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
libraries,
|
|
247
|
+
contracts: [...merged.values()],
|
|
248
|
+
warnings
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
const describeLibrary = ({ name, version, count, checkPath }) => ({
|
|
252
|
+
name,
|
|
253
|
+
version,
|
|
254
|
+
count,
|
|
255
|
+
checker: Boolean(checkPath)
|
|
256
|
+
});
|
|
257
|
+
async function listContracts(context, options = {}) {
|
|
258
|
+
const discovery = await discoverContracts(context);
|
|
259
|
+
const terms = (options.query ?? "").toLowerCase().split(/\s+/).filter(Boolean);
|
|
260
|
+
const same = (value, expected) => !expected?.trim() || text(value)?.toLowerCase() === expected.trim().toLowerCase();
|
|
261
|
+
const matched = discovery.contracts.filter(({ contract }) => {
|
|
262
|
+
if (!same(contract.tier, options.tier) || !same(contract.category, options.category)) return false;
|
|
263
|
+
const searched = [
|
|
264
|
+
contract.name,
|
|
265
|
+
text(contract.title),
|
|
266
|
+
text(contract.description),
|
|
267
|
+
text(contract.use),
|
|
268
|
+
...classNamesOf(contract)
|
|
269
|
+
].join("\n").toLowerCase();
|
|
270
|
+
return terms.every((term) => searched.includes(term));
|
|
271
|
+
});
|
|
272
|
+
return {
|
|
273
|
+
version: CONTRACT_REPORT_VERSION,
|
|
274
|
+
root: context.root,
|
|
275
|
+
libraries: discovery.libraries.map(describeLibrary),
|
|
276
|
+
contracts: matched.map(({ contract, source }) => ({
|
|
277
|
+
name: contract.name,
|
|
278
|
+
title: text(contract.title),
|
|
279
|
+
tier: text(contract.tier),
|
|
280
|
+
category: text(contract.category),
|
|
281
|
+
description: text(contract.description),
|
|
282
|
+
use: text(contract.use),
|
|
283
|
+
script: text(contract.script),
|
|
284
|
+
source
|
|
285
|
+
})),
|
|
286
|
+
diagnostics: discovery.warnings,
|
|
287
|
+
summary: {
|
|
288
|
+
libraries: discovery.libraries.length,
|
|
289
|
+
contracts: discovery.contracts.length,
|
|
290
|
+
returned: matched.length
|
|
291
|
+
},
|
|
292
|
+
...discovery.contracts.length ? {} : { note: NO_CONTRACTS_NOTE }
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function editDistance(a, b) {
|
|
296
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
297
|
+
for (let i = 1; i <= a.length; i++) {
|
|
298
|
+
const current = [i];
|
|
299
|
+
for (let j = 1; j <= b.length; j++) current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
300
|
+
previous = current;
|
|
301
|
+
}
|
|
302
|
+
return previous[b.length];
|
|
303
|
+
}
|
|
304
|
+
/** The names nearest to what was asked: one that contains it first, then a near spelling or a name it contains. */
|
|
305
|
+
function closest(target, candidates, limit = 5) {
|
|
306
|
+
const wanted = target.toLowerCase();
|
|
307
|
+
return candidates.map((candidate) => {
|
|
308
|
+
const name = candidate.toLowerCase();
|
|
309
|
+
const gap = Math.abs(name.length - wanted.length) / 100;
|
|
310
|
+
return {
|
|
311
|
+
candidate,
|
|
312
|
+
distance: name.includes(wanted) ? gap : Math.min(editDistance(wanted, name), wanted.includes(name) ? 1 + gap : Infinity)
|
|
313
|
+
};
|
|
314
|
+
}).sort((a, b) => a.distance - b.distance || a.candidate.localeCompare(b.candidate)).slice(0, limit).map((item) => item.candidate);
|
|
315
|
+
}
|
|
316
|
+
async function getContract(context, options) {
|
|
317
|
+
const discovery = await discoverContracts(context);
|
|
318
|
+
const name = options.name.trim();
|
|
319
|
+
const byClass = /* @__PURE__ */ new Map();
|
|
320
|
+
for (const item of discovery.contracts) for (const className of classNamesOf(item.contract)) if (!byClass.has(className)) byClass.set(className, item);
|
|
321
|
+
const named = discovery.contracts.find((item) => item.contract.name === name);
|
|
322
|
+
const found = named ?? byClass.get(name);
|
|
323
|
+
if (!found) {
|
|
324
|
+
const classes = closest(name, [...byClass.keys()]);
|
|
325
|
+
const contracts = [.../* @__PURE__ */ new Set([...classes.slice(0, 2).map((className) => byClass.get(className).contract.name), ...closest(name, discovery.contracts.map((item) => item.contract.name))])].slice(0, 5);
|
|
326
|
+
const missing = `No contract is named "${name}" and no contract has a class "${name}".`;
|
|
327
|
+
const lookup = (message, alternatives) => ({
|
|
328
|
+
code: "CONTRACT_NOT_FOUND",
|
|
329
|
+
phase: "contract-lookup",
|
|
330
|
+
severity: "info",
|
|
331
|
+
message,
|
|
332
|
+
alternatives
|
|
333
|
+
});
|
|
334
|
+
throw Object.assign(/* @__PURE__ */ new Error(discovery.contracts.length ? `${missing} Closest contracts: ${contracts.join(", ")}. Closest classes: ${classes.join(", ")}. Call mastercss_contract_list for every contract.` : `${missing} ${NO_CONTRACTS_NOTE}`), {
|
|
335
|
+
code: "CONTRACT_NOT_FOUND",
|
|
336
|
+
diagnostics: [
|
|
337
|
+
...contracts.length ? [lookup("Closest contract names.", contracts)] : [],
|
|
338
|
+
...classes.length ? [lookup("Closest class names.", classes)] : [],
|
|
339
|
+
...discovery.warnings
|
|
340
|
+
]
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
version: CONTRACT_REPORT_VERSION,
|
|
345
|
+
root: context.root,
|
|
346
|
+
name,
|
|
347
|
+
matched: named ? "contract" : "class",
|
|
348
|
+
contract: found.contract,
|
|
349
|
+
source: found.source,
|
|
350
|
+
diagnostics: discovery.warnings
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
const checkerFailure = (library, action, error) => Object.assign(/* @__PURE__ */ new Error(`The contract checker of ${library.name} ${action}: ${getErrorMessage(error)}`), { code: "CONTRACT_CHECKER_FAILED" });
|
|
354
|
+
/**
|
|
355
|
+
* The worker that runs one checker. It imports the module afresh, so an upgraded library needs no restart, and
|
|
356
|
+
* the server can stop a checker that hangs or exhausts its memory. `knows` stays in the server: the worker asks
|
|
357
|
+
* for each class it has not seen and waits for the answer, so a checker still calls it synchronously.
|
|
358
|
+
*/
|
|
359
|
+
const CHECKER_WORKER = `
|
|
360
|
+
const { parentPort, workerData } = require('node:worker_threads')
|
|
361
|
+
const { checkURL, contracts, signal } = workerData
|
|
362
|
+
const answers = new Map()
|
|
363
|
+
function knows(className) {
|
|
364
|
+
const key = String(className)
|
|
365
|
+
if (!answers.has(key)) {
|
|
366
|
+
Atomics.store(signal, 0, 0)
|
|
367
|
+
parentPort.postMessage({ type: 'knows', className: key })
|
|
368
|
+
Atomics.wait(signal, 0, 0)
|
|
369
|
+
answers.set(key, signal[1] === 1)
|
|
370
|
+
}
|
|
371
|
+
return answers.get(key)
|
|
372
|
+
}
|
|
373
|
+
const describe = error => error instanceof Error ? error.message : String(error)
|
|
374
|
+
const loading = import(checkURL).then(module => {
|
|
375
|
+
if (typeof module.check !== 'function') throw new Error('the module does not export a check function')
|
|
376
|
+
return module.check
|
|
377
|
+
})
|
|
378
|
+
loading.then(() => parentPort.postMessage({ type: 'ready' }), error => parentPort.postMessage({ type: 'failed', message: describe(error) }))
|
|
379
|
+
parentPort.on('message', async ({ content, strict, withKnows }) => {
|
|
380
|
+
try {
|
|
381
|
+
const reported = await (await loading)(content, contracts, withKnows ? { knows, strict } : { strict })
|
|
382
|
+
parentPort.postMessage(Array.isArray(reported)
|
|
383
|
+
? { type: 'result', diagnostics: JSON.parse(JSON.stringify(reported.slice(0, ${MAX_DIAGNOSTICS_PER_FILE}))) }
|
|
384
|
+
: { type: 'failed', message: 'check() did not return an array of diagnostics' })
|
|
385
|
+
} catch (error) {
|
|
386
|
+
parentPort.postMessage({ type: 'failed', message: describe(error) })
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
`;
|
|
390
|
+
/** Start the checker a library ships. Discovery already proved the file is inside that library. */
|
|
391
|
+
async function startChecker(library, contracts, knows) {
|
|
392
|
+
const { timeoutMs, memoryMb } = contractCheckerLimits;
|
|
393
|
+
const signal = new Int32Array(new SharedArrayBuffer(8));
|
|
394
|
+
const worker = new Worker(CHECKER_WORKER, {
|
|
395
|
+
eval: true,
|
|
396
|
+
stdout: true,
|
|
397
|
+
workerData: {
|
|
398
|
+
checkURL: pathToFileURL(library.checkPath).href,
|
|
399
|
+
contracts,
|
|
400
|
+
signal
|
|
401
|
+
},
|
|
402
|
+
resourceLimits: { maxOldGenerationSizeMb: memoryMb }
|
|
403
|
+
});
|
|
404
|
+
worker.stdout.pipe(process.stderr, { end: false });
|
|
405
|
+
let waiting;
|
|
406
|
+
let ended;
|
|
407
|
+
const end = (error) => {
|
|
408
|
+
ended ??= error;
|
|
409
|
+
waiting?.reject(ended);
|
|
410
|
+
waiting = void 0;
|
|
411
|
+
};
|
|
412
|
+
worker.on("error", end);
|
|
413
|
+
worker.on("exit", (code) => end(/* @__PURE__ */ new Error(`its worker stopped with exit code ${code}`)));
|
|
414
|
+
worker.on("message", (message) => {
|
|
415
|
+
if (message.type === "knows") {
|
|
416
|
+
signal[1] = knows?.(String(message.className)) ? 1 : 0;
|
|
417
|
+
Atomics.store(signal, 0, 1);
|
|
418
|
+
Atomics.notify(signal, 0);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const current = waiting;
|
|
422
|
+
waiting = void 0;
|
|
423
|
+
if (message.type === "failed") current?.reject(new Error(String(message.message)));
|
|
424
|
+
else current?.resolve(message);
|
|
425
|
+
});
|
|
426
|
+
/** The worker's next answer, or the reason it gave none within the time limit. */
|
|
427
|
+
const answer = (action, send) => new Promise((resolve, reject) => {
|
|
428
|
+
if (ended) return reject(ended);
|
|
429
|
+
const timer = setTimeout(() => {
|
|
430
|
+
end(/* @__PURE__ */ new Error(`did not finish within ${timeoutMs} ms`));
|
|
431
|
+
worker.terminate();
|
|
432
|
+
}, timeoutMs);
|
|
433
|
+
waiting = {
|
|
434
|
+
resolve: (message) => {
|
|
435
|
+
clearTimeout(timer);
|
|
436
|
+
resolve(message);
|
|
437
|
+
},
|
|
438
|
+
reject: (error) => {
|
|
439
|
+
clearTimeout(timer);
|
|
440
|
+
reject(error);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
send?.();
|
|
444
|
+
}).catch((error) => {
|
|
445
|
+
throw checkerFailure(library, action, error);
|
|
446
|
+
});
|
|
447
|
+
const dispose = async () => {
|
|
448
|
+
ended ??= /* @__PURE__ */ new Error("its worker was stopped");
|
|
449
|
+
await worker.terminate();
|
|
450
|
+
};
|
|
451
|
+
try {
|
|
452
|
+
await answer("could not be loaded");
|
|
453
|
+
} catch (error) {
|
|
454
|
+
await dispose();
|
|
455
|
+
throw error;
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
check: async (content, strict, filePath) => (await answer(`failed on ${filePath}`, () => worker.postMessage({
|
|
459
|
+
content,
|
|
460
|
+
strict,
|
|
461
|
+
withKnows: Boolean(knows)
|
|
462
|
+
}))).diagnostics,
|
|
463
|
+
dispose
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
function normalizeDiagnostic(value, source) {
|
|
467
|
+
const diagnostic = record(value);
|
|
468
|
+
if (typeof diagnostic.message !== "string") return [];
|
|
469
|
+
const position = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : 0;
|
|
470
|
+
const className = text(diagnostic.className);
|
|
471
|
+
const contract = text(diagnostic.contract);
|
|
472
|
+
return [{
|
|
473
|
+
code: text(diagnostic.code) ?? "contract",
|
|
474
|
+
severity: diagnostic.severity === "warning" || diagnostic.severity === "info" ? diagnostic.severity : "error",
|
|
475
|
+
message: diagnostic.message,
|
|
476
|
+
line: position(diagnostic.line),
|
|
477
|
+
column: position(diagnostic.column),
|
|
478
|
+
...className ? { className } : {},
|
|
479
|
+
...contract ? { contract } : {},
|
|
480
|
+
source
|
|
481
|
+
}];
|
|
482
|
+
}
|
|
483
|
+
async function readCheckInputs(context, options) {
|
|
484
|
+
const skippedFiles = [];
|
|
485
|
+
if (options.content !== void 0) return {
|
|
486
|
+
inputs: [{
|
|
487
|
+
filePath: context.resolveVirtualPath(options.filePath || "index.html"),
|
|
488
|
+
content: options.content
|
|
489
|
+
}],
|
|
490
|
+
skippedFiles
|
|
491
|
+
};
|
|
492
|
+
const filePaths = (await resolveSourceFiles(context, options.patterns, options.patterns ? [] : DEFAULT_IGNORE_PATTERNS, DEFAULT_CHECK_PATTERNS)).sort();
|
|
493
|
+
if (filePaths.length > MAX_CHECK_FILES) throw Object.assign(/* @__PURE__ */ new Error(`The patterns match ${filePaths.length} files; a contract check reads at most ${MAX_CHECK_FILES}. Pass narrower patterns.`), { code: "CONTRACT_CHECK_TOO_MANY_FILES" });
|
|
494
|
+
const inputs = [];
|
|
495
|
+
for (const filePath of filePaths) if ((await stat(filePath)).size > MAX_CHECK_FILE_BYTES) skippedFiles.push({
|
|
496
|
+
filePath,
|
|
497
|
+
reason: `larger than ${MAX_CHECK_FILE_BYTES} bytes`
|
|
498
|
+
});
|
|
499
|
+
else inputs.push({
|
|
500
|
+
filePath,
|
|
501
|
+
content: await readFile(filePath, "utf8")
|
|
502
|
+
});
|
|
503
|
+
return {
|
|
504
|
+
inputs,
|
|
505
|
+
skippedFiles
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
async function checkContracts(context, options = {}) {
|
|
509
|
+
const discovery = await discoverContracts(context);
|
|
510
|
+
const report = {
|
|
511
|
+
version: CONTRACT_REPORT_VERSION,
|
|
512
|
+
root: context.root,
|
|
513
|
+
libraries: discovery.libraries.map(describeLibrary)
|
|
514
|
+
};
|
|
515
|
+
const notChecked = (status, note) => ({
|
|
516
|
+
...report,
|
|
517
|
+
checkers: [],
|
|
518
|
+
classValidation: "none",
|
|
519
|
+
strict: false,
|
|
520
|
+
files: [],
|
|
521
|
+
diagnostics: discovery.warnings,
|
|
522
|
+
coverage: {
|
|
523
|
+
status,
|
|
524
|
+
checkedFiles: [],
|
|
525
|
+
skippedFiles: [],
|
|
526
|
+
description: `${note} No markup was checked; zero diagnostics does not verify any source.`
|
|
527
|
+
},
|
|
528
|
+
summary: {
|
|
529
|
+
checkedFiles: 0,
|
|
530
|
+
filesWithDiagnostics: 0,
|
|
531
|
+
diagnostics: 0,
|
|
532
|
+
errors: 0,
|
|
533
|
+
warnings: 0
|
|
534
|
+
},
|
|
535
|
+
note
|
|
536
|
+
});
|
|
537
|
+
if (!discovery.contracts.length) return notChecked("no-contracts", NO_CONTRACTS_NOTE);
|
|
538
|
+
const checking = discovery.libraries.filter((library) => library.checkPath);
|
|
539
|
+
if (!checking.length) return notChecked("no-checker", "Contracts were found, but no library declares a checker in \"masterCSS.check\". Read contracts with mastercss_contract_list and mastercss_contract_get.");
|
|
540
|
+
const groups = checking.map((library, index) => ({
|
|
541
|
+
library,
|
|
542
|
+
contracts: discovery.contracts.filter((item) => checking.some((other) => other.name === item.library) ? item.library === library.name : index === 0).map((item) => item.contract)
|
|
543
|
+
})).filter((group) => group.contracts.length);
|
|
544
|
+
const { inputs, skippedFiles } = await readCheckInputs(context, options);
|
|
545
|
+
const manifest = await loadWorkspaceManifest(context);
|
|
546
|
+
const session = manifest.status === "loaded" ? createMCPToolingSession(manifest.manifest, manifest.nativeClassNames) : void 0;
|
|
547
|
+
const checkers = [];
|
|
548
|
+
try {
|
|
549
|
+
let knows;
|
|
550
|
+
if (session) {
|
|
551
|
+
const native = new Set(manifest.nativeClassNames);
|
|
552
|
+
const known = /* @__PURE__ */ new Map();
|
|
553
|
+
knows = (className) => {
|
|
554
|
+
if (native.has(className)) return true;
|
|
555
|
+
let result = known.get(className);
|
|
556
|
+
if (result === void 0) {
|
|
557
|
+
try {
|
|
558
|
+
result = session.inspectClassName(className).matchStatus === "matched";
|
|
559
|
+
} catch {
|
|
560
|
+
result = false;
|
|
561
|
+
}
|
|
562
|
+
known.set(className, result);
|
|
563
|
+
}
|
|
564
|
+
return result;
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
const strict = Boolean(knows);
|
|
568
|
+
for (const group of groups) checkers.push({
|
|
569
|
+
...group,
|
|
570
|
+
...await startChecker(group.library, group.contracts, knows)
|
|
571
|
+
});
|
|
572
|
+
const results = [];
|
|
573
|
+
for (const input of inputs) {
|
|
574
|
+
const diagnostics = [];
|
|
575
|
+
for (const { library, check } of checkers) {
|
|
576
|
+
const reported = await check(input.content, strict, input.filePath);
|
|
577
|
+
diagnostics.push(...reported.flatMap((diagnostic) => normalizeDiagnostic(diagnostic, library.name)));
|
|
578
|
+
}
|
|
579
|
+
diagnostics.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
580
|
+
results.push({
|
|
581
|
+
filePath: input.filePath,
|
|
582
|
+
diagnostics
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
const files = results.filter((file) => file.diagnostics.length);
|
|
586
|
+
const flat = files.flatMap((file) => file.diagnostics.map((diagnostic) => ({
|
|
587
|
+
...diagnostic,
|
|
588
|
+
phase: "contract-check",
|
|
589
|
+
filePath: file.filePath
|
|
590
|
+
})));
|
|
591
|
+
return {
|
|
592
|
+
...report,
|
|
593
|
+
manifest: manifestMetadata(manifest),
|
|
594
|
+
checkers: checkers.map(({ library, contracts }) => ({
|
|
595
|
+
package: library.name,
|
|
596
|
+
version: library.version,
|
|
597
|
+
contracts: contracts.length
|
|
598
|
+
})),
|
|
599
|
+
classValidation: knows ? "project-manifest" : "none",
|
|
600
|
+
strict,
|
|
601
|
+
files,
|
|
602
|
+
diagnostics: [...discovery.warnings, ...flat],
|
|
603
|
+
coverage: {
|
|
604
|
+
status: inputs.length ? "complete" : "no-inputs",
|
|
605
|
+
checkedFiles: inputs.map((input) => input.filePath),
|
|
606
|
+
skippedFiles,
|
|
607
|
+
description: inputs.length ? "Every path in checkedFiles was read by the checkers listed in checkers. The files array lists only files with diagnostics. A checker reads markup as written: classes built at run time, parts whose root lives in another file, and browser behavior are not checked." : "No markup inputs matched, so no checker ran. Zero diagnostics does not verify any source."
|
|
608
|
+
},
|
|
609
|
+
summary: {
|
|
610
|
+
checkedFiles: inputs.length,
|
|
611
|
+
filesWithDiagnostics: files.length,
|
|
612
|
+
diagnostics: flat.length,
|
|
613
|
+
errors: flat.filter((diagnostic) => diagnostic.severity === "error").length,
|
|
614
|
+
warnings: flat.filter((diagnostic) => diagnostic.severity === "warning").length
|
|
615
|
+
},
|
|
616
|
+
...knows ? {} : { note: `The project manifest could not be loaded (${manifest.status === "error" ? manifest.error : "unknown"}), so the checker cannot tell a utility from a misspelled class and ran without strict mode.` }
|
|
617
|
+
};
|
|
618
|
+
} finally {
|
|
619
|
+
await Promise.all(checkers.map((checker) => checker.dispose()));
|
|
620
|
+
session?.dispose();
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
export { MAX_CHECK_CONTENT_LENGTH, checkContracts, contractCheckerLimits, discoverContracts, getContract, listContracts };
|
package/dist/output-schema.js
CHANGED
|
@@ -2,6 +2,7 @@ import { change, classDiff, completion, diagnostic, envelopeMetadata, inspection
|
|
|
2
2
|
import { extractSummary, extractedFile, formatFile, formatSummary, lintCoverage, lintFile, lintSummary, previewSummary, scanSummary, scannedFile, scanner, stylesheets } from "./source-schema.js";
|
|
3
3
|
import { projectComparison, projectSnapshot } from "./project-comparison-schema.js";
|
|
4
4
|
import { designSystemReport } from "./design-system-schema.js";
|
|
5
|
+
import { contractCheck, contractGet, contractList } from "./contract-schema.js";
|
|
5
6
|
import * as z from "zod/v4";
|
|
6
7
|
// src/output-schema.ts
|
|
7
8
|
const object = z.record(z.string(), z.json());
|
|
@@ -178,7 +179,10 @@ const fields = {
|
|
|
178
179
|
manifest: metadata,
|
|
179
180
|
completions: z.array(completion),
|
|
180
181
|
total: z.number()
|
|
181
|
-
}
|
|
182
|
+
},
|
|
183
|
+
mastercss_contract_list: contractList,
|
|
184
|
+
mastercss_contract_get: contractGet,
|
|
185
|
+
mastercss_contract_check: contractCheck
|
|
182
186
|
};
|
|
183
187
|
function toolOutputSchema(name) {
|
|
184
188
|
if (!fields[name]) throw new Error(`Missing output schema for ${name}`);
|
package/dist/server.js
CHANGED
|
@@ -14,6 +14,7 @@ import { projectSnapshot } from "./project-comparison-schema.js";
|
|
|
14
14
|
import { designSystemOptions } from "./design-system-schema.js";
|
|
15
15
|
import { toolOutputSchema } from "./output-schema.js";
|
|
16
16
|
import { suggestClasses } from "./suggest.js";
|
|
17
|
+
import { MAX_CHECK_CONTENT_LENGTH, checkContracts, getContract, listContracts } from "./contracts.js";
|
|
17
18
|
import { readFileSync } from "node:fs";
|
|
18
19
|
import { dirname, resolve } from "node:path";
|
|
19
20
|
import { fileURLToPath } from "node:url";
|
|
@@ -372,6 +373,50 @@ function registerTools(server, context) {
|
|
|
372
373
|
openWorldHint: false
|
|
373
374
|
}
|
|
374
375
|
}, async (input) => executeTool(() => suggestSyntax(context, input)));
|
|
376
|
+
server.registerTool("mastercss_contract_list", {
|
|
377
|
+
title: "List Design System Contracts",
|
|
378
|
+
description: "List the component contracts that the workspace's design-system dependencies declare in \"masterCSS.contracts\", merged with the project's own. Filter by query, tier, or category. Returns an empty list with a note when nothing declares contracts.",
|
|
379
|
+
inputSchema: {
|
|
380
|
+
query: z.string().optional(),
|
|
381
|
+
tier: z.string().optional(),
|
|
382
|
+
category: z.string().optional()
|
|
383
|
+
},
|
|
384
|
+
outputSchema: toolOutputSchema("mastercss_contract_list"),
|
|
385
|
+
annotations: {
|
|
386
|
+
readOnlyHint: true,
|
|
387
|
+
destructiveHint: false,
|
|
388
|
+
idempotentHint: true,
|
|
389
|
+
openWorldHint: false
|
|
390
|
+
}
|
|
391
|
+
}, async (input) => executeTool(() => listContracts(context, input)));
|
|
392
|
+
server.registerTool("mastercss_contract_get", {
|
|
393
|
+
title: "Get Design System Contract",
|
|
394
|
+
description: "Return one full contract and its source by contract name or by any class name the contract defines. Read it before writing that component's markup.",
|
|
395
|
+
inputSchema: { name: z.string().min(1) },
|
|
396
|
+
outputSchema: toolOutputSchema("mastercss_contract_get"),
|
|
397
|
+
annotations: {
|
|
398
|
+
readOnlyHint: true,
|
|
399
|
+
destructiveHint: false,
|
|
400
|
+
idempotentHint: true,
|
|
401
|
+
openWorldHint: false
|
|
402
|
+
}
|
|
403
|
+
}, async (input) => executeTool(() => getContract(context, input)));
|
|
404
|
+
server.registerTool("mastercss_contract_check", {
|
|
405
|
+
title: "Check Markup Against Contracts",
|
|
406
|
+
description: "Check an in-memory source buffer or project files against the merged contracts with the checker module a design-system dependency, or the workspace package itself, declares in \"masterCSS.check\". Runs that package's code in a worker thread with a time and memory limit. Does not write files; reports when no library provides a checker.",
|
|
407
|
+
inputSchema: {
|
|
408
|
+
content: z.string().max(MAX_CHECK_CONTENT_LENGTH).optional(),
|
|
409
|
+
filePath: z.string().optional(),
|
|
410
|
+
patterns: z.array(z.string()).optional()
|
|
411
|
+
},
|
|
412
|
+
outputSchema: toolOutputSchema("mastercss_contract_check"),
|
|
413
|
+
annotations: {
|
|
414
|
+
readOnlyHint: true,
|
|
415
|
+
destructiveHint: false,
|
|
416
|
+
idempotentHint: true,
|
|
417
|
+
openWorldHint: false
|
|
418
|
+
}
|
|
419
|
+
}, async (input) => executeTool(() => checkContracts(context, input)));
|
|
375
420
|
server.registerTool("mastercss_preview_fixes", {
|
|
376
421
|
title: "Preview Master CSS Fixes",
|
|
377
422
|
description: "Create a diff preview for lint fixes or generated CSS output and return a confirmation token.",
|
package/package.json
CHANGED
|
@@ -50,17 +50,17 @@
|
|
|
50
50
|
"provenance": true
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@master/css": "2.1.
|
|
54
|
-
"@master/css-compiler": "2.1.
|
|
55
|
-
"@master/css-language-service": "2.1.
|
|
56
|
-
"@master/css-tooling": "2.1.
|
|
57
|
-
"@master/css-preset": "2.1.
|
|
58
|
-
"@master/css-schema": "2.1.
|
|
53
|
+
"@master/css": "2.1.3",
|
|
54
|
+
"@master/css-compiler": "2.1.3",
|
|
55
|
+
"@master/css-language-service": "2.1.3",
|
|
56
|
+
"@master/css-tooling": "2.1.3",
|
|
57
|
+
"@master/css-preset": "2.1.3",
|
|
58
|
+
"@master/css-schema": "2.1.3",
|
|
59
59
|
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
60
60
|
"fast-glob": "^3.3.3",
|
|
61
61
|
"vscode-languageserver-textdocument": "^1.0.16",
|
|
62
62
|
"zod": "^4.6.7"
|
|
63
63
|
},
|
|
64
|
-
"version": "2.1.
|
|
65
|
-
"gitHead": "
|
|
64
|
+
"version": "2.1.3",
|
|
65
|
+
"gitHead": "8eb81a6a72e733c43c799659e6493f3f464b8cf9"
|
|
66
66
|
}
|