@patrizzos/stylesafe 0.1.5

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 stylesafe
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,125 @@
1
+ # StyleSafe - MCP and CLI
2
+
3
+ An MCP server and CLI that catches CSS cascade conflicts and Tailwind utility clashes before they ship — built for AI coding agents and CI workflows.
4
+
5
+ ## What it does
6
+
7
+ - Detects duplicate CSS declarations and dead rules from specificity conflicts
8
+ - Flags silent style overrides that may be accidental
9
+ - Finds conflicting Tailwind utilities (padding, margin, background, shadow, opacity, border, ring, etc.)
10
+ - Understands `cn()`, `clsx()`, and `twMerge()` call expressions, not just plain `className` strings
11
+ - Returns structured agent-friendly reports with `confidence`, `riskScore`, and `nextStep`
12
+
13
+ ## Quick start
14
+
15
+ ### As a CLI tool
16
+
17
+ ```bash
18
+ npm install -g stylesafe
19
+ stylesafe src/components/Button.jsx
20
+ stylesafe --changed --fail-on-issues
21
+ stylesafe --projectRoot src
22
+ stylesafe --watch src
23
+ ```
24
+
25
+ ### As an MCP server
26
+
27
+ ```json
28
+ {
29
+ "mcpServers": {
30
+ "stylesafe": {
31
+ "command": "node",
32
+ "args": ["/absolute/path/to/server.js"]
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ ## Example output
39
+
40
+ ```bash
41
+ stylesafe examples/tailwind-conflict.jsx
42
+ ```
43
+
44
+ Returns a structured report:
45
+
46
+ ```json
47
+ {
48
+ "totalIssues": 5,
49
+ "riskScore": 275,
50
+ "averageRiskScore": 55,
51
+ "riskLevel": "medium",
52
+ "passed": true,
53
+ "clean": false,
54
+ "files": [
55
+ {
56
+ "filename": "examples/tailwind-conflict.jsx",
57
+ "issues": [
58
+ {
59
+ "type": "tailwind-conflict",
60
+ "category": "padding",
61
+ "classes": ["p-4", "px-8"],
62
+ "confidence": "medium",
63
+ "riskScore": 55,
64
+ "nextStep": "Choose one utility from the conflicting set or split the classes by scope.",
65
+ "requiresUserConfirmation": true
66
+ }
67
+ ]
68
+ }
69
+ ]
70
+ }
71
+ ```
72
+
73
+ `passed: true` means no hard errors. `clean: true` means zero issues of any kind.
74
+
75
+ ## Use cases
76
+
77
+ - **AI code generation**: Catch style bugs before the agent commits changes
78
+ - **PR gate**: Block merged changes that introduce cascade conflicts or utility clashes
79
+ - **Agent workflows**: Integrate as a confirmation step in multi-turn coding tasks
80
+ - **Style audits**: Run across a project to find existing issues
81
+
82
+ ## CLI options
83
+
84
+ - `stylesafe <file>` — analyze a single file
85
+ - `stylesafe <dir>` — analyze a directory recursively
86
+ - `stylesafe --projectRoot <dir>` — same as above
87
+ - `stylesafe --changed` — analyze git-changed files (for PR/CI)
88
+ - `stylesafe --changed --fail-on-issues` — exit with code 1 if any issues found
89
+ - `stylesafe --watch src` — re-run on every file change
90
+ - `npm test` — run regression tests
91
+
92
+ ## Config
93
+
94
+ Place a `.styleintegrityrc` file in your project root:
95
+
96
+ ```json
97
+ {
98
+ "ignore": ["dist", "*.min.css"],
99
+ "failOn": ["error", "warning"]
100
+ }
101
+ ```
102
+
103
+ ## GitHub Actions
104
+
105
+ Add to `.github/workflows/style-check.yml`:
106
+
107
+ ```yaml
108
+ name: stylesafe
109
+
110
+ on:
111
+ pull_request:
112
+ push:
113
+ branches: [main]
114
+
115
+ jobs:
116
+ stylesafe:
117
+ runs-on: ubuntu-latest
118
+ steps:
119
+ - uses: actions/checkout@v4
120
+ - uses: actions/setup-node@v4
121
+ with:
122
+ node-version: 20
123
+ - run: npm install
124
+ - run: node server.js --changed --fail-on-issues
125
+ ```
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@patrizzos/stylesafe",
3
+ "mcpName": "io.github.Patrizzos/stylesafe",
4
+ "version": "0.1.5",
5
+ "description": "MCP server and CLI that catches CSS cascade conflicts and Tailwind utility clashes before they ship.",
6
+ "main": "server.js",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/Patrizzos/stylesafe.git"
10
+ },
11
+ "bin": {
12
+ "stylesafe": "server.js"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "server.js",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "start": "node server.js",
22
+ "test": "node test/run.js"
23
+ },
24
+ "keywords": [
25
+ "mcp",
26
+ "css",
27
+ "tailwind",
28
+ "lint",
29
+ "style",
30
+ "frontend",
31
+ "agent"
32
+ ],
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "license": "MIT"
37
+ }
package/server.js ADDED
@@ -0,0 +1 @@
1
+ require('./src/server');
@@ -0,0 +1,125 @@
1
+ const { parseCSS } = require('./cssParser');
2
+ const { calculateSpecificity, compareSpecificity, specificityToString } = require('./specificity');
3
+
4
+ function extractTokens(selector) {
5
+ const compound = selector.trim().split(/\s+/).pop(); // rightmost compound selector
6
+ const tokens = compound.match(/[.#]?[\w-]+/g) || [];
7
+ return new Set(tokens);
8
+ }
9
+
10
+ function tokensOverlap(setA, setB) {
11
+ const [small, large] = setA.size <= setB.size ? [setA, setB] : [setB, setA];
12
+ for (const t of small) {
13
+ if (!large.has(t)) return false;
14
+ }
15
+ return small.size > 0;
16
+ }
17
+
18
+ function flattenRules(rules) {
19
+ const flat = [];
20
+ rules.forEach((rule, ruleIdx) => {
21
+ rule.selectors.forEach(selector => {
22
+ flat.push({
23
+ selector,
24
+ declarations: rule.declarations,
25
+ mediaContext: rule.mediaContext,
26
+ sourceOrder: ruleIdx,
27
+ specificity: calculateSpecificity(selector),
28
+ });
29
+ });
30
+ });
31
+ return flat;
32
+ }
33
+
34
+ function analyzeCascade(cssText, filename = '<input>') {
35
+ const rules = parseCSS(cssText);
36
+ const flat = flattenRules(rules);
37
+ const issues = [];
38
+
39
+ for (const rule of rules) {
40
+ const seen = new Map();
41
+ for (const decl of rule.declarations) {
42
+ if (seen.has(decl.prop) && !decl.important) {
43
+ issues.push({
44
+ type: 'duplicate-in-rule',
45
+ severity: 'warning',
46
+ selector: rule.selectors.join(', '),
47
+ prop: decl.prop,
48
+ message: `Property "${decl.prop}" is declared more than once in the same rule. Only the last value ("${decl.value}") takes effect; earlier value is dead code.`,
49
+ });
50
+ }
51
+ seen.set(decl.prop, decl.value);
52
+ }
53
+ }
54
+
55
+ for (let i = 0; i < flat.length; i++) {
56
+ for (let j = i + 1; j < flat.length; j++) {
57
+ const a = flat[i];
58
+ const b = flat[j];
59
+ if (a.mediaContext !== b.mediaContext) continue; // different media contexts, skip (not directly comparable)
60
+ const tokensA = extractTokens(a.selector);
61
+ const tokensB = extractTokens(b.selector);
62
+ if (!tokensOverlap(tokensA, tokensB)) continue;
63
+
64
+ const aProps = new Map(a.declarations.map(d => [d.prop, d]));
65
+ const bProps = new Map(b.declarations.map(d => [d.prop, d]));
66
+
67
+ for (const [prop, declB] of bProps) {
68
+ if (!aProps.has(prop)) continue;
69
+ const declA = aProps.get(prop);
70
+ if (declA.value === declB.value) continue; // not actually conflicting in effect
71
+
72
+ const cmp = compareSpecificity(b.specificity, a.specificity);
73
+ const bWinsBySource = b.sourceOrder > a.sourceOrder;
74
+
75
+ if (cmp < 0 && bWinsBySource === false) {
76
+ } else if (cmp < 0 && !declB.important && declA.important !== true) {
77
+ issues.push({
78
+ type: 'specificity-override',
79
+ severity: 'error',
80
+ prop,
81
+ message: `"${b.selector}" sets ${prop}: ${declB.value}, but it can never apply because "${a.selector}" (specificity ${specificityToString(a.specificity)} vs ${specificityToString(b.specificity)}) sets the same property and wins regardless of source order.`,
82
+ losingSelector: b.selector,
83
+ winningSelector: a.selector,
84
+ });
85
+ } else if (b.sourceOrder > a.sourceOrder && cmp >= 0) {
86
+ issues.push({
87
+ type: 'silent-override',
88
+ severity: 'info',
89
+ prop,
90
+ message: `"${b.selector}" (later in source) overrides "${a.selector}"'s ${prop}: ${declA.value} → ${declB.value}. Confirm this is intentional.`,
91
+ losingSelector: a.selector,
92
+ winningSelector: b.selector,
93
+ });
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ const importantCount = flat.reduce(
100
+ (sum, r) => sum + r.declarations.filter(d => d.important).length,
101
+ 0
102
+ );
103
+ if (importantCount > 0) {
104
+ issues.push({
105
+ type: 'important-usage',
106
+ severity: importantCount > 3 ? 'warning' : 'info',
107
+ message: `${importantCount} use(s) of !important found. Each one raises the bar for any future rule trying to override it intentionally.`,
108
+ });
109
+ }
110
+
111
+ return {
112
+ filename,
113
+ ruleCount: rules.length,
114
+ issues,
115
+ summary: summarize(issues),
116
+ };
117
+ }
118
+
119
+ function summarize(issues) {
120
+ const counts = { error: 0, warning: 0, info: 0 };
121
+ for (const issue of issues) counts[issue.severity] = (counts[issue.severity] || 0) + 1;
122
+ return counts;
123
+ }
124
+
125
+ module.exports = { analyzeCascade };
@@ -0,0 +1,182 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { execSync } = require('child_process');
4
+ const { analyzeCascade } = require('./cascadeAnalyzer');
5
+ const { extractClassStringsFromMarkup, detectTailwindConflicts } = require('./tailwindAnalyzer');
6
+
7
+ const CSS_EXTENSIONS = new Set(['.css', '.scss']);
8
+ const MARKUP_EXTENSIONS = new Set(['.jsx', '.tsx', '.html', '.vue', '.js', '.ts']);
9
+
10
+ function getConfidence(issue) {
11
+ if (issue.type === 'specificity-override' || issue.type === 'duplicate-in-rule') return 'high';
12
+ if (issue.type === 'tailwind-conflict' || issue.type === 'important-usage') return 'medium';
13
+ return 'medium';
14
+ }
15
+
16
+ function getRiskScore(issue) {
17
+ if (issue.type === 'duplicate-in-rule') return 80;
18
+ if (issue.type === 'specificity-override') return 75;
19
+ if (issue.type === 'important-usage') return 65;
20
+ if (issue.type === 'tailwind-conflict') return 55;
21
+ return 40;
22
+ }
23
+
24
+ function toIssueWithAction(issue, filePath) {
25
+ const explanation = issue.message || 'Style issue detected.';
26
+ const nextStep = issue.type === 'specificity-override'
27
+ ? 'Review the selectors and adjust the weaker rule or increase specificity intentionally.'
28
+ : issue.type === 'duplicate-in-rule'
29
+ ? 'Remove the duplicate declaration or consolidate the values into a single declaration.'
30
+ : issue.type === 'tailwind-conflict'
31
+ ? 'Choose one utility from the conflicting set or split the classes by scope.'
32
+ : 'Review this override and confirm the intended winner before changing the styles.';
33
+
34
+ const suggestions = [];
35
+ if (issue.type === 'specificity-override') {
36
+ suggestions.push(`Replace or simplify the lower-specificity rule in ${filePath}`);
37
+ suggestions.push('Make the intended winner more explicit with a more specific selector');
38
+ } else if (issue.type === 'duplicate-in-rule') {
39
+ suggestions.push(`Remove the duplicate property from ${filePath}`);
40
+ suggestions.push('Keep a single canonical declaration for the property');
41
+ } else if (issue.type === 'tailwind-conflict') {
42
+ suggestions.push('Choose a single utility from the conflicting set');
43
+ suggestions.push('Split conflicting utilities into different variant scopes');
44
+ } else {
45
+ suggestions.push('Confirm whether the override is intentional');
46
+ suggestions.push('Refactor the selectors if the override was accidental');
47
+ }
48
+
49
+ const recommendedAction = issue.type === 'specificity-override'
50
+ ? 'Review the selector hierarchy before making any change.'
51
+ : issue.type === 'duplicate-in-rule'
52
+ ? 'Remove the duplicate declaration and keep one canonical value.'
53
+ : issue.type === 'tailwind-conflict'
54
+ ? 'Choose one utility or split the classes by variant scope.'
55
+ : 'Review the override and confirm whether it is intentional.';
56
+
57
+ return {
58
+ ...issue,
59
+ confidence: getConfidence(issue),
60
+ riskScore: getRiskScore(issue),
61
+ explanation,
62
+ suggestions: suggestions.slice(0, 3),
63
+ nextStep,
64
+ recommendedAction,
65
+ requiresUserConfirmation: true,
66
+ };
67
+ }
68
+
69
+ function collectChangedFiles() {
70
+ try {
71
+ const raw = execSync('git diff --name-only --diff-filter=ACMRTUXB HEAD', { encoding: 'utf8' });
72
+ return raw
73
+ .split(/\r?\n/)
74
+ .map(line => line.trim())
75
+ .filter(Boolean)
76
+ .filter(file => {
77
+ const ext = path.extname(file).toLowerCase();
78
+ return CSS_EXTENSIONS.has(ext) || MARKUP_EXTENSIONS.has(ext);
79
+ });
80
+ } catch {
81
+ return [];
82
+ }
83
+ }
84
+
85
+ function collectFilesToAnalyze(input) {
86
+ if (Array.isArray(input)) return input.filter(Boolean);
87
+ if (input && typeof input === 'object') {
88
+ if (input.changedFiles) return collectChangedFiles();
89
+ if (Array.isArray(input.files)) return input.files.filter(Boolean);
90
+ if (input.projectRoot) {
91
+ const root = input.projectRoot;
92
+ const results = [];
93
+ const walk = currentPath => {
94
+ if (!fs.existsSync(currentPath)) return;
95
+ const entries = fs.readdirSync(currentPath, { withFileTypes: true });
96
+ for (const entry of entries) {
97
+ if (entry.name.startsWith('.')) continue;
98
+ if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'build') continue;
99
+ const fullPath = path.join(currentPath, entry.name);
100
+ if (entry.isDirectory()) {
101
+ walk(fullPath);
102
+ } else if (entry.isFile()) {
103
+ const ext = path.extname(entry.name).toLowerCase();
104
+ if (CSS_EXTENSIONS.has(ext) || MARKUP_EXTENSIONS.has(ext)) results.push(fullPath);
105
+ }
106
+ }
107
+ };
108
+ walk(root);
109
+ return results;
110
+ }
111
+ }
112
+ return [];
113
+ }
114
+
115
+ /**
116
+ * Checks a set of files for style integrity issues. This is the single function the
117
+ * MCP tool calls. Returns a structured report an agent can read and act on directly.
118
+ */
119
+ function checkStyleIntegrity(input) {
120
+ const filePaths = collectFilesToAnalyze(input);
121
+ const report = {
122
+ files: [],
123
+ totalIssues: 0,
124
+ riskScore: 0,
125
+ averageRiskScore: 0,
126
+ riskLevel: 'low',
127
+ summary: { error: 0, warning: 0, info: 0, highConfidence: 0, mediumConfidence: 0, lowConfidence: 0 },
128
+ agentGuidance: {
129
+ requiresUserConfirmation: true,
130
+ message: 'Analyze the findings, explain the risk, and ask for permission before applying any fix.',
131
+ },
132
+ overview: {
133
+ recommendedNextAction: 'Review the highest-confidence issues first and ask for confirmation before changing styles.',
134
+ },
135
+ };
136
+
137
+ for (const filePath of filePaths) {
138
+ if (!fs.existsSync(filePath)) {
139
+ report.files.push({ filename: filePath, error: 'File not found' });
140
+ continue;
141
+ }
142
+
143
+ const stat = fs.statSync(filePath);
144
+ if (stat.isDirectory()) continue;
145
+
146
+ const ext = path.extname(filePath).toLowerCase();
147
+ const source = fs.readFileSync(filePath, 'utf8');
148
+ const fileReport = { filename: filePath, issues: [] };
149
+
150
+ if (CSS_EXTENSIONS.has(ext)) {
151
+ const result = analyzeCascade(source, filePath);
152
+ fileReport.issues.push(...result.issues.map(issue => toIssueWithAction(issue, filePath)));
153
+ }
154
+
155
+ if (MARKUP_EXTENSIONS.has(ext)) {
156
+ const classStrings = extractClassStringsFromMarkup(source);
157
+ for (const cs of classStrings) {
158
+ fileReport.issues.push(...detectTailwindConflicts(cs, cs).map(issue => toIssueWithAction(issue, filePath)));
159
+ }
160
+ }
161
+
162
+ for (const issue of fileReport.issues) {
163
+ report.summary[issue.severity] = (report.summary[issue.severity] || 0) + 1;
164
+ if (issue.confidence === 'high') report.summary.highConfidence++;
165
+ if (issue.confidence === 'medium') report.summary.mediumConfidence++;
166
+ if (issue.confidence === 'low') report.summary.lowConfidence++;
167
+ report.totalIssues++;
168
+ report.riskScore += issue.riskScore || 0;
169
+ }
170
+ report.files.push(fileReport);
171
+ }
172
+
173
+ report.passed = report.summary.error === 0;
174
+ report.clean = report.totalIssues === 0;
175
+ if (report.totalIssues > 0) {
176
+ report.averageRiskScore = Math.round(report.riskScore / report.totalIssues);
177
+ report.riskLevel = report.averageRiskScore >= 70 ? 'high' : report.averageRiskScore >= 50 ? 'medium' : 'low';
178
+ }
179
+ return report;
180
+ }
181
+
182
+ module.exports = { checkStyleIntegrity };
@@ -0,0 +1,142 @@
1
+ function stripComments(css) {
2
+ return css.replace(/\/\*[\s\S]*?\*\//g, '');
3
+ }
4
+
5
+ function parseDeclarations(block) {
6
+ const decls = [];
7
+ const parts = block.split(';');
8
+ for (const part of parts) {
9
+ const trimmed = part.trim();
10
+ if (!trimmed) continue;
11
+ const colonIdx = trimmed.indexOf(':');
12
+ if (colonIdx === -1) continue;
13
+ const prop = trimmed.slice(0, colonIdx).trim().toLowerCase();
14
+ let value = trimmed.slice(colonIdx + 1).trim();
15
+ const important = /!important\s*$/i.test(value);
16
+ if (important) value = value.replace(/!important\s*$/i, '').trim();
17
+ decls.push({ prop, value, important });
18
+ }
19
+ return decls;
20
+ }
21
+
22
+ function combineSelectors(parentSelector, childSelector) {
23
+ const parent = parentSelector.trim();
24
+ const child = childSelector.trim();
25
+ if (!parent) return [child];
26
+ if (!child) return [parent];
27
+ if (child.startsWith('&')) {
28
+ return [parent + child.slice(1)];
29
+ }
30
+ return [`${parent} ${child}`];
31
+ }
32
+
33
+ function parseStyleBlock(blockText, parentSelectors = [], mediaStack = []) {
34
+ const declarations = [];
35
+ const nestedRules = [];
36
+ let cursor = 0;
37
+
38
+ while (cursor < blockText.length) {
39
+ const nextOpen = blockText.indexOf('{', cursor);
40
+ const nextSemi = blockText.indexOf(';', cursor);
41
+
42
+ if (nextOpen === -1 && nextSemi === -1) break;
43
+
44
+ if (nextOpen !== -1 && (nextSemi === -1 || nextOpen < nextSemi)) {
45
+ const selectorText = blockText.slice(cursor, nextOpen).trim();
46
+ if (selectorText) {
47
+ const closeIdx = findMatchingBrace(blockText, nextOpen);
48
+ const childBlock = blockText.slice(nextOpen + 1, closeIdx);
49
+ const combinedSelectors = parentSelectors.flatMap(parent => combineSelectors(parent, selectorText));
50
+ const childResult = parseStyleBlock(childBlock, combinedSelectors, mediaStack);
51
+ if (childResult.declarations.length > 0) {
52
+ nestedRules.push({
53
+ selectors: combinedSelectors,
54
+ declarations: childResult.declarations,
55
+ mediaContext: mediaStack.length > 0 ? mediaStack.join(' > ') : null,
56
+ raw: blockText.slice(nextOpen, closeIdx + 1),
57
+ start: cursor,
58
+ });
59
+ }
60
+ nestedRules.push(...childResult.nestedRules);
61
+ cursor = closeIdx + 1;
62
+ continue;
63
+ }
64
+ }
65
+
66
+ const semicolonIndex = nextSemi === -1 ? blockText.length : nextSemi;
67
+ const segment = blockText.slice(cursor, semicolonIndex).trim();
68
+ if (segment) {
69
+ declarations.push(...parseDeclarations(segment));
70
+ }
71
+ cursor = semicolonIndex + 1;
72
+ }
73
+
74
+ return { declarations, nestedRules };
75
+ }
76
+
77
+ function parseCSS(cssText) {
78
+ const css = stripComments(cssText);
79
+ const rules = [];
80
+ let i = 0;
81
+ let mediaStack = [];
82
+
83
+ while (i < css.length) {
84
+ while (i < css.length && /\s/.test(css[i])) i++;
85
+ if (i >= css.length) break;
86
+
87
+ if (css[i] === '@') {
88
+ const braceIdx = css.indexOf('{', i);
89
+ const semiIdx = css.indexOf(';', i);
90
+ if (braceIdx === -1 || (semiIdx !== -1 && semiIdx < braceIdx)) {
91
+ i = semiIdx === -1 ? css.length : semiIdx + 1;
92
+ continue;
93
+ }
94
+ const atRuleHeader = css.slice(i, braceIdx).trim();
95
+ mediaStack.push(atRuleHeader);
96
+ i = braceIdx + 1;
97
+ continue;
98
+ }
99
+
100
+ if (css[i] === '}') {
101
+ if (mediaStack.length > 0) mediaStack.pop();
102
+ i++;
103
+ continue;
104
+ }
105
+
106
+ const braceIdx = css.indexOf('{', i);
107
+ if (braceIdx === -1) break;
108
+ const selectorText = css.slice(i, braceIdx).trim();
109
+ const closeIdx = findMatchingBrace(css, braceIdx);
110
+ const declBlock = css.slice(braceIdx + 1, closeIdx);
111
+ const selectors = selectorText.split(',').map(s => s.trim()).filter(Boolean);
112
+ const parsedBlock = parseStyleBlock(declBlock, selectors, mediaStack);
113
+
114
+ if (parsedBlock.declarations.length > 0) {
115
+ rules.push({
116
+ selectors,
117
+ declarations: parsedBlock.declarations,
118
+ mediaContext: mediaStack.length > 0 ? mediaStack.join(' > ') : null,
119
+ raw: css.slice(i, closeIdx + 1),
120
+ start: i,
121
+ });
122
+ }
123
+
124
+ rules.push(...parsedBlock.nestedRules);
125
+ i = closeIdx + 1;
126
+ }
127
+
128
+ return rules;
129
+ }
130
+
131
+ function findMatchingBrace(css, openIdx) {
132
+ let depth = 1;
133
+ let i = openIdx + 1;
134
+ while (i < css.length && depth > 0) {
135
+ if (css[i] === '{') depth++;
136
+ else if (css[i] === '}') depth--;
137
+ i++;
138
+ }
139
+ return i - 1;
140
+ }
141
+
142
+ module.exports = { parseCSS, parseDeclarations };
package/src/server.js ADDED
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Minimal MCP server implementing the core JSON-RPC transport for the style-integrity tool.
4
+ */
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const readline = require('readline');
8
+ const { checkStyleIntegrity } = require('./checkStyleIntegrity');
9
+
10
+
11
+ const TOOL_DEFINITION = {
12
+ name: 'check_style_integrity',
13
+ description:
14
+ 'Analyzes CSS/SCSS files and Tailwind-related markup for cascade conflicts, duplicate declarations, specificity issues, and conflicting utilities. ' +
15
+ 'The result is a structured report with explanation, suggested fixes, and a next step for the agent, intended for human review before any style change is applied.',
16
+ inputSchema: {
17
+ type: 'object',
18
+ properties: {
19
+ files: {
20
+ type: 'array',
21
+ items: { type: 'string' },
22
+ description: 'Absolute or relative file paths to analyze (.css, .scss, .jsx, .tsx, .html, .vue, .js, .ts)',
23
+ },
24
+ projectRoot: {
25
+ type: 'string',
26
+ description: 'A project root directory to analyze recursively for style-related files',
27
+ },
28
+ changedFiles: {
29
+ type: 'boolean',
30
+ description: 'Analyze git changed files in the current repository working tree',
31
+ },
32
+ },
33
+ additionalProperties: false,
34
+ },
35
+ };
36
+
37
+ function send(message) {
38
+ process.stdout.write(JSON.stringify(message) + '\n');
39
+ }
40
+
41
+ function isStyleTarget(target) {
42
+ const ext = path.extname(target).toLowerCase();
43
+ return ['.css', '.scss', '.jsx', '.tsx', '.html', '.vue', '.js', '.ts'].includes(ext);
44
+ }
45
+
46
+ function loadConfig(startDir) {
47
+ // Walk up from startDir looking for .styleintegrityrc
48
+ let dir = path.resolve(startDir);
49
+ for (let i = 0; i < 6; i++) {
50
+ const candidate = path.join(dir, '.styleintegrityrc');
51
+ if (fs.existsSync(candidate)) {
52
+ try { return JSON.parse(fs.readFileSync(candidate, 'utf8')); } catch { return {}; }
53
+ }
54
+ const parent = path.dirname(dir);
55
+ if (parent === dir) break;
56
+ dir = parent;
57
+ }
58
+ return {};
59
+ }
60
+
61
+ function printCliUsage() {
62
+ console.log('Usage: node server.js [--changed] [--fail-on-issues] [--watch] [--projectRoot <dir>] [<file-or-directory>]');
63
+ console.log(' --changed Analyze git changed files in the current repository');
64
+ console.log(' --fail-on-issues Exit with code 1 if any issues are found');
65
+ console.log(' --watch Re-run analysis on file changes (use with file or --projectRoot)');
66
+ console.log(' --projectRoot DIR Analyze a directory recursively for style files');
67
+ console.log(' <file-or-directory> Analyze a single file or directory by extension');
68
+ console.log('');
69
+ console.log('Config: place a .styleintegrityrc JSON file in your project root.');
70
+ console.log(' { "ignore": ["dist", "*.min.css"], "failOn": ["error", "warning"] }');
71
+ }
72
+
73
+ function runCli(args) {
74
+ if (args.length === 0) {
75
+ printCliUsage();
76
+ return;
77
+ }
78
+
79
+ if (args.includes('--help') || args.includes('-h')) {
80
+ printCliUsage();
81
+ return;
82
+ }
83
+
84
+ let input;
85
+ const failOnIssues = args.includes('--fail-on-issues');
86
+ const changedFiles = args.includes('--changed');
87
+ const watchMode = args.includes('--watch');
88
+ const config = loadConfig(process.cwd());
89
+
90
+ if (changedFiles) {
91
+ input = { changedFiles: true };
92
+ } else if (args.includes('--projectRoot')) {
93
+ const idx = args.indexOf('--projectRoot');
94
+ input = { projectRoot: args[idx + 1] };
95
+ } else {
96
+ const target = args.find(a => !a.startsWith('--'));
97
+ if (!target) { printCliUsage(); return; }
98
+ input = isStyleTarget(target) ? { files: [target] } : { projectRoot: target };
99
+ }
100
+
101
+ function runAnalysis() {
102
+ const report = checkStyleIntegrity(input, config);
103
+ console.log(JSON.stringify(report, null, 2));
104
+ if (failOnIssues && !report.clean) process.exitCode = 1;
105
+ }
106
+
107
+ runAnalysis();
108
+
109
+ if (watchMode) {
110
+ const watchTarget = input.projectRoot || (Array.isArray(input.files) ? path.dirname(input.files[0]) : process.cwd());
111
+ console.error(`[stylesafe] Watching ${watchTarget} for changes...`);
112
+ let debounce;
113
+ fs.watch(watchTarget, { recursive: true }, (_, filename) => {
114
+ if (!filename) return;
115
+ const ext = path.extname(filename).toLowerCase();
116
+ if (!['.css', '.scss', '.jsx', '.tsx', '.html', '.vue', '.js', '.ts'].includes(ext)) return;
117
+ clearTimeout(debounce);
118
+ debounce = setTimeout(() => {
119
+ console.error(`[stylesafe] Change detected in ${filename}, re-analyzing...`);
120
+ runAnalysis();
121
+ }, 150);
122
+ });
123
+ }
124
+ }
125
+
126
+ function handleRequest(req) {
127
+ const { id, method, params } = req;
128
+
129
+ if (method === 'initialize') {
130
+ send({ jsonrpc: '2.0', id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'stylesafe', version: '0.1.0' } } });
131
+ return;
132
+ }
133
+
134
+ if (method === 'notifications/initialized') {
135
+ return;
136
+ }
137
+
138
+ if (method === 'tools/list') {
139
+ send({ jsonrpc: '2.0', id, result: { tools: [TOOL_DEFINITION] } });
140
+ return;
141
+ }
142
+
143
+ if (method === 'tools/call') {
144
+ const { name, arguments: args } = params || {};
145
+ if (name !== 'check_style_integrity') {
146
+ send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Unknown tool: ${name}` } });
147
+ return;
148
+ }
149
+ try {
150
+ const files = (args && args.files) || [];
151
+ const projectRoot = (args && args.projectRoot) || null;
152
+ const changedFiles = args && args.changedFiles;
153
+ const report = checkStyleIntegrity(changedFiles ? { changedFiles } : projectRoot ? { projectRoot } : files);
154
+ send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(report, null, 2) }], isError: !report.passed } });
155
+ } catch (err) {
156
+ send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: `Error running analysis: ${err.message}` }], isError: true } });
157
+ }
158
+ return;
159
+ }
160
+
161
+ if (id !== undefined) {
162
+ send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } });
163
+ }
164
+ }
165
+
166
+ if (require.main === module) {
167
+ const args = process.argv.slice(2);
168
+ if (args.length > 0) {
169
+ runCli(args);
170
+ return;
171
+ }
172
+
173
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
174
+ rl.on('line', line => {
175
+ const trimmed = line.trim();
176
+ if (!trimmed) return;
177
+
178
+ let req;
179
+ try {
180
+ req = JSON.parse(trimmed);
181
+ } catch {
182
+ return;
183
+ }
184
+
185
+ handleRequest(req);
186
+ });
187
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Computes CSS specificity for a single selector string as [inlineFlag, idCount, classCount, typeCount].
3
+ * Follows the standard algorithm: IDs > classes/attrs/pseudo-classes > elements/pseudo-elements.
4
+ * Combinators (>, +, ~, space) and universal selector (*) don't add weight.
5
+ */
6
+
7
+ function compareSpecificity(a, b) {
8
+ for (let i = 0; i < 4; i++) {
9
+ if (a[i] !== b[i]) return a[i] - b[i];
10
+ }
11
+ return 0;
12
+ }
13
+
14
+ function specificityToString(spec) {
15
+ return `(${spec[0]},${spec[1]},${spec[2]},${spec[3]})`;
16
+ }
17
+
18
+ function calculateSpecificity(selector) {
19
+ let sel = selector.trim();
20
+ let idCount = 0;
21
+ let classCount = 0;
22
+ let typeCount = 0;
23
+
24
+ // Strip pseudo-element (counts as type): ::before, ::after, etc. (also legacy single-colon :before/:after)
25
+ const pseudoElements = /::?(before|after|first-line|first-letter|placeholder|selection|marker)\b/gi;
26
+ sel = sel.replace(pseudoElements, () => {
27
+ typeCount++;
28
+ return '';
29
+ });
30
+
31
+ // :not(), :is(), :where() — :where() contributes 0, :not()/:is() contribute the specificity of their most specific argument.
32
+ // Simplified: treat :not(...) and :is(...) contents as normal selectors (recurse), :where(...) strip entirely with 0 weight.
33
+ sel = sel.replace(/:where\([^)]*\)/gi, '');
34
+ sel = sel.replace(/:(not|is|has)\(([^)]*)\)/gi, (_, _name, inner) => ` ${inner} `);
35
+
36
+ // IDs
37
+ sel = sel.replace(/#[\w-]+/g, () => { idCount++; return ''; });
38
+
39
+ // Classes, attribute selectors, pseudo-classes (excluding pseudo-elements already removed)
40
+ sel = sel.replace(/\.[\w-]+/g, () => { classCount++; return ''; });
41
+ sel = sel.replace(/\[[^\]]*\]/g, () => { classCount++; return ''; });
42
+ sel = sel.replace(/:[\w-]+/g, () => { classCount++; return ''; });
43
+
44
+ // Remaining type selectors (element names) and universal selector
45
+ const typeMatches = sel.match(/(^|[\s>+~])([a-zA-Z][\w-]*)/g);
46
+ if (typeMatches) {
47
+ for (const m of typeMatches) {
48
+ const tag = m.replace(/^[\s>+~]+/, '');
49
+ if (tag && tag !== '*') typeCount++;
50
+ }
51
+ }
52
+
53
+ return [0, idCount, classCount, typeCount];
54
+ }
55
+
56
+ module.exports = { calculateSpecificity, compareSpecificity, specificityToString };
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Detects conflicting Tailwind utility classes within the same class list —
3
+ * e.g. "flex hidden" (both control display), "p-4 p-8" (both control all-side padding),
4
+ * "text-left text-right" (both control text-align).
5
+ *
6
+ * This is a deliberately curated table, not a full Tailwind config parser. It covers the
7
+ * utility families that are most commonly involved in agent-introduced bugs: layout/display,
8
+ * spacing, sizing, typography, flex/grid alignment, color. Extend CATEGORIES as needed.
9
+ */
10
+
11
+ // Each utility definition includes the CSS property set it writes. This allows the
12
+ // analyzer to detect real Tailwind conflicts such as p-4 vs px-8 while allowing
13
+ // non-conflicting combos like px-3 and py-1.
14
+ const UTILITY_DEFINITIONS = [
15
+ { name: 'display', pattern: /^(block|inline-block|inline|flex|inline-flex|grid|inline-grid|hidden|table|contents|flow-root)$/, properties: ['display'] },
16
+ { name: 'position', pattern: /^(static|fixed|absolute|relative|sticky)$/, properties: ['position'] },
17
+ { name: 'padding-all', pattern: /^p-(\d+|px|\[.+\]|auto)$/, properties: ['padding-top', 'padding-right', 'padding-bottom', 'padding-left'] },
18
+ { name: 'padding-x', pattern: /^px-(\d+|px|\[.+\]|auto)$/, properties: ['padding-left', 'padding-right'] },
19
+ { name: 'padding-y', pattern: /^py-(\d+|px|\[.+\]|auto)$/, properties: ['padding-top', 'padding-bottom'] },
20
+ { name: 'padding-top', pattern: /^pt-(\d+|px|\[.+\]|auto)$/, properties: ['padding-top'] },
21
+ { name: 'padding-right', pattern: /^pr-(\d+|px|\[.+\]|auto)$/, properties: ['padding-right'] },
22
+ { name: 'padding-bottom', pattern: /^pb-(\d+|px|\[.+\]|auto)$/, properties: ['padding-bottom'] },
23
+ { name: 'padding-left', pattern: /^pl-(\d+|px|\[.+\]|auto)$/, properties: ['padding-left'] },
24
+ { name: 'margin-all', pattern: /^m-(\d+|px|\[.+\]|auto)$/, properties: ['margin-top', 'margin-right', 'margin-bottom', 'margin-left'] },
25
+ { name: 'margin-x', pattern: /^mx-(\d+|px|\[.+\]|auto)$/, properties: ['margin-left', 'margin-right'] },
26
+ { name: 'margin-y', pattern: /^my-(\d+|px|\[.+\]|auto)$/, properties: ['margin-top', 'margin-bottom'] },
27
+ { name: 'margin-top', pattern: /^mt-(\d+|px|\[.+\]|auto)$/, properties: ['margin-top'] },
28
+ { name: 'margin-right', pattern: /^mr-(\d+|px|\[.+\]|auto)$/, properties: ['margin-right'] },
29
+ { name: 'margin-bottom', pattern: /^mb-(\d+|px|\[.+\]|auto)$/, properties: ['margin-bottom'] },
30
+ { name: 'margin-left', pattern: /^ml-(\d+|px|\[.+\]|auto)$/, properties: ['margin-left'] },
31
+ { name: 'width', pattern: /^w-(\d+|px|full|screen|auto|fit|min|max|\[.+\]|\d+\/\d+)$/, properties: ['width'] },
32
+ { name: 'height', pattern: /^h-(\d+|px|full|screen|auto|fit|min|max|\[.+\]|\d+\/\d+)$/, properties: ['height'] },
33
+ { name: 'text-align', pattern: /^text-(left|center|right|justify|start|end)$/, properties: ['text-align'] },
34
+ { name: 'font-weight', pattern: /^font-(thin|extralight|light|normal|medium|semibold|bold|extrabold|black)$/, properties: ['font-weight'] },
35
+ { name: 'font-size', pattern: /^text-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)$/, properties: ['font-size'] },
36
+ { name: 'text-color', pattern: /^text-(?:\[(?:.+)\]|(?:red|blue|green|yellow|purple|pink|gray|grey|indigo|black|white|orange|teal|cyan|slate|zinc|neutral|stone|amber|lime|emerald|sky|violet|fuchsia|rose)(?:-?\d{0,3})?)$/, properties: ['color'] },
37
+ { name: 'background-color', pattern: /^bg-(?:\[(?:.+)\]|(?:red|blue|green|yellow|purple|pink|gray|grey|indigo|black|white|orange|teal|cyan|slate|zinc|neutral|stone|amber|lime|emerald|sky|violet|fuchsia|rose)(?:-?\d{0,3})?)$/, properties: ['background-color'] },
38
+ { name: 'flex-direction', pattern: /^flex-(row|row-reverse|col|col-reverse)$/, properties: ['flex-direction'] },
39
+ { name: 'justify-content', pattern: /^justify-(start|end|center|between|around|evenly)$/, properties: ['justify-content'] },
40
+ { name: 'align-items', pattern: /^items-(start|end|center|baseline|stretch)$/, properties: ['align-items'] },
41
+ { name: 'border-radius', pattern: /^rounded(-\w+)?(-\d+)?$/, properties: ['border-radius'] },
42
+ { name: 'overflow', pattern: /^overflow-(auto|hidden|visible|scroll|x-auto|x-hidden|y-auto|y-hidden)$/, properties: ['overflow'] },
43
+ { name: 'float', pattern: /^float-(left|right|none)$/, properties: ['float'] },
44
+ { name: 'shadow', pattern: /^shadow(-[a-z]+)?$/, properties: ['box-shadow'] },
45
+ { name: 'opacity', pattern: /^opacity-(0|25|50|75|100)$/, properties: ['opacity'] },
46
+ { name: 'gap', pattern: /^gap-(\d+|px|\[.+\]|auto)$/, properties: ['gap'] },
47
+ { name: 'gap-x', pattern: /^gap-x-(\d+|px|\[.+\]|auto)$/, properties: ['column-gap'] },
48
+ { name: 'gap-y', pattern: /^gap-y-(\d+|px|\[.+\]|auto)$/, properties: ['row-gap'] },
49
+ { name: 'border', pattern: /^border(?:-(\d+|px|solid|dashed|dotted|double|none))?$/, properties: ['border-top', 'border-right', 'border-bottom', 'border-left'] },
50
+ { name: 'border-x', pattern: /^border-x(?:-(\d+|px|solid|dashed|dotted|double|none))?$/, properties: ['border-left', 'border-right'] },
51
+ { name: 'border-y', pattern: /^border-y(?:-(\d+|px|solid|dashed|dotted|double|none))?$/, properties: ['border-top', 'border-bottom'] },
52
+ { name: 'border-top', pattern: /^border-t(?:-(\d+|px|solid|dashed|dotted|double|none))?$/, properties: ['border-top'] },
53
+ { name: 'border-right', pattern: /^border-r(?:-(\d+|px|solid|dashed|dotted|double|none))?$/, properties: ['border-right'] },
54
+ { name: 'border-bottom', pattern: /^border-b(?:-(\d+|px|solid|dashed|dotted|double|none))?$/, properties: ['border-bottom'] },
55
+ { name: 'border-left', pattern: /^border-l(?:-(\d+|px|solid|dashed|dotted|double|none))?$/, properties: ['border-left'] },
56
+ { name: 'ring', pattern: /^ring(?:-(\d+|px|offset|inset|white|black|red|blue|green|yellow|purple|pink|gray|slate|zinc|neutral|stone|amber|emerald|teal|cyan|sky|violet|fuchsia|rose)(?:-?\d{0,3})?)?$/, properties: ['box-shadow'] },
57
+ { name: 'ring-offset', pattern: /^ring-offset(?:-(\d+|px|white|black|red|blue|green|yellow|purple|pink|gray|slate|zinc|neutral|stone|amber|emerald|teal|cyan|sky|violet|fuchsia|rose)(?:-?\d{0,3})?)?$/, properties: ['box-shadow'] },
58
+ { name: 'text-decoration', pattern: /^(underline|line-through|no-underline)$/, properties: ['text-decoration'] },
59
+ ];
60
+
61
+ function splitVariant(cls) {
62
+ const parts = cls.split(':');
63
+ const base = parts.pop();
64
+ const scope = parts.join(':') || '__base__';
65
+ return { scope, base };
66
+ }
67
+
68
+ function normalizeBaseClass(baseClass) {
69
+ return baseClass.replace(/^(?:[A-Za-z0-9_-]+:)+/, '');
70
+ }
71
+
72
+ function categorize(baseClass) {
73
+ const normalized = normalizeBaseClass(baseClass);
74
+ for (const def of UTILITY_DEFINITIONS) {
75
+ if (def.pattern.test(normalized)) return { category: def.name, properties: def.properties };
76
+ }
77
+ return null;
78
+ }
79
+
80
+ function detectTailwindConflicts(classString, context = '') {
81
+ const classes = classString.split(/\s+/).filter(Boolean);
82
+ const byScope = new Map();
83
+
84
+ for (const cls of classes) {
85
+ const { scope, base } = splitVariant(cls);
86
+ const info = categorize(base);
87
+ if (!info) continue;
88
+ if (!byScope.has(scope)) {
89
+ byScope.set(scope, { propertyMap: new Map(), classMeta: new Map() });
90
+ }
91
+
92
+ const scopeData = byScope.get(scope);
93
+ scopeData.classMeta.set(cls, info.category);
94
+
95
+ for (const property of info.properties) {
96
+ if (!scopeData.propertyMap.has(property)) scopeData.propertyMap.set(property, []);
97
+ scopeData.propertyMap.get(property).push(cls);
98
+ }
99
+ }
100
+
101
+ const conflicts = [];
102
+ for (const [scope, scopeData] of byScope) {
103
+ const groupedConflicts = new Map();
104
+
105
+ for (const clsList of scopeData.propertyMap.values()) {
106
+ if (clsList.length <= 1) continue;
107
+ const uniqueClasses = Array.from(new Set(clsList)).sort();
108
+ const key = uniqueClasses.join('|');
109
+ const conflict = groupedConflicts.get(key) || { classes: uniqueClasses, categories: new Set() };
110
+ uniqueClasses.forEach(cls => conflict.categories.add(scopeData.classMeta.get(cls)));
111
+ groupedConflicts.set(key, conflict);
112
+ }
113
+
114
+ for (const conflict of groupedConflicts.values()) {
115
+ const rawCategory = [...conflict.categories][0];
116
+ const category = rawCategory.replace(/-(all|x|y|top|right|bottom|left)$/, '');
117
+ conflicts.push({
118
+ type: 'tailwind-conflict',
119
+ severity: 'warning',
120
+ scope: scope === '__base__' ? '(no variant)' : scope,
121
+ category,
122
+ classes: conflict.classes,
123
+ context,
124
+ message: `Conflicting "${category}" utilities in the same scope${scope !== '__base__' ? ` (${scope}:)` : ''}: ${conflict.classes.join(', ')}. The later class in the source order usually wins, so this may be unintended.`,
125
+ });
126
+ }
127
+ }
128
+
129
+ return conflicts;
130
+ }
131
+
132
+ /**
133
+ * Pulls class strings out of JSX/HTML/template files for Tailwind conflict analysis.
134
+ * Handles:
135
+ * - className="..." and class="..."
136
+ * - className={`...`} template literals
137
+ * - cn('...', '...'), clsx('...'), twMerge('...') call expressions
138
+ * - Object keys in cn({ 'class-name': condition }) syntax
139
+ */
140
+ function extractClassStringsFromMarkup(source) {
141
+ const results = [];
142
+
143
+ // 1. Simple string and template-literal className/class attributes
144
+ const attrPattern = /(?:className|class)\s*=\s*(?:"([^"]*)"|'([^']*)'|\{`([^`]*)`\})/g;
145
+ let match;
146
+ while ((match = attrPattern.exec(source)) !== null) {
147
+ const raw = match[1] ?? match[2] ?? match[3] ?? '';
148
+ const cleaned = raw.replace(/\$\{[^}]*\}/g, '').trim();
149
+ if (cleaned) results.push(cleaned);
150
+ }
151
+
152
+ // 2. cn(...), clsx(...), twMerge(...), tw(...) call expressions
153
+ // Extract all string literal arguments and object-syntax string keys.
154
+ const callPattern = /\b(?:cn|clsx|twMerge|tw|classNames)\s*\(([^)]{0,800})\)/g;
155
+ while ((match = callPattern.exec(source)) !== null) {
156
+ const args = match[1];
157
+ // String literal args: 'foo bar' or "foo bar"
158
+ const stringArgs = /(?:"|')([^"']+)(?:"|')/g;
159
+ let strMatch;
160
+ const collected = [];
161
+ while ((strMatch = stringArgs.exec(args)) !== null) {
162
+ collected.push(strMatch[1].trim());
163
+ }
164
+ if (collected.length > 0) {
165
+ // Join all string args into one class list for conflict detection
166
+ results.push(collected.join(' '));
167
+ }
168
+ }
169
+
170
+ return results;
171
+ }
172
+
173
+ module.exports = { detectTailwindConflicts, extractClassStringsFromMarkup };