@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,372 @@
1
+ /**
2
+ * buildFileChanges.js
3
+ *
4
+ * Produce per-changed-file "file change rows" from the current snapshot's enriched changed_files.
5
+ *
6
+ * This is intentionally a PURE transform:
7
+ * - It does not read DB state.
8
+ * - It does not attempt to compute cross-commit sequencing (prev_idx/deltas).
9
+ * Those can be computed later server-side (window functions) or in report queries.
10
+ *
11
+ * Output shape matches the `public.file_changes` table columns closely.
12
+ *
13
+ * FIELD SEMANTICS:
14
+ * - idx: Per-commit index (0-based), stable within this commit's changed files
15
+ * - prev_idx: Cross-commit sequence (set server-side via window function)
16
+ * - lines_changed: Actual changed lines (from blame or diff stats)
17
+ * - change_size: Total magnitude (insertions + deletions)
18
+ * - insertions/deletions: Raw git diff stats
19
+ * - author_id: Current commit author (who is touching the file)
20
+ * - prev_top_author_id: Previous top author (whose code is being touched)
21
+ * - is_overwrite: True when prev_top_author_id differs from author_id
22
+ *
23
+ * COMPUTED POST-INSERT (not in this transform):
24
+ * - changes_last_7_days, changes_last_30_days
25
+ * - unique_authors_last_30_days
26
+ * - days_since_last_change
27
+ * - avg_change_size_last_26_weeks
28
+ * - avg_weeks_between_changes
29
+ */
30
+
31
+ function toIsoDate(value) {
32
+ if (!value) return null;
33
+ const d = new Date(value);
34
+ if (Number.isNaN(d.getTime())) return null;
35
+ return d.toISOString();
36
+ }
37
+
38
+ function pickPath(cf) {
39
+ return (
40
+ (cf && typeof cf.path === "string" && cf.path) ||
41
+ (cf && typeof cf.file === "string" && cf.file) ||
42
+ (cf && typeof cf.to === "string" && cf.to) ||
43
+ (cf && typeof cf.from === "string" && cf.from) ||
44
+ null
45
+ );
46
+ }
47
+
48
+ function safeNumber(v) {
49
+ if (typeof v === "number" && Number.isFinite(v)) return v;
50
+ if (typeof v === "string") {
51
+ const s = v.trim();
52
+ if (!s) return null;
53
+ const n = Number(s);
54
+ return Number.isFinite(n) ? n : null;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function safePercentage(v) {
60
+ const n = safeNumber(v);
61
+ if (n === null) return null;
62
+ // Clamp to valid percentage range
63
+ return Math.max(0, Math.min(100, n));
64
+ }
65
+
66
+ function inferFileClass(path) {
67
+ if (!path || typeof path !== "string") return null;
68
+ const p = path.toLowerCase();
69
+
70
+ if (p.endsWith(".md") || p.endsWith(".mdx")) return "docs";
71
+ if (
72
+ p.includes("design-tokens") ||
73
+ p.includes("/tokens/") ||
74
+ p.endsWith(".tokens.json")
75
+ )
76
+ return "tokens";
77
+
78
+ if (
79
+ p.endsWith(".json") ||
80
+ p.endsWith(".yaml") ||
81
+ p.endsWith(".yml") ||
82
+ p.endsWith(".toml") ||
83
+ p.endsWith(".ini") ||
84
+ p.endsWith(".lock") ||
85
+ p.endsWith("package.json") ||
86
+ p.endsWith("tsconfig.json") ||
87
+ p.endsWith("eslint.config.js") ||
88
+ p.endsWith(".eslintrc") ||
89
+ p.endsWith(".eslintrc.js") ||
90
+ p.endsWith(".eslintrc.json") ||
91
+ p.endsWith(".prettierrc") ||
92
+ p.endsWith(".prettierrc.json") ||
93
+ p.endsWith(".prettierrc.js")
94
+ ) {
95
+ return "config";
96
+ }
97
+
98
+ if (p.endsWith(".tsx") || p.endsWith(".jsx")) return "ui";
99
+ if (p.endsWith(".ts") || p.endsWith(".js")) return "code";
100
+
101
+ if (
102
+ p.endsWith(".css") ||
103
+ p.endsWith(".scss") ||
104
+ p.endsWith(".sass") ||
105
+ p.endsWith(".less") ||
106
+ p.endsWith(".styl")
107
+ ) {
108
+ return "style";
109
+ }
110
+
111
+ return null;
112
+ }
113
+
114
+ function pickDiffStats(cf) {
115
+ // Support a few possible shapes coming from enrichment.
116
+ const ds = cf?.diff_stats || cf?.diffStats || cf?.numstat || null;
117
+ const insertions = safeNumber(ds?.insertions ?? ds?.added ?? ds?.ins ?? null);
118
+ const deletions = safeNumber(ds?.deletions ?? ds?.removed ?? ds?.del ?? null);
119
+
120
+ // numstat can emit '-' for binary; represent that as nulls and diffStatsOk=false.
121
+ const binary =
122
+ ds?.insertions === "-" ||
123
+ ds?.deletions === "-" ||
124
+ ds?.added === "-" ||
125
+ ds?.removed === "-";
126
+
127
+ const diffStatsOk = !binary && (insertions !== null || deletions !== null);
128
+
129
+ return {
130
+ insertions,
131
+ deletions,
132
+ diffStatsOk,
133
+ changeSize:
134
+ insertions !== null || deletions !== null
135
+ ? (insertions || 0) + (deletions || 0)
136
+ : null,
137
+ };
138
+ }
139
+
140
+ function computeConfidence({
141
+ blameCoveragePct,
142
+ diffStatsOk,
143
+ linesChanged,
144
+ status,
145
+ isOverwrite,
146
+ prevTopAuthorPct,
147
+ }) {
148
+ // Deterministic confidence model derived from a small set of observable signals.
149
+ // Intended meaning: "How reliable are the attribution + magnitude signals for this row?"
150
+ //
151
+ // Signals:
152
+ // - blameCoveragePct (primary)
153
+ // - diffStatsOk (supports magnitude, esp. config/lockfiles)
154
+ // - linesChanged (guards against M + 0)
155
+ // - overwrite strength (only when isOverwrite): depends on blame coverage + prev top author dominance
156
+ //
157
+ // RETURNS: float 0.0..1.0 (matches schema: confidence real)
158
+
159
+ const bc = safeNumber(blameCoveragePct); // accepts numeric-ish strings like "40.1"
160
+ const topPct = safeNumber(prevTopAuthorPct);
161
+ const lc = safeNumber(linesChanged);
162
+
163
+ // New files: attribution to commit author is reliable, but overwrite/prior-author signals are N/A.
164
+ if (status === "A") {
165
+ return 0.7;
166
+ }
167
+
168
+ let c = 0;
169
+
170
+ // blame coverage contribution (0..0.75)
171
+ if (bc === null) c += 0.05;
172
+ else if (bc >= 95) c += 0.75;
173
+ else if (bc >= 75) c += 0.6;
174
+ else if (bc >= 50) c += 0.45;
175
+ else if (bc > 0) c += 0.25;
176
+
177
+ // diff stats contribution (0..0.2)
178
+ if (diffStatsOk) c += 0.2;
179
+
180
+ // non-zero magnitude contribution (0..0.1)
181
+ if (lc !== null && lc > 0) c += 0.1;
182
+
183
+ // overwrite strength contribution (0..0.15)
184
+ // We only award this when we can actually judge overwrite with meaningful blame coverage.
185
+ // Strength increases when the previous top author dominated the changed lines.
186
+ if (isOverwrite) {
187
+ const validBc = safePercentage(blameCoveragePct);
188
+ const validTopPct = safePercentage(prevTopAuthorPct);
189
+
190
+ if (validBc !== null && validBc > 0 && validTopPct !== null && validTopPct > 0) {
191
+ // Both values are already 0-100, calculate strength
192
+ const strength = (validTopPct / 100) * (validBc / 100);
193
+ c += 0.15 * strength;
194
+ }
195
+ }
196
+
197
+ // clamp 0..1
198
+ if (c < 0) c = 0;
199
+ if (c > 1) c = 1;
200
+ return c;
201
+ }
202
+
203
+ /**
204
+ * Infer overwrite vs previous author based on blame_stats for the change ranges.
205
+ * This matches the spirit of your current node model:
206
+ * - if we have meaningful blame coverage and a prev top author that differs from the commit author => overwrite.
207
+ */
208
+ function computeOverwrite(cf, commitAuthorId) {
209
+ const blame = cf && cf.blame_stats ? cf.blame_stats : null;
210
+ const coverage = safePercentage(blame && blame.coverage_pct);
211
+ const top =
212
+ blame && blame.prev_authors && Array.isArray(blame.prev_authors.top)
213
+ ? blame.prev_authors.top[0]
214
+ : null;
215
+
216
+ const prevTopAuthorId =
217
+ top && typeof top.author_id === "string" ? top.author_id : null;
218
+ const prevTopAuthorPct = safePercentage(top && top.pct);
219
+
220
+ // Can only judge overwrite if:
221
+ // 1. We have blame coverage (file existed before)
222
+ // 2. We have a previous top author
223
+ // 3. We have a commit author to compare against
224
+ const canJudge =
225
+ coverage !== null && coverage > 0 && prevTopAuthorId && commitAuthorId;
226
+ const isOverwrite = !!(canJudge && prevTopAuthorId !== commitAuthorId);
227
+
228
+ return {
229
+ isOverwrite,
230
+ prevTopAuthorId,
231
+ prevTopAuthorPct,
232
+ blameCoveragePct: coverage,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Returns rows for insertion/upsert into public.file_changes
238
+ */
239
+ export function buildFileChanges({ commit, changedFiles, snapshotId = null }) {
240
+ console.log("🧩 buildFileChanges:start");
241
+
242
+ if (!commit || !commit.timestamp) {
243
+ return [];
244
+ }
245
+ if (!Array.isArray(changedFiles) || changedFiles.length === 0) {
246
+ return [];
247
+ }
248
+
249
+ const commitTsIso = toIsoDate(commit.timestamp);
250
+ if (!commitTsIso) {
251
+ return [];
252
+ }
253
+
254
+ const commitSha = (commit && commit.sha) || null;
255
+ const commitAuthorId =
256
+ (commit && typeof commit.author_id === "string" && commit.author_id) ||
257
+ (changedFiles[0] && changedFiles[0].commit_author_id) ||
258
+ null;
259
+
260
+ const rows = changedFiles
261
+ .map((cf, i) => {
262
+ const path = pickPath(cf);
263
+ if (!path) return null;
264
+
265
+ const status = typeof cf.status === "string" ? cf.status : null;
266
+
267
+ const { insertions, deletions, diffStatsOk, changeSize } = pickDiffStats(cf);
268
+
269
+ // For new files (status "A"), there's no previous version to blame
270
+ // For modified files, we may have blame_stats.lines_changed
271
+ const blameLines = safeNumber(cf?.blame_stats?.lines_changed);
272
+
273
+ // lines_changed: represents the actual lines that changed in the file
274
+ // - For new files: prefer diff stats (total lines added)
275
+ // - For modified files: prefer blame stats (actual changed lines) when available
276
+ // - Fallback to diff stats sum, then cf.lines_changed
277
+ const linesChanged =
278
+ status === "A"
279
+ ? changeSize ?? safeNumber(cf?.lines_changed) ?? null
280
+ : blameLines ?? changeSize ?? safeNumber(cf?.lines_changed) ?? null;
281
+
282
+ const locFile =
283
+ safeNumber(cf?.indicators?.loc) ?? safeNumber(cf?.loc) ?? null;
284
+
285
+ // For new files, there is no previous author (isOverwrite will be false)
286
+ const {
287
+ isOverwrite,
288
+ prevTopAuthorId,
289
+ prevTopAuthorPct,
290
+ blameCoveragePct,
291
+ } = status === "A"
292
+ ? { isOverwrite: false, prevTopAuthorId: null, prevTopAuthorPct: null, blameCoveragePct: null }
293
+ : computeOverwrite(cf, commitAuthorId);
294
+
295
+ const fileClass =
296
+ (cf && typeof cf.class === "string" && cf.class) ||
297
+ (cf && typeof cf.file_class === "string" && cf.file_class) ||
298
+ inferFileClass(path);
299
+
300
+ // Extract dependency information from enriched changed_files
301
+ const imports = Array.isArray(cf.imports) ? cf.imports : [];
302
+ const importedBy = Array.isArray(cf.imported_by) ? cf.imported_by : [];
303
+ const importCount = imports.length;
304
+ const importedByCount = importedBy.length;
305
+
306
+ // Minimal event typing at "single commit" granularity
307
+ const eventType = isOverwrite ? "overwrite" : "normal";
308
+
309
+ return {
310
+ // Core identification
311
+ path,
312
+ file_class: fileClass,
313
+ idx: i, // Per-commit index (0-based, stable within this commit)
314
+
315
+ // Commit context
316
+ snapshot_id: snapshotId,
317
+ commit_sha: commitSha || (cf && cf.commit_sha) || null,
318
+ commit_timestamp: commitTsIso,
319
+ author_id: (cf && cf.commit_author_id) || commitAuthorId || null, // Current commit author (touching)
320
+
321
+ // Change metadata
322
+ status, // Git status: A (added), M (modified), D (deleted), R (renamed)
323
+ lines_changed: linesChanged, // Semantic: actual changed lines (blame or diff)
324
+ insertions: insertions, // Raw git: lines inserted
325
+ deletions: deletions, // Raw git: lines deleted
326
+ change_size: changeSize ?? linesChanged, // Total magnitude (prefer ins+del, fallback to lines_changed)
327
+ diff_stats_ok: !!diffStatsOk, // True if diff stats are valid (not binary)
328
+
329
+ // File metrics
330
+ loc_file: locFile, // Lines of code in the file
331
+
332
+ // Attribution & blame
333
+ blame_coverage_pct: blameCoveragePct, // Percentage of changed lines with blame data (0-100)
334
+ blame_ok: blameCoveragePct !== null && blameCoveragePct > 0,
335
+ prev_top_author_id: prevTopAuthorId, // Previous top author (whose code is being touched)
336
+ prev_top_author_pct: prevTopAuthorPct, // % of changed lines owned by prev author (0-100)
337
+
338
+ // Derived signals
339
+ is_overwrite: isOverwrite, // True when prev_top_author_id differs from author_id
340
+ confidence: computeConfidence({
341
+ blameCoveragePct,
342
+ diffStatsOk: !!diffStatsOk,
343
+ linesChanged,
344
+ status,
345
+ isOverwrite,
346
+ prevTopAuthorPct,
347
+ }), // Confidence score 0.0-1.0
348
+
349
+ // Dependency tracking
350
+ imports: imports, // Array of file paths this file imports
351
+ imported_by: importedBy, // Array of file paths that import this file
352
+ import_count: importCount, // Number of imports (for quick queries)
353
+ imported_by_count: importedByCount, // Fan-in (how many files depend on this)
354
+
355
+ // Cross-commit sequencing (computed server-side via window functions)
356
+ prev_idx: null, // Index of previous change to this file
357
+ delta_lines_changed: null, // Change in lines_changed vs previous
358
+ delta_time_seconds: null, // Time delta vs previous change
359
+ author_changed: null, // True if author_id differs from previous
360
+
361
+ // Event classification
362
+ event_type: eventType, // "overwrite" | "normal" (can extend: "refactor", "cleanup", etc.)
363
+ };
364
+ })
365
+ .filter(Boolean);
366
+
367
+ console.log("🧩 buildFileChanges:end");
368
+
369
+ return rows;
370
+ }
371
+
372
+ export default buildFileChanges;