fallow-code-scan 0.1.0
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/README.md +35 -0
- package/package.json +38 -0
- package/public/app-copy.js +31 -0
- package/public/app-findings.js +54 -0
- package/public/app-format.js +148 -0
- package/public/app-icons.js +32 -0
- package/public/app.js +395 -0
- package/public/index.html +97 -0
- package/public/styles.css +791 -0
- package/src/fallowBinary.js +68 -0
- package/src/fallowReport.js +336 -0
- package/src/paths.js +30 -0
- package/src/server.js +309 -0
- package/src/start.js +87 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
|
|
4
|
+
const { ensureVerified } = require("fallow/scripts/lazy-verify.js");
|
|
5
|
+
|
|
6
|
+
function resolveFallowBinary(packageRoot) {
|
|
7
|
+
const scopePath = resolveFallowCliScopePath(packageRoot);
|
|
8
|
+
const packageNames = installedPackageNames(scopePath);
|
|
9
|
+
const selectedName = preferredPackageName(packageNames);
|
|
10
|
+
|
|
11
|
+
if (!selectedName) {
|
|
12
|
+
throw new Error("Fallow platform binary is not installed. Run npm install.");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const packageName = `@fallow-cli/${selectedName}`;
|
|
16
|
+
const packagePath = path.join(scopePath, selectedName);
|
|
17
|
+
const manifestPath = path.join(packagePath, "package.json");
|
|
18
|
+
const binaryPath = path.join(packagePath, process.platform === "win32" ? "fallow.exe" : "fallow");
|
|
19
|
+
|
|
20
|
+
verifyBinary(packageName, packagePath, manifestPath, binaryPath);
|
|
21
|
+
return binaryPath;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveFallowCliScopePath(packageRoot) {
|
|
25
|
+
const fallowManifestPath = require.resolve("fallow/package.json", {
|
|
26
|
+
paths: [packageRoot]
|
|
27
|
+
});
|
|
28
|
+
const nodeModulesRoot = path.dirname(path.dirname(fallowManifestPath));
|
|
29
|
+
return path.join(nodeModulesRoot, "@fallow-cli");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function installedPackageNames(scopePath) {
|
|
33
|
+
try {
|
|
34
|
+
return fs.readdirSync(scopePath).filter((name) => {
|
|
35
|
+
return fs.existsSync(path.join(scopePath, name, "package.json"));
|
|
36
|
+
});
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error.code === "ENOENT") return [];
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function preferredPackageName(packageNames) {
|
|
44
|
+
const platformNames = packageNames.filter((name) => name.startsWith(`${process.platform}-`));
|
|
45
|
+
const exactName = platformNames.find((name) => name.includes(`-${process.arch}`));
|
|
46
|
+
return exactName || platformNames[0] || null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function verifyBinary(packageName, packagePath, manifestPath, binaryPath) {
|
|
50
|
+
if (!fs.existsSync(binaryPath)) {
|
|
51
|
+
throw new Error(`Fallow binary is missing at ${binaryPath}. Run npm install.`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const result = ensureVerified({
|
|
55
|
+
manifestPath,
|
|
56
|
+
packageName,
|
|
57
|
+
platformPkgDir: packagePath
|
|
58
|
+
});
|
|
59
|
+
if (!result.ok) {
|
|
60
|
+
throw new Error(`Fallow binary verification failed: ${result.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = {
|
|
65
|
+
preferredPackageName,
|
|
66
|
+
resolveFallowCliScopePath,
|
|
67
|
+
resolveFallowBinary
|
|
68
|
+
};
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
const CHECK_SECTIONS = [
|
|
2
|
+
["unused_files", "Unused files"],
|
|
3
|
+
["unused_exports", "Unused exports"],
|
|
4
|
+
["unused_types", "Unused types"],
|
|
5
|
+
["unused_dependencies", "Unused dependencies"],
|
|
6
|
+
["unused_enum_members", "Unused enum members"],
|
|
7
|
+
["unused_class_members", "Unused class members"],
|
|
8
|
+
["unresolved_imports", "Unresolved imports"],
|
|
9
|
+
["unlisted_dependencies", "Unlisted dependencies"],
|
|
10
|
+
["duplicate_exports", "Duplicate exports"],
|
|
11
|
+
["circular_dependencies", "Circular dependencies"],
|
|
12
|
+
["re_export_cycles", "Re-export cycles"],
|
|
13
|
+
["boundary_violations", "Boundary violations"],
|
|
14
|
+
["stale_suppressions", "Stale suppressions"]
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const SUMMARY_FIELDS = [
|
|
18
|
+
["filesAnalyzed", "files_analyzed"],
|
|
19
|
+
["functionsAnalyzed", "functions_analyzed"],
|
|
20
|
+
["functionsAboveThreshold", "functions_above_threshold"],
|
|
21
|
+
["averageMaintainability", "average_maintainability"],
|
|
22
|
+
["criticalCount", "severity_critical_count"],
|
|
23
|
+
["highCount", "severity_high_count"],
|
|
24
|
+
["moderateCount", "severity_moderate_count"]
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const VITAL_SIGN_FIELDS = [
|
|
28
|
+
["averageCyclomatic", "avg_cyclomatic"],
|
|
29
|
+
["p90Cyclomatic", "p90_cyclomatic"],
|
|
30
|
+
["maintainabilityLowPercent", "maintainability_low_pct"],
|
|
31
|
+
["couplingHighPercent", "coupling_high_pct"],
|
|
32
|
+
["totalLoc", "total_loc"]
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const FILE_SCORE_FIELDS = [
|
|
36
|
+
["maintainability", "maintainability_index"],
|
|
37
|
+
["cyclomatic", "total_cyclomatic"],
|
|
38
|
+
["cognitive", "total_cognitive"],
|
|
39
|
+
["fanIn", "fan_in"],
|
|
40
|
+
["fanOut", "fan_out"],
|
|
41
|
+
["lines", "lines"]
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
function normalizeFallowReport(report, meta = {}) {
|
|
45
|
+
const parts = splitReport(report);
|
|
46
|
+
const checkSections = buildCheckSections(parts.check);
|
|
47
|
+
const counts = buildCounts(parts, checkSections);
|
|
48
|
+
const check = buildCheckSummary(parts.check, checkSections);
|
|
49
|
+
const duplication = buildDuplicationSummary(parts.dupes, parts.cloneGroups);
|
|
50
|
+
const health = buildHealthSummary(parts);
|
|
51
|
+
const hardFindings = buildHardFindings(check, duplication, health);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
generatedAt: stringOr(meta.generatedAt, new Date().toISOString()),
|
|
55
|
+
durationMs: numberOr(meta.durationMs, numberOr(report.elapsed_ms, 0)),
|
|
56
|
+
exitCode: numberOr(meta.exitCode, 0),
|
|
57
|
+
command: stringOr(meta.command, "fallow --format json --quiet"),
|
|
58
|
+
status: hardFindings.count === 0 ? "clear" : "attention",
|
|
59
|
+
version: stringValue(report.version),
|
|
60
|
+
overview: buildOverview(parts, counts),
|
|
61
|
+
hardFindings,
|
|
62
|
+
check,
|
|
63
|
+
duplication,
|
|
64
|
+
health
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function splitReport(report) {
|
|
69
|
+
const health = objectValue(report.health);
|
|
70
|
+
return {
|
|
71
|
+
check: objectValue(report.check),
|
|
72
|
+
dupes: objectValue(report.dupes),
|
|
73
|
+
health,
|
|
74
|
+
cloneGroups: asArray(objectValue(report.dupes).clone_groups),
|
|
75
|
+
healthFindings: asArray(health.findings),
|
|
76
|
+
targets: asArray(health.targets)
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildCounts(parts, checkSections) {
|
|
81
|
+
const checkIssueCount = totalCheckIssues(parts.check, checkSections);
|
|
82
|
+
const duplicateCount = cloneGroupCount(parts.dupes, parts.cloneGroups);
|
|
83
|
+
const hardFindingCount = checkIssueCount + duplicateCount + parts.healthFindings.length;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
checkIssueCount,
|
|
87
|
+
duplicateCount,
|
|
88
|
+
hardFindingCount
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildOverview(parts, counts) {
|
|
93
|
+
const summary = normalizeNumberFields(parts.health.summary, SUMMARY_FIELDS);
|
|
94
|
+
return [
|
|
95
|
+
metric("blocking-findings", "Blocking Findings", counts.hardFindingCount, zeroTone(counts.hardFindingCount)),
|
|
96
|
+
metric(
|
|
97
|
+
"refactoring-suggestions",
|
|
98
|
+
"Refactoring Suggestions",
|
|
99
|
+
parts.targets.length,
|
|
100
|
+
zeroTone(parts.targets.length, "neutral")
|
|
101
|
+
),
|
|
102
|
+
metric("maintainability", "Maintainability", summary.averageMaintainability, maintainabilityTone(summary), "%")
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function metric(id, label, value, tone, suffix = "") {
|
|
107
|
+
return {
|
|
108
|
+
id,
|
|
109
|
+
label,
|
|
110
|
+
value,
|
|
111
|
+
tone,
|
|
112
|
+
suffix
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function buildCheckSummary(check, sections) {
|
|
117
|
+
return {
|
|
118
|
+
totalIssues: totalCheckIssues(check, sections),
|
|
119
|
+
entryPointCount: numberOr(objectValue(check.entry_points).total, 0),
|
|
120
|
+
sections
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildDuplicationSummary(dupes, cloneGroups) {
|
|
125
|
+
const stats = objectValue(dupes.stats);
|
|
126
|
+
return {
|
|
127
|
+
cloneGroupCount: cloneGroupCount(dupes, cloneGroups),
|
|
128
|
+
cloneInstanceCount: numberOr(stats.clone_instances, 0),
|
|
129
|
+
duplicatedLineCount: numberOr(stats.duplicated_lines, 0),
|
|
130
|
+
duplicatedPercent: numberOr(stats.duplication_percentage, 0),
|
|
131
|
+
groups: cloneGroups.map(normalizeCloneGroup)
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function buildHealthSummary(parts) {
|
|
136
|
+
return {
|
|
137
|
+
findingCount: parts.healthFindings.length,
|
|
138
|
+
summary: normalizeNumberFields(parts.health.summary, SUMMARY_FIELDS),
|
|
139
|
+
vitalSigns: normalizeNumberFields(parts.health.vital_signs, VITAL_SIGN_FIELDS),
|
|
140
|
+
findings: parts.healthFindings.map(normalizeHealthFinding),
|
|
141
|
+
fileScores: asArray(parts.health.file_scores).slice(0, 12).map(normalizeFileScore),
|
|
142
|
+
targets: parts.targets.map(normalizeTarget)
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildCheckSections(check) {
|
|
147
|
+
const summary = objectValue(check.summary);
|
|
148
|
+
|
|
149
|
+
return CHECK_SECTIONS.map(([id, label]) => checkSection(check, summary, id, label)).filter(hasFindings);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function checkSection(check, summary, id, label) {
|
|
153
|
+
const records = asArray(check[id]);
|
|
154
|
+
return {
|
|
155
|
+
id,
|
|
156
|
+
label,
|
|
157
|
+
count: numberOr(summary[id], records.length),
|
|
158
|
+
records: records.map((record) => normalizeCheckRecord(id, record))
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildHardFindings(check, duplication, health) {
|
|
163
|
+
const sections = [
|
|
164
|
+
...check.sections,
|
|
165
|
+
duplicationSection(duplication),
|
|
166
|
+
complexitySection(health)
|
|
167
|
+
].filter(hasFindings);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
count: sections.reduce((sum, section) => sum + section.count, 0),
|
|
171
|
+
sections
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function duplicationSection(duplication) {
|
|
176
|
+
return {
|
|
177
|
+
id: "duplicated_code",
|
|
178
|
+
label: "Duplicated code groups",
|
|
179
|
+
count: duplication.cloneGroupCount,
|
|
180
|
+
records: duplication.groups.map((group) => ({
|
|
181
|
+
title: `${group.lineCount} duplicated lines`,
|
|
182
|
+
detail: group.instances.join(" | ")
|
|
183
|
+
}))
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function complexitySection(health) {
|
|
188
|
+
return {
|
|
189
|
+
id: "complexity_findings",
|
|
190
|
+
label: "Complexity findings",
|
|
191
|
+
count: health.findingCount,
|
|
192
|
+
records: health.findings.map((finding) => ({
|
|
193
|
+
title: pathWithLine(finding.path, finding.line) || finding.title,
|
|
194
|
+
detail: `Cyclomatic complexity ${finding.cyclomatic}, cognitive complexity ${finding.cognitive}`
|
|
195
|
+
}))
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function normalizeCheckRecord(sectionId, record) {
|
|
200
|
+
if (sectionId === "duplicate_exports") return duplicateExportRecord(record);
|
|
201
|
+
return genericCheckRecord(record, sectionId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function duplicateExportRecord(record) {
|
|
205
|
+
return {
|
|
206
|
+
title: stringOr(record.export_name, "Duplicate export"),
|
|
207
|
+
detail: asArray(record.locations).map(formatLocation).filter(Boolean).join(" | ")
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function genericCheckRecord(record, sectionId) {
|
|
212
|
+
return {
|
|
213
|
+
title: firstString([record.path, record.file, record.dependency, record.export_name, sectionId]),
|
|
214
|
+
detail: stringOr(formatLocation(record), firstString([record.reason, record.message]))
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function normalizeCloneGroup(group) {
|
|
219
|
+
return {
|
|
220
|
+
title: firstString([group.fingerprint, group.id, "Clone group"]),
|
|
221
|
+
lineCount: numberOr(firstNumber([group.line_count, group.lines]), 0),
|
|
222
|
+
tokenCount: numberOr(firstNumber([group.token_count, group.tokens]), 0),
|
|
223
|
+
instances: asArray(group.instances).map(formatLocation).filter(Boolean)
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function normalizeHealthFinding(finding) {
|
|
228
|
+
return {
|
|
229
|
+
title: firstString([finding.function_name, finding.symbol, finding.path, "Health finding"]),
|
|
230
|
+
path: stringValue(finding.path),
|
|
231
|
+
line: numberOr(finding.line, 0),
|
|
232
|
+
severity: stringOr(finding.severity, "moderate"),
|
|
233
|
+
cyclomatic: numberOr(finding.cyclomatic, 0),
|
|
234
|
+
cognitive: numberOr(finding.cognitive, 0)
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function normalizeFileScore(score) {
|
|
239
|
+
return {
|
|
240
|
+
path: stringValue(score.path),
|
|
241
|
+
...normalizeNumberFields(score, FILE_SCORE_FIELDS)
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function normalizeTarget(target) {
|
|
246
|
+
return {
|
|
247
|
+
path: stringValue(target.path),
|
|
248
|
+
priority: numberOr(target.priority, 0),
|
|
249
|
+
efficiency: numberOr(target.efficiency, 0),
|
|
250
|
+
category: stringValue(target.category),
|
|
251
|
+
effort: stringValue(target.effort),
|
|
252
|
+
confidence: stringValue(target.confidence),
|
|
253
|
+
recommendation: stringValue(target.recommendation)
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function normalizeNumberFields(source, fields) {
|
|
258
|
+
const sourceObject = objectValue(source);
|
|
259
|
+
return Object.fromEntries(fields.map(([targetKey, sourceKey]) => [targetKey, numberOr(sourceObject[sourceKey], 0)]));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function cloneGroupCount(dupes, cloneGroups) {
|
|
263
|
+
return numberOr(objectValue(dupes.stats).clone_groups, cloneGroups.length);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function totalCheckIssues(check, sections) {
|
|
267
|
+
const total = finiteNumber(check.total_issues);
|
|
268
|
+
if (total !== null) return total;
|
|
269
|
+
return sections.reduce((sum, section) => sum + section.count, 0);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function formatLocation(location) {
|
|
273
|
+
const locationObject = objectValue(location);
|
|
274
|
+
const path = firstString([locationObject.path, locationObject.file]);
|
|
275
|
+
const line = firstNumber([locationObject.line, locationObject.start_line]);
|
|
276
|
+
return pathWithLine(path, line);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function pathWithLine(path, line) {
|
|
280
|
+
if (!path) return "";
|
|
281
|
+
if (line === null) return path;
|
|
282
|
+
return `${path}:${line}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function zeroTone(value, clearTone = "good") {
|
|
286
|
+
return value === 0 ? clearTone : "warn";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function maintainabilityTone(summary) {
|
|
290
|
+
if (summary.averageMaintainability >= 80) return "good";
|
|
291
|
+
if (summary.averageMaintainability >= 60) return "warn";
|
|
292
|
+
return "critical";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function hasFindings(section) {
|
|
296
|
+
return section.count > 0;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function asArray(value) {
|
|
300
|
+
return Array.isArray(value) ? value : [];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function objectValue(value) {
|
|
304
|
+
return value && typeof value === "object" ? value : {};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function firstNumber(values) {
|
|
308
|
+
const found = values.map(finiteNumber).find((value) => value !== null);
|
|
309
|
+
return found === undefined ? null : found;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function finiteNumber(value) {
|
|
313
|
+
const number = Number(value);
|
|
314
|
+
return Number.isFinite(number) ? number : null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function numberOr(value, fallback) {
|
|
318
|
+
const number = finiteNumber(value);
|
|
319
|
+
return number === null ? fallback : number;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function firstString(values) {
|
|
323
|
+
return values.map(stringValue).find(Boolean) || "";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function stringOr(value, fallback) {
|
|
327
|
+
return stringValue(value) || fallback;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function stringValue(value) {
|
|
331
|
+
return typeof value === "string" ? value : "";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
module.exports = {
|
|
335
|
+
normalizeFallowReport
|
|
336
|
+
};
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const path = require("node:path");
|
|
2
|
+
const crypto = require("node:crypto");
|
|
3
|
+
const os = require("node:os");
|
|
4
|
+
|
|
5
|
+
const PACKAGE_ROOT = path.resolve(__dirname, "..");
|
|
6
|
+
const PROJECT_ROOT = path.resolve(process.env.CODE_SCAN_ROOT || process.cwd());
|
|
7
|
+
const PUBLIC_ROOT = path.resolve(__dirname, "../public");
|
|
8
|
+
const PID_FILE_PATH = path.join(os.tmpdir(), `code-scan-${projectHash(PROJECT_ROOT)}.pid`);
|
|
9
|
+
|
|
10
|
+
function projectHash(projectRoot) {
|
|
11
|
+
return crypto.createHash("sha256").update(projectRoot).digest("hex").slice(0, 12);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function resolvePublicPath(requestPathname) {
|
|
15
|
+
const requestedPath = requestPathname === "/" ? "/index.html" : requestPathname;
|
|
16
|
+
const resolvedPath = path.resolve(PUBLIC_ROOT, `.${decodeURIComponent(requestedPath)}`);
|
|
17
|
+
|
|
18
|
+
if (resolvedPath !== PUBLIC_ROOT && !resolvedPath.startsWith(`${PUBLIC_ROOT}${path.sep}`)) {
|
|
19
|
+
throw new Error("Requested path is outside the public root");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return resolvedPath;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = {
|
|
26
|
+
PACKAGE_ROOT,
|
|
27
|
+
PID_FILE_PATH,
|
|
28
|
+
PROJECT_ROOT,
|
|
29
|
+
resolvePublicPath
|
|
30
|
+
};
|