@patrizzos/stylespeak 0.1.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/LICENSE +21 -0
- package/README.md +168 -0
- package/package.json +28 -0
- package/src/cssParser.js +100 -0
- package/src/cssomBuilder.js +138 -0
- package/src/resolveStyles.js +96 -0
- package/src/selectorMatcher.js +66 -0
- package/src/server.js +181 -0
- package/src/specificity.js +51 -0
- package/src/traceProperty.js +100 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 stylespeak
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# StyleSpeak
|
|
2
|
+
|
|
3
|
+
A companion MCP server and CLI to [stylesafe](https://www.npmjs.com/package/stylesafe) that makes CSS legible to AI agents — resolving cascade, tracing properties, and explaining what applies and why before an agent touches a single line of styles.
|
|
4
|
+
|
|
5
|
+
## The problem
|
|
6
|
+
|
|
7
|
+
AI coding agents write CSS without being able to see its effect. They modify a rule, assume it worked, and move on — unaware that a higher-specificity rule elsewhere already overrides it, or that a combinator rule in another file is silently winning. stylespeak gives agents a structured knowledge layer to consult *before* making changes.
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
**`resolve_styles`** — answers "what CSS actually applies to this selector?"
|
|
12
|
+
|
|
13
|
+
Given a selector and a set of files, returns every CSS property the selector would receive, which rule wins for each, and which rules were overridden — with confidence levels since no real DOM is available.
|
|
14
|
+
|
|
15
|
+
**`trace_property`** — answers "everywhere this property is set, who wins?"
|
|
16
|
+
|
|
17
|
+
Given a property name and a set of files, returns every rule that sets it, groups competing rules that target overlapping selectors, and shows the full cascade chain for each group. Use this to understand the blast radius of a change before making it.
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
### As a CLI tool
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install -g stylespeak
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Resolve what applies to a selector:
|
|
28
|
+
```bash
|
|
29
|
+
stylespeak resolve ".btn.primary" src/styles/main.css
|
|
30
|
+
stylespeak resolve "#header a" src/styles/base.css src/styles/header.css
|
|
31
|
+
stylespeak resolve ".card-title" --projectRoot src/styles
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Trace a property across files:
|
|
35
|
+
```bash
|
|
36
|
+
stylespeak trace "color" src/styles/main.css
|
|
37
|
+
stylespeak trace "background-color" --projectRoot src/styles
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### As an MCP server
|
|
41
|
+
|
|
42
|
+
Add to your MCP client config (Cursor: `.cursor/mcp.json`, VS Code: `.vscode/mcp.json`):
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"mcpServers": {
|
|
47
|
+
"stylespeak": {
|
|
48
|
+
"command": "node",
|
|
49
|
+
"args": ["/absolute/path/to/stylespeak/src/server.js"]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Once connected, agents can call:
|
|
56
|
+
- `resolve_styles({ selector, files, projectRoot })`
|
|
57
|
+
- `trace_property({ property, files, projectRoot })`
|
|
58
|
+
|
|
59
|
+
## Example output
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
stylespeak resolve ".btn.primary" src/styles/buttons.css
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"query": { "selector": ".btn.primary", "filesAnalyzed": ["buttons.css"] },
|
|
68
|
+
"properties": {
|
|
69
|
+
"background-color": {
|
|
70
|
+
"winner": {
|
|
71
|
+
"value": "darkblue",
|
|
72
|
+
"selector": ".btn.primary",
|
|
73
|
+
"specificity": "(0,0,2,0)",
|
|
74
|
+
"source": { "file": "buttons.css", "line": 9 }
|
|
75
|
+
},
|
|
76
|
+
"overridden": [
|
|
77
|
+
{
|
|
78
|
+
"value": "blue",
|
|
79
|
+
"selector": ".btn",
|
|
80
|
+
"specificity": "(0,0,1,0)",
|
|
81
|
+
"reason": "lower specificity"
|
|
82
|
+
}
|
|
83
|
+
],
|
|
84
|
+
"confidence": "certain"
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"summary": "Found 6 properties applying to \".btn.primary\" from 5 matched rule(s).",
|
|
88
|
+
"agentNote": "1 property is certain. 2 properties are likely. 3 properties are possible (combinator rules — depends on DOM context). 3 properties have overridden rules — review before modifying."
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Confidence levels
|
|
93
|
+
|
|
94
|
+
Since stylespeak performs static analysis without a real DOM, every resolved property carries a confidence level:
|
|
95
|
+
|
|
96
|
+
| Level | Meaning |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `certain` | Exact selector match — rule definitively applies |
|
|
99
|
+
| `likely` | Rule tokens are a subset of the queried selector — applies in most cases |
|
|
100
|
+
| `possible` | Combinator rule (e.g. `.sidebar .btn`) — depends on DOM ancestry, unknown without rendering |
|
|
101
|
+
|
|
102
|
+
An agent should treat `certain` and `likely` results as ground truth, and `possible` results as conditional — they may apply depending on where the element lives in the DOM.
|
|
103
|
+
|
|
104
|
+
## Use cases
|
|
105
|
+
|
|
106
|
+
- **Before editing styles** — call `resolve_styles` to understand the full cascade context first
|
|
107
|
+
- **Before changing a property** — call `trace_property` to see the blast radius across all files
|
|
108
|
+
- **Debugging "why isn't my CSS working"** — trace the property to find the higher-specificity rule that's winning
|
|
109
|
+
- **Style audits** — run across a project directory to map what's actually applying where
|
|
110
|
+
|
|
111
|
+
## CLI options
|
|
112
|
+
|
|
113
|
+
Both commands accept:
|
|
114
|
+
- One or more file paths as positional arguments
|
|
115
|
+
- `--projectRoot <dir>` to analyze all CSS/SCSS files in a directory recursively
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
stylespeak resolve ".btn" src/main.css src/components.css
|
|
119
|
+
stylespeak trace "padding" --projectRoot src/styles
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## How it pairs with stylesafe
|
|
123
|
+
|
|
124
|
+
**stylesafe** catches problems in your CSS — conflicts, dead rules, Tailwind clashes — before they ship.
|
|
125
|
+
|
|
126
|
+
**stylespeak** explains your CSS — resolving cascade, tracing properties, mapping what applies and why — so agents understand before they act.
|
|
127
|
+
|
|
128
|
+
Use stylesafe as a post-edit check. Use stylespeak as a pre-edit consultation. Together they give AI coding agents a complete feedback loop on styles.
|
|
129
|
+
|
|
130
|
+
## GitHub Actions
|
|
131
|
+
|
|
132
|
+
```yaml
|
|
133
|
+
name: style check
|
|
134
|
+
|
|
135
|
+
on: [pull_request]
|
|
136
|
+
|
|
137
|
+
jobs:
|
|
138
|
+
stylespeak:
|
|
139
|
+
runs-on: ubuntu-latest
|
|
140
|
+
steps:
|
|
141
|
+
- uses: actions/checkout@v4
|
|
142
|
+
- uses: actions/setup-node@v4
|
|
143
|
+
with:
|
|
144
|
+
node-version: 20
|
|
145
|
+
- run: npm install -g stylespeak
|
|
146
|
+
- run: stylespeak trace "color" --projectRoot src/styles
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Architecture
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
src/
|
|
153
|
+
cssParser.js — dependency-free CSS tokenizer with line number tracking
|
|
154
|
+
specificity.js — standard (id, class, type) specificity calculator
|
|
155
|
+
cssomBuilder.js — builds in-memory cascade model from multiple files
|
|
156
|
+
selectorMatcher.js — heuristic selector matching with confidence levels
|
|
157
|
+
resolveStyles.js — resolve_styles tool implementation
|
|
158
|
+
traceProperty.js — trace_property tool implementation
|
|
159
|
+
server.js — MCP server (stdio JSON-RPC) + CLI entry point
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Zero external dependencies. Requires Node.js 18+.
|
|
163
|
+
|
|
164
|
+
## Roadmap
|
|
165
|
+
|
|
166
|
+
- **v0.2** — CSS custom property (variable) resolution, CSS Modules scope awareness
|
|
167
|
+
- **v0.3** — AST component graph analysis for more accurate selector matching without a DOM
|
|
168
|
+
- **v1.0** — Chrome DevTools Protocol integration for live cascade resolution against a running browser
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@patrizzos/stylespeak",
|
|
3
|
+
"mcpName": "io.github.Patrizzos/stylespeak",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "MCP server and CLI that makes CSS legible to AI agents — resolves cascade, traces properties, explains what applies and why.",
|
|
6
|
+
"main": "src/server.js",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/Patrizzos/stylespeak.git"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"stylespeak": "src/server.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"start": "node src/server.js",
|
|
21
|
+
"test": "node test/run.js"
|
|
22
|
+
},
|
|
23
|
+
"keywords": ["mcp", "css", "tailwind", "cascade", "agent", "ai", "devtools"],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT"
|
|
28
|
+
}
|
package/src/cssParser.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS Parser for stylescope.
|
|
3
|
+
* Improved over stylesafe's version with:
|
|
4
|
+
* - Line number tracking for every rule (for source location in output)
|
|
5
|
+
* - At-rule context preservation (@media, @layer, @supports)
|
|
6
|
+
* - Returns { rules } where each rule has { selectors, declarations, source: { file, line }, mediaContext }
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
function stripComments(css) {
|
|
10
|
+
return css.replace(/\/\*[\s\S]*?\*\//g, match => match.replace(/[^\n]/g, ' '));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getLineNumber(text, index) {
|
|
14
|
+
return text.slice(0, index).split('\n').length;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseDeclarations(block) {
|
|
18
|
+
const decls = [];
|
|
19
|
+
const parts = block.split(';');
|
|
20
|
+
for (const part of parts) {
|
|
21
|
+
const trimmed = part.trim();
|
|
22
|
+
if (!trimmed) continue;
|
|
23
|
+
const colonIdx = trimmed.indexOf(':');
|
|
24
|
+
if (colonIdx === -1) continue;
|
|
25
|
+
const prop = trimmed.slice(0, colonIdx).trim().toLowerCase();
|
|
26
|
+
let value = trimmed.slice(colonIdx + 1).trim();
|
|
27
|
+
if (!prop) continue;
|
|
28
|
+
const important = /!important\s*$/i.test(value);
|
|
29
|
+
if (important) value = value.replace(/!important\s*$/i, '').trim();
|
|
30
|
+
decls.push({ prop, value, important });
|
|
31
|
+
}
|
|
32
|
+
return decls;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function findMatchingBrace(css, openIdx) {
|
|
36
|
+
let depth = 1;
|
|
37
|
+
let i = openIdx + 1;
|
|
38
|
+
while (i < css.length && depth > 0) {
|
|
39
|
+
if (css[i] === '{') depth++;
|
|
40
|
+
else if (css[i] === '}') depth--;
|
|
41
|
+
i++;
|
|
42
|
+
}
|
|
43
|
+
return i - 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseCSS(cssText, filename = '<input>') {
|
|
47
|
+
const css = stripComments(cssText);
|
|
48
|
+
const rules = [];
|
|
49
|
+
let i = 0;
|
|
50
|
+
const atStack = [];
|
|
51
|
+
|
|
52
|
+
while (i < css.length) {
|
|
53
|
+
while (i < css.length && /\s/.test(css[i])) i++;
|
|
54
|
+
if (i >= css.length) break;
|
|
55
|
+
|
|
56
|
+
if (css[i] === '@') {
|
|
57
|
+
const braceIdx = css.indexOf('{', i);
|
|
58
|
+
const semiIdx = css.indexOf(';', i);
|
|
59
|
+
if (braceIdx === -1 || (semiIdx !== -1 && semiIdx < braceIdx)) {
|
|
60
|
+
i = semiIdx === -1 ? css.length : semiIdx + 1;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const atHeader = css.slice(i, braceIdx).trim();
|
|
64
|
+
atStack.push(atHeader);
|
|
65
|
+
i = braceIdx + 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (css[i] === '}') {
|
|
70
|
+
if (atStack.length > 0) atStack.pop();
|
|
71
|
+
i++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const braceIdx = css.indexOf('{', i);
|
|
76
|
+
if (braceIdx === -1) break;
|
|
77
|
+
|
|
78
|
+
const selectorText = css.slice(i, braceIdx).trim();
|
|
79
|
+
if (!selectorText) { i = braceIdx + 1; continue; }
|
|
80
|
+
|
|
81
|
+
const closeIdx = findMatchingBrace(css, braceIdx);
|
|
82
|
+
const declBlock = css.slice(braceIdx + 1, closeIdx);
|
|
83
|
+
const declarations = parseDeclarations(declBlock);
|
|
84
|
+
const selectors = selectorText.split(',').map(s => s.trim()).filter(Boolean);
|
|
85
|
+
const line = getLineNumber(css, i);
|
|
86
|
+
|
|
87
|
+
rules.push({
|
|
88
|
+
selectors,
|
|
89
|
+
declarations,
|
|
90
|
+
mediaContext: atStack.length > 0 ? atStack.join(' > ') : null,
|
|
91
|
+
source: { file: filename, line },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
i = closeIdx + 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return rules;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { parseCSS };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSSOM Builder — reads one or more CSS files and builds an in-memory cascade model.
|
|
3
|
+
*
|
|
4
|
+
* The model is a flat ordered list of enriched rules, each carrying:
|
|
5
|
+
* - selectors (array)
|
|
6
|
+
* - declarations (array of { prop, value, important })
|
|
7
|
+
* - source { file, line }
|
|
8
|
+
* - mediaContext (string | null)
|
|
9
|
+
* - specificity per selector (computed on build)
|
|
10
|
+
* - sourceOrder (global integer, cross-file, determines cascade tiebreaking)
|
|
11
|
+
*
|
|
12
|
+
* This is the shared foundation both resolveStyles and traceProperty query against.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const { parseCSS } = require('./cssParser');
|
|
18
|
+
const { calculateSpecificity, compareSpecificity, specificityToString } = require('./specificity');
|
|
19
|
+
|
|
20
|
+
const STYLE_EXTENSIONS = new Set(['.css', '.scss']);
|
|
21
|
+
|
|
22
|
+
function buildCSSOM(filePaths) {
|
|
23
|
+
const flatRules = [];
|
|
24
|
+
let sourceOrder = 0;
|
|
25
|
+
|
|
26
|
+
for (const filePath of filePaths) {
|
|
27
|
+
if (!fs.existsSync(filePath)) continue;
|
|
28
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
29
|
+
if (!STYLE_EXTENSIONS.has(ext)) continue;
|
|
30
|
+
|
|
31
|
+
const cssText = fs.readFileSync(filePath, 'utf8');
|
|
32
|
+
const rules = parseCSS(cssText, filePath);
|
|
33
|
+
|
|
34
|
+
for (const rule of rules) {
|
|
35
|
+
for (const selector of rule.selectors) {
|
|
36
|
+
flatRules.push({
|
|
37
|
+
selector: selector.trim(),
|
|
38
|
+
declarations: rule.declarations,
|
|
39
|
+
source: rule.source,
|
|
40
|
+
mediaContext: rule.mediaContext,
|
|
41
|
+
specificity: calculateSpecificity(selector),
|
|
42
|
+
sourceOrder: sourceOrder++,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { rules: flatRules, fileCount: filePaths.length };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Discovers all CSS/SCSS files under a directory recursively.
|
|
53
|
+
*/
|
|
54
|
+
function discoverStyleFiles(dir) {
|
|
55
|
+
const results = [];
|
|
56
|
+
if (!fs.existsSync(dir)) return results;
|
|
57
|
+
|
|
58
|
+
function walk(current) {
|
|
59
|
+
const entries = fs.readdirSync(current, { withFileTypes: true });
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
const fullPath = path.join(current, entry.name);
|
|
62
|
+
if (entry.isDirectory()) {
|
|
63
|
+
if (!['node_modules', '.git', 'dist', 'build', '.next', 'out'].includes(entry.name)) {
|
|
64
|
+
walk(fullPath);
|
|
65
|
+
}
|
|
66
|
+
} else if (STYLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
|
67
|
+
results.push(fullPath);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
walk(dir);
|
|
73
|
+
return results;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolves which value wins for a given property across a set of rules
|
|
78
|
+
* that all potentially apply to an element (pre-filtered to matching rules).
|
|
79
|
+
* Returns { winner, overridden[] }.
|
|
80
|
+
*/
|
|
81
|
+
function resolveCascade(matchingRules, prop) {
|
|
82
|
+
const relevant = matchingRules.filter(r =>
|
|
83
|
+
r.declarations.some(d => d.prop === prop)
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
if (relevant.length === 0) return null;
|
|
87
|
+
|
|
88
|
+
// Sort by cascade order: !important first, then specificity, then source order
|
|
89
|
+
const withDecl = relevant.map(rule => ({
|
|
90
|
+
rule,
|
|
91
|
+
decl: rule.declarations.find(d => d.prop === prop),
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
const CONFIDENCE_RANK = { certain: 0, likely: 1, possible: 2 };
|
|
95
|
+
|
|
96
|
+
withDecl.sort((a, b) => {
|
|
97
|
+
// !important always wins
|
|
98
|
+
if (a.decl.important && !b.decl.important) return -1;
|
|
99
|
+
if (!a.decl.important && b.decl.important) return 1;
|
|
100
|
+
// confidence first — certain/likely beats possible (possible rules need DOM context)
|
|
101
|
+
const confA = CONFIDENCE_RANK[a.rule.matchConfidence] ?? 1;
|
|
102
|
+
const confB = CONFIDENCE_RANK[b.rule.matchConfidence] ?? 1;
|
|
103
|
+
if (confA !== confB) return confA - confB;
|
|
104
|
+
// then specificity
|
|
105
|
+
const cmp = compareSpecificity(b.rule.specificity, a.rule.specificity);
|
|
106
|
+
if (cmp !== 0) return cmp;
|
|
107
|
+
// then source order (later wins)
|
|
108
|
+
return b.rule.sourceOrder - a.rule.sourceOrder;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const [winning, ...losing] = withDecl;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
winner: {
|
|
115
|
+
value: winning.decl.value,
|
|
116
|
+
important: winning.decl.important,
|
|
117
|
+
selector: winning.rule.selector,
|
|
118
|
+
specificity: specificityToString(winning.rule.specificity),
|
|
119
|
+
source: winning.rule.source,
|
|
120
|
+
mediaContext: winning.rule.mediaContext,
|
|
121
|
+
},
|
|
122
|
+
overridden: losing.map(({ rule, decl }) => ({
|
|
123
|
+
value: decl.value,
|
|
124
|
+
important: decl.important,
|
|
125
|
+
selector: rule.selector,
|
|
126
|
+
specificity: specificityToString(rule.specificity),
|
|
127
|
+
source: rule.source,
|
|
128
|
+
mediaContext: rule.mediaContext,
|
|
129
|
+
reason: losing.length > 0
|
|
130
|
+
? decl.important && !winning.decl.important ? 'overridden by !important'
|
|
131
|
+
: compareSpecificity(rule.specificity, winning.rule.specificity) < 0 ? 'lower specificity'
|
|
132
|
+
: 'earlier in source order'
|
|
133
|
+
: 'lower specificity',
|
|
134
|
+
})),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = { buildCSSOM, discoverStyleFiles, resolveCascade, specificityToString };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resolve_styles — answers "what CSS actually applies to this selector?"
|
|
3
|
+
*
|
|
4
|
+
* Given a selector string and a set of files, returns a structured map of every
|
|
5
|
+
* CSS property that could apply, who wins, and who got overridden — with confidence
|
|
6
|
+
* levels so agents know how much to trust each answer.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { buildCSSOM, discoverStyleFiles, resolveCascade } = require('./cssomBuilder');
|
|
11
|
+
const { matchSelector } = require('./selectorMatcher');
|
|
12
|
+
|
|
13
|
+
function resolveStyles({ selector, files = [], projectRoot = null }) {
|
|
14
|
+
if (!selector) return { error: 'selector is required' };
|
|
15
|
+
|
|
16
|
+
// Resolve file list
|
|
17
|
+
let filePaths = [...files];
|
|
18
|
+
if (projectRoot) filePaths.push(...discoverStyleFiles(projectRoot));
|
|
19
|
+
if (filePaths.length === 0) return { error: 'No CSS files found. Provide files[] or projectRoot.' };
|
|
20
|
+
|
|
21
|
+
const cssom = buildCSSOM(filePaths);
|
|
22
|
+
|
|
23
|
+
// Find all rules that could apply to this selector, tagged with match confidence
|
|
24
|
+
const matchingRules = [];
|
|
25
|
+
for (const rule of cssom.rules) {
|
|
26
|
+
const confidence = matchSelector(rule.selector, selector);
|
|
27
|
+
if (confidence !== 'none') {
|
|
28
|
+
matchingRules.push({ ...rule, matchConfidence: confidence });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (matchingRules.length === 0) {
|
|
33
|
+
return {
|
|
34
|
+
query: { selector, filesAnalyzed: filePaths.map(f => path.basename(f)) },
|
|
35
|
+
properties: {},
|
|
36
|
+
matchedRules: 0,
|
|
37
|
+
summary: 'No rules found matching "' + selector + '" across ' + filePaths.length + ' file(s).',
|
|
38
|
+
agentNote: 'Either the selector does not exist yet, the files provided do not contain it, or the selector uses a pattern the static analyzer cannot match. Try broadening the file list or checking the selector spelling.',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Collect all unique properties across matching rules
|
|
43
|
+
const allProps = new Set();
|
|
44
|
+
for (const rule of matchingRules) {
|
|
45
|
+
for (const decl of rule.declarations) allProps.add(decl.prop);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Resolve each property through the cascade
|
|
49
|
+
const properties = {};
|
|
50
|
+
let overrideCount = 0;
|
|
51
|
+
|
|
52
|
+
for (const prop of allProps) {
|
|
53
|
+
const resolution = resolveCascade(matchingRules, prop);
|
|
54
|
+
if (!resolution) continue;
|
|
55
|
+
|
|
56
|
+
// Confidence for the property = lowest confidence among rules involved
|
|
57
|
+
const involvedRules = matchingRules.filter(r => r.declarations.some(d => d.prop === prop));
|
|
58
|
+
const lowestConfidence = involvedRules.some(r => r.matchConfidence === 'possible') ? 'possible'
|
|
59
|
+
: involvedRules.some(r => r.matchConfidence === 'likely') ? 'likely' : 'certain';
|
|
60
|
+
|
|
61
|
+
if (resolution.overridden.length > 0) overrideCount++;
|
|
62
|
+
|
|
63
|
+
properties[prop] = {
|
|
64
|
+
winner: resolution.winner,
|
|
65
|
+
overridden: resolution.overridden,
|
|
66
|
+
confidence: lowestConfidence,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Group by confidence for the agent note
|
|
71
|
+
const certain = Object.entries(properties).filter(([, v]) => v.confidence === 'certain').map(([k]) => k);
|
|
72
|
+
const likely = Object.entries(properties).filter(([, v]) => v.confidence === 'likely').map(([k]) => k);
|
|
73
|
+
const possible = Object.entries(properties).filter(([, v]) => v.confidence === 'possible').map(([k]) => k);
|
|
74
|
+
const mediaWinners = Object.values(properties).filter(p => p.winner.mediaContext).length;
|
|
75
|
+
|
|
76
|
+
const agentNoteParts = [];
|
|
77
|
+
if (certain.length) agentNoteParts.push(certain.length + ' propert' + (certain.length === 1 ? 'y is' : 'ies are') + ' certain (exact selector match).');
|
|
78
|
+
if (likely.length) agentNoteParts.push(likely.length + ' propert' + (likely.length === 1 ? 'y is' : 'ies are') + ' likely (rule applies to this selector based on token overlap).');
|
|
79
|
+
if (possible.length) agentNoteParts.push(possible.length + ' propert' + (possible.length === 1 ? 'y is' : 'ies are') + ' possible (via ancestor/combinator rule - depends on DOM context).');
|
|
80
|
+
if (overrideCount) agentNoteParts.push(overrideCount + ' propert' + (overrideCount === 1 ? 'y has' : 'ies have') + ' overridden rules - review before modifying.');
|
|
81
|
+
if (mediaWinners > 0) agentNoteParts.push(mediaWinners + ' propert' + (mediaWinners === 1 ? "y's" : "ies'") + ' winning value comes from a @media rule - only applies at that breakpoint.');
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
query: {
|
|
85
|
+
selector,
|
|
86
|
+
filesAnalyzed: filePaths.map(f => path.basename(f)),
|
|
87
|
+
rulesScanned: cssom.rules.length,
|
|
88
|
+
},
|
|
89
|
+
properties,
|
|
90
|
+
matchedRules: matchingRules.length,
|
|
91
|
+
summary: 'Found ' + Object.keys(properties).length + ' properties applying to "' + selector + '" from ' + matchingRules.length + ' matched rule(s) across ' + filePaths.length + ' file(s).',
|
|
92
|
+
agentNote: agentNoteParts.join(' '),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { resolveStyles };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selector matcher — determines whether a CSS rule's selector could apply
|
|
3
|
+
* to a queried selector string, without a real DOM.
|
|
4
|
+
*
|
|
5
|
+
* Three match levels, each returned with a confidence score:
|
|
6
|
+
*
|
|
7
|
+
* 'certain' — selectors are identical (exact match)
|
|
8
|
+
* 'likely' — the rule selector's rightmost compound is a subset of the query
|
|
9
|
+
* (e.g. rule ".btn" matches query ".btn.primary" — .btn would apply)
|
|
10
|
+
* 'possible' — the rule uses a combinator that could apply to an ancestor/sibling
|
|
11
|
+
* of the queried element (e.g. ".sidebar .btn" when querying ".btn")
|
|
12
|
+
*
|
|
13
|
+
* 'none' means no meaningful relationship — skip.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
function parseCompound(selector) {
|
|
17
|
+
// Split on combinators, return rightmost compound selector's tokens
|
|
18
|
+
const parts = selector.trim().split(/(?<=[^\s>+~])\s*[\s>+~]+\s*/);
|
|
19
|
+
const rightmost = parts[parts.length - 1].trim();
|
|
20
|
+
const tokens = new Set();
|
|
21
|
+
|
|
22
|
+
// IDs
|
|
23
|
+
for (const m of rightmost.matchAll(/#([\w-]+)/g)) tokens.add('#' + m[1]);
|
|
24
|
+
// Classes
|
|
25
|
+
for (const m of rightmost.matchAll(/\.([\w-]+)/g)) tokens.add('.' + m[1]);
|
|
26
|
+
// Tag
|
|
27
|
+
const tagMatch = rightmost.match(/^([a-zA-Z][\w-]*)/);
|
|
28
|
+
if (tagMatch && tagMatch[1] !== '*') tokens.add(tagMatch[1].toLowerCase());
|
|
29
|
+
// Attrs
|
|
30
|
+
for (const m of rightmost.matchAll(/\[([^\]]+)\]/g)) tokens.add('[' + m[1] + ']');
|
|
31
|
+
|
|
32
|
+
return { tokens, hasCombinator: parts.length > 1, full: selector.trim() };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function matchSelector(ruleSelector, querySelector) {
|
|
36
|
+
const rule = parseCompound(ruleSelector);
|
|
37
|
+
const query = parseCompound(querySelector);
|
|
38
|
+
|
|
39
|
+
// Exact match
|
|
40
|
+
if (rule.full === query.full) return 'certain';
|
|
41
|
+
|
|
42
|
+
// Combinator check must come before subset check — a rule like ".sidebar .btn"
|
|
43
|
+
// has rightmost compound ".btn" which would falsely match as 'likely' against
|
|
44
|
+
// a query of ".btn" if we checked subsets first. Combinator rules always require
|
|
45
|
+
// DOM context so the best they can be is 'possible'.
|
|
46
|
+
if (rule.hasCombinator) {
|
|
47
|
+
for (const t of rule.tokens) {
|
|
48
|
+
if (query.tokens.has(t)) return 'possible';
|
|
49
|
+
}
|
|
50
|
+
return 'none';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// No combinator — rule tokens are a subset of query tokens → likely
|
|
54
|
+
// e.g. rule ".btn" vs query ".btn.primary" → .btn would apply to .btn.primary
|
|
55
|
+
if (rule.tokens.size > 0) {
|
|
56
|
+
let allMatch = true;
|
|
57
|
+
for (const t of rule.tokens) {
|
|
58
|
+
if (!query.tokens.has(t)) { allMatch = false; break; }
|
|
59
|
+
}
|
|
60
|
+
if (allMatch) return 'likely';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return 'none';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { matchSelector, parseCompound };
|
package/src/server.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* stylespeak — MCP server + CLI
|
|
4
|
+
*
|
|
5
|
+
* MCP mode: reads JSON-RPC from stdin, exposes two tools:
|
|
6
|
+
* resolve_styles — what CSS applies to a selector, who wins, who loses
|
|
7
|
+
* trace_property — every rule setting a property, competing groups, blast radius
|
|
8
|
+
*
|
|
9
|
+
* CLI mode: node server.js resolve .btn src/styles/main.css
|
|
10
|
+
* node server.js trace color --projectRoot src
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const readline = require('readline');
|
|
14
|
+
const { resolveStyles } = require('./resolveStyles');
|
|
15
|
+
const { traceProperty } = require('./traceProperty');
|
|
16
|
+
|
|
17
|
+
// ─── Tool definitions ────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const TOOLS = [
|
|
20
|
+
{
|
|
21
|
+
name: 'resolve_styles',
|
|
22
|
+
description:
|
|
23
|
+
'Resolves what CSS actually applies to a given selector. Returns every property ' +
|
|
24
|
+
'the selector would receive, which rule wins for each property, and which rules ' +
|
|
25
|
+
'were overridden — with confidence levels (certain/likely/possible) since no real ' +
|
|
26
|
+
'DOM is available. Call this before modifying styles for an element to understand ' +
|
|
27
|
+
'the full cascade context first.',
|
|
28
|
+
inputSchema: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
selector: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description: 'CSS selector to resolve, e.g. ".btn.primary", "#header a:hover"',
|
|
34
|
+
},
|
|
35
|
+
files: {
|
|
36
|
+
type: 'array',
|
|
37
|
+
items: { type: 'string' },
|
|
38
|
+
description: 'Absolute paths to CSS/SCSS files to analyze.',
|
|
39
|
+
},
|
|
40
|
+
projectRoot: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
description: 'Optional: path to a project root. All CSS/SCSS files will be discovered recursively.',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
required: ['selector'],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'trace_property',
|
|
50
|
+
description:
|
|
51
|
+
'Traces a CSS property across all provided files — finding every rule that sets it, ' +
|
|
52
|
+
'grouping competing rules that target overlapping selectors, and showing who wins in ' +
|
|
53
|
+
'each group. Use this to understand the blast radius of a property before changing it, ' +
|
|
54
|
+
'or to find out why a property value isn\'t applying as expected.',
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
property: {
|
|
59
|
+
type: 'string',
|
|
60
|
+
description: 'CSS property name to trace, e.g. "color", "background-color", "padding"',
|
|
61
|
+
},
|
|
62
|
+
files: {
|
|
63
|
+
type: 'array',
|
|
64
|
+
items: { type: 'string' },
|
|
65
|
+
description: 'Absolute paths to CSS/SCSS files to analyze.',
|
|
66
|
+
},
|
|
67
|
+
projectRoot: {
|
|
68
|
+
type: 'string',
|
|
69
|
+
description: 'Optional: path to a project root. All CSS/SCSS files will be discovered recursively.',
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
required: ['property'],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
// ─── MCP server ──────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
function send(message) {
|
|
80
|
+
process.stdout.write(JSON.stringify(message) + '\n');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function runTool(name, args) {
|
|
84
|
+
if (name === 'resolve_styles') return resolveStyles(args || {});
|
|
85
|
+
if (name === 'trace_property') return traceProperty(args || {});
|
|
86
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function handleRequest(req) {
|
|
90
|
+
const { id, method, params } = req;
|
|
91
|
+
|
|
92
|
+
if (method === 'initialize') {
|
|
93
|
+
return send({
|
|
94
|
+
jsonrpc: '2.0', id,
|
|
95
|
+
result: {
|
|
96
|
+
protocolVersion: '2024-11-05',
|
|
97
|
+
capabilities: { tools: {} },
|
|
98
|
+
serverInfo: { name: 'stylespeak', version: '0.1.0' },
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (method === 'notifications/initialized') return;
|
|
104
|
+
|
|
105
|
+
if (method === 'tools/list') {
|
|
106
|
+
return send({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (method === 'tools/call') {
|
|
110
|
+
const { name, arguments: args } = params || {};
|
|
111
|
+
try {
|
|
112
|
+
const result = runTool(name, args);
|
|
113
|
+
return send({
|
|
114
|
+
jsonrpc: '2.0', id,
|
|
115
|
+
result: { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] },
|
|
116
|
+
});
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return send({
|
|
119
|
+
jsonrpc: '2.0', id,
|
|
120
|
+
result: { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (id !== undefined) {
|
|
126
|
+
send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── CLI mode ────────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
function runCLI(args) {
|
|
133
|
+
const command = args[0];
|
|
134
|
+
|
|
135
|
+
if (!command || command === '--help' || command === '-h') {
|
|
136
|
+
console.log('stylespeak — CSS knowledge layer for AI agents\n');
|
|
137
|
+
console.log('Usage:');
|
|
138
|
+
console.log(' node server.js resolve <selector> [file...] [--projectRoot <dir>]');
|
|
139
|
+
console.log(' node server.js trace <property> [file...] [--projectRoot <dir>]\n');
|
|
140
|
+
console.log('Examples:');
|
|
141
|
+
console.log(' node server.js resolve ".btn.primary" src/styles/main.css');
|
|
142
|
+
console.log(' node server.js trace "color" --projectRoot src/styles');
|
|
143
|
+
console.log(' node server.js resolve "#header a" src/styles/base.css src/styles/header.css');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const projectRootIdx = args.indexOf('--projectRoot');
|
|
148
|
+
const projectRoot = projectRootIdx !== -1 ? args[projectRootIdx + 1] : null;
|
|
149
|
+
const fileArgs = args.slice(1).filter(a => !a.startsWith('--') && a !== projectRoot);
|
|
150
|
+
|
|
151
|
+
let result;
|
|
152
|
+
if (command === 'resolve') {
|
|
153
|
+
const selector = fileArgs[0];
|
|
154
|
+
const files = fileArgs.slice(1);
|
|
155
|
+
result = resolveStyles({ selector, files, projectRoot });
|
|
156
|
+
} else if (command === 'trace') {
|
|
157
|
+
const property = fileArgs[0];
|
|
158
|
+
const files = fileArgs.slice(1);
|
|
159
|
+
result = traceProperty({ property, files, projectRoot });
|
|
160
|
+
} else {
|
|
161
|
+
console.error(`Unknown command: ${command}. Use resolve or trace.`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log(JSON.stringify(result, null, 2));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Entry point ─────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
const cliArgs = process.argv.slice(2);
|
|
171
|
+
|
|
172
|
+
if (cliArgs.length > 0) {
|
|
173
|
+
runCLI(cliArgs);
|
|
174
|
+
} else {
|
|
175
|
+
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
176
|
+
rl.on('line', line => {
|
|
177
|
+
const trimmed = line.trim();
|
|
178
|
+
if (!trimmed) return;
|
|
179
|
+
try { handleRequest(JSON.parse(trimmed)); } catch { /* ignore malformed */ }
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS Specificity calculator.
|
|
3
|
+
* Returns [inline, id, class, type] as a 4-tuple.
|
|
4
|
+
* Handles :where() (zero specificity), :not()/:is()/:has() (argument specificity).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
function calculateSpecificity(selector) {
|
|
8
|
+
let sel = selector.trim();
|
|
9
|
+
let id = 0, cls = 0, typ = 0;
|
|
10
|
+
|
|
11
|
+
// pseudo-elements count as type
|
|
12
|
+
sel = sel.replace(/::?(before|after|first-line|first-letter|placeholder|selection|marker|backdrop|cue)\b/gi, () => { typ++; return ''; });
|
|
13
|
+
|
|
14
|
+
// :where() contributes zero
|
|
15
|
+
sel = sel.replace(/:where\([^)]*\)/gi, '');
|
|
16
|
+
|
|
17
|
+
// :not(), :is(), :has() — recurse into args (simplified: treat contents as normal)
|
|
18
|
+
sel = sel.replace(/:(not|is|has)\(([^)]*)\)/gi, (_, _n, inner) => ` ${inner} `);
|
|
19
|
+
|
|
20
|
+
// IDs
|
|
21
|
+
sel = sel.replace(/#[\w-]+/g, () => { id++; return ''; });
|
|
22
|
+
|
|
23
|
+
// classes, attributes, pseudo-classes
|
|
24
|
+
sel = sel.replace(/\.[\w-]+/g, () => { cls++; return ''; });
|
|
25
|
+
sel = sel.replace(/\[[^\]]*\]/g, () => { cls++; return ''; });
|
|
26
|
+
sel = sel.replace(/:[\w-]+(\([^)]*\))?/g, () => { cls++; return ''; });
|
|
27
|
+
|
|
28
|
+
// type selectors
|
|
29
|
+
const typeMatches = sel.match(/(^|[\s>+~])([a-zA-Z][\w-]*)/g);
|
|
30
|
+
if (typeMatches) {
|
|
31
|
+
for (const m of typeMatches) {
|
|
32
|
+
const tag = m.replace(/^[\s>+~]+/, '');
|
|
33
|
+
if (tag && tag !== '*') typ++;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return [0, id, cls, typ];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function compareSpecificity(a, b) {
|
|
41
|
+
for (let i = 0; i < 4; i++) {
|
|
42
|
+
if (a[i] !== b[i]) return a[i] - b[i];
|
|
43
|
+
}
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function specificityToString(s) {
|
|
48
|
+
return `(${s[0]},${s[1]},${s[2]},${s[3]})`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { calculateSpecificity, compareSpecificity, specificityToString };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trace_property — answers "everywhere this CSS property is set, who wins?"
|
|
3
|
+
*
|
|
4
|
+
* Given a property name and a set of files, returns every rule that touches
|
|
5
|
+
* that property — grouped by selector, showing who wins and who loses for each
|
|
6
|
+
* competing group. Useful for agents trying to understand the full blast radius
|
|
7
|
+
* of a property before changing it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { buildCSSOM, discoverStyleFiles, resolveCascade, specificityToString } = require('./cssomBuilder');
|
|
12
|
+
const { matchSelector } = require('./selectorMatcher');
|
|
13
|
+
|
|
14
|
+
function traceProperty({ property, files = [], projectRoot = null }) {
|
|
15
|
+
if (!property) return { error: 'property is required' };
|
|
16
|
+
|
|
17
|
+
let filePaths = [...files];
|
|
18
|
+
if (projectRoot) filePaths.push(...discoverStyleFiles(projectRoot));
|
|
19
|
+
if (filePaths.length === 0) return { error: 'No CSS files found. Provide files[] or projectRoot.' };
|
|
20
|
+
|
|
21
|
+
const cssom = buildCSSOM(filePaths);
|
|
22
|
+
|
|
23
|
+
// Find every rule that sets this property
|
|
24
|
+
const occurrences = cssom.rules.filter(rule =>
|
|
25
|
+
rule.declarations.some(d => d.prop === property.toLowerCase())
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
if (occurrences.length === 0) {
|
|
29
|
+
return {
|
|
30
|
+
query: { property, filesAnalyzed: filePaths.map(f => path.basename(f)) },
|
|
31
|
+
occurrences: [],
|
|
32
|
+
competingGroups: [],
|
|
33
|
+
summary: `"${property}" is not set in any of the ${filePaths.length} analyzed file(s).`,
|
|
34
|
+
agentNote: `The property doesn't exist yet — safe to introduce. Check inherited values or browser defaults if you're seeing unexpected behavior.`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Build competing groups: selectors that could target overlapping elements.
|
|
39
|
+
// Two rules compete if EITHER direction of matchSelector returns non-'none' —
|
|
40
|
+
// e.g. .btn competes with .btn.primary because .btn applies to .btn.primary elements,
|
|
41
|
+
// even though .btn.primary does not apply to plain .btn elements.
|
|
42
|
+
const groups = [];
|
|
43
|
+
const assigned = new Set();
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < occurrences.length; i++) {
|
|
46
|
+
if (assigned.has(i)) continue;
|
|
47
|
+
const group = [occurrences[i]];
|
|
48
|
+
assigned.add(i);
|
|
49
|
+
|
|
50
|
+
for (let j = i + 1; j < occurrences.length; j++) {
|
|
51
|
+
if (assigned.has(j)) continue;
|
|
52
|
+
const confAB = matchSelector(occurrences[j].selector, occurrences[i].selector);
|
|
53
|
+
const confBA = matchSelector(occurrences[i].selector, occurrences[j].selector);
|
|
54
|
+
if (confAB !== 'none' || confBA !== 'none') {
|
|
55
|
+
group.push(occurrences[j]);
|
|
56
|
+
assigned.add(j);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (group.length > 1) {
|
|
61
|
+
const resolution = resolveCascade(group, property.toLowerCase());
|
|
62
|
+
groups.push({
|
|
63
|
+
selectors: group.map(r => r.selector),
|
|
64
|
+
winner: resolution ? resolution.winner : null,
|
|
65
|
+
overridden: resolution ? resolution.overridden : [],
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Format all occurrences for output
|
|
71
|
+
const formatted = occurrences.map(rule => {
|
|
72
|
+
const decl = rule.declarations.find(d => d.prop === property.toLowerCase());
|
|
73
|
+
return {
|
|
74
|
+
selector: rule.selector,
|
|
75
|
+
value: decl.value,
|
|
76
|
+
important: decl.important,
|
|
77
|
+
specificity: specificityToString(rule.specificity),
|
|
78
|
+
source: rule.source,
|
|
79
|
+
mediaContext: rule.mediaContext || null,
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const affectedFiles = [...new Set(occurrences.map(r => path.basename(r.source.file)))];
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
query: {
|
|
87
|
+
property,
|
|
88
|
+
filesAnalyzed: filePaths.map(f => path.basename(f)),
|
|
89
|
+
rulesScanned: cssom.rules.length,
|
|
90
|
+
},
|
|
91
|
+
occurrences: formatted,
|
|
92
|
+
competingGroups: groups,
|
|
93
|
+
summary: `"${property}" is set ${occurrences.length} time(s) across ${affectedFiles.join(', ')}.${groups.length > 0 ? ` ${groups.length} competing group(s) found where rules override each other.` : ''}`,
|
|
94
|
+
agentNote: groups.length > 0
|
|
95
|
+
? `Changing "${property}" in one rule may have no effect if a competing rule with higher specificity or later source order wins. Review competing groups before editing.`
|
|
96
|
+
: `No competing rules found — changes to "${property}" in these selectors should apply cleanly.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { traceProperty };
|