@controlfront/detect 0.0.1
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/bin/cfb.js +202 -0
- package/package.json +64 -0
- package/src/commands/baseline.js +198 -0
- package/src/commands/init.js +309 -0
- package/src/commands/login.js +71 -0
- package/src/commands/logout.js +44 -0
- package/src/commands/scan.js +1547 -0
- package/src/commands/snapshot.js +191 -0
- package/src/commands/sync.js +127 -0
- package/src/config/baseUrl.js +49 -0
- package/src/data/tailwind-core-spec.js +149 -0
- package/src/engine/runRules.js +210 -0
- package/src/lib/collectDeclaredTokensAuto.js +67 -0
- package/src/lib/collectTokenMatches.js +330 -0
- package/src/lib/collectTokenMatches.js.regex +252 -0
- package/src/lib/loadRules.js +73 -0
- package/src/rules/core/no-hardcoded-colors.js +28 -0
- package/src/rules/core/no-hardcoded-spacing.js +29 -0
- package/src/rules/core/no-inline-styles.js +28 -0
- package/src/utils/authorId.js +106 -0
- package/src/utils/buildAIContributions.js +224 -0
- package/src/utils/buildBlameData.js +388 -0
- package/src/utils/buildDeclaredCssVars.js +185 -0
- package/src/utils/buildDeclaredJson.js +214 -0
- package/src/utils/buildFileChanges.js +372 -0
- package/src/utils/buildRuntimeUsage.js +337 -0
- package/src/utils/detectDeclaredDrift.js +59 -0
- package/src/utils/extractImports.js +178 -0
- package/src/utils/fileExtensions.js +65 -0
- package/src/utils/generateInsights.js +332 -0
- package/src/utils/getAllFiles.js +63 -0
- package/src/utils/getCommitMetaData.js +102 -0
- package/src/utils/getLine.js +14 -0
- package/src/utils/resolveProjectForFolder/index.js +47 -0
- package/src/utils/twClassify.js +138 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
4
|
+
import { createGetLine } from "../utils/getLine.js";
|
|
5
|
+
|
|
6
|
+
// Resolve paths relative to the installed package location, not CWD
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const PKG_ROOT = path.resolve(__dirname, ".."); // engine/.. = package src root (or dist root)
|
|
9
|
+
|
|
10
|
+
// Built-in rules shipped with the package
|
|
11
|
+
const coreRulesDir = path.join(PKG_ROOT, "rules", "core");
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
const ruleSet = [];
|
|
15
|
+
|
|
16
|
+
async function loadRulesFromDir(dir) {
|
|
17
|
+
if (!fs.existsSync(dir)) return [];
|
|
18
|
+
const files = fs.readdirSync(dir).filter(
|
|
19
|
+
f => /\.(?:m?js|cjs)$/.test(f) && !/^index\.(?:m?js|cjs)$/.test(f)
|
|
20
|
+
);
|
|
21
|
+
const loaded = await Promise.all(
|
|
22
|
+
files.map(async file => {
|
|
23
|
+
const rulePath = path.join(dir, file);
|
|
24
|
+
const mod = await import(pathToFileURL(rulePath).href);
|
|
25
|
+
// Accept: export const rule = {...}, export default {...}, CJS default/shape, or an array of rules
|
|
26
|
+
const candidate =
|
|
27
|
+
mod.rule ||
|
|
28
|
+
(mod.default && (mod.default.rule || mod.default)) ||
|
|
29
|
+
mod.default ||
|
|
30
|
+
mod;
|
|
31
|
+
|
|
32
|
+
const asArray = Array.isArray(candidate)
|
|
33
|
+
? candidate
|
|
34
|
+
: Array.isArray(candidate?.rules)
|
|
35
|
+
? candidate.rules
|
|
36
|
+
: null;
|
|
37
|
+
|
|
38
|
+
const toValidate = asArray ?? [candidate?.rule ?? candidate];
|
|
39
|
+
|
|
40
|
+
const valid = toValidate.filter(
|
|
41
|
+
r => r && typeof r.evaluate === "function" && r.meta && typeof r.meta.slug === "string"
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
if (valid.length === 0) {
|
|
45
|
+
// Skip non-rule files (e.g., index.js barrels)
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
// Ensure each rule has meta fields and annotate origin for debugging
|
|
49
|
+
const withMeta = valid.map((r) => {
|
|
50
|
+
if (!r.meta) r.meta = {};
|
|
51
|
+
if (!r.meta.slug) {
|
|
52
|
+
const base = path.basename(file, path.extname(file));
|
|
53
|
+
r.meta.slug = base;
|
|
54
|
+
}
|
|
55
|
+
if (!r.meta.name) {
|
|
56
|
+
r.meta.name = r.meta.slug;
|
|
57
|
+
}
|
|
58
|
+
if (!r.meta.severity) {
|
|
59
|
+
r.meta.severity = "error"; // default
|
|
60
|
+
}
|
|
61
|
+
r.__origin = rulePath;
|
|
62
|
+
return r;
|
|
63
|
+
});
|
|
64
|
+
return withMeta;
|
|
65
|
+
})
|
|
66
|
+
);
|
|
67
|
+
return loaded.flat().filter(Boolean);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function loadAllRules() {
|
|
71
|
+
const rules = await loadRulesFromDir(coreRulesDir);
|
|
72
|
+
ruleSet.length = 0;
|
|
73
|
+
ruleSet.push(...rules);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function getCoreRules() {
|
|
77
|
+
// If someone imports this module before top-level await runs (edge case), ensure rules are loaded
|
|
78
|
+
if (!ruleSet || ruleSet.length === 0) {
|
|
79
|
+
await loadAllRules();
|
|
80
|
+
}
|
|
81
|
+
// Return a shallow copy to avoid accidental mutation by callers
|
|
82
|
+
return [...ruleSet];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildStyleFocusedContent(filePath, content) {
|
|
86
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
87
|
+
const templateLike = new Set([".vue", ".svelte", ".astro", ".html", ".htm", ".mdx"]);
|
|
88
|
+
if (!templateLike.has(ext)) return content;
|
|
89
|
+
|
|
90
|
+
const lines = content.split("\n");
|
|
91
|
+
let inStyle = false;
|
|
92
|
+
const out = new Array(lines.length);
|
|
93
|
+
|
|
94
|
+
for (let i = 0; i < lines.length; i++) {
|
|
95
|
+
const line = lines[i];
|
|
96
|
+
const startIdx = line.indexOf("<style");
|
|
97
|
+
const endIdx = line.indexOf("</style>");
|
|
98
|
+
|
|
99
|
+
// Entering a <style> block (can be same line)
|
|
100
|
+
if (startIdx !== -1) inStyle = true;
|
|
101
|
+
|
|
102
|
+
const keep = inStyle || /style\s*=/.test(line);
|
|
103
|
+
|
|
104
|
+
out[i] = keep ? line : "";
|
|
105
|
+
|
|
106
|
+
// Exiting a </style> block (can be same line)
|
|
107
|
+
if (endIdx !== -1) inStyle = false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return out.join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function ensureCIShape(v, rule, file, getLine, getContextForLine) {
|
|
114
|
+
const line =
|
|
115
|
+
v.line != null
|
|
116
|
+
? v.line
|
|
117
|
+
: getLine(typeof v.index === "number" ? v.index : 0);
|
|
118
|
+
const severity =
|
|
119
|
+
v.severity ||
|
|
120
|
+
(v.ctx && v.ctx.config && v.ctx.config.severity) ||
|
|
121
|
+
rule.meta?.severity ||
|
|
122
|
+
"error";
|
|
123
|
+
const codeContext =
|
|
124
|
+
v.codeContext != null ? v.codeContext : getContextForLine(line);
|
|
125
|
+
return {
|
|
126
|
+
rule: v.rule || rule.meta?.slug,
|
|
127
|
+
message: v.message || "",
|
|
128
|
+
severity,
|
|
129
|
+
file: v.file || file,
|
|
130
|
+
line,
|
|
131
|
+
codeContext,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function runRules(filePaths, config) {
|
|
136
|
+
if (config && Array.isArray(config.ruleDirs) && config.ruleDirs.length > 0) {
|
|
137
|
+
const loadedRulesArrays = await Promise.all(
|
|
138
|
+
config.ruleDirs.map(dir => loadRulesFromDir(dir))
|
|
139
|
+
);
|
|
140
|
+
ruleSet.length = 0;
|
|
141
|
+
for (const arr of loadedRulesArrays) {
|
|
142
|
+
ruleSet.push(...arr);
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
if (!ruleSet || ruleSet.length === 0) {
|
|
146
|
+
await loadAllRules();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const allViolations = [];
|
|
150
|
+
const appliedRules = [];
|
|
151
|
+
|
|
152
|
+
for (const filePath of filePaths) {
|
|
153
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
154
|
+
const lines = content.split("\n");
|
|
155
|
+
const effectiveContent = buildStyleFocusedContent(filePath, content);
|
|
156
|
+
|
|
157
|
+
const getLine = createGetLine(content);
|
|
158
|
+
|
|
159
|
+
const getContextForLine = (lineNumber) => {
|
|
160
|
+
const windowLines = [
|
|
161
|
+
lines[lineNumber - 3] || "",
|
|
162
|
+
lines[lineNumber - 2] || "",
|
|
163
|
+
lines[lineNumber - 1] || "",
|
|
164
|
+
lines[lineNumber] || "",
|
|
165
|
+
lines[lineNumber + 1] || ""
|
|
166
|
+
];
|
|
167
|
+
return windowLines.map(line => line.trimEnd()).join("\n").trim();
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
for (const rule of ruleSet) {
|
|
171
|
+
const bucket = [];
|
|
172
|
+
const relativePath = path.relative(process.cwd(), filePath).replace(/\\/g, "/");
|
|
173
|
+
const ctx = {
|
|
174
|
+
config,
|
|
175
|
+
report(v) {
|
|
176
|
+
bucket.push(ensureCIShape(v, rule, relativePath, getLine, getContextForLine));
|
|
177
|
+
},
|
|
178
|
+
getLine,
|
|
179
|
+
};
|
|
180
|
+
if (typeof rule.evaluate === "function") {
|
|
181
|
+
const res = rule.evaluate(relativePath, effectiveContent, ctx);
|
|
182
|
+
if (res && typeof res.then === "function") {
|
|
183
|
+
// eslint-disable-next-line no-await-in-loop
|
|
184
|
+
await res; // allow async evaluate()
|
|
185
|
+
} else if (Array.isArray(res)) {
|
|
186
|
+
for (const v of res) ctx.report(v);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
allViolations.push(...bucket);
|
|
190
|
+
|
|
191
|
+
appliedRules.push({
|
|
192
|
+
slug: rule.meta?.slug || "unknown-rule",
|
|
193
|
+
name: rule.meta?.name || "Unknown Rule",
|
|
194
|
+
description: rule.meta?.description || "",
|
|
195
|
+
severity: rule.meta?.severity || "unspecified",
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const uniqueAppliedRules = [];
|
|
201
|
+
const seenSlugs = new Set();
|
|
202
|
+
for (const rule of appliedRules) {
|
|
203
|
+
if (!seenSlugs.has(rule.slug)) {
|
|
204
|
+
seenSlugs.add(rule.slug);
|
|
205
|
+
uniqueAppliedRules.push(rule);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { violations: allViolations, appliedRules: uniqueAppliedRules };
|
|
210
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Recursively searches the project for JSON files that appear to contain declared design tokens
|
|
6
|
+
* (based on the presence of $value, value, $type, or type fields).
|
|
7
|
+
* Returns a merged object of all detected token files keyed by relative file path.
|
|
8
|
+
*/
|
|
9
|
+
function looksLikeTokenFile(json) {
|
|
10
|
+
if (!json || typeof json !== "object") return false;
|
|
11
|
+
const str = JSON.stringify(json);
|
|
12
|
+
return (
|
|
13
|
+
str.includes('"$value"') ||
|
|
14
|
+
str.includes('"value"') ||
|
|
15
|
+
str.includes('"$type"') ||
|
|
16
|
+
str.includes('"type"')
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function collectDeclaredTokensAuto(projectRoot) {
|
|
21
|
+
const declaredFiles = [];
|
|
22
|
+
|
|
23
|
+
function walk(dir) {
|
|
24
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
25
|
+
for (const e of entries) {
|
|
26
|
+
const fullPath = path.join(dir, e.name);
|
|
27
|
+
if (e.isDirectory()) {
|
|
28
|
+
if (e.name === "node_modules" || e.name.startsWith(".")) continue;
|
|
29
|
+
walk(fullPath);
|
|
30
|
+
} else if (e.isFile() && e.name.endsWith(".json")) {
|
|
31
|
+
if (
|
|
32
|
+
e.name === "scan-report.json" ||
|
|
33
|
+
e.name === "cf-violations.json" ||
|
|
34
|
+
fullPath.includes(`${path.sep}.cf${path.sep}`)
|
|
35
|
+
) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const raw = fs.readFileSync(fullPath, "utf8");
|
|
40
|
+
const parsed = JSON.parse(raw);
|
|
41
|
+
if (looksLikeTokenFile(parsed)) {
|
|
42
|
+
declaredFiles.push(fullPath);
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// ignore unreadable or invalid JSON files
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
walk(projectRoot);
|
|
52
|
+
|
|
53
|
+
if (declaredFiles.length === 0) return null;
|
|
54
|
+
|
|
55
|
+
const result = {};
|
|
56
|
+
for (const file of declaredFiles) {
|
|
57
|
+
try {
|
|
58
|
+
result[path.relative(projectRoot, file)] = JSON.parse(
|
|
59
|
+
fs.readFileSync(file, "utf8")
|
|
60
|
+
);
|
|
61
|
+
} catch {
|
|
62
|
+
// skip broken files
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import * as babelParser from "@babel/parser";
|
|
2
|
+
import traverseModule from "@babel/traverse";
|
|
3
|
+
import postcss from "postcss";
|
|
4
|
+
import safeParser from "postcss-safe-parser";
|
|
5
|
+
|
|
6
|
+
const traverse = traverseModule.default;
|
|
7
|
+
|
|
8
|
+
function isCssVarUsage(value) {
|
|
9
|
+
return value.includes("var(--");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function extractCssVars(value) {
|
|
13
|
+
return (value.match(/var\((--[a-zA-Z0-9-_]+)\)/g) || []);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isColorValue(value) {
|
|
17
|
+
return /#([0-9a-f]{3,8})\b|oklch\(|hsl\(|rgb\(/i.test(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isLengthValue(value) {
|
|
21
|
+
return /\b\d+(px|rem|em|%)\b/.test(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function extractClassesFromNode(node, filePath) {
|
|
25
|
+
if (!node) return [];
|
|
26
|
+
|
|
27
|
+
switch (node.type) {
|
|
28
|
+
case "StringLiteral":
|
|
29
|
+
return node.value.split(/\s+/).filter(Boolean);
|
|
30
|
+
|
|
31
|
+
case "TemplateLiteral":
|
|
32
|
+
{
|
|
33
|
+
let combined = "";
|
|
34
|
+
for (let i = 0; i < node.quasis.length; i++) {
|
|
35
|
+
combined += node.quasis[i].value.cooked;
|
|
36
|
+
if (i < node.expressions.length) {
|
|
37
|
+
combined += "${expr}";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return combined.split(/\s+/).filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
case "ArrayExpression":
|
|
44
|
+
{
|
|
45
|
+
return node.elements.flatMap((el) => extractClassesFromNode(el, filePath));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
case "ObjectExpression":
|
|
49
|
+
{
|
|
50
|
+
const classes = [];
|
|
51
|
+
node.properties.forEach((prop) => {
|
|
52
|
+
if (prop.key) {
|
|
53
|
+
const line = prop.loc?.start.line || node.loc?.start.line || null;
|
|
54
|
+
if (prop.key.type === "StringLiteral") {
|
|
55
|
+
const keyValue = prop.key.value;
|
|
56
|
+
console.log("DEBUG cn() object key encountered:", keyValue, "at", filePath);
|
|
57
|
+
if (
|
|
58
|
+
(prop.value.type === "BooleanLiteral" && prop.value.value === true) ||
|
|
59
|
+
prop.value.type === "Identifier"
|
|
60
|
+
) {
|
|
61
|
+
classes.push(...keyValue.split(/\s+/).filter(Boolean));
|
|
62
|
+
}
|
|
63
|
+
} else if (prop.key.type === "TemplateLiteral") {
|
|
64
|
+
const text = prop.key.quasis.map(q => q.value.cooked).join("");
|
|
65
|
+
console.log("DEBUG cn() object key encountered:", text, "at", filePath);
|
|
66
|
+
classes.push(...text.split(/\s+/).filter(Boolean));
|
|
67
|
+
} else if (prop.key.type === "Identifier") {
|
|
68
|
+
const keyValue = prop.key.name;
|
|
69
|
+
console.log("DEBUG cn() object key encountered:", keyValue, "at", filePath);
|
|
70
|
+
classes.push(...keyValue.split(/\s+/).filter(Boolean));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
return classes;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
case "Identifier":
|
|
78
|
+
return [node.name];
|
|
79
|
+
|
|
80
|
+
case "ConditionalExpression":
|
|
81
|
+
return [
|
|
82
|
+
...extractClassesFromNode(node.consequent, filePath),
|
|
83
|
+
...extractClassesFromNode(node.alternate, filePath),
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
default:
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function collectTokenMatches(filePath, content) {
|
|
92
|
+
const matches = [];
|
|
93
|
+
console.log("DEBUG collectTokenMatches running for", filePath);
|
|
94
|
+
|
|
95
|
+
//
|
|
96
|
+
// --- AST: Tailwind class detection (cn, classnames, clsx)
|
|
97
|
+
//
|
|
98
|
+
if (filePath.endsWith(".js") || filePath.endsWith(".jsx") || filePath.endsWith(".ts") || filePath.endsWith(".tsx")) {
|
|
99
|
+
try {
|
|
100
|
+
const ast = babelParser.parse(content, {
|
|
101
|
+
sourceType: "module",
|
|
102
|
+
plugins: ["jsx", "typescript"],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
traverse(ast, {
|
|
106
|
+
CallExpression(path) {
|
|
107
|
+
const callee = path.node.callee;
|
|
108
|
+
if (
|
|
109
|
+
callee.type === "Identifier" &&
|
|
110
|
+
["cn", "classnames", "clsx"].includes(callee.name)
|
|
111
|
+
) {
|
|
112
|
+
path.node.arguments.forEach((arg) => {
|
|
113
|
+
const line = arg.loc?.start.line || path.node.loc?.start.line || null;
|
|
114
|
+
extractClassesFromNode(arg, filePath).forEach((cls) => {
|
|
115
|
+
if (cls) {
|
|
116
|
+
const normalized = cls; // (normalization would happen later)
|
|
117
|
+
console.log("DEBUG normalizeToken input:", cls, "=>", normalized);
|
|
118
|
+
matches.push({
|
|
119
|
+
file: filePath,
|
|
120
|
+
raw: cls,
|
|
121
|
+
value: normalized,
|
|
122
|
+
category: "tailwind-class",
|
|
123
|
+
source: "inferred",
|
|
124
|
+
line,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
JSXAttribute(path) {
|
|
132
|
+
if (path.node.name.name === "className") {
|
|
133
|
+
const value = path.node.value;
|
|
134
|
+
if (!value) return;
|
|
135
|
+
if (value.type === "StringLiteral") {
|
|
136
|
+
const line = value.loc?.start.line || path.node.loc?.start.line || null;
|
|
137
|
+
extractClassesFromNode(value, filePath).forEach((cls) => {
|
|
138
|
+
if (cls) {
|
|
139
|
+
const normalized = cls; // (normalization would happen later)
|
|
140
|
+
console.log("DEBUG normalizeToken input:", cls, "=>", normalized);
|
|
141
|
+
matches.push({
|
|
142
|
+
file: filePath,
|
|
143
|
+
raw: cls,
|
|
144
|
+
value: normalized,
|
|
145
|
+
category: "tailwind-class",
|
|
146
|
+
source: "inferred",
|
|
147
|
+
line,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
} else if (value.type === "JSXExpressionContainer") {
|
|
152
|
+
const expr = value.expression;
|
|
153
|
+
if (!expr) return;
|
|
154
|
+
const line = expr.loc?.start.line || path.node.loc?.start.line || null;
|
|
155
|
+
extractClassesFromNode(expr, filePath).forEach((cls) => {
|
|
156
|
+
if (cls) {
|
|
157
|
+
const normalized = cls; // (normalization would happen later)
|
|
158
|
+
console.log("DEBUG normalizeToken input:", cls, "=>", normalized);
|
|
159
|
+
matches.push({
|
|
160
|
+
file: filePath,
|
|
161
|
+
raw: cls,
|
|
162
|
+
value: normalized,
|
|
163
|
+
category: "tailwind-class",
|
|
164
|
+
source: "inferred",
|
|
165
|
+
line,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
TaggedTemplateExpression(path) {
|
|
173
|
+
const tag = path.node.tag;
|
|
174
|
+
|
|
175
|
+
// Check if tag is styled.* or styled(...) or css
|
|
176
|
+
let isStyled = false;
|
|
177
|
+
if (tag.type === "MemberExpression" && tag.object.name === "styled") {
|
|
178
|
+
isStyled = true;
|
|
179
|
+
} else if (tag.type === "CallExpression" && tag.callee.name === "styled") {
|
|
180
|
+
isStyled = true;
|
|
181
|
+
} else if (tag.type === "Identifier" && tag.name === "css") {
|
|
182
|
+
isStyled = true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!isStyled) return;
|
|
186
|
+
|
|
187
|
+
const quasi = path.node.quasi;
|
|
188
|
+
quasi.quasis.forEach((templateElement) => {
|
|
189
|
+
const cssText = templateElement.value.cooked;
|
|
190
|
+
if (!cssText) return;
|
|
191
|
+
|
|
192
|
+
const line = templateElement.loc?.start.line || path.node.loc?.start.line || null;
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const root = postcss().process(cssText, { parser: safeParser }).root;
|
|
196
|
+
|
|
197
|
+
root.walkDecls((decl) => {
|
|
198
|
+
// CSS variable declarations
|
|
199
|
+
if (decl.prop.startsWith("--")) {
|
|
200
|
+
const selector = decl.parent?.selector || null;
|
|
201
|
+
matches.push({
|
|
202
|
+
file: filePath,
|
|
203
|
+
raw: `${decl.prop}: ${decl.value}`,
|
|
204
|
+
value: decl.value,
|
|
205
|
+
category: "css-var-decl",
|
|
206
|
+
source: "explicit",
|
|
207
|
+
line,
|
|
208
|
+
context: selector,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// CSS variable usage
|
|
213
|
+
if (isCssVarUsage(decl.value)) {
|
|
214
|
+
const varMatches = extractCssVars(decl.value);
|
|
215
|
+
varMatches.forEach((vm) => {
|
|
216
|
+
matches.push({
|
|
217
|
+
file: filePath,
|
|
218
|
+
raw: vm,
|
|
219
|
+
value: vm,
|
|
220
|
+
category: "css-var",
|
|
221
|
+
source: "explicit",
|
|
222
|
+
line,
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Hardcoded colors
|
|
228
|
+
if (isColorValue(decl.value)) {
|
|
229
|
+
matches.push({
|
|
230
|
+
file: filePath,
|
|
231
|
+
raw: decl.value,
|
|
232
|
+
value: decl.value,
|
|
233
|
+
category: "color",
|
|
234
|
+
source: "explicit",
|
|
235
|
+
line,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Hardcoded spacing
|
|
240
|
+
if (isLengthValue(decl.value)) {
|
|
241
|
+
matches.push({
|
|
242
|
+
file: filePath,
|
|
243
|
+
raw: decl.value,
|
|
244
|
+
value: decl.value,
|
|
245
|
+
category: "length",
|
|
246
|
+
source: "explicit",
|
|
247
|
+
line,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
} catch (err) {
|
|
252
|
+
console.warn("PostCSS parse failed for styled template in", filePath, err.message);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
} catch (err) {
|
|
258
|
+
console.warn("AST parse failed for", filePath, err.message);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
//
|
|
263
|
+
// --- PostCSS: CSS parsing (variables, colors, spacing)
|
|
264
|
+
//
|
|
265
|
+
if (filePath.endsWith(".css") || filePath.endsWith(".scss")) {
|
|
266
|
+
try {
|
|
267
|
+
const root = postcss().process(content, { parser: safeParser }).root;
|
|
268
|
+
|
|
269
|
+
root.walkDecls((decl) => {
|
|
270
|
+
const line = decl.source?.start?.line || null;
|
|
271
|
+
// CSS variable declarations
|
|
272
|
+
if (decl.prop.startsWith("--")) {
|
|
273
|
+
const selector = decl.parent?.selector || null;
|
|
274
|
+
matches.push({
|
|
275
|
+
file: filePath,
|
|
276
|
+
raw: `${decl.prop}: ${decl.value}`,
|
|
277
|
+
value: decl.value,
|
|
278
|
+
category: "css-var-decl",
|
|
279
|
+
source: "explicit",
|
|
280
|
+
line,
|
|
281
|
+
context: selector,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// CSS variable usage
|
|
286
|
+
if (isCssVarUsage(decl.value)) {
|
|
287
|
+
const varMatches = extractCssVars(decl.value);
|
|
288
|
+
varMatches.forEach((vm) => {
|
|
289
|
+
matches.push({
|
|
290
|
+
file: filePath,
|
|
291
|
+
raw: vm,
|
|
292
|
+
value: vm,
|
|
293
|
+
category: "css-var",
|
|
294
|
+
source: "explicit",
|
|
295
|
+
line,
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Hardcoded colors
|
|
301
|
+
if (isColorValue(decl.value)) {
|
|
302
|
+
matches.push({
|
|
303
|
+
file: filePath,
|
|
304
|
+
raw: decl.value,
|
|
305
|
+
value: decl.value,
|
|
306
|
+
category: "color",
|
|
307
|
+
source: "explicit",
|
|
308
|
+
line,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Hardcoded spacing
|
|
313
|
+
if (isLengthValue(decl.value)) {
|
|
314
|
+
matches.push({
|
|
315
|
+
file: filePath,
|
|
316
|
+
raw: decl.value,
|
|
317
|
+
value: decl.value,
|
|
318
|
+
category: "length",
|
|
319
|
+
source: "explicit",
|
|
320
|
+
line,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
} catch (err) {
|
|
325
|
+
console.warn("PostCSS parse failed for", filePath, err.message);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return matches;
|
|
330
|
+
}
|