@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.
Files changed (35) hide show
  1. package/bin/cfb.js +202 -0
  2. package/package.json +64 -0
  3. package/src/commands/baseline.js +198 -0
  4. package/src/commands/init.js +309 -0
  5. package/src/commands/login.js +71 -0
  6. package/src/commands/logout.js +44 -0
  7. package/src/commands/scan.js +1547 -0
  8. package/src/commands/snapshot.js +191 -0
  9. package/src/commands/sync.js +127 -0
  10. package/src/config/baseUrl.js +49 -0
  11. package/src/data/tailwind-core-spec.js +149 -0
  12. package/src/engine/runRules.js +210 -0
  13. package/src/lib/collectDeclaredTokensAuto.js +67 -0
  14. package/src/lib/collectTokenMatches.js +330 -0
  15. package/src/lib/collectTokenMatches.js.regex +252 -0
  16. package/src/lib/loadRules.js +73 -0
  17. package/src/rules/core/no-hardcoded-colors.js +28 -0
  18. package/src/rules/core/no-hardcoded-spacing.js +29 -0
  19. package/src/rules/core/no-inline-styles.js +28 -0
  20. package/src/utils/authorId.js +106 -0
  21. package/src/utils/buildAIContributions.js +224 -0
  22. package/src/utils/buildBlameData.js +388 -0
  23. package/src/utils/buildDeclaredCssVars.js +185 -0
  24. package/src/utils/buildDeclaredJson.js +214 -0
  25. package/src/utils/buildFileChanges.js +372 -0
  26. package/src/utils/buildRuntimeUsage.js +337 -0
  27. package/src/utils/detectDeclaredDrift.js +59 -0
  28. package/src/utils/extractImports.js +178 -0
  29. package/src/utils/fileExtensions.js +65 -0
  30. package/src/utils/generateInsights.js +332 -0
  31. package/src/utils/getAllFiles.js +63 -0
  32. package/src/utils/getCommitMetaData.js +102 -0
  33. package/src/utils/getLine.js +14 -0
  34. package/src/utils/resolveProjectForFolder/index.js +47 -0
  35. 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
+ }