@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,252 @@
1
+ // Helper: Extracts a balanced block (e.g., for parentheses)
2
+ function extractBalanced(source, startIndex, openChar = '(', closeChar = ')') {
3
+ let depth = 0, started = false;
4
+ for (let i = startIndex; i < source.length; i++) {
5
+ const ch = source[i];
6
+ if (ch === openChar) { depth++; started = true; }
7
+ else if (ch === closeChar) {
8
+ depth--;
9
+ if (depth === 0 && started) return { block: source.slice(startIndex + 1, i), endIndex: i };
10
+ }
11
+ }
12
+ return { block: '', endIndex: startIndex };
13
+ }
14
+
15
+ // Helper: Finds the line number and snippet at a given index in the source
16
+ function lineNumberAtIndex(source, index) {
17
+ let line = 1, lastNL = -1;
18
+ for (let i = 0; i < source.length && i < index; i++) if (source.charCodeAt(i) === 10) { line++; lastNL = i; }
19
+ const lineStart = lastNL + 1;
20
+ const lineEnd = source.indexOf('\n', index) === -1 ? source.length : source.indexOf('\n', index);
21
+ return { line, snippet: source.slice(lineStart, lineEnd).trim().slice(0, 500) };
22
+ }
23
+ /**
24
+ * @typedef {Object} TokenMatch
25
+ * @property {string} file - Path to file where value was found
26
+ * @property {number} line - Line number (1-based)
27
+ * @property {string} raw - Raw value (e.g. "#ff0000", "16px", "var(--brand-primary)")
28
+ * @property {"color"|"length"|"css-var"|"gradient"|"css-var-decl"|"number"|"font-family"|"shadow"|"tailwind-class"} category
29
+ * @property {string} snippet - Line snippet for preview
30
+ * @property {string} [context] - Optional wrapping selector context
31
+ */
32
+
33
+ import fs from "fs";
34
+
35
+ /**
36
+ * Scan files for token-like values.
37
+ * @param {string[]} files
38
+ * @returns {TokenMatch[]}
39
+ */
40
+ export function collectTokenMatches(files) {
41
+ const colorHex = /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;
42
+ const colorFunc = /\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\(\s*[^)]+\)/g;
43
+ const cssVarUse = /var\(\s*--[a-zA-Z0-9\-_]+(?:\s*,\s*[^)]+)?\)/g;
44
+ const cssVarDecl = /--[a-zA-Z0-9\-_]+\s*:\s*[^;]+;/g;
45
+ const gradient = /\b(?:linear-gradient|radial-gradient|conic-gradient)\(\s*[^)]+\)/g;
46
+ const lengthVal = /\b\d*\.?\d+\s*(?:px|rem|em|vh|vw|%|ch|vmin|vmax|cm|mm|in|pt|pc)\b/g;
47
+ const unitlessNumber = /\b\d*\.?\d+\b/g;
48
+ const fontFamily = /font-family\s*:\s*([^;]+);/i;
49
+ const shadow = /\b(?:box-shadow|text-shadow)\s*:\s*([^;]+);/i;
50
+ const tailwindClassAttr = /\b(?:class|className)\s*=\s*(?:(["'])([^"']+)\1|{["']([^"']+)["']})/g;
51
+
52
+ /** @type {TokenMatch[]} */
53
+ const matches = [];
54
+
55
+ const cssFileExts = new Set([".css", ".scss", ".less", ".pcss", ".postcss"]);
56
+
57
+ for (const file of files) {
58
+ let content;
59
+ try {
60
+ content = fs.readFileSync(file, "utf8");
61
+ } catch {
62
+ continue;
63
+ }
64
+
65
+ // Extra pass: capture Tailwind classes inside cn(...) calls robustly
66
+ try {
67
+ const cnCall = /cn\s*\(/g;
68
+ let m;
69
+ while ((m = cnCall.exec(content)) !== null) {
70
+ const { block } = extractBalanced(content, m.index + m[0].length - 1, '(', ')');
71
+ if (!block) continue;
72
+ // Object keys
73
+ const keyRegex = /["'`]([^"'`]+)["'`]\s*:/gs;
74
+ let km;
75
+ while ((km = keyRegex.exec(block)) !== null) {
76
+ km[1].split(/\s+/).forEach(cls => {
77
+ if (cls.trim()) {
78
+ const { line, snippet } = lineNumberAtIndex(content, m.index + km.index);
79
+ matches.push({ file, line, raw: cls.trim(), category: "tailwind-class", snippet });
80
+ }
81
+ });
82
+ }
83
+ // String literal args
84
+ const strRegex = /(['"`])([^'"`]*?)\1(?!\s*:)/gs;
85
+ let sm;
86
+ while ((sm = strRegex.exec(block)) !== null) {
87
+ sm[2].split(/\s+/).forEach(cls => {
88
+ if (cls.trim()) {
89
+ const { line, snippet } = lineNumberAtIndex(content, m.index + sm.index);
90
+ matches.push({ file, line, raw: cls.trim(), category: "tailwind-class", snippet });
91
+ }
92
+ });
93
+ }
94
+ }
95
+ } catch {}
96
+
97
+ const lines = content.split(/\r?\n/);
98
+
99
+ // Determine if file is CSS-like
100
+ const isCssLike = cssFileExts.has(
101
+ file.slice(file.lastIndexOf(".")).toLowerCase()
102
+ );
103
+
104
+ /** @type {string[]} */
105
+ const selectorStack = [];
106
+
107
+ for (let idx = 0; idx < lines.length; idx++) {
108
+ const lineText = lines[idx];
109
+ const line = idx + 1;
110
+ let m;
111
+
112
+ // If CSS-like file, track selector context by parsing lines with '{' and '}'
113
+ if (isCssLike) {
114
+ // Match selectors before '{'
115
+ const selectorMatch = lineText.match(/^\s*([^{]+)\s*{/);
116
+ if (selectorMatch) {
117
+ const selector = selectorMatch[1].trim();
118
+ selectorStack.push(selector);
119
+ }
120
+ // Count closing braces to pop selector context
121
+ // Note: handle multiple '}' on same line
122
+ const closingBraces = (lineText.match(/}/g) || []).length;
123
+ for (let i = 0; i < closingBraces; i++) {
124
+ if (selectorStack.length > 0) {
125
+ selectorStack.pop();
126
+ }
127
+ }
128
+ }
129
+
130
+ const currentContext = selectorStack.length > 0 ? selectorStack[selectorStack.length - 1] : "";
131
+
132
+ // Colors (hex)
133
+ while ((m = colorHex.exec(lineText)) !== null) {
134
+ matches.push({
135
+ file,
136
+ line,
137
+ raw: m[0],
138
+ category: "color",
139
+ snippet: lineText.trim().slice(0, 500),
140
+ });
141
+ }
142
+ // Colors (functions)
143
+ while ((m = colorFunc.exec(lineText)) !== null) {
144
+ matches.push({
145
+ file,
146
+ line,
147
+ raw: m[0],
148
+ category: "color",
149
+ snippet: lineText.trim().slice(0, 500),
150
+ });
151
+ }
152
+
153
+ // CSS var declarations
154
+ while ((m = cssVarDecl.exec(lineText)) !== null) {
155
+ matches.push({
156
+ file,
157
+ line,
158
+ raw: m[0],
159
+ category: "css-var-decl",
160
+ snippet: lineText.trim().slice(0, 500),
161
+ context: currentContext || undefined,
162
+ });
163
+ }
164
+
165
+ // CSS var uses
166
+ while ((m = cssVarUse.exec(lineText)) !== null) {
167
+ matches.push({
168
+ file,
169
+ line,
170
+ raw: m[0],
171
+ category: "css-var",
172
+ snippet: lineText.trim().slice(0, 500),
173
+ });
174
+ }
175
+
176
+ // Gradients
177
+ while ((m = gradient.exec(lineText)) !== null) {
178
+ matches.push({
179
+ file,
180
+ line,
181
+ raw: m[0],
182
+ category: "gradient",
183
+ snippet: lineText.trim().slice(0, 500),
184
+ });
185
+ }
186
+
187
+ // Lengths
188
+ while ((m = lengthVal.exec(lineText)) !== null) {
189
+ matches.push({
190
+ file,
191
+ line,
192
+ raw: m[0],
193
+ category: "length",
194
+ snippet: lineText.trim().slice(0, 500),
195
+ });
196
+ }
197
+
198
+ // Unitless numbers
199
+ while ((m = unitlessNumber.exec(lineText)) !== null) {
200
+ matches.push({
201
+ file,
202
+ line,
203
+ raw: m[0],
204
+ category: "number",
205
+ snippet: lineText.trim().slice(0, 500),
206
+ });
207
+ }
208
+
209
+ // Font-family
210
+ if ((m = fontFamily.exec(lineText)) !== null) {
211
+ matches.push({
212
+ file,
213
+ line,
214
+ raw: m[1].trim(),
215
+ category: "font-family",
216
+ snippet: lineText.trim().slice(0, 500),
217
+ });
218
+ }
219
+
220
+ // Shadows
221
+ if ((m = shadow.exec(lineText)) !== null) {
222
+ matches.push({
223
+ file,
224
+ line,
225
+ raw: m[1].trim(),
226
+ category: "shadow",
227
+ snippet: lineText.trim().slice(0, 500),
228
+ });
229
+ }
230
+
231
+ // Tailwind classes
232
+ while ((m = tailwindClassAttr.exec(lineText)) !== null) {
233
+ const classesStr = m[2] || m[3] || "";
234
+ const classes = classesStr.trim().split(/\s+/);
235
+ for (const cls of classes) {
236
+ matches.push({
237
+ file,
238
+ line,
239
+ raw: cls,
240
+ category: "tailwind-class",
241
+ snippet: lineText.trim().slice(0, 500),
242
+ });
243
+ }
244
+ }
245
+
246
+ // Tailwind classes inside cn({ "..." : condition })
247
+ // (Handled above via robust multi-line cn(...) parser)
248
+ }
249
+ }
250
+
251
+ return matches;
252
+ }
@@ -0,0 +1,73 @@
1
+ // src/lib/loadRules.js
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { createClient } from "@supabase/supabase-js";
5
+
6
+ export async function loadRules(mode = "core") {
7
+ if (mode === "ci") {
8
+ // --- CI: fetch rules from Supabase ---
9
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
10
+ const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
11
+
12
+ if (!supabaseUrl || !supabaseAnonKey) {
13
+ throw new Error("Supabase env vars missing for CI mode");
14
+ }
15
+
16
+ const supabase = createClient(supabaseUrl, supabaseAnonKey);
17
+ const { data, error } = await supabase
18
+ .from("rules")
19
+ .select("slug, name, description, severity, code, default_active")
20
+ .eq("default_active", true);
21
+
22
+ if (error) throw error;
23
+
24
+ return data.map(rule => {
25
+ // turn code string into executable function
26
+ let run;
27
+ try {
28
+ // eslint-disable-next-line no-new-func
29
+ run = new Function("file", "context", rule.code);
30
+ } catch (e) {
31
+ console.error(`❌ Failed to compile rule ${rule.slug}`, e);
32
+ run = () => [];
33
+ }
34
+ return { ...rule, run };
35
+ });
36
+ }
37
+
38
+ // --- Local: load from filesystem ---
39
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../..");
40
+ const dirs = [];
41
+
42
+ if (mode === "core") {
43
+ dirs.push(path.resolve(repoRoot, "src/rules/core"));
44
+ } else if (mode === "dev") {
45
+ dirs.push(path.resolve(repoRoot, "src/rules/dev"));
46
+ } else if (mode === "dev+core" || mode === "core+dev") {
47
+ dirs.push(
48
+ path.resolve(repoRoot, "src/rules/core"),
49
+ path.resolve(repoRoot, "src/rules/dev")
50
+ );
51
+ } else {
52
+ throw new Error(`Unknown rules mode: ${mode}`);
53
+ }
54
+
55
+ const ruleFiles = [];
56
+ for (const dir of dirs) {
57
+ if (fs.existsSync(dir)) {
58
+ const files = fs.readdirSync(dir).filter(f => f.endsWith(".js"));
59
+ for (const f of files) {
60
+ ruleFiles.push({ dir, file: f });
61
+ }
62
+ }
63
+ }
64
+
65
+ const rules = await Promise.all(
66
+ ruleFiles.map(async ({ dir, file }) => {
67
+ const mod = await import(path.join(dir, file));
68
+ return mod.default;
69
+ })
70
+ );
71
+
72
+ return rules;
73
+ }
@@ -0,0 +1,28 @@
1
+ // src/rules/core/no-hardcoded-colors.js (ESM)
2
+ export default {
3
+ meta: {
4
+ slug: "no-hardcoded-colors",
5
+ name: "No Hardcoded Colors",
6
+ description: "Flags hex/rgb/hsl literals; use DS tokens instead.",
7
+ severity: "error",
8
+ },
9
+ files: ["**/*.{js,jsx,ts,tsx,css,scss,less,html}"],
10
+ evaluate(file, content, ctx) {
11
+ const re = /#(?:[0-9a-fA-F]{3}){1,2}\b|rgba?\([^)]*\)|hsla?\([^)]*\)/g;
12
+ let m;
13
+ while ((m = re.exec(content))) {
14
+ const line = content.slice(0, m.index).split("\n").length;
15
+ ctx.report({
16
+ rule: this.meta.slug,
17
+ message: `Hardcoded color ${m[0]} — use a token`,
18
+ severity: (ctx.config && ctx.config.severity) || this.meta.severity || "error",
19
+ file,
20
+ line,
21
+ codeContext: content
22
+ .split("\n")
23
+ .slice(Math.max(0, line - 2), line + 1)
24
+ .join("\n"),
25
+ });
26
+ }
27
+ },
28
+ };
@@ -0,0 +1,29 @@
1
+ export default {
2
+ meta: {
3
+ slug: "no-hardcoded-spacing",
4
+ name: "No Hardcoded Spacing",
5
+ description: "Disallow raw spacing values; use spacing tokens.",
6
+ severity: "error",
7
+ },
8
+ files: ["**/*.{ts,tsx,js,jsx,css,scss,less,html}"],
9
+ evaluate(file, content, ctx) {
10
+ const regex = /(margin(?:Top|Bottom|Left|Right)?|padding(?:Top|Bottom|Left|Right)?|gap|lineHeight)\s*:\s*{?\s*["']?\d+(\.\d+)?(px|em|rem|pt|%|vw|vh|vmin|vmax|ch|ex)?["']?\s*}?/gi;
11
+ const lines = content.split("\n");
12
+
13
+ lines.forEach((lineContent, idx) => {
14
+ for (const match of lineContent.matchAll(regex)) {
15
+ ctx.report({
16
+ rule: this.meta.slug,
17
+ message: `Hardcoded spacing \`${match[0]}\` — use a spacing token`,
18
+ severity: (ctx.config && ctx.config.severity) || this.meta.severity || "error",
19
+ file,
20
+ line: idx + 1,
21
+ codeContext: content
22
+ .split("\n")
23
+ .slice(Math.max(0, idx - 1), idx + 2)
24
+ .join("\n"),
25
+ });
26
+ }
27
+ });
28
+ },
29
+ };
@@ -0,0 +1,28 @@
1
+ // src/rules/core/no-inline-styles.js (ESM)
2
+ export default {
3
+ meta: {
4
+ slug: "no-inline-styles",
5
+ name: "No Inline Styles",
6
+ description: "Prevent inline style attributes in JSX/HTML; use tokens or classes instead.",
7
+ severity: "error",
8
+ },
9
+ files: ["**/*.{js,jsx,ts,tsx,html}"],
10
+ evaluate(file, content, ctx) {
11
+ const re = /style\s*=\s*(\{\s*\{[^]*?\}\s*\}|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g;
12
+ let m;
13
+ while ((m = re.exec(content))) {
14
+ const line = content.slice(0, m.index).split("\n").length;
15
+ ctx.report({
16
+ rule: this.meta.slug,
17
+ message: "Inline style detected — use tokens or classes instead",
18
+ severity: (ctx.config && ctx.config.severity) || this.meta.severity || "error",
19
+ file,
20
+ line,
21
+ codeContext: content
22
+ .split("\n")
23
+ .slice(Math.max(0, line - 2), line + 1)
24
+ .join("\n"),
25
+ });
26
+ }
27
+ },
28
+ };
@@ -0,0 +1,106 @@
1
+ /**
2
+ * authorId.js
3
+ *
4
+ * Robust author identification strategy.
5
+ *
6
+ * Git provides:
7
+ * - author.name
8
+ * - author.email
9
+ *
10
+ * Neither is perfect:
11
+ * - Email can change (work -> personal, typos, company changes)
12
+ * - Name can vary (full vs abbreviated, unicode normalization)
13
+ *
14
+ * Strategy:
15
+ * 1. Use email as primary (most stable)
16
+ * 2. Normalize email (lowercase, trim)
17
+ * 3. Hash for privacy
18
+ * 4. Track email -> author_id mapping server-side for deduplication
19
+ */
20
+
21
+ import crypto from "crypto";
22
+
23
+ function sha256(value) {
24
+ if (typeof value !== "string" || value.length === 0) return null;
25
+ return crypto.createHash("sha256").update(value, "utf8").digest("hex");
26
+ }
27
+
28
+ /**
29
+ * Normalize email for consistent hashing
30
+ */
31
+ function normalizeEmail(email) {
32
+ if (!email || typeof email !== "string") return null;
33
+ return email.toLowerCase().trim();
34
+ }
35
+
36
+ /**
37
+ * Generate author ID from git metadata
38
+ *
39
+ * Priority:
40
+ * 1. email (normalized & hashed)
41
+ * 2. name + email combo (fallback if email alone is ambiguous)
42
+ * 3. name only (last resort)
43
+ */
44
+ export function generateAuthorId(author) {
45
+ // Input can be:
46
+ // - { email: "...", name: "..." }
47
+ // - just an email string
48
+ // - null/undefined
49
+
50
+ let email = null;
51
+ let name = null;
52
+
53
+ if (typeof author === "string") {
54
+ email = author;
55
+ } else if (author && typeof author === "object") {
56
+ email = author.email || author.author_email || null;
57
+ name = author.name || author.author_name || author.author || null;
58
+ }
59
+
60
+ const normalizedEmail = normalizeEmail(email);
61
+ const normalizedName = name ? name.trim() : null;
62
+
63
+ // Strategy 1: Hash email (best)
64
+ if (normalizedEmail) {
65
+ return sha256(normalizedEmail);
66
+ }
67
+
68
+ // Strategy 2: Hash name (fallback - less stable)
69
+ if (normalizedName) {
70
+ console.warn("⚠️ No email for author, using name only (less stable):", normalizedName);
71
+ return sha256(normalizedName);
72
+ }
73
+
74
+ console.warn("⚠️ No author information available");
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * Extract author metadata for storage
80
+ * Returns both the hashed ID and the original email/name for server-side mapping
81
+ */
82
+ export function extractAuthorMetadata(author) {
83
+ let email = null;
84
+ let name = null;
85
+
86
+ if (typeof author === "string") {
87
+ email = author;
88
+ } else if (author && typeof author === "object") {
89
+ email = author.email || author.author_email || null;
90
+ name = author.name || author.author_name || author.author || null;
91
+ }
92
+
93
+ const normalizedEmail = normalizeEmail(email);
94
+ const normalizedName = name ? name.trim() : null;
95
+
96
+ return {
97
+ author_id: generateAuthorId(author),
98
+ author_email: normalizedEmail,
99
+ author_name: normalizedName,
100
+ // Include original for debugging/auditing
101
+ author_email_original: email,
102
+ author_name_original: name,
103
+ };
104
+ }
105
+
106
+ export default generateAuthorId;