@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.
@@ -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
+ CLI Building entry: src/index.ts
7
+ CLI Using tsconfig: tsconfig.json
8
+ CLI tsup v8.5.1
9
+ CLI Target: es2020
10
+ CJS Build start
11
+ ESM Build start
12
+ ESM dist/index.mjs 6.58 KB
13
+ ESM ⚡️ Build success in 59ms
14
+ CJS dist/index.js 8.35 KB
15
+ CJS ⚡️ 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
+  RUN  v4.1.4 /Users/pengcao/projects/aiready/packages/hygiene-audit
8
+
9
+ [?2026h
10
+  ❯ src/__tests__/hygiene.test.ts [queued]
11
+
12
+  Test Files 0 passed (1)
13
+  Tests 0 passed (0)
14
+  Start at 09:12:55
15
+  Duration 101ms
16
+ [?2026l[?2026h
17
+  ❯ src/__tests__/hygiene.test.ts 0/2
18
+
19
+  Test Files 0 passed (1)
20
+  Tests 0 passed (2)
21
+  Start at 09:12:55
22
+  Duration 525ms
23
+ [?2026l ✓ src/__tests__/hygiene.test.ts (2 tests) 2ms
24
+ ✓ HygieneAuditProvider (2)
25
+ ✓ should be registered in ToolRegistry 1ms
26
+ ✓ should have correct aliases 0ms
27
+
28
+  Test Files  1 passed (1)
29
+  Tests  2 passed (2)
30
+  Start at  09:12:55
31
+  Duration  536ms (transform 125ms, setup 0ms, import 394ms, tests 2ms, environment 0ms)
32
+
33
+ [?25h
@@ -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 };
@@ -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());
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*"]
8
+ }