Skip to content

Commit 270016c

Browse files
committed
Refactor analyzer into modules and add EJS template
1 parent 2cd8db7 commit 270016c

File tree

15 files changed

+5038
-3819
lines changed

15 files changed

+5038
-3819
lines changed

analyze.mjs

Lines changed: 176 additions & 3811 deletions
Large diffs are not rendered by default.

lib/analyze.mjs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { exec } from './exec.mjs';
2+
import { startSpan } from './tracing.mjs';
3+
import { runCounter } from './counter.mjs';
4+
5+
export function analyzeCommits(repoDir, commits, existingResults = [], options) {
6+
const { emitProgress, jsonProgress, counterTool } = options;
7+
const span = startSpan('analyzer.analyze_commits', {
8+
'analyzer.commits.new': commits.length,
9+
'analyzer.commits.existing': existingResults.length
10+
});
11+
12+
if (!jsonProgress) {
13+
console.log(`\n🔍 Analyzing ${commits.length} commits...\n`);
14+
}
15+
16+
const results = [...existingResults];
17+
const allLanguages = new Set();
18+
19+
for (const result of existingResults) {
20+
Object.keys(result.languages).forEach(lang => allLanguages.add(lang));
21+
}
22+
23+
const startTime = Date.now();
24+
const totalCommits = commits.length;
25+
26+
try {
27+
for (let i = 0; i < commits.length; i++) {
28+
const commit = commits[i];
29+
const progress = `[${i + 1}/${commits.length}]`;
30+
31+
const commitProgress = 15 + Math.floor((i / totalCommits) * 70);
32+
33+
if (!jsonProgress) {
34+
process.stdout.write(`${progress} ${commit.hash.substring(0, 8)} (${commit.date})...`);
35+
}
36+
37+
emitProgress('analyzing', commitProgress, `Analyzing commit ${i + 1} of ${totalCommits}`, {
38+
current_commit: i + 1,
39+
total_commits: totalCommits,
40+
commit_hash: commit.hash.substring(0, 8),
41+
commit_date: commit.date
42+
});
43+
44+
exec(`git -C "${repoDir}" checkout -q ${commit.hash}`, { silent: true });
45+
46+
const counterData = runCounter(repoDir, { tool: counterTool, emitProgress, jsonProgress });
47+
48+
Object.keys(counterData.languages).forEach(lang => allLanguages.add(lang));
49+
50+
let totalLines = 0;
51+
let totalFiles = 0;
52+
let totalBytes = 0;
53+
for (const lang in counterData.languages) {
54+
totalLines += counterData.languages[lang].code || 0;
55+
totalFiles += counterData.languages[lang].files || 0;
56+
totalBytes += counterData.languages[lang].bytes || 0;
57+
}
58+
59+
results.push({
60+
commit: commit.hash,
61+
date: commit.date,
62+
message: commit.message,
63+
analysis: counterData.analysis,
64+
languages: counterData.languages,
65+
totalLines,
66+
totalFiles,
67+
totalBytes
68+
});
69+
70+
if (!jsonProgress) {
71+
process.stdout.write(' ✓\n');
72+
}
73+
}
74+
75+
const elapsedSeconds = (Date.now() - startTime) / 1000;
76+
77+
if (commits.length > 0) {
78+
if (!jsonProgress) {
79+
console.log(`\n✓ Analysis complete (${elapsedSeconds.toFixed(2)}s)`);
80+
console.log(`📊 Languages found: ${Array.from(allLanguages).sort().join(', ')}`);
81+
}
82+
emitProgress('analyzing', 85, 'Analysis complete', {
83+
elapsed_seconds: elapsedSeconds,
84+
languages: Array.from(allLanguages).sort()
85+
});
86+
}
87+
88+
if (results.length === 0) {
89+
span.setAttributes({
90+
'analyzer.languages.count': 0,
91+
'analyzer.total_lines': 0,
92+
'analyzer.duration_seconds': elapsedSeconds
93+
});
94+
span.setStatus('ok');
95+
96+
return {
97+
results: [],
98+
allLanguages: [],
99+
analysisTime: elapsedSeconds
100+
};
101+
}
102+
103+
const finalCommit = results[results.length - 1];
104+
let totalLinesInFinal = 0;
105+
for (const lang in finalCommit.languages) {
106+
totalLinesInFinal += finalCommit.languages[lang].code;
107+
}
108+
109+
const languageOrder = Array.from(allLanguages).sort((a, b) => {
110+
const linesA = finalCommit.languages[a]?.code || 0;
111+
const linesB = finalCommit.languages[b]?.code || 0;
112+
const percA = totalLinesInFinal > 0 ? (linesA / totalLinesInFinal) : 0;
113+
const percB = totalLinesInFinal > 0 ? (linesB / totalLinesInFinal) : 0;
114+
115+
if (percB !== percA) {
116+
return percB - percA;
117+
}
118+
return a.localeCompare(b);
119+
});
120+
121+
span.setAttributes({
122+
'analyzer.languages.count': allLanguages.size,
123+
'analyzer.total_lines': totalLinesInFinal,
124+
'analyzer.duration_seconds': elapsedSeconds
125+
});
126+
span.setStatus('ok');
127+
128+
return {
129+
results,
130+
allLanguages: languageOrder,
131+
analysisTime: elapsedSeconds
132+
};
133+
} catch (err) {
134+
span.setStatus('error', err.message);
135+
span.recordException(err);
136+
throw err;
137+
} finally {
138+
span.end();
139+
}
140+
}

lib/audio.mjs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { AUDIO_MAX_VOICES, AUDIO_DETUNE_MAX } from './constants.mjs';
2+
3+
/**
4+
* Compute pre-baked audio data for all commits and all metrics.
5+
* This moves audio state calculation from browser runtime to build time.
6+
*/
7+
export function computeAudioData(results, allLanguages) {
8+
if (results.length === 0) return [];
9+
10+
const metrics = ['lines', 'files', 'bytes'];
11+
const metricKeys = { lines: 'code', files: 'files', bytes: 'bytes' };
12+
13+
const minMax = {};
14+
for (const metric of metrics) {
15+
let min = Infinity;
16+
let max = 0;
17+
for (const commit of results) {
18+
let total = 0;
19+
for (const lang in commit.languages) {
20+
total += commit.languages[lang][metricKeys[metric]] || 0;
21+
}
22+
min = Math.min(min, total);
23+
max = Math.max(max, total);
24+
}
25+
minMax[metric] = { min, max };
26+
}
27+
28+
const audioData = [];
29+
const maxVoices = Math.min(allLanguages.length, AUDIO_MAX_VOICES);
30+
31+
for (let i = 0; i < results.length; i++) {
32+
const commit = results[i];
33+
const prevCommit = i > 0 ? results[i - 1] : null;
34+
35+
const frameData = {};
36+
37+
for (const metric of metrics) {
38+
const key = metricKeys[metric];
39+
const { min, max } = minMax[metric];
40+
41+
let total = 0;
42+
for (const lang in commit.languages) {
43+
total += commit.languages[lang][key] || 0;
44+
}
45+
46+
const masterIntensity = max > min
47+
? Math.round(((total - min) / (max - min)) * 100) / 100
48+
: 1;
49+
50+
const activeVoices = [];
51+
52+
for (let v = 0; v < maxVoices; v++) {
53+
const lang = allLanguages[v];
54+
const value = commit.languages[lang]?.[key] || 0;
55+
const prevValue = prevCommit?.languages[lang]?.[key] || 0;
56+
57+
const gain = total > 0 ? value / total : 0;
58+
if (gain === 0) continue;
59+
60+
let detune = 0;
61+
if (prevCommit && prevValue > 0) {
62+
const growthRate = (value - prevValue) / prevValue;
63+
detune = Math.max(-AUDIO_DETUNE_MAX, Math.min(AUDIO_DETUNE_MAX, growthRate * AUDIO_DETUNE_MAX));
64+
} else if (value > 0 && prevValue === 0) {
65+
detune = AUDIO_DETUNE_MAX * 0.5;
66+
}
67+
68+
activeVoices.push([v, Math.round(gain * 100) / 100, Math.round(detune * 10) / 10]);
69+
}
70+
71+
frameData[metric] = [masterIntensity, ...activeVoices];
72+
}
73+
74+
audioData.push(frameData);
75+
}
76+
77+
return audioData;
78+
}

lib/constants.mjs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export const SCHEMA_VERSION = '2.2';
2+
export const DEFAULT_COUNTER_TOOL = 'scc';
3+
4+
// Exclude directories for code counting (common build/dependency folders)
5+
export const EXCLUDE_DIRS = [
6+
'node_modules',
7+
'.git',
8+
'dist',
9+
'build',
10+
'target',
11+
'pkg',
12+
'.venv',
13+
'venv',
14+
'__pycache__',
15+
'.pytest_cache',
16+
'.mypy_cache',
17+
'vendor'
18+
];
19+
20+
// Audio sonification constants (used for pre-computing audio data)
21+
export const AUDIO_MAX_VOICES = 16; // Max languages with audio
22+
export const AUDIO_DETUNE_MAX = 25; // Max pitch variation in cents (+/- 25)

lib/counter.mjs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { EXCLUDE_DIRS } from './constants.mjs';
2+
import { exec } from './exec.mjs';
3+
4+
function emptyAnalysis(tool) {
5+
return {
6+
languages: {},
7+
analysis: {
8+
elapsed_seconds: 0,
9+
n_files: 0,
10+
n_lines: 0,
11+
files_per_second: 0,
12+
lines_per_second: 0,
13+
counter_tool: tool,
14+
counter_version: 'unknown'
15+
}
16+
};
17+
}
18+
19+
function runScc(repoDir, { emitProgress, jsonProgress }) {
20+
try {
21+
const startTime = Date.now();
22+
23+
const excludeArgs = EXCLUDE_DIRS.map(d => `--exclude-dir "${d}"`).join(' ');
24+
25+
const result = exec(
26+
`scc "${repoDir}" --format json --no-cocomo ${excludeArgs} --count-as "editorconfig:INI"`,
27+
{ silent: true, ignoreError: true }
28+
);
29+
30+
const elapsedSeconds = (Date.now() - startTime) / 1000;
31+
32+
if (!result) {
33+
return {
34+
languages: {},
35+
analysis: {
36+
elapsed_seconds: elapsedSeconds,
37+
n_files: 0,
38+
n_lines: 0,
39+
files_per_second: 0,
40+
lines_per_second: 0,
41+
counter_tool: 'scc',
42+
counter_version: 'unknown'
43+
}
44+
};
45+
}
46+
47+
const data = JSON.parse(result);
48+
49+
let totalFiles = 0;
50+
let totalLines = 0;
51+
52+
const languages = {};
53+
for (const entry of data) {
54+
const langName = entry.Name;
55+
56+
languages[langName] = {
57+
files: entry.Count || 0,
58+
blank: entry.Blank || 0,
59+
comment: entry.Comment || 0,
60+
code: entry.Code || 0,
61+
complexity: entry.Complexity || 0,
62+
bytes: entry.Bytes || 0,
63+
lines: entry.Lines || 0
64+
};
65+
66+
totalFiles += entry.Count || 0;
67+
totalLines += entry.Lines || 0;
68+
}
69+
70+
const analysis = {
71+
elapsed_seconds: elapsedSeconds,
72+
n_files: totalFiles,
73+
n_lines: totalLines,
74+
files_per_second: elapsedSeconds > 0 ? totalFiles / elapsedSeconds : 0,
75+
lines_per_second: elapsedSeconds > 0 ? totalLines / elapsedSeconds : 0,
76+
counter_tool: 'scc',
77+
counter_version: '3.x'
78+
};
79+
80+
return { languages, analysis };
81+
} catch (error) {
82+
if (!jsonProgress) {
83+
console.error(` ⚠ Warning: scc failed - ${error.message}`);
84+
} else {
85+
emitProgress('analyzing', 0, 'scc failed', { error: error.message });
86+
}
87+
return emptyAnalysis('scc');
88+
}
89+
}
90+
91+
function runCloc(repoDir, { emitProgress, jsonProgress }) {
92+
try {
93+
const excludeDirs = EXCLUDE_DIRS.join(',');
94+
const result = exec(
95+
`cloc "${repoDir}" --json --quiet --exclude-dir=${excludeDirs}`,
96+
{ silent: true, ignoreError: true }
97+
);
98+
99+
if (!result) {
100+
return emptyAnalysis('cloc');
101+
}
102+
103+
const data = JSON.parse(result);
104+
105+
const header = data.header || {};
106+
const analysis = {
107+
elapsed_seconds: header.elapsed_seconds || 0,
108+
n_files: header.n_files || 0,
109+
n_lines: header.n_lines || 0,
110+
files_per_second: header.files_per_second || 0,
111+
lines_per_second: header.lines_per_second || 0,
112+
counter_tool: 'cloc',
113+
counter_version: header.cloc_version || 'unknown'
114+
};
115+
116+
const languages = {};
117+
for (const [lang, stats] of Object.entries(data)) {
118+
if (lang !== 'header' && lang !== 'SUM') {
119+
languages[lang] = {
120+
files: stats.nFiles || 0,
121+
blank: stats.blank || 0,
122+
comment: stats.comment || 0,
123+
code: stats.code || 0
124+
};
125+
}
126+
}
127+
128+
return { languages, analysis };
129+
} catch (error) {
130+
if (!jsonProgress) {
131+
console.error(` ⚠ Warning: cloc failed - ${error.message}`);
132+
} else {
133+
emitProgress('analyzing', 0, 'cloc failed', { error: error.message });
134+
}
135+
return emptyAnalysis('cloc');
136+
}
137+
}
138+
139+
export function runCounter(repoDir, { tool, emitProgress, jsonProgress }) {
140+
if (tool === 'scc') {
141+
return runScc(repoDir, { emitProgress, jsonProgress });
142+
}
143+
return runCloc(repoDir, { emitProgress, jsonProgress });
144+
}

0 commit comments

Comments
 (0)