@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,332 @@
1
+
2
+ import { isTailwindDefault } from "./twClassify.js";
3
+
4
+ const DEBUG = false;
5
+
6
+ export default function generateInsights(declaredJsonTokens, declaredCssVars, runtimeUsage) {
7
+ const declaredJson = Array.isArray(declaredJsonTokens) ? declaredJsonTokens : [];
8
+ const declaredCss = Array.isArray(declaredCssVars) ? declaredCssVars : [];
9
+ const runtime = Array.isArray(runtimeUsage) ? runtimeUsage : [];
10
+
11
+ // Normalize keys for consistent matching across JSON, CSS, and runtime usage
12
+ const getKey = (t) => {
13
+ try {
14
+ if (t == null) return "";
15
+ let raw = "";
16
+
17
+ // Handle all input types defensively
18
+ if (typeof t === "string") raw = t;
19
+ else if (typeof t === "object") {
20
+ if (t.cssVar && typeof t.cssVar === "string") raw = t.cssVar;
21
+ else if (t.name && typeof t.name === "string") raw = t.name;
22
+ else raw = JSON.stringify(t);
23
+ } else {
24
+ raw = String(t);
25
+ }
26
+
27
+ if (typeof raw !== "string") raw = String(raw);
28
+
29
+ // 1️⃣ Extract from Tailwind patterns like bg-[var(--color-accent)]
30
+ const twMatch = raw.match(/var\(--([^)]+)\)/);
31
+ if (twMatch) return `--${twMatch[1]}`;
32
+
33
+ // 2️⃣ Extract raw CSS var if the key itself starts with --
34
+ if (raw.startsWith("--")) return raw.trim();
35
+
36
+ // 3️⃣ Extract inside bracketed patterns like border-[--panel-border]
37
+ const bracketMatch = raw.match(/\[--([^\]]+)\]/);
38
+ if (bracketMatch) return `--${bracketMatch[1]}`;
39
+
40
+ // 3.5️⃣ Handle alias-prefixed tokens (e.g. --alias-panel-border → --panel-border)
41
+ if (raw.startsWith("--alias-")) return raw.replace("--alias-", "--");
42
+
43
+ // 4️⃣ Default: return the cleaned name
44
+ return typeof raw === "string" ? raw.trim() : String(raw);
45
+ } catch (err) {
46
+ console.error("Error in getKey():", err, t);
47
+ return "";
48
+ }
49
+ };
50
+
51
+ const jsonKeys = new Set(declaredJson.map(getKey));
52
+ const cssKeys = new Set(declaredCss.map(getKey));
53
+
54
+ // Filter runtime to exclude Tailwind defaults and inline-style tokens
55
+ const filteredRuntime = runtime.filter(item => !isTailwindDefault(item) && item.type !== "inline-style");
56
+
57
+ const runtimeKeys = new Set(filteredRuntime.map(getKey));
58
+
59
+ // Maps for drift detection
60
+ const jsonMap = new Map(declaredJson.map(t => [getKey(t), t.value]));
61
+ const cssMap = new Map(declaredCss.map(t => [getKey(t), t.value]));
62
+
63
+ // Helper to map Tailwind color utilities to CSS variables
64
+ function tailwindToCssVar(token) {
65
+ // Match bg-*, text-*, fill-*, stroke-*
66
+ if (typeof token !== "string") return null;
67
+ // e.g. bg-primary, bg-accent, text-accent, fill-accent, stroke-accent
68
+ const m = token.match(/^(bg|text|fill|stroke)-(.+)$/);
69
+ if (m) {
70
+ // Remove square brackets if present (e.g. bg-[accent] or text-[accent])
71
+ let colorName = m[2].replace(/^\[|\]$/g, "");
72
+ // If colorName already starts with '--', just use it
73
+ if (colorName.startsWith("--")) {
74
+ return colorName;
75
+ }
76
+ // Map to CSS var naming convention (e.g. --color-primary)
77
+ return `--color-${colorName}`;
78
+ }
79
+ return null;
80
+ }
81
+
82
+ // Missing tokens: used in runtime but not declared in JSON or CSS,
83
+ // excluding known Tailwind defaults and inline-style tokens from "missing" classification
84
+ const missing_tokens = [];
85
+ const alias_mapped_tokens = [];
86
+ for (const key of runtimeKeys) {
87
+ if (DEBUG) console.log("🔍 Missing token check", {
88
+ key,
89
+ isTailwind: isTailwindDefault(key),
90
+ hasJson: jsonKeys.has(key),
91
+ hasCss: cssKeys.has(key)
92
+ });
93
+ if (jsonKeys.has(key) || isTailwindDefault(key)) continue;
94
+ // Try to map Tailwind utility to CSS var
95
+ const cssAlias = tailwindToCssVar(key);
96
+ if (cssAlias && cssKeys.has(cssAlias)) {
97
+ alias_mapped_tokens.push({ tailwind: key, cssVar: cssAlias });
98
+ continue; // Do not add to missing_tokens
99
+ }
100
+ missing_tokens.push(key);
101
+ }
102
+
103
+ // Drifted values: same token exists in both JSON and CSS but with different values
104
+ const drifted_values = [];
105
+ for (const [key, cssValue] of cssMap.entries()) {
106
+ const jsonValue = jsonMap.get(key);
107
+ if (jsonValue && jsonValue.trim() !== cssValue.trim()) {
108
+ drifted_values.push({
109
+ name: key,
110
+ declared: jsonValue,
111
+ found: cssValue
112
+ });
113
+ }
114
+ }
115
+
116
+ // CSS-only tokens
117
+ const css_only_tokens = [...cssKeys].filter(key => !jsonKeys.has(key));
118
+
119
+ const inline_tokens = runtime.filter(t => t.type === "inline-style");
120
+
121
+ // --- Token-backed inline style analysis ---
122
+ // Build declared variable set (JSON + CSS)
123
+ const declaredVars = new Set([
124
+ ...declaredJson.map(getKey),
125
+ ...declaredCss.map(getKey)
126
+ ]);
127
+ // For each inline style, check if value references a CSS var
128
+ for (const inline of inline_tokens) {
129
+ const val = (inline && typeof inline.value === "string") ? inline.value : "";
130
+ const varMatch = val.match(/var\(\s*(--[a-zA-Z0-9-_]+)\s*\)/);
131
+ if (varMatch) {
132
+ const varName = varMatch[1];
133
+ inline.tokenBacked = true;
134
+ inline.tokenDeclared = declaredVars.has(varName);
135
+ inline.tokenMissing = !declaredVars.has(varName);
136
+ } else {
137
+ inline.tokenBacked = false;
138
+ inline.tokenDeclared = false;
139
+ inline.tokenMissing = false;
140
+ }
141
+ }
142
+
143
+ // Themes from both JSON and CSS, excluding "tailwind-inline"
144
+ const themesSet = new Set(
145
+ declaredJson.concat(declaredCss)
146
+ .map(t => t.theme)
147
+ .filter(theme => theme && theme !== "tailwind-inline")
148
+ );
149
+ const themesList = [...themesSet];
150
+ const themesCount = themesList.length;
151
+
152
+ // Extract Tailwind inline overrides from declaredCssVars
153
+ const tailwindInlineTokens = declaredCss.filter(t => t.theme === "tailwind-inline");
154
+ const tailwindInlineKeys = tailwindInlineTokens.map(getKey);
155
+
156
+ // Check which tailwind inline overrides are declared in JSON
157
+ const declaredJsonKeys = new Set(declaredJson.map(getKey));
158
+ const tailwind_inline_issues = tailwindInlineKeys.filter(key => !declaredJsonKeys.has(key));
159
+
160
+ // Counts summary
161
+ const counts = {
162
+ declaredJsonTokensCount: declaredJson.length,
163
+ declaredCssVarsCount: declaredCss.length,
164
+ runtimeUsageCount: filteredRuntime.length
165
+ };
166
+
167
+ // Full summary with recommendations
168
+ const summary = {
169
+ ...counts,
170
+ missingTokensCount: missing_tokens.length,
171
+ driftedValuesCount: drifted_values.length,
172
+ cssOnlyTokensCount: css_only_tokens.length,
173
+ inlineTokensCount: inline_tokens.length,
174
+ themesCount,
175
+ themesList,
176
+ tailwindInlineCount: tailwindInlineKeys.length,
177
+ tailwindInlineIssuesCount: tailwind_inline_issues.length,
178
+ recommendations: []
179
+ };
180
+
181
+ if (missing_tokens.length > 0)
182
+ summary.recommendations.push("Declare missing tokens in JSON.");
183
+ if (drifted_values.length > 0)
184
+ summary.recommendations.push("Fix drifted token values.");
185
+ if (themesCount > 1)
186
+ summary.recommendations.push("Multiple themes detected; verify theme usage.");
187
+ if (css_only_tokens.length > 0)
188
+ summary.recommendations.push("Consider moving CSS-only tokens into your JSON tokens.");
189
+ if (tailwind_inline_issues.length > 0)
190
+ summary.recommendations.push("Declare Tailwind inline overrides missing in JSON.");
191
+
192
+ // Construct userView section
193
+ const coverageScore = Math.round((counts.declaredJsonTokensCount / (counts.runtimeUsageCount || 1)) * 100);
194
+ const inlineUsagePercent = counts.runtimeUsageCount ? (summary.inlineTokensCount / counts.runtimeUsageCount) * 100 : 0;
195
+
196
+ const userView = {
197
+ issues: {
198
+ "🛑 Missing Tokens": missing_tokens,
199
+ "⚠️ Drifted Tokens": drifted_values.map(d => d.name),
200
+ "🖌️ Inline Styles": inline_tokens.map(t => getKey(t)),
201
+ "🌈 Tailwind Inline Overrides": tailwindInlineKeys
202
+ },
203
+ metrics: {
204
+ coverageScore,
205
+ themesDetected: themesList,
206
+ adoptionSummary: {
207
+ tokenCoverage: `${coverageScore}%`,
208
+ inlineUsage: inlineUsagePercent,
209
+ driftDetected: drifted_values.length,
210
+ tailwindInlineOverrides: tailwindInlineKeys.length
211
+ }
212
+ },
213
+ recommendations: summary.recommendations.map(msg => {
214
+ let priority = "low";
215
+ if (msg === "Declare missing tokens in JSON.") priority = "high";
216
+ else if (msg === "Fix drifted token values.") priority = "medium";
217
+ else if (msg === "Multiple themes detected; verify theme usage.") priority = "low";
218
+ else if (msg === "Consider moving CSS-only tokens into your JSON tokens.") priority = "low";
219
+ else if (msg === "Declare Tailwind inline overrides missing in JSON.") priority = "medium";
220
+ return { priority, message: msg };
221
+ })
222
+ };
223
+
224
+ // --- Stack Analysis ---
225
+ const tailwindCount = runtime.filter(r => r.type === "className").length;
226
+ const tokenVarCount = runtime.filter(r => r.type === "css-var").length;
227
+ const inlineStyleCount = runtime.filter(r => r.type === "inline-style").length;
228
+
229
+ const styledOrEmotionCount = inline_tokens.filter(t => t.source === "emotion" || t.source === "emotion-template").length;
230
+
231
+ const hardcodedLiteralCount = inline_tokens.filter(t => t.tokenBacked === false && typeof t.value === "string" && !t.value.includes("var(")).length;
232
+
233
+ const totalCount = tailwindCount + tokenVarCount + inlineStyleCount;
234
+
235
+ // To avoid division by zero
236
+ const safeTotal = totalCount || 1;
237
+
238
+ const stackAnalysis = {
239
+ composition: [
240
+ { category: "Tailwind Utilities", count: tailwindCount, percent: Math.round((tailwindCount / safeTotal) * 100) },
241
+ { category: "Token-backed CSS Vars", count: tokenVarCount, percent: Math.round((tokenVarCount / safeTotal) * 100) },
242
+ { category: "Emotion/Styled Components", count: styledOrEmotionCount, percent: Math.round((styledOrEmotionCount / safeTotal) * 100) },
243
+ { category: "Inline Styles", count: inlineStyleCount, percent: Math.round((inlineStyleCount / safeTotal) * 100) },
244
+ { category: "Hardcoded Literals", count: hardcodedLiteralCount, percent: Math.round((hardcodedLiteralCount / safeTotal) * 100) }
245
+ ],
246
+ total: totalCount,
247
+ notes: [
248
+ "Tailwind utilities dominate (~45%)",
249
+ "Token-backed vars provide strong DS linkage (~30%)",
250
+ "Inline and Emotion styles fill remainder"
251
+ ]
252
+ };
253
+
254
+ const compositionSummary = {
255
+ layers: [
256
+ {
257
+ layer: "Tailwind CSS (v4)",
258
+ detectedApproach: "Utility-first classes & @theme inline vars",
259
+ files: [...new Set(runtime.filter(r => r.type === "className").map(r => r.file))],
260
+ examples: runtime.filter(r => r.type === "className").slice(0, 3).map(r => r.name),
261
+ notes: "Default utilities (p-4, space-y-2) + custom var-based utilities (bg-[var(--color-accent)])"
262
+ },
263
+ {
264
+ layer: "Styled Components",
265
+ detectedApproach: "Tagged template literals using var(--token)",
266
+ files: [...new Set(inline_tokens.filter(t => t.source === "styled-components").map(t => t.file))],
267
+ examples: inline_tokens.filter(t => t.source === "styled-components").slice(0, 3).map(t => `${t.name}: ${t.value}`),
268
+ notes: "Stable use of token-backed props (--panel-bg, --radius-lg, etc.)"
269
+ },
270
+ {
271
+ layer: "Emotion (object syntax)",
272
+ detectedApproach: "JS object styles via styled.div()",
273
+ files: [...new Set(inline_tokens.filter(t => t.source === "emotion").map(t => t.file))],
274
+ examples: inline_tokens.filter(t => t.source === "emotion").slice(0, 3).map(t => `${t.name}: ${t.value}`),
275
+ notes: "Correctly resolved var(--color-accent) and --color-foreground usage"
276
+ },
277
+ {
278
+ layer: "Inline Style Props",
279
+ detectedApproach: "JSX style={{}} with both literals & vars",
280
+ files: [...new Set(inline_tokens.filter(t => !t.source).map(t => t.file))],
281
+ examples: inline_tokens.filter(t => !t.source).slice(0, 3).map(t => `${t.name}: ${t.value}`),
282
+ notes: "Mixture of token-backed (var(--panel-border)) and hardcoded values (8px, #999)"
283
+ },
284
+ {
285
+ layer: "Theme Objects / Tokens",
286
+ detectedApproach: "Central tokens.json, CSS vars, & multi-theme",
287
+ files: ["tokens.json", ":root", ".dark", ".dark-high-contrast"],
288
+ examples: declaredCss.slice(0, 3).map(t => getKey(t)),
289
+ notes: `Full variable definition coverage + ${themesCount} themes detected`
290
+ },
291
+ {
292
+ layer: "Tailwind Inline Theme Block",
293
+ detectedApproach: "@theme inline { ... } syntax",
294
+ files: [...new Set(declaredCss.filter(t => t.theme === "tailwind-inline").map(t => t.file))],
295
+ examples: declaredCss.filter(t => t.theme === "tailwind-inline").slice(0, 3).map(t => `${getKey(t)}: ${t.value}`),
296
+ notes: "Used for color token aliasing, e.g. --color-mango-bango"
297
+ },
298
+ {
299
+ layer: "AST-Detected Literals",
300
+ detectedApproach: "Raw inline values bypassing tokens",
301
+ files: [...new Set(inline_tokens.filter(t => !t.tokenBacked && !t.value?.includes('var(')).map(t => t.file))],
302
+ examples: inline_tokens.filter(t => !t.tokenBacked && !t.value?.includes('var(')).slice(0, 3).map(t => t.value),
303
+ notes: `Hardcoded values detected outside token system (${inline_tokens.filter(t => !t.tokenBacked && !t.value?.includes('var(')).length} total)`
304
+ }
305
+ ],
306
+ summary: {
307
+ totalLayers: 7,
308
+ hasTailwind: runtime.some(r => r.type === "className"),
309
+ hasEmotion: inline_tokens.some(t => t.source === "emotion"),
310
+ hasStyledComponents: inline_tokens.some(t => t.source === "styled-components"),
311
+ hasInline: inline_tokens.some(t => !t.source),
312
+ hasTokensJson: declaredJson.length > 0,
313
+ hasThemeInline: declaredCss.some(t => t.theme === "tailwind-inline"),
314
+ hardcodedLiteralCount: inline_tokens.filter(t => !t.tokenBacked && !t.value?.includes('var(')).length
315
+ }
316
+ };
317
+
318
+ // Final insights object
319
+ return {
320
+ counts,
321
+ missing_tokens,
322
+ alias_mapped_tokens,
323
+ drifted_values,
324
+ css_only_tokens,
325
+ inline_tokens, // ✅ new field for inline styles
326
+ themes: themesList,
327
+ summary,
328
+ userView,
329
+ stackAnalysis,
330
+ compositionSummary
331
+ };
332
+ }
@@ -0,0 +1,63 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import micromatch from "micromatch";
4
+
5
+ const DEFAULT_EXCLUDE_PATTERNS = [
6
+ "**/node_modules/**",
7
+ "**/.git/**",
8
+ "**/dist/**",
9
+ "**/build/**",
10
+ "**/.next/**",
11
+ "**/coverage/**",
12
+ ];
13
+
14
+ export default function getAllFiles(
15
+ dir,
16
+ exts = [
17
+ ".js",
18
+ ".ts",
19
+ ".jsx",
20
+ ".tsx",
21
+ ".css",
22
+ ".scss",
23
+ ".sass",
24
+ ".less",
25
+ ".styl",
26
+ ".vue",
27
+ ".svelte",
28
+ ".astro",
29
+ ".html",
30
+ ".htm",
31
+ ".mdx",
32
+ ".md",
33
+ ".json",
34
+ ".jsonc",
35
+ ".yml",
36
+ ".yaml",
37
+ ".cjs",
38
+ ".mjs",
39
+ ],
40
+ files = [],
41
+ excludePatterns = DEFAULT_EXCLUDE_PATTERNS,
42
+ baseDir = process.cwd()
43
+ ) {
44
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
45
+
46
+ for (const entry of entries) {
47
+ const fullPath = path.join(dir, entry.name);
48
+ const relativePath = path.relative(baseDir, fullPath).replace(/\\/g, "/");
49
+
50
+ if (excludePatterns.length) {
51
+ const isExcluded = micromatch.isMatch(relativePath, excludePatterns, { nocase: true });
52
+ if (isExcluded) continue;
53
+ }
54
+
55
+ if (entry.isDirectory()) {
56
+ getAllFiles(fullPath, exts, files, excludePatterns, baseDir);
57
+ } else if (exts.includes(path.extname(entry.name))) {
58
+ files.push(fullPath);
59
+ }
60
+ }
61
+
62
+ return files;
63
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * getCommitMetaData
3
+ *
4
+ * Git commit metadata helper.
5
+ * Best-effort, non-fatal.
6
+ *
7
+ * Mirrors legacy drift/baseline behaviour:
8
+ * - ISO 8601 timestamp (Postgres-safe)
9
+ * - includes branch + commit message
10
+ */
11
+
12
+ import { execSync } from "child_process";
13
+
14
+ export default function getCommitMetaData() {
15
+ try {
16
+ const sha = execSync("git rev-parse HEAD", {
17
+ encoding: "utf8",
18
+ stdio: ["ignore", "pipe", "ignore"],
19
+ }).trim();
20
+
21
+ let changed_files = [];
22
+ try {
23
+ const changedFilesOutput = execSync(`git diff-tree --no-commit-id --name-status -r ${sha}`, {
24
+ encoding: "utf8",
25
+ stdio: ["ignore", "pipe", "ignore"],
26
+ });
27
+ changed_files = changedFilesOutput
28
+ .split("\n")
29
+ .map(line => line.trim())
30
+ .filter(line => line.length > 0)
31
+ .map(line => {
32
+ const parts = line.split("\t");
33
+ const status = parts[0];
34
+ if (status === "R" || status === "C") {
35
+ return {
36
+ status,
37
+ path: parts[2],
38
+ old_path: parts[1],
39
+ };
40
+ } else {
41
+ return {
42
+ status,
43
+ path: parts[1],
44
+ old_path: null,
45
+ };
46
+ }
47
+ });
48
+ } catch {
49
+ changed_files = [];
50
+ }
51
+
52
+ const author = execSync("git log -1 --pretty=format:%an", {
53
+ encoding: "utf8",
54
+ stdio: ["ignore", "pipe", "ignore"],
55
+ }).trim();
56
+
57
+ const author_email = execSync("git log -1 --pretty=format:%ae", {
58
+ encoding: "utf8",
59
+ stdio: ["ignore", "pipe", "ignore"],
60
+ }).trim();
61
+
62
+ const timestamp = execSync("git log -1 --pretty=format:%cI", {
63
+ encoding: "utf8",
64
+ stdio: ["ignore", "pipe", "ignore"],
65
+ }).trim();
66
+
67
+ let branch = execSync("git rev-parse --abbrev-ref HEAD", {
68
+ encoding: "utf8",
69
+ stdio: ["ignore", "pipe", "ignore"],
70
+ }).trim();
71
+
72
+ // Detached HEAD or unknown branch name
73
+ if (!branch || branch === "HEAD") {
74
+ branch = "main";
75
+ }
76
+
77
+ const message = execSync("git log -1 --pretty=format:%s", {
78
+ encoding: "utf8",
79
+ stdio: ["ignore", "pipe", "ignore"],
80
+ }).trim();
81
+
82
+ return {
83
+ sha,
84
+ author,
85
+ author_email,
86
+ timestamp,
87
+ branch,
88
+ message,
89
+ changed_files,
90
+ };
91
+ } catch {
92
+ return {
93
+ sha: null,
94
+ author: null,
95
+ author_email: null,
96
+ timestamp: null,
97
+ branch: null,
98
+ message: null,
99
+ changed_files: [],
100
+ };
101
+ }
102
+ }
@@ -0,0 +1,14 @@
1
+ export function createGetLine(content) {
2
+ const lineBreaks = [];
3
+ for (let i = 0; i < content.length; i++) {
4
+ if (content[i] === "\n") lineBreaks.push(i);
5
+ }
6
+
7
+ return (index) => {
8
+ let line = 0;
9
+ while (line < lineBreaks.length && index > lineBreaks[line]) {
10
+ line++;
11
+ }
12
+ return line + 1;
13
+ };
14
+ }
@@ -0,0 +1,47 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ /**
5
+ * Find the git repo root by walking upward from CWD until a `.git` directory exists.
6
+ */
7
+ function findGitRepoRoot(startDir = process.cwd()) {
8
+ let dir = startDir;
9
+
10
+ while (true) {
11
+ const gitDir = path.join(dir, ".git");
12
+ if (fs.existsSync(gitDir) && fs.lstatSync(gitDir).isDirectory()) {
13
+ return dir;
14
+ }
15
+
16
+ const parent = path.dirname(dir);
17
+ if (parent === dir) {
18
+ // Hit filesystem root, no .git found
19
+ return null;
20
+ }
21
+ dir = parent;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Resolve the project.json inside .git/cf/project.json (global per repo).
27
+ */
28
+ export async function resolveProjectForFolder() {
29
+ const repoRoot = findGitRepoRoot();
30
+ if (!repoRoot) {
31
+ return null;
32
+ }
33
+
34
+ const projectJsonPath = path.join(repoRoot, ".git", "cf", "project.json");
35
+
36
+ if (!fs.existsSync(projectJsonPath)) {
37
+ return null;
38
+ }
39
+
40
+ try {
41
+ const raw = fs.readFileSync(projectJsonPath, "utf8");
42
+ return JSON.parse(raw);
43
+ } catch (err) {
44
+ console.error("❌ Failed to parse .git/cf/project.json:", err);
45
+ return null;
46
+ }
47
+ }
@@ -0,0 +1,138 @@
1
+ import {
2
+ VARIANT_PREFIX_RE,
3
+ TAILWIND_PATTERNS,
4
+ ARBITRARY_RE as _ARBITRARY_RE,
5
+ KNOWN_PREFIXES
6
+ } from "../data/tailwind-core-spec.js";
7
+
8
+ const BRACKET_RE = /\[(.+?)\]/; // e.g. bg-[...], text-[...]
9
+ const VAR_RE = /var\(\s*--([a-z0-9-_]+)\s*\)/gi;
10
+
11
+ // More robust ARBITRARY_RE that matches nested parentheses and calc() expressions inside Tailwind arbitrary brackets
12
+ const ARBITRARY_RE = /\[[^\]]*\([^\]]*\)[^\]]*\]|\[.*?\]/;
13
+
14
+ // Match leading variant prefixes like "sm:", "md:", "hover:", "focus:", etc.
15
+ const PREFIX_RE = /^(?:sm:|md:|lg:|xl:|2xl:|hover:|focus:|active:|disabled:|visited:|dark:)+/;
16
+
17
+ function stripVariants(input) {
18
+ if (typeof input !== "string") {
19
+ console.warn("⚠️ stripVariants expected string but got:", typeof input, input);
20
+ return "";
21
+ }
22
+
23
+ // Remove variant prefixes once; no loop needed since Tailwind chains are simple
24
+ return input.replace(PREFIX_RE, "");
25
+ }
26
+
27
+ function isDefaultTailwindBase(base) {
28
+ return TAILWIND_PATTERNS.some(re => re.test(base));
29
+ }
30
+
31
+ function hasKnownPrefix(base) {
32
+ return KNOWN_PREFIXES.some(p => base === p || base.startsWith(`${p}-`));
33
+ }
34
+
35
+ function isDeclaredVar(cssVarName, declaredJson, declaredCssVars) {
36
+ return (
37
+ declaredJson?.some(t => t.cssVar === cssVarName) ||
38
+ declaredCssVars?.some(t => t.cssVar === cssVarName)
39
+ );
40
+ }
41
+
42
+ function extractBalancedContent(str) {
43
+ const startIndex = str.indexOf('[');
44
+ if (startIndex === -1) return "";
45
+
46
+ let bracketCount = 0;
47
+ let parenCount = 0;
48
+ let endIndex = -1;
49
+
50
+ for (let i = startIndex; i < str.length; i++) {
51
+ const char = str[i];
52
+ if (char === '[') {
53
+ bracketCount++;
54
+ } else if (char === ']') {
55
+ bracketCount--;
56
+ if (bracketCount === 0 && parenCount === 0) {
57
+ endIndex = i;
58
+ break;
59
+ }
60
+ } else if (char === '(') {
61
+ parenCount++;
62
+ } else if (char === ')') {
63
+ parenCount--;
64
+ }
65
+ }
66
+
67
+ if (endIndex === -1) return "";
68
+
69
+ return str.slice(startIndex + 1, endIndex).trim();
70
+ }
71
+
72
+ /**
73
+ * Classifies a runtime class string into:
74
+ * - tailwind-default
75
+ * - tailwind-arbitrary
76
+ * - tailwind-custom-implicit
77
+ * - class-generic
78
+ */
79
+ export function classifyTailwindClass(cls, declaredJson = [], declaredCssVars = []) {
80
+ const name = String(cls).trim();
81
+ if (!name) return { classification: "class-generic", isTailwindDefault: false };
82
+
83
+ // 1️⃣ Arbitrary syntax → always Tailwind, but not default scale
84
+ if (name.includes("[") && name.includes("]")) {
85
+ const inside = extractBalancedContent(name);
86
+ const varMatches = [...inside.matchAll(VAR_RE)];
87
+ const inferredVars = varMatches.map(m => `--${m[1]}`);
88
+ const inferredVar = inferredVars[0] || null;
89
+ const tokenBacked = inferredVars.some(v => isDeclaredVar(v, declaredJson, declaredCssVars));
90
+ return {
91
+ classification: "tailwind-arbitrary",
92
+ isTailwindDefault: false,
93
+ inferredVar,
94
+ tokenBacked
95
+ };
96
+ }
97
+
98
+ // 2️⃣ Remove variants, check base
99
+ const base = stripVariants(name);
100
+
101
+ if (isDefaultTailwindBase(base)) {
102
+ return { classification: "tailwind-default", isTailwindDefault: true };
103
+ }
104
+
105
+ // 3️⃣ Tailwind-style custom utility (implicit)
106
+ if (hasKnownPrefix(base)) {
107
+ const suffix = base.split("-").slice(1).join("-");
108
+ const candidates = [
109
+ `--${suffix}`,
110
+ `--color-${suffix}`,
111
+ `--${suffix.replace(/^color-/, "")}`
112
+ ];
113
+ const inferredVar = candidates.find(v => isDeclaredVar(v, declaredJson, declaredCssVars)) || null;
114
+ return {
115
+ classification: "tailwind-custom-implicit",
116
+ isTailwindDefault: false,
117
+ inferredVar: inferredVar || undefined,
118
+ tokenBacked: Boolean(inferredVar)
119
+ };
120
+ }
121
+
122
+ // 4️⃣ Otherwise → generic
123
+ return { classification: "class-generic", isTailwindDefault: false };
124
+ }
125
+
126
+ export function isTailwindDefault(cls) {
127
+ // Gracefully handle unexpected input types
128
+ if (typeof cls !== "string") {
129
+ cls = cls?.name || "";
130
+ if (!cls) return false; // silently skip non-string inputs
131
+ }
132
+
133
+ if (!cls) return false;
134
+
135
+ const clean = stripVariants(cls);
136
+
137
+ return isDefaultTailwindBase(clean);
138
+ }