Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions card/lib/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ function topBlasts(data, calcBlast, n) {
return ranked.slice(0, n);
}

function countAggregateIssueItems(issues, titleNeedle) {
const issue = issues.find(
(entry) => entry && typeof entry.title === 'string' && entry.title.includes(titleNeedle)
);
return issue && Array.isArray(issue.items) ? issue.items.length : 0;
}

function snapshotFromAnalysis(data, helpers, ctx) {
const stats = (data && data.stats) || {};
const calcBlast = helpers.calcBlast;
Expand All @@ -67,10 +74,11 @@ function snapshotFromAnalysis(data, helpers, ctx) {

const fragility = topBlasts(data, calcBlast, 3);

// Issues breakdown
// Issues breakdown. The analyzer emits one aggregate issue per kind;
// the real count is items.length, not how many matching issue objects exist.
const issues = Array.isArray(data && data.issues) ? data.issues : [];
const circular = issues.filter((i) => i && i.title && i.title.includes('Circular')).length;
const godObjects = issues.filter((i) => i && i.title && i.title.includes('Large')).length;
const circular = countAggregateIssueItems(issues, 'Circular');
const godObjects = countAggregateIssueItems(issues, 'Large');
const avgCoupling = stats.files > 0 ? stats.connections / stats.files : 0;

// Top folders by file count
Expand Down
5 changes: 5 additions & 0 deletions tests/fixtures/three-cycles/a1.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { b1 } from './b1.js';

export function a1() {
return b1();
}
5 changes: 5 additions & 0 deletions tests/fixtures/three-cycles/a2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { b2 } from './b2.js';

export function a2() {
return b2();
}
5 changes: 5 additions & 0 deletions tests/fixtures/three-cycles/a3.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { b3 } from './b3.js';

export function a3() {
return b3();
}
5 changes: 5 additions & 0 deletions tests/fixtures/three-cycles/b1.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { a1 } from './a1.js';

export function b1() {
return a1();
}
5 changes: 5 additions & 0 deletions tests/fixtures/three-cycles/b2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { a2 } from './a2.js';

export function b2() {
return a2();
}
5 changes: 5 additions & 0 deletions tests/fixtures/three-cycles/b3.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { a3 } from './a3.js';

export function b3() {
return a3();
}
103 changes: 103 additions & 0 deletions tests/snapshot-from-analysis.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import test from 'node:test';

const require = createRequire(import.meta.url);
const __dirname = dirname(fileURLToPath(import.meta.url));
const threeCyclesRoot = join(__dirname, 'fixtures', 'three-cycles');
const { snapshotFromAnalysis } = require('../card/lib/state.js');
const { analyze } = require('../card/analyze.js');

function snapshotOf(data) {
return snapshotFromAnalysis(data, {}, { sha: 'test' });
}

test('snapshotFromAnalysis counts circular and god-object items, not issue objects', () => {
const snapshot = snapshotOf({
stats: { files: 8, functions: 40, loc: 200, connections: 6 },
issues: [
{
type: 'critical',
title: '3 Circular Dependencies',
desc: 'Files that import each other',
items: [
{ name: 'a1.js ↔ b1.js' },
{ name: 'a2.js ↔ b2.js' },
{ name: 'a3.js ↔ b3.js' },
],
},
{
type: 'critical',
title: '2 Large Files',
desc: 'Files with 15+ functions',
items: [{ name: 'god1.js (16 fns)' }, { name: 'god2.js (18 fns)' }],
},
],
});

assert.equal(snapshot.circular, 3);
assert.equal(snapshot.godObjects, 2);
});

test('snapshotFromAnalysis reports 0 when the aggregate issue is missing or has no items', () => {
assert.equal(snapshotOf({ issues: [] }).circular, 0);
assert.equal(snapshotOf({ issues: [] }).godObjects, 0);
assert.equal(snapshotOf({}).circular, 0);
assert.equal(
snapshotOf({
issues: [{ type: 'critical', title: '3 Circular Dependencies' }],
}).circular,
0
);
assert.equal(
snapshotOf({
issues: [{ type: 'critical', title: '2 Large Files', items: null }],
}).godObjects,
0
);
});

test('three independent JS cycles snapshot as 3, not a 0-or-1 presence flag', async () => {
const result = await analyze({ repoRoot: threeCyclesRoot });
const circularIssue = result.data.issues.find(
(issue) => issue && issue.title && issue.title.includes('Circular')
);

assert.ok(circularIssue, 'analyzer should emit one aggregate circular-dependency issue');
assert.equal(circularIssue.title, '3 Circular Dependencies');
assert.equal(circularIssue.items.length, 3);
assert.equal(result.data.issues.filter((issue) => issue.title.includes('Circular')).length, 1);
assert.equal(result.snapshot.circular, 3);
assert.notEqual(result.snapshot.circular, 1);
});

test('multiple god-object files snapshot as the items count, not 1', async (t) => {
const fixture = await mkdtemp(join(tmpdir(), 'codeflow-god-objects-'));
t.after(() => rm(fixture, { recursive: true, force: true }));

function largeFile(prefix, count) {
return Array.from(
{ length: count },
(_, index) => `export function ${prefix}${index}() { return ${index}; }\n`
).join('');
}

await writeFile(join(fixture, 'god1.js'), largeFile('one', 16));
await writeFile(join(fixture, 'god2.js'), largeFile('two', 18));
await writeFile(join(fixture, 'small.js'), 'export function tiny() { return 1; }\n');

const result = await analyze({ repoRoot: fixture });
const godIssue = result.data.issues.find(
(issue) => issue && issue.title && issue.title.includes('Large')
);

assert.ok(godIssue, 'analyzer should emit one aggregate large-files issue');
assert.equal(godIssue.title, '2 Large Files');
assert.equal(godIssue.items.length, 2);
assert.equal(result.snapshot.godObjects, 2);
assert.notEqual(result.snapshot.godObjects, 1);
});
Loading