@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,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;
|