@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,185 @@
1
+ import fs from "fs";
2
+ import postcss from "postcss";
3
+ import safeParser from "postcss-safe-parser";
4
+ import { buildDeclaredJson } from "./buildDeclaredJson.js";
5
+
6
+ const DEBUG = false;
7
+
8
+ // Helper to extract theme name from a selector
9
+ function extractThemeName(selector) {
10
+ selector = selector.trim();
11
+ if (selector === ":root") {
12
+ return null;
13
+ } else if (selector.startsWith(".")) {
14
+ return selector.substring(1);
15
+ } else if (selector.startsWith(":")) {
16
+ return selector.substring(1);
17
+ }
18
+ return null;
19
+ }
20
+
21
+ // Recursive function to process nodes and extract CSS vars
22
+ function processNodes(nodes, theme = null, tokens = [], file) {
23
+ nodes.forEach((node) => {
24
+ if (node.type === "rule") {
25
+ const currentTheme = extractThemeName(node.selector) ?? theme;
26
+ node.walkDecls((decl) => {
27
+ if (decl.prop.startsWith("--") && decl.value) {
28
+ tokens.push({
29
+ name: decl.prop.slice(2),
30
+ cssVar: decl.prop,
31
+ value: decl.value.trim().replace(/^['"]|['"]$/g, ""),
32
+ type: null,
33
+ theme: currentTheme,
34
+ source: "css",
35
+ file,
36
+ line: decl.source?.start?.line || null,
37
+ });
38
+ } else if (DEBUG && decl.prop.startsWith("--")) {
39
+ console.warn(`[buildDeclaredCssVars] Skipped CSS var with missing value in ${file}: ${decl.prop}`);
40
+ }
41
+ });
42
+ } else if (node.type === "atrule") {
43
+ if (node.name === "theme" && node.params.trim() === "inline") {
44
+ // @theme inline { ... }
45
+ processNodes(node.nodes || [], "tailwind-inline", tokens, file);
46
+ } else {
47
+ if (node.nodes) {
48
+ processNodes(node.nodes, theme, tokens, file);
49
+ }
50
+ }
51
+ }
52
+ });
53
+ return tokens;
54
+ }
55
+
56
+ // Unified parsing function using PostCSS AST
57
+ function parseCssVarsWithPostcss(content, file) {
58
+ const tokens = [];
59
+ let root;
60
+ try {
61
+ root = postcss.parse(content, { from: file, parser: safeParser });
62
+ } catch (e) {
63
+ // If parsing fails, return empty tokens
64
+ return tokens;
65
+ }
66
+ return processNodes(root.nodes, null, tokens, file);
67
+ }
68
+
69
+ // Main function to build declared CSS variable tokens from project files
70
+ export async function buildDeclaredCssVars({ projectRoot, files }) {
71
+ if (!files?.length) return [];
72
+ let declaredJsonTokens = [];
73
+ try {
74
+ const maybeTokens = await buildDeclaredJson({ projectRoot, files });
75
+ declaredJsonTokens = Array.isArray(maybeTokens) ? maybeTokens : [];
76
+ } catch {
77
+ declaredJsonTokens = [];
78
+ }
79
+
80
+ // Fallback to ensure cssVar and theme are properly normalized
81
+ for (const t of declaredJsonTokens) {
82
+ if (!t.cssVar && t.name) {
83
+ const normalizedName = t.name.replace(/\./g, "-");
84
+ t.cssVar = normalizedName.startsWith("--") ? normalizedName : `--${normalizedName}`;
85
+ }
86
+ if (!t.theme || t.theme === "root") {
87
+ t.theme = "default";
88
+ }
89
+ }
90
+
91
+ const allTokens = [];
92
+ for (const file of files) {
93
+ const content = await fs.promises.readFile(file, "utf8");
94
+ const parsedTokens = parseCssVarsWithPostcss(content, file);
95
+ allTokens.push(...parsedTokens);
96
+ }
97
+
98
+ // Normalize themes for all tokens
99
+ for (const t of allTokens) {
100
+ if (t.theme == null || t.theme === "root") t.theme = "default";
101
+ }
102
+
103
+ // Consistent key normalization
104
+ const normalizeKey = (cssVar, theme) => {
105
+ if (!cssVar) return "";
106
+ return `${cssVar.trim().toLowerCase()}|${(theme || "default").trim().toLowerCase()}`;
107
+ };
108
+
109
+ // Build declaredJsonGroupMap using normalized keys
110
+ const declaredJsonGroupMap = declaredJsonTokens.reduce((map, token) => {
111
+ const key = normalizeKey(token.cssVar, token.theme);
112
+ if (!map.has(key)) map.set(key, []);
113
+ map.get(key).push(token);
114
+ return map;
115
+ }, new Map());
116
+
117
+ const normalizeValue = (v) => {
118
+ if (typeof v !== "string") return v;
119
+ const s = v
120
+ .replace(/^['"]|['"]$/g, "")
121
+ .replace(/[;]+$/, "")
122
+ .replace(/\s+/g, " ")
123
+ .trim()
124
+ .toLowerCase();
125
+ if (/^#[0-9a-f]{3}$/.test(s)) {
126
+ return "#" + s[1] + s[1] + s[2] + s[2] + s[3] + s[3];
127
+ }
128
+ return s;
129
+ };
130
+
131
+ for (const token of allTokens) {
132
+ const themeKey = token.theme || "default";
133
+ const exactKey = normalizeKey(token.cssVar, themeKey);
134
+
135
+ // Exact theme match first
136
+ let candidates = declaredJsonGroupMap.get(exactKey) || [];
137
+
138
+ // Fallback to default only for default theme
139
+ if (!candidates.length && themeKey === "default") {
140
+ candidates =
141
+ declaredJsonGroupMap.get(normalizeKey(token.cssVar, "default")) ||
142
+ declaredJsonGroupMap.get(normalizeKey(token.cssVar, null)) ||
143
+ [];
144
+ }
145
+
146
+ let matchedJson = null;
147
+ if (candidates.length) {
148
+ const eqVal = (t) => normalizeValue(t.value) === normalizeValue(token.value);
149
+ matchedJson = candidates.find(eqVal) || candidates[0] || null;
150
+ }
151
+
152
+ token.token_backed = matchedJson;
153
+ }
154
+
155
+ // Group tokens by cssVar and theme
156
+ const groupedMap = new Map();
157
+ for (const token of allTokens) {
158
+ const cssVar = token.cssVar;
159
+ const name = token.name;
160
+ const themeKey = token.theme === null ? "default" : token.theme;
161
+ if (!groupedMap.has(cssVar)) {
162
+ groupedMap.set(cssVar, {
163
+ cssVar,
164
+ name,
165
+ themes: {},
166
+ });
167
+ }
168
+ const group = groupedMap.get(cssVar);
169
+ if (!group.themes[themeKey]) {
170
+ group.themes[themeKey] = { entries: [] };
171
+ }
172
+ group.themes[themeKey].entries.push({
173
+ value: token.value,
174
+ file: token.file,
175
+ source: token.source,
176
+ token_backed: token.token_backed,
177
+ line: token.line,
178
+ });
179
+ }
180
+
181
+ const result = Array.from(groupedMap.values());
182
+
183
+ if (DEBUG) console.log(`[buildDeclaredCssVars] Found ${allTokens.length} CSS variable tokens.`);
184
+ return result;
185
+ }
@@ -0,0 +1,214 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ /**
5
+ * Flatten W3C / Style Dictionary token JSON into a simple array.
6
+ */
7
+ function flattenTokens(input, filePath = "", prefix = "") {
8
+ if (!input || typeof input !== "object") return [];
9
+
10
+ // Determine the correct root object to traverse
11
+ const root = (() => {
12
+ // Start from tokens if present, otherwise the whole input
13
+ const base = "tokens" in input && typeof input.tokens === "object" ? input.tokens : input;
14
+
15
+ // Preserve top-level modes / $themes even if tokens key exists
16
+ const merged = { ...base };
17
+ if (input.modes && typeof input.modes === "object") {
18
+ merged.modes = input.modes;
19
+ }
20
+ if (input.$themes && typeof input.$themes === "object") {
21
+ merged.$themes = input.$themes;
22
+ }
23
+ return merged;
24
+ })();
25
+
26
+ const out = [];
27
+ // The deduplication map is now declared inside each flattenTokens call,
28
+ // so deduplication is per-file and not across files.
29
+ const seen = new Map(); // dedupeKey -> token object
30
+
31
+ function inferType(name, explicitType) {
32
+ if (explicitType) return explicitType;
33
+ if (name.startsWith("color.")) return "color";
34
+ if (name.startsWith("space.") || name.startsWith("size.")) return "dimension";
35
+ if (name.startsWith("radius.") || name.startsWith("border.")) return "dimension";
36
+ if (name.startsWith("font-size.")) return "dimension";
37
+ if (name.startsWith("font.")) return "fontFamily";
38
+ return null;
39
+ }
40
+
41
+ for (const [key, val] of Object.entries(root)) {
42
+ const pathName = prefix ? `${prefix}.${key}` : key;
43
+
44
+ // Handle W3C $themes or Style Dictionary modes
45
+ if ((key === "$themes" || key === "modes") && val && typeof val === "object") {
46
+ for (const [themeName, themeTokens] of Object.entries(val)) {
47
+ if (!themeTokens || typeof themeTokens !== "object") continue;
48
+ for (const [themeTokenKey, themeTokenVal] of Object.entries(themeTokens)) {
49
+ if (!themeTokenVal || typeof themeTokenVal !== "object") continue;
50
+ const name = prefix ? `${prefix}.${themeTokenKey}` : themeTokenKey;
51
+ const theme = themeName || "default";
52
+
53
+ if ("$value" in themeTokenVal || "value" in themeTokenVal) {
54
+ const type = inferType(name, themeTokenVal.$type ?? themeTokenVal.type ?? null);
55
+ // Always push tokens from themes/modes without deduplication across files
56
+ out.push({
57
+ name,
58
+ value: themeTokenVal.$value ?? themeTokenVal.value,
59
+ type,
60
+ cssVar: `--${name.replace(/\./g, "-")}`,
61
+ source: "json",
62
+ file: filePath,
63
+ theme,
64
+ });
65
+ } else {
66
+ const nestedTokens = flattenTokens(themeTokenVal, filePath, name);
67
+ nestedTokens.forEach(t => {
68
+ t.theme = theme;
69
+ out.push(t);
70
+ });
71
+ }
72
+ }
73
+ }
74
+ }
75
+ // Handle CSS-like theme selectors e.g. .dark, .light
76
+ else if (
77
+ key.startsWith(".") &&
78
+ val && typeof val === "object"
79
+ ) {
80
+ const themeName = key.slice(1) || "default";
81
+ const flattenedThemeTokens = flattenTokens(val, filePath, prefix);
82
+ flattenedThemeTokens.forEach(token => {
83
+ token.theme = themeName;
84
+ });
85
+ // Do not deduplicate theme selector tokens by value or name+theme.
86
+ // This ensures all dark-only tokens and multiple per-theme file entries are preserved.
87
+ flattenedThemeTokens.forEach(token => {
88
+ out.push(token);
89
+ });
90
+ }
91
+ // W3C format ($value/$type)
92
+ else if (val && typeof val === "object" && ("$value" in val || "value" in val)) {
93
+ const name = pathName;
94
+ const theme = "default";
95
+ const dedupeKey = `${name}::${theme}::${filePath}`;
96
+ const type = inferType(name, val.$type ?? val.type ?? null);
97
+ const newToken = {
98
+ name,
99
+ value: val.$value ?? val.value,
100
+ type,
101
+ cssVar: `--${name.replace(/\./g, "-")}`,
102
+ source: "json",
103
+ file: filePath,
104
+ theme,
105
+ };
106
+ if (seen.has(dedupeKey)) {
107
+ // Merge logic for default tokens
108
+ const existing = seen.get(dedupeKey);
109
+ if (existing.file !== newToken.file) {
110
+ const files = new Set((existing.file + "," + newToken.file).split(","));
111
+ existing.file = Array.from(files).join(",");
112
+ }
113
+ if (existing.value === undefined && newToken.value !== undefined) {
114
+ existing.value = newToken.value;
115
+ }
116
+ if (existing.type === undefined && newToken.type !== undefined) {
117
+ existing.type = newToken.type;
118
+ }
119
+ } else {
120
+ out.push(newToken);
121
+ seen.set(dedupeKey, newToken);
122
+ }
123
+ } else if (val && typeof val === "object") {
124
+ const nestedTokens = flattenTokens(val, filePath, pathName);
125
+ nestedTokens.forEach(t => {
126
+ t.theme = t.theme || "default";
127
+ // For nested tokens, retain deduplication per file for non-theme tokens.
128
+ const dedupeKey = `${t.name}::${t.theme}::${filePath}`;
129
+ if (seen.has(dedupeKey)) {
130
+ // Merge logic for nested tokens
131
+ const existing = seen.get(dedupeKey);
132
+ if (existing.file !== t.file) {
133
+ const files = new Set((existing.file + "," + t.file).split(","));
134
+ existing.file = Array.from(files).join(",");
135
+ }
136
+ if (existing.value === undefined && t.value !== undefined) {
137
+ existing.value = t.value;
138
+ }
139
+ if (existing.type === undefined && t.type !== undefined) {
140
+ existing.type = t.type;
141
+ }
142
+ } else {
143
+ out.push(t);
144
+ seen.set(dedupeKey, t);
145
+ }
146
+ });
147
+ }
148
+ }
149
+
150
+ // Alias expansion logic
151
+ const expanded = [...out];
152
+ for (const t of out) {
153
+ let refName = null;
154
+ if (t.name.startsWith("alias.")) {
155
+ refName = t.name.replace(/^alias\./, "");
156
+ } else if (typeof t.value === "string") {
157
+ const match = t.value.match(/^\{([^}]+)\}$/);
158
+ if (match) {
159
+ refName = match[1];
160
+ }
161
+ }
162
+ if (refName) {
163
+ expanded.push({
164
+ name: t.name.startsWith("alias.") ? refName : t.name,
165
+ cssVar: t.cssVar.replace(/^--alias-/, "--"),
166
+ value: `var(--${refName.replace(/\./g, "-")})`,
167
+ resolvedFrom: t.name,
168
+ });
169
+ }
170
+ }
171
+
172
+ return expanded;
173
+ }
174
+
175
+ /**
176
+ * Build declared_json model for ControlFront.
177
+ *
178
+ * Note: This implementation ensures that all dark mode tokens and
179
+ * multiple per-theme file entries are preserved in the output array,
180
+ * even if they have the same name, theme, and value, but come from different files.
181
+ * Deduplication now happens per file, not across files, and value-based deduplication
182
+ * for theme tokens has been removed.
183
+ *
184
+ * File discovery now happens in scan.js.
185
+ */
186
+ export async function buildDeclaredJson({ projectRoot, files } = {}) {
187
+ const root = projectRoot || process.cwd();
188
+ const searchFiles = ["tokens.json", "tokens.w3c.json"];
189
+ const allFiles = Array.isArray(files) ? files : [];
190
+
191
+ const foundFiles = allFiles.filter(f => searchFiles.includes(path.basename(f)));
192
+ if (foundFiles.length === 0) return [];
193
+
194
+ const tokens = [];
195
+ for (const file of foundFiles) {
196
+ try {
197
+ // Normalize to absolute path
198
+ const absFile = path.resolve(file);
199
+ const rawContent = await fs.promises.readFile(absFile, "utf8");
200
+ const parsed = JSON.parse(rawContent);
201
+ const flattened = flattenTokens(parsed, path.relative(root, absFile));
202
+ if (Array.isArray(flattened)) {
203
+ tokens.push(...flattened);
204
+ }
205
+ } catch (err) {
206
+ console.warn(`⚠️ Failed to parse ${file}:`, err.message);
207
+ continue;
208
+ }
209
+ }
210
+
211
+ if (!Array.isArray(tokens)) return [];
212
+
213
+ return tokens;
214
+ }