@aiready/hygiene-audit 0.6.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/.turbo/turbo-build.log +19 -0
- package/.turbo/turbo-test.log +33 -0
- package/dist/index.d.mts +11 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +241 -0
- package/dist/index.mjs +214 -0
- package/package.json +50 -0
- package/src/__tests__/hygiene.test.ts +17 -0
- package/src/index.ts +251 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
> @aiready/hygiene-audit@0.6.0 build /Users/pengcao/projects/aiready/packages/hygiene-audit
|
|
4
|
+
> tsup src/index.ts src/cli.ts --format cjs,esm --dts
|
|
5
|
+
|
|
6
|
+
[34mCLI[39m Building entry: src/index.ts
|
|
7
|
+
[34mCLI[39m Using tsconfig: tsconfig.json
|
|
8
|
+
[34mCLI[39m tsup v8.5.1
|
|
9
|
+
[34mCLI[39m Target: es2020
|
|
10
|
+
[34mCJS[39m Build start
|
|
11
|
+
[34mESM[39m Build start
|
|
12
|
+
[32mESM[39m [1mdist/index.mjs [22m[32m6.58 KB[39m
|
|
13
|
+
[32mESM[39m ⚡️ Build success in 59ms
|
|
14
|
+
[32mCJS[39m [1mdist/index.js [22m[32m8.35 KB[39m
|
|
15
|
+
[32mCJS[39m ⚡️ Build success in 60ms
|
|
16
|
+
DTS Build start
|
|
17
|
+
DTS ⚡️ Build success in 691ms
|
|
18
|
+
DTS dist/index.d.ts 429.00 B
|
|
19
|
+
DTS dist/index.d.mts 429.00 B
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
> @aiready/hygiene-audit@0.6.0 test /Users/pengcao/projects/aiready/packages/hygiene-audit
|
|
4
|
+
> vitest run
|
|
5
|
+
|
|
6
|
+
[?25l
|
|
7
|
+
[1m[30m[46m RUN [49m[39m[22m [36mv4.1.4 [39m[90m/Users/pengcao/projects/aiready/packages/hygiene-audit[39m
|
|
8
|
+
|
|
9
|
+
[?2026h
|
|
10
|
+
[1m[33m ❯ [39m[22msrc/__tests__/hygiene.test.ts[2m [queued][22m
|
|
11
|
+
|
|
12
|
+
[2m Test Files [22m[1m[32m0 passed[39m[22m[90m (1)[39m
|
|
13
|
+
[2m Tests [22m[1m[32m0 passed[39m[22m[90m (0)[39m
|
|
14
|
+
[2m Start at [22m09:12:55
|
|
15
|
+
[2m Duration [22m101ms
|
|
16
|
+
[?2026l[?2026h[K[1A[K[1A[K[1A[K[1A[K[1A[K[1A[K[1A[K
|
|
17
|
+
[1m[33m ❯ [39m[22msrc/__tests__/hygiene.test.ts[2m 0/2[22m
|
|
18
|
+
|
|
19
|
+
[2m Test Files [22m[1m[32m0 passed[39m[22m[90m (1)[39m
|
|
20
|
+
[2m Tests [22m[1m[32m0 passed[39m[22m[90m (2)[39m
|
|
21
|
+
[2m Start at [22m09:12:55
|
|
22
|
+
[2m Duration [22m525ms
|
|
23
|
+
[?2026l[K[1A[K[1A[K[1A[K[1A[K[1A[K[1A[K[1A[K [32m✓[39m src/__tests__/hygiene.test.ts [2m([22m[2m2 tests[22m[2m)[22m[32m 2[2mms[22m[39m
|
|
24
|
+
[32m✓[39m HygieneAuditProvider [2m(2)[22m
|
|
25
|
+
[32m✓[39m should be registered in ToolRegistry[32m 1[2mms[22m[39m
|
|
26
|
+
[32m✓[39m should have correct aliases[32m 0[2mms[22m[39m
|
|
27
|
+
|
|
28
|
+
[2m Test Files [22m [1m[32m1 passed[39m[22m[90m (1)[39m
|
|
29
|
+
[2m Tests [22m [1m[32m2 passed[39m[22m[90m (2)[39m
|
|
30
|
+
[2m Start at [22m 09:12:55
|
|
31
|
+
[2m Duration [22m 536ms[2m (transform 125ms, setup 0ms, import 394ms, tests 2ms, environment 0ms)[22m
|
|
32
|
+
|
|
33
|
+
[?25h
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ToolProvider, ToolName, ScanOptions, SpokeOutput, ToolScoringOutput } from '@aiready/core';
|
|
2
|
+
|
|
3
|
+
declare class HygieneAuditProvider implements ToolProvider {
|
|
4
|
+
readonly id = ToolName.HygieneAudit;
|
|
5
|
+
readonly alias: string[];
|
|
6
|
+
analyze(options: ScanOptions): Promise<SpokeOutput>;
|
|
7
|
+
score(output: SpokeOutput, _options: ScanOptions): ToolScoringOutput;
|
|
8
|
+
private calculateScore;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export { HygieneAuditProvider };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ToolProvider, ToolName, ScanOptions, SpokeOutput, ToolScoringOutput } from '@aiready/core';
|
|
2
|
+
|
|
3
|
+
declare class HygieneAuditProvider implements ToolProvider {
|
|
4
|
+
readonly id = ToolName.HygieneAudit;
|
|
5
|
+
readonly alias: string[];
|
|
6
|
+
analyze(options: ScanOptions): Promise<SpokeOutput>;
|
|
7
|
+
score(output: SpokeOutput, _options: ScanOptions): ToolScoringOutput;
|
|
8
|
+
private calculateScore;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export { HygieneAuditProvider };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
HygieneAuditProvider: () => HygieneAuditProvider
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(index_exports);
|
|
36
|
+
var import_core = require("@aiready/core");
|
|
37
|
+
var import_child_process = require("child_process");
|
|
38
|
+
var import_util = require("util");
|
|
39
|
+
var import_ripgrep = require("@vscode/ripgrep");
|
|
40
|
+
var fs = __toESM(require("fs"));
|
|
41
|
+
var path = __toESM(require("path"));
|
|
42
|
+
var execFileAsync = (0, import_util.promisify)(import_child_process.execFile);
|
|
43
|
+
var HygieneAuditProvider = class {
|
|
44
|
+
constructor() {
|
|
45
|
+
this.id = import_core.ToolName.HygieneAudit;
|
|
46
|
+
this.alias = ["hygiene", "codebase-hygiene", "metabolism"];
|
|
47
|
+
}
|
|
48
|
+
async analyze(options) {
|
|
49
|
+
const { rootDir } = options;
|
|
50
|
+
const rawIssues = [];
|
|
51
|
+
let filesAnalyzed = 0;
|
|
52
|
+
let debtMarkers = 0;
|
|
53
|
+
try {
|
|
54
|
+
const { stdout } = await execFileAsync(import_ripgrep.rgPath, [
|
|
55
|
+
"--count-matches",
|
|
56
|
+
"--fixed-strings",
|
|
57
|
+
"-e",
|
|
58
|
+
"TODO",
|
|
59
|
+
"-e",
|
|
60
|
+
"FIXME",
|
|
61
|
+
"--glob",
|
|
62
|
+
"!**/node_modules/**",
|
|
63
|
+
"--glob",
|
|
64
|
+
"!**/.git/**",
|
|
65
|
+
"--glob",
|
|
66
|
+
"!**/dist/**",
|
|
67
|
+
rootDir
|
|
68
|
+
]);
|
|
69
|
+
const lines = stdout.split("\n").filter(Boolean);
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
const parts = line.split(":");
|
|
72
|
+
const count = parseInt(parts[parts.length - 1], 10);
|
|
73
|
+
if (!isNaN(count)) {
|
|
74
|
+
debtMarkers += count;
|
|
75
|
+
filesAnalyzed++;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error.code !== 1)
|
|
80
|
+
console.error("[Hygiene] Error counting markers:", error);
|
|
81
|
+
}
|
|
82
|
+
if (debtMarkers > 0) {
|
|
83
|
+
rawIssues.push({
|
|
84
|
+
type: import_core.IssueType.ArchitectureInconsistency,
|
|
85
|
+
severity: debtMarkers > 20 ? import_core.Severity.Major : import_core.Severity.Minor,
|
|
86
|
+
location: { file: "codebase", line: 0 },
|
|
87
|
+
message: `Codebase Hygiene: Found ${debtMarkers} TODO/FIXME markers.`,
|
|
88
|
+
recommendation: "Address or resolve old TODO markers to maintain codebase health."
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const emptyDirs = [];
|
|
92
|
+
const scanEmpty = (dir) => {
|
|
93
|
+
if (!fs.existsSync(dir)) return;
|
|
94
|
+
const files = fs.readdirSync(dir);
|
|
95
|
+
if (files.length === 0) {
|
|
96
|
+
emptyDirs.push(path.relative(rootDir, dir));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
for (const file of files) {
|
|
100
|
+
const fullPath = path.join(dir, file);
|
|
101
|
+
if (fs.statSync(fullPath).isDirectory()) {
|
|
102
|
+
if ([
|
|
103
|
+
"node_modules",
|
|
104
|
+
".git",
|
|
105
|
+
"dist",
|
|
106
|
+
".sst",
|
|
107
|
+
".turbo",
|
|
108
|
+
".next"
|
|
109
|
+
].includes(file))
|
|
110
|
+
continue;
|
|
111
|
+
scanEmpty(fullPath);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
if (fs.existsSync(rootDir)) scanEmpty(rootDir);
|
|
116
|
+
if (emptyDirs.length > 0) {
|
|
117
|
+
rawIssues.push({
|
|
118
|
+
type: import_core.IssueType.DeadCode,
|
|
119
|
+
severity: import_core.Severity.Minor,
|
|
120
|
+
location: { file: "structure", line: 0 },
|
|
121
|
+
message: `Empty directories: Found ${emptyDirs.length} empty folders.`,
|
|
122
|
+
recommendation: `Prune empty directories to keep project structure clean: ${emptyDirs.slice(0, 3).join(", ")}`
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const orphanedFiles = [];
|
|
126
|
+
const allFiles = [];
|
|
127
|
+
const collectFiles = (dir) => {
|
|
128
|
+
if (!fs.existsSync(dir)) return;
|
|
129
|
+
const files = fs.readdirSync(dir);
|
|
130
|
+
for (const file of files) {
|
|
131
|
+
const fullPath = path.join(dir, file);
|
|
132
|
+
if (fs.statSync(fullPath).isDirectory()) {
|
|
133
|
+
if ([
|
|
134
|
+
"node_modules",
|
|
135
|
+
".git",
|
|
136
|
+
"dist",
|
|
137
|
+
".sst",
|
|
138
|
+
".turbo",
|
|
139
|
+
".next"
|
|
140
|
+
].includes(file))
|
|
141
|
+
continue;
|
|
142
|
+
collectFiles(fullPath);
|
|
143
|
+
} else if (file.endsWith(".ts") || file.endsWith(".js") || file.endsWith(".tsx") || file.endsWith(".jsx")) {
|
|
144
|
+
allFiles.push(fullPath);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
if (fs.existsSync(rootDir)) collectFiles(rootDir);
|
|
149
|
+
for (const file of allFiles) {
|
|
150
|
+
const base = path.basename(file, path.extname(file));
|
|
151
|
+
if (base === "index" || base.endsWith(".test") || base.endsWith(".spec"))
|
|
152
|
+
continue;
|
|
153
|
+
let referenced = false;
|
|
154
|
+
try {
|
|
155
|
+
const { status } = await execFileAsync(import_ripgrep.rgPath, [
|
|
156
|
+
"--quiet",
|
|
157
|
+
"--fixed-strings",
|
|
158
|
+
"--word-regexp",
|
|
159
|
+
"--glob",
|
|
160
|
+
`!${path.relative(rootDir, file)}`,
|
|
161
|
+
"--glob",
|
|
162
|
+
"!**/node_modules/**",
|
|
163
|
+
base,
|
|
164
|
+
rootDir
|
|
165
|
+
]);
|
|
166
|
+
if (status === 0) referenced = true;
|
|
167
|
+
} catch (e) {
|
|
168
|
+
if (e.code === 0) referenced = true;
|
|
169
|
+
}
|
|
170
|
+
if (!referenced) orphanedFiles.push(path.relative(rootDir, file));
|
|
171
|
+
}
|
|
172
|
+
if (orphanedFiles.length > 0) {
|
|
173
|
+
rawIssues.push({
|
|
174
|
+
type: import_core.IssueType.DeadCode,
|
|
175
|
+
severity: import_core.Severity.Major,
|
|
176
|
+
location: { file: "codebase", line: 0 },
|
|
177
|
+
message: `Orphaned files: Found ${orphanedFiles.length} potentially unused files.`,
|
|
178
|
+
recommendation: `Audit and remove orphaned files to reduce cognitive load: ${orphanedFiles.slice(0, 3).join(", ")}`
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const results = (0, import_core.groupIssuesByFile)(rawIssues);
|
|
182
|
+
const score = this.calculateScore(
|
|
183
|
+
debtMarkers,
|
|
184
|
+
emptyDirs.length,
|
|
185
|
+
orphanedFiles.length
|
|
186
|
+
);
|
|
187
|
+
const summary = {
|
|
188
|
+
score,
|
|
189
|
+
criticalIssues: rawIssues.filter((i) => i.severity === import_core.Severity.Critical).length,
|
|
190
|
+
majorIssues: rawIssues.filter((i) => i.severity === import_core.Severity.Major).length,
|
|
191
|
+
minorIssues: rawIssues.filter((i) => i.severity === import_core.Severity.Minor).length,
|
|
192
|
+
totalIssues: rawIssues.length,
|
|
193
|
+
totalFiles: filesAnalyzed,
|
|
194
|
+
recommendations: rawIssues.map((i) => i.recommendation)
|
|
195
|
+
};
|
|
196
|
+
return (0, import_core.buildSpokeOutput)(this.id, "0.1.0", summary, results, {
|
|
197
|
+
debtMarkers,
|
|
198
|
+
emptyDirs,
|
|
199
|
+
orphanedFiles
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
score(output, _options) {
|
|
203
|
+
const score = typeof output.summary.score === "number" ? output.summary.score : 0;
|
|
204
|
+
const debtCount = output.metadata?.debtMarkers || 0;
|
|
205
|
+
const orphanCount = (output.metadata?.orphanedFiles || []).length;
|
|
206
|
+
return {
|
|
207
|
+
toolName: this.id,
|
|
208
|
+
score,
|
|
209
|
+
rawMetrics: output.metadata,
|
|
210
|
+
factors: [
|
|
211
|
+
{
|
|
212
|
+
name: "Technical Debt",
|
|
213
|
+
impact: debtCount > 0 ? -Math.min(20, debtCount) : 5,
|
|
214
|
+
description: `Found ${debtCount} TODO/FIXME markers.`
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: "File Orphans",
|
|
218
|
+
impact: orphanCount > 0 ? -Math.min(30, orphanCount * 5) : 10,
|
|
219
|
+
description: `Found ${orphanCount} potentially unused files.`
|
|
220
|
+
}
|
|
221
|
+
],
|
|
222
|
+
recommendations: output.summary.recommendations.map((action) => ({
|
|
223
|
+
action,
|
|
224
|
+
estimatedImpact: 5,
|
|
225
|
+
priority: import_core.RecommendationPriority.Medium
|
|
226
|
+
}))
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
calculateScore(debt, empty, orphans) {
|
|
230
|
+
let score = 100;
|
|
231
|
+
score -= debt * 1;
|
|
232
|
+
score -= empty * 2;
|
|
233
|
+
score -= orphans * 5;
|
|
234
|
+
return Math.max(0, Math.min(100, score));
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
import_core.ToolRegistry.register(new HygieneAuditProvider());
|
|
238
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
239
|
+
0 && (module.exports = {
|
|
240
|
+
HygieneAuditProvider
|
|
241
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
ToolRegistry,
|
|
4
|
+
ToolName,
|
|
5
|
+
Severity,
|
|
6
|
+
IssueType,
|
|
7
|
+
RecommendationPriority,
|
|
8
|
+
buildSpokeOutput,
|
|
9
|
+
groupIssuesByFile
|
|
10
|
+
} from "@aiready/core";
|
|
11
|
+
import { execFile } from "child_process";
|
|
12
|
+
import { promisify } from "util";
|
|
13
|
+
import { rgPath } from "@vscode/ripgrep";
|
|
14
|
+
import * as fs from "fs";
|
|
15
|
+
import * as path from "path";
|
|
16
|
+
var execFileAsync = promisify(execFile);
|
|
17
|
+
var HygieneAuditProvider = class {
|
|
18
|
+
constructor() {
|
|
19
|
+
this.id = ToolName.HygieneAudit;
|
|
20
|
+
this.alias = ["hygiene", "codebase-hygiene", "metabolism"];
|
|
21
|
+
}
|
|
22
|
+
async analyze(options) {
|
|
23
|
+
const { rootDir } = options;
|
|
24
|
+
const rawIssues = [];
|
|
25
|
+
let filesAnalyzed = 0;
|
|
26
|
+
let debtMarkers = 0;
|
|
27
|
+
try {
|
|
28
|
+
const { stdout } = await execFileAsync(rgPath, [
|
|
29
|
+
"--count-matches",
|
|
30
|
+
"--fixed-strings",
|
|
31
|
+
"-e",
|
|
32
|
+
"TODO",
|
|
33
|
+
"-e",
|
|
34
|
+
"FIXME",
|
|
35
|
+
"--glob",
|
|
36
|
+
"!**/node_modules/**",
|
|
37
|
+
"--glob",
|
|
38
|
+
"!**/.git/**",
|
|
39
|
+
"--glob",
|
|
40
|
+
"!**/dist/**",
|
|
41
|
+
rootDir
|
|
42
|
+
]);
|
|
43
|
+
const lines = stdout.split("\n").filter(Boolean);
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
const parts = line.split(":");
|
|
46
|
+
const count = parseInt(parts[parts.length - 1], 10);
|
|
47
|
+
if (!isNaN(count)) {
|
|
48
|
+
debtMarkers += count;
|
|
49
|
+
filesAnalyzed++;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error.code !== 1)
|
|
54
|
+
console.error("[Hygiene] Error counting markers:", error);
|
|
55
|
+
}
|
|
56
|
+
if (debtMarkers > 0) {
|
|
57
|
+
rawIssues.push({
|
|
58
|
+
type: IssueType.ArchitectureInconsistency,
|
|
59
|
+
severity: debtMarkers > 20 ? Severity.Major : Severity.Minor,
|
|
60
|
+
location: { file: "codebase", line: 0 },
|
|
61
|
+
message: `Codebase Hygiene: Found ${debtMarkers} TODO/FIXME markers.`,
|
|
62
|
+
recommendation: "Address or resolve old TODO markers to maintain codebase health."
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const emptyDirs = [];
|
|
66
|
+
const scanEmpty = (dir) => {
|
|
67
|
+
if (!fs.existsSync(dir)) return;
|
|
68
|
+
const files = fs.readdirSync(dir);
|
|
69
|
+
if (files.length === 0) {
|
|
70
|
+
emptyDirs.push(path.relative(rootDir, dir));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
const fullPath = path.join(dir, file);
|
|
75
|
+
if (fs.statSync(fullPath).isDirectory()) {
|
|
76
|
+
if ([
|
|
77
|
+
"node_modules",
|
|
78
|
+
".git",
|
|
79
|
+
"dist",
|
|
80
|
+
".sst",
|
|
81
|
+
".turbo",
|
|
82
|
+
".next"
|
|
83
|
+
].includes(file))
|
|
84
|
+
continue;
|
|
85
|
+
scanEmpty(fullPath);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
if (fs.existsSync(rootDir)) scanEmpty(rootDir);
|
|
90
|
+
if (emptyDirs.length > 0) {
|
|
91
|
+
rawIssues.push({
|
|
92
|
+
type: IssueType.DeadCode,
|
|
93
|
+
severity: Severity.Minor,
|
|
94
|
+
location: { file: "structure", line: 0 },
|
|
95
|
+
message: `Empty directories: Found ${emptyDirs.length} empty folders.`,
|
|
96
|
+
recommendation: `Prune empty directories to keep project structure clean: ${emptyDirs.slice(0, 3).join(", ")}`
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const orphanedFiles = [];
|
|
100
|
+
const allFiles = [];
|
|
101
|
+
const collectFiles = (dir) => {
|
|
102
|
+
if (!fs.existsSync(dir)) return;
|
|
103
|
+
const files = fs.readdirSync(dir);
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
const fullPath = path.join(dir, file);
|
|
106
|
+
if (fs.statSync(fullPath).isDirectory()) {
|
|
107
|
+
if ([
|
|
108
|
+
"node_modules",
|
|
109
|
+
".git",
|
|
110
|
+
"dist",
|
|
111
|
+
".sst",
|
|
112
|
+
".turbo",
|
|
113
|
+
".next"
|
|
114
|
+
].includes(file))
|
|
115
|
+
continue;
|
|
116
|
+
collectFiles(fullPath);
|
|
117
|
+
} else if (file.endsWith(".ts") || file.endsWith(".js") || file.endsWith(".tsx") || file.endsWith(".jsx")) {
|
|
118
|
+
allFiles.push(fullPath);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
if (fs.existsSync(rootDir)) collectFiles(rootDir);
|
|
123
|
+
for (const file of allFiles) {
|
|
124
|
+
const base = path.basename(file, path.extname(file));
|
|
125
|
+
if (base === "index" || base.endsWith(".test") || base.endsWith(".spec"))
|
|
126
|
+
continue;
|
|
127
|
+
let referenced = false;
|
|
128
|
+
try {
|
|
129
|
+
const { status } = await execFileAsync(rgPath, [
|
|
130
|
+
"--quiet",
|
|
131
|
+
"--fixed-strings",
|
|
132
|
+
"--word-regexp",
|
|
133
|
+
"--glob",
|
|
134
|
+
`!${path.relative(rootDir, file)}`,
|
|
135
|
+
"--glob",
|
|
136
|
+
"!**/node_modules/**",
|
|
137
|
+
base,
|
|
138
|
+
rootDir
|
|
139
|
+
]);
|
|
140
|
+
if (status === 0) referenced = true;
|
|
141
|
+
} catch (e) {
|
|
142
|
+
if (e.code === 0) referenced = true;
|
|
143
|
+
}
|
|
144
|
+
if (!referenced) orphanedFiles.push(path.relative(rootDir, file));
|
|
145
|
+
}
|
|
146
|
+
if (orphanedFiles.length > 0) {
|
|
147
|
+
rawIssues.push({
|
|
148
|
+
type: IssueType.DeadCode,
|
|
149
|
+
severity: Severity.Major,
|
|
150
|
+
location: { file: "codebase", line: 0 },
|
|
151
|
+
message: `Orphaned files: Found ${orphanedFiles.length} potentially unused files.`,
|
|
152
|
+
recommendation: `Audit and remove orphaned files to reduce cognitive load: ${orphanedFiles.slice(0, 3).join(", ")}`
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const results = groupIssuesByFile(rawIssues);
|
|
156
|
+
const score = this.calculateScore(
|
|
157
|
+
debtMarkers,
|
|
158
|
+
emptyDirs.length,
|
|
159
|
+
orphanedFiles.length
|
|
160
|
+
);
|
|
161
|
+
const summary = {
|
|
162
|
+
score,
|
|
163
|
+
criticalIssues: rawIssues.filter((i) => i.severity === Severity.Critical).length,
|
|
164
|
+
majorIssues: rawIssues.filter((i) => i.severity === Severity.Major).length,
|
|
165
|
+
minorIssues: rawIssues.filter((i) => i.severity === Severity.Minor).length,
|
|
166
|
+
totalIssues: rawIssues.length,
|
|
167
|
+
totalFiles: filesAnalyzed,
|
|
168
|
+
recommendations: rawIssues.map((i) => i.recommendation)
|
|
169
|
+
};
|
|
170
|
+
return buildSpokeOutput(this.id, "0.1.0", summary, results, {
|
|
171
|
+
debtMarkers,
|
|
172
|
+
emptyDirs,
|
|
173
|
+
orphanedFiles
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
score(output, _options) {
|
|
177
|
+
const score = typeof output.summary.score === "number" ? output.summary.score : 0;
|
|
178
|
+
const debtCount = output.metadata?.debtMarkers || 0;
|
|
179
|
+
const orphanCount = (output.metadata?.orphanedFiles || []).length;
|
|
180
|
+
return {
|
|
181
|
+
toolName: this.id,
|
|
182
|
+
score,
|
|
183
|
+
rawMetrics: output.metadata,
|
|
184
|
+
factors: [
|
|
185
|
+
{
|
|
186
|
+
name: "Technical Debt",
|
|
187
|
+
impact: debtCount > 0 ? -Math.min(20, debtCount) : 5,
|
|
188
|
+
description: `Found ${debtCount} TODO/FIXME markers.`
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: "File Orphans",
|
|
192
|
+
impact: orphanCount > 0 ? -Math.min(30, orphanCount * 5) : 10,
|
|
193
|
+
description: `Found ${orphanCount} potentially unused files.`
|
|
194
|
+
}
|
|
195
|
+
],
|
|
196
|
+
recommendations: output.summary.recommendations.map((action) => ({
|
|
197
|
+
action,
|
|
198
|
+
estimatedImpact: 5,
|
|
199
|
+
priority: RecommendationPriority.Medium
|
|
200
|
+
}))
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
calculateScore(debt, empty, orphans) {
|
|
204
|
+
let score = 100;
|
|
205
|
+
score -= debt * 1;
|
|
206
|
+
score -= empty * 2;
|
|
207
|
+
score -= orphans * 5;
|
|
208
|
+
return Math.max(0, Math.min(100, score));
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
ToolRegistry.register(new HygieneAuditProvider());
|
|
212
|
+
export {
|
|
213
|
+
HygieneAuditProvider
|
|
214
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aiready/hygiene-audit",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Codebase-level metabolic health and technical debt audit for AI-readiness",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"aiready-hygiene": "./dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"require": "./dist/index.js",
|
|
15
|
+
"import": "./dist/index.mjs"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"aiready",
|
|
20
|
+
"tech-debt",
|
|
21
|
+
"code-health",
|
|
22
|
+
"metabolism",
|
|
23
|
+
"ai-code",
|
|
24
|
+
"refactoring"
|
|
25
|
+
],
|
|
26
|
+
"author": "AIReady Team",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@vscode/ripgrep": "^1.15.9",
|
|
30
|
+
"chalk": "^5.6.2",
|
|
31
|
+
"commander": "^14.0.3",
|
|
32
|
+
"@aiready/core": "0.28.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^24.12.2",
|
|
36
|
+
"eslint": "^10.2.0",
|
|
37
|
+
"tsup": "^8.5.1"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsup src/index.ts src/cli.ts --format cjs,esm --dts",
|
|
44
|
+
"dev": "tsup src/index.ts src/cli.ts --format cjs,esm --dts --watch",
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"lint": "eslint src",
|
|
47
|
+
"type-check": "tsc --noEmit",
|
|
48
|
+
"format-check": "prettier --check . --ignore-path ../../.prettierignore"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ToolRegistry, ToolName } from '@aiready/core';
|
|
3
|
+
import '../index';
|
|
4
|
+
|
|
5
|
+
describe('HygieneAuditProvider', () => {
|
|
6
|
+
it('should be registered in ToolRegistry', () => {
|
|
7
|
+
const provider = ToolRegistry.get(ToolName.HygieneAudit);
|
|
8
|
+
expect(provider).toBeDefined();
|
|
9
|
+
expect(provider?.id).toBe(ToolName.HygieneAudit);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should have correct aliases', () => {
|
|
13
|
+
const provider = ToolRegistry.get(ToolName.HygieneAudit);
|
|
14
|
+
expect(provider?.alias).toContain('hygiene');
|
|
15
|
+
expect(provider?.alias).toContain('metabolism');
|
|
16
|
+
});
|
|
17
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ToolProvider,
|
|
3
|
+
ToolRegistry,
|
|
4
|
+
ToolName,
|
|
5
|
+
Severity,
|
|
6
|
+
IssueType,
|
|
7
|
+
ScanOptions,
|
|
8
|
+
SpokeOutput,
|
|
9
|
+
ToolScoringOutput,
|
|
10
|
+
RecommendationPriority,
|
|
11
|
+
buildSpokeOutput,
|
|
12
|
+
groupIssuesByFile,
|
|
13
|
+
} from '@aiready/core';
|
|
14
|
+
import { execFile } from 'child_process';
|
|
15
|
+
import { promisify } from 'util';
|
|
16
|
+
import { rgPath } from '@vscode/ripgrep';
|
|
17
|
+
import * as fs from 'fs';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
|
|
20
|
+
const execFileAsync = promisify(execFile);
|
|
21
|
+
|
|
22
|
+
export class HygieneAuditProvider implements ToolProvider {
|
|
23
|
+
public readonly id = ToolName.HygieneAudit;
|
|
24
|
+
public readonly alias = ['hygiene', 'codebase-hygiene', 'metabolism'];
|
|
25
|
+
|
|
26
|
+
public async analyze(options: ScanOptions): Promise<SpokeOutput> {
|
|
27
|
+
const { rootDir } = options;
|
|
28
|
+
const rawIssues = [];
|
|
29
|
+
let filesAnalyzed = 0;
|
|
30
|
+
|
|
31
|
+
// 1. Count Debt Markers (TODO/FIXME)
|
|
32
|
+
let debtMarkers = 0;
|
|
33
|
+
try {
|
|
34
|
+
const { stdout } = await execFileAsync(rgPath, [
|
|
35
|
+
'--count-matches',
|
|
36
|
+
'--fixed-strings',
|
|
37
|
+
'-e',
|
|
38
|
+
'TODO',
|
|
39
|
+
'-e',
|
|
40
|
+
'FIXME',
|
|
41
|
+
'--glob',
|
|
42
|
+
'!**/node_modules/**',
|
|
43
|
+
'--glob',
|
|
44
|
+
'!**/.git/**',
|
|
45
|
+
'--glob',
|
|
46
|
+
'!**/dist/**',
|
|
47
|
+
rootDir,
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const lines = stdout.split('\n').filter(Boolean);
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
const parts = line.split(':');
|
|
53
|
+
const count = parseInt(parts[parts.length - 1], 10);
|
|
54
|
+
if (!isNaN(count)) {
|
|
55
|
+
debtMarkers += count;
|
|
56
|
+
filesAnalyzed++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch (error: any) {
|
|
60
|
+
if (error.code !== 1)
|
|
61
|
+
console.error('[Hygiene] Error counting markers:', error);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (debtMarkers > 0) {
|
|
65
|
+
rawIssues.push({
|
|
66
|
+
type: IssueType.ArchitectureInconsistency,
|
|
67
|
+
severity: debtMarkers > 20 ? Severity.Major : Severity.Minor,
|
|
68
|
+
location: { file: 'codebase', line: 0 },
|
|
69
|
+
message: `Codebase Hygiene: Found ${debtMarkers} TODO/FIXME markers.`,
|
|
70
|
+
recommendation:
|
|
71
|
+
'Address or resolve old TODO markers to maintain codebase health.',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 2. Identify Empty Directories (Structure waste)
|
|
76
|
+
const emptyDirs: string[] = [];
|
|
77
|
+
const scanEmpty = (dir: string) => {
|
|
78
|
+
if (!fs.existsSync(dir)) return;
|
|
79
|
+
const files = fs.readdirSync(dir);
|
|
80
|
+
if (files.length === 0) {
|
|
81
|
+
emptyDirs.push(path.relative(rootDir, dir));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
for (const file of files) {
|
|
85
|
+
const fullPath = path.join(dir, file);
|
|
86
|
+
if (fs.statSync(fullPath).isDirectory()) {
|
|
87
|
+
if (
|
|
88
|
+
[
|
|
89
|
+
'node_modules',
|
|
90
|
+
'.git',
|
|
91
|
+
'dist',
|
|
92
|
+
'.sst',
|
|
93
|
+
'.turbo',
|
|
94
|
+
'.next',
|
|
95
|
+
].includes(file)
|
|
96
|
+
)
|
|
97
|
+
continue;
|
|
98
|
+
scanEmpty(fullPath);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
if (fs.existsSync(rootDir)) scanEmpty(rootDir);
|
|
103
|
+
|
|
104
|
+
if (emptyDirs.length > 0) {
|
|
105
|
+
rawIssues.push({
|
|
106
|
+
type: IssueType.DeadCode,
|
|
107
|
+
severity: Severity.Minor,
|
|
108
|
+
location: { file: 'structure', line: 0 },
|
|
109
|
+
message: `Empty directories: Found ${emptyDirs.length} empty folders.`,
|
|
110
|
+
recommendation: `Prune empty directories to keep project structure clean: ${emptyDirs.slice(0, 3).join(', ')}`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 3. Orphaned Files (Redundant logic)
|
|
115
|
+
const orphanedFiles: string[] = [];
|
|
116
|
+
const allFiles: string[] = [];
|
|
117
|
+
const collectFiles = (dir: string) => {
|
|
118
|
+
if (!fs.existsSync(dir)) return;
|
|
119
|
+
const files = fs.readdirSync(dir);
|
|
120
|
+
for (const file of files) {
|
|
121
|
+
const fullPath = path.join(dir, file);
|
|
122
|
+
if (fs.statSync(fullPath).isDirectory()) {
|
|
123
|
+
if (
|
|
124
|
+
[
|
|
125
|
+
'node_modules',
|
|
126
|
+
'.git',
|
|
127
|
+
'dist',
|
|
128
|
+
'.sst',
|
|
129
|
+
'.turbo',
|
|
130
|
+
'.next',
|
|
131
|
+
].includes(file)
|
|
132
|
+
)
|
|
133
|
+
continue;
|
|
134
|
+
collectFiles(fullPath);
|
|
135
|
+
} else if (
|
|
136
|
+
file.endsWith('.ts') ||
|
|
137
|
+
file.endsWith('.js') ||
|
|
138
|
+
file.endsWith('.tsx') ||
|
|
139
|
+
file.endsWith('.jsx')
|
|
140
|
+
) {
|
|
141
|
+
allFiles.push(fullPath);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
if (fs.existsSync(rootDir)) collectFiles(rootDir);
|
|
146
|
+
|
|
147
|
+
for (const file of allFiles) {
|
|
148
|
+
const base = path.basename(file, path.extname(file));
|
|
149
|
+
if (base === 'index' || base.endsWith('.test') || base.endsWith('.spec'))
|
|
150
|
+
continue;
|
|
151
|
+
|
|
152
|
+
let referenced = false;
|
|
153
|
+
try {
|
|
154
|
+
const { status } = (await execFileAsync(rgPath, [
|
|
155
|
+
'--quiet',
|
|
156
|
+
'--fixed-strings',
|
|
157
|
+
'--word-regexp',
|
|
158
|
+
'--glob',
|
|
159
|
+
`!${path.relative(rootDir, file)}`,
|
|
160
|
+
'--glob',
|
|
161
|
+
'!**/node_modules/**',
|
|
162
|
+
base,
|
|
163
|
+
rootDir,
|
|
164
|
+
])) as any;
|
|
165
|
+
if (status === 0) referenced = true;
|
|
166
|
+
} catch (e: any) {
|
|
167
|
+
if (e.code === 0) referenced = true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!referenced) orphanedFiles.push(path.relative(rootDir, file));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (orphanedFiles.length > 0) {
|
|
174
|
+
rawIssues.push({
|
|
175
|
+
type: IssueType.DeadCode,
|
|
176
|
+
severity: Severity.Major,
|
|
177
|
+
location: { file: 'codebase', line: 0 },
|
|
178
|
+
message: `Orphaned files: Found ${orphanedFiles.length} potentially unused files.`,
|
|
179
|
+
recommendation: `Audit and remove orphaned files to reduce cognitive load: ${orphanedFiles.slice(0, 3).join(', ')}`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const results = groupIssuesByFile(rawIssues);
|
|
184
|
+
const score = this.calculateScore(
|
|
185
|
+
debtMarkers,
|
|
186
|
+
emptyDirs.length,
|
|
187
|
+
orphanedFiles.length
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const summary = {
|
|
191
|
+
score,
|
|
192
|
+
criticalIssues: rawIssues.filter((i) => i.severity === Severity.Critical)
|
|
193
|
+
.length,
|
|
194
|
+
majorIssues: rawIssues.filter((i) => i.severity === Severity.Major)
|
|
195
|
+
.length,
|
|
196
|
+
minorIssues: rawIssues.filter((i) => i.severity === Severity.Minor)
|
|
197
|
+
.length,
|
|
198
|
+
totalIssues: rawIssues.length,
|
|
199
|
+
totalFiles: filesAnalyzed,
|
|
200
|
+
recommendations: rawIssues.map((i) => i.recommendation),
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
return buildSpokeOutput(this.id, '0.1.0', summary, results, {
|
|
204
|
+
debtMarkers,
|
|
205
|
+
emptyDirs,
|
|
206
|
+
orphanedFiles,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
public score(output: SpokeOutput, _options: ScanOptions): ToolScoringOutput {
|
|
211
|
+
const score =
|
|
212
|
+
typeof output.summary.score === 'number' ? output.summary.score : 0;
|
|
213
|
+
const debtCount = (output.metadata?.debtMarkers as number) || 0;
|
|
214
|
+
const orphanCount = ((output.metadata?.orphanedFiles as any[]) || [])
|
|
215
|
+
.length;
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
toolName: this.id,
|
|
219
|
+
score,
|
|
220
|
+
rawMetrics: output.metadata as any,
|
|
221
|
+
factors: [
|
|
222
|
+
{
|
|
223
|
+
name: 'Technical Debt',
|
|
224
|
+
impact: debtCount > 0 ? -Math.min(20, debtCount) : 5,
|
|
225
|
+
description: `Found ${debtCount} TODO/FIXME markers.`,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: 'File Orphans',
|
|
229
|
+
impact: orphanCount > 0 ? -Math.min(30, orphanCount * 5) : 10,
|
|
230
|
+
description: `Found ${orphanCount} potentially unused files.`,
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
recommendations: output.summary.recommendations.map((action: string) => ({
|
|
234
|
+
action,
|
|
235
|
+
estimatedImpact: 5,
|
|
236
|
+
priority: RecommendationPriority.Medium,
|
|
237
|
+
})),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private calculateScore(debt: number, empty: number, orphans: number): number {
|
|
242
|
+
let score = 100;
|
|
243
|
+
score -= debt * 1;
|
|
244
|
+
score -= empty * 2;
|
|
245
|
+
score -= orphans * 5;
|
|
246
|
+
return Math.max(0, Math.min(100, score));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Register with the global registry
|
|
251
|
+
ToolRegistry.register(new HygieneAuditProvider());
|