@m4x_7/type-debt 1.0.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/constants.ts +15 -0
- package/core/chunkProcessor.ts +27 -0
- package/core/reporter.ts +46 -0
- package/core/typeDebt.ts +125 -0
- package/dist/index.js +27 -0
- package/index.ts +66 -0
- package/package.json +40 -0
- package/tests/typeDebt.test.ts +93 -0
- package/tsconfig.json +46 -0
- package/type-debt-report.md +22 -0
- package/types.ts +14 -0
- package/utils.ts +43 -0
package/constants.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Project } from "ts-morph";
|
|
2
|
+
import { getTypeDebt } from "./typeDebt";
|
|
3
|
+
import { FileReport } from "../types";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
export function processChunk(chunk: string[]): FileReport[] {
|
|
7
|
+
|
|
8
|
+
const project = new Project({ skipAddingFilesFromTsConfig: true });
|
|
9
|
+
project.addSourceFilesAtPaths(chunk);
|
|
10
|
+
|
|
11
|
+
const chunkResults: FileReport[] = [];
|
|
12
|
+
|
|
13
|
+
project.forgetNodesCreatedInBlock(() => {
|
|
14
|
+
for (const file of project.getSourceFiles()) {
|
|
15
|
+
chunkResults.push({
|
|
16
|
+
filePath: file.getFilePath(),
|
|
17
|
+
typeDebtMetrics: getTypeDebt(file)
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
for (const file of project.getSourceFiles()) {
|
|
23
|
+
project.removeSourceFile(file);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return chunkResults;
|
|
27
|
+
}
|
package/core/reporter.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { promises as fs } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { FileReport } from "../types"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
export async function generateMarkdownReport(results: FileReport[], targetDir: string, outputPath = "type-debt-report.md") {
|
|
7
|
+
if (results.length === 0) return;
|
|
8
|
+
|
|
9
|
+
const totalFiles = results.length;
|
|
10
|
+
const totalScore = results.reduce((acc, curr) => acc + curr.typeDebtMetrics.score, 0);
|
|
11
|
+
const averageScore = Math.round(totalScore / totalFiles);
|
|
12
|
+
|
|
13
|
+
const sortedResults = [...results].sort((a, b) =>
|
|
14
|
+
a.typeDebtMetrics.score - b.typeDebtMetrics.score
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
let md = `Type Debt Report\n\n`;
|
|
18
|
+
md += `> Generated on: ${new Date().toUTCString()}\n\n`;
|
|
19
|
+
|
|
20
|
+
md += `## Summary\n`;
|
|
21
|
+
md += `- Total Files Scanned: ${totalFiles}\n`;
|
|
22
|
+
md += `- Average Score: ${averageScore}/100\n\n`;
|
|
23
|
+
|
|
24
|
+
md += `## Top 10 Worst Offenders\n\n`;
|
|
25
|
+
|
|
26
|
+
md += `| Score | File Path | Suppressions | Implicit \`any\` | Explicit \`any\` | \`as any\` | Non-Null (\`!\`) |\n`;
|
|
27
|
+
|
|
28
|
+
md += `| :---: | :--- | :---: | :---: | :---: | :---: | :---: |\n`;
|
|
29
|
+
|
|
30
|
+
const worstOffenders = sortedResults.slice(0, 10);
|
|
31
|
+
|
|
32
|
+
const absoluteTargetDir = path.resolve(targetDir);
|
|
33
|
+
|
|
34
|
+
for (const result of worstOffenders) {
|
|
35
|
+
const metrics = result.typeDebtMetrics;
|
|
36
|
+
const cleanPath = path.relative(absoluteTargetDir, result.filePath);
|
|
37
|
+
md += `| ${metrics.score} | \`${cleanPath}\` | ${metrics.suppressions} | ${metrics.implicitAny} | ${metrics.explicitAny} | ${metrics.asAny} | ${metrics.nonNullAssertions} |\n`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
await fs.writeFile(outputPath, md, "utf-8");
|
|
42
|
+
console.log(`\nMarkdown report successfully generated at: ${outputPath}`);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error(`\nFailed to write Markdown report:`, error);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/core/typeDebt.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { SourceFile, SyntaxKind } from "ts-morph";
|
|
2
|
+
import { TypeDebtMetrics } from "../types";
|
|
3
|
+
import { CONSTANTS } from "../constants";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
const SUPPRESSION_REGEX = /(?:\/\/|\/\*)\s*@ts-(ignore|expect-error|nocheck)/g;
|
|
7
|
+
|
|
8
|
+
export function getTypeDebt(sourceFile: SourceFile): TypeDebtMetrics {
|
|
9
|
+
|
|
10
|
+
const metrics: TypeDebtMetrics = {
|
|
11
|
+
explicitAny: 0,
|
|
12
|
+
implicitAny: 0,
|
|
13
|
+
asAny: 0,
|
|
14
|
+
suppressions: 0,
|
|
15
|
+
nonNullAssertions: 0,
|
|
16
|
+
validTypes: 0,
|
|
17
|
+
score: 0
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
sourceFile.forEachDescendant(node => {
|
|
21
|
+
|
|
22
|
+
const kind = node.getKind();
|
|
23
|
+
|
|
24
|
+
// explicit any
|
|
25
|
+
if (kind === SyntaxKind.AnyKeyword) {
|
|
26
|
+
const parent = node.getParent();
|
|
27
|
+
if (parent?.getKind() !== SyntaxKind.AsExpression) {
|
|
28
|
+
metrics.explicitAny++;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// as any
|
|
33
|
+
else if (kind === SyntaxKind.AsExpression) {
|
|
34
|
+
|
|
35
|
+
const asExpression = node.asKindOrThrow(SyntaxKind.AsExpression);
|
|
36
|
+
|
|
37
|
+
if (asExpression.getTypeNode()?.getKind() === SyntaxKind.AnyKeyword) {
|
|
38
|
+
metrics.asAny++;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// implicit any
|
|
44
|
+
if (kind === SyntaxKind.Parameter) {
|
|
45
|
+
const parameter = node.asKindOrThrow(SyntaxKind.Parameter);
|
|
46
|
+
|
|
47
|
+
if (!parameter.getTypeNode() && !parameter.getInitializer()) {
|
|
48
|
+
const parentFunction = parameter.getParent();
|
|
49
|
+
const grandParent = parentFunction?.getParent();
|
|
50
|
+
|
|
51
|
+
// Ignore parameters in callbacks (e.g., .map(item => ...))
|
|
52
|
+
const isCallback =
|
|
53
|
+
(parentFunction?.getKind() === SyntaxKind.ArrowFunction ||
|
|
54
|
+
parentFunction?.getKind() === SyntaxKind.FunctionExpression) &&
|
|
55
|
+
grandParent?.getKind() === SyntaxKind.CallExpression;
|
|
56
|
+
|
|
57
|
+
if (!isCallback) {
|
|
58
|
+
metrics.implicitAny++;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// not null expression
|
|
64
|
+
if (kind === SyntaxKind.NonNullExpression) {
|
|
65
|
+
metrics.nonNullAssertions++;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
// valid types
|
|
70
|
+
else if (isValidType(kind)) {
|
|
71
|
+
metrics.validTypes++;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
//suppressions
|
|
77
|
+
const text = sourceFile.getFullText();
|
|
78
|
+
const matches = text.match(SUPPRESSION_REGEX);
|
|
79
|
+
metrics.suppressions = matches ? matches.length : 0;
|
|
80
|
+
|
|
81
|
+
metrics.score = calculateScore(metrics);
|
|
82
|
+
|
|
83
|
+
return metrics;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function calculateScore(metrics: TypeDebtMetrics): number {
|
|
87
|
+
|
|
88
|
+
const totalDebtInstances =
|
|
89
|
+
metrics.explicitAny +
|
|
90
|
+
metrics.implicitAny +
|
|
91
|
+
metrics.asAny +
|
|
92
|
+
metrics.suppressions +
|
|
93
|
+
metrics.nonNullAssertions;
|
|
94
|
+
|
|
95
|
+
const totalTypeNodes = metrics.validTypes + totalDebtInstances;
|
|
96
|
+
|
|
97
|
+
if (totalTypeNodes == 0) {
|
|
98
|
+
return 100;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
else {
|
|
102
|
+
const baseScore = (metrics.validTypes / totalTypeNodes) * 100;
|
|
103
|
+
const rawPenalty = calculatePenalty(metrics);
|
|
104
|
+
const scaledPenalty = (rawPenalty / totalTypeNodes) * 100;
|
|
105
|
+
return Math.max(0, Math.round(baseScore - scaledPenalty));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function calculatePenalty(metrics: TypeDebtMetrics): number {
|
|
111
|
+
return metrics.explicitAny * CONSTANTS.TYPE_DEBT_METRICS.EXPLICIT_ANY_WEIGHT
|
|
112
|
+
+ metrics.implicitAny * CONSTANTS.TYPE_DEBT_METRICS.IMPLICIT_ANY_WEIGHT
|
|
113
|
+
+ metrics.asAny * CONSTANTS.TYPE_DEBT_METRICS.AS_ANY_WEIGHT
|
|
114
|
+
+ metrics.nonNullAssertions * CONSTANTS.TYPE_DEBT_METRICS.NON_NULL_ASSERTION_WEIGHT
|
|
115
|
+
+ metrics.suppressions * CONSTANTS.TYPE_DEBT_METRICS.SUPPRESSION_WEIGHT;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isValidType(kind: SyntaxKind): boolean {
|
|
119
|
+
return (kind === SyntaxKind.TypeReference ||
|
|
120
|
+
kind === SyntaxKind.StringKeyword ||
|
|
121
|
+
kind === SyntaxKind.NumberKeyword ||
|
|
122
|
+
kind === SyntaxKind.BooleanKeyword ||
|
|
123
|
+
kind === SyntaxKind.InterfaceDeclaration ||
|
|
124
|
+
kind === SyntaxKind.TypeAliasDeclaration);
|
|
125
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var X=Object.defineProperty;var P=Object.getOwnPropertySymbols;var Z=Object.prototype.hasOwnProperty,V=Object.prototype.propertyIsEnumerable;var A=(t,e,s)=>e in t?X(t,e,{enumerable:!0,configurable:!0,writable:!0,value:s}):t[e]=s,w=(t,e)=>{for(var s in e||(e={}))Z.call(e,s)&&A(t,s,e[s]);if(P)for(var s of P(e))V.call(e,s)&&A(t,s,e[s]);return t};var c=(t,e,s)=>A(t,typeof e!="symbol"?e+"":e,s);var S=(t,e,s)=>new Promise((r,i)=>{var o=u=>{try{n(s.next(u))}catch(h){i(h)}},l=u=>{try{n(s.throw(u))}catch(h){i(h)}},n=u=>u.done?r(u.value):Promise.resolve(u.value).then(o,l);n((s=s.apply(t,e)).next())});import{createRequire as J}from"module";import{basename as tt,dirname as k,normalize as et,relative as st,resolve as rt,sep as I}from"path";import*as it from"fs";var Vt={};var _=J(Vt.url);function nt(t){let e=et(t);return e.length>1&&e[e.length-1]===I&&(e=e.substring(0,e.length-1)),e}var ot=/[\\/]/g;function R(t,e){return t.replace(ot,e)}var lt=/^[a-z]:[\\/]$/i;function ut(t){return t==="/"||lt.test(t)}function F(t,e){let{resolvePaths:s,normalizePath:r,pathSeparator:i}=e,o=process.platform==="win32"&&t.includes("/")||t.startsWith(".");if(s&&(t=rt(t)),(r||o)&&(t=nt(t)),t===".")return"";let l=t[t.length-1]!==i;return R(l?t+i:t,i)}function C(t,e){return e+t}function ct(t,e){return function(s,r){return r.startsWith(t)?r.slice(t.length)+s:R(st(t,r),e.pathSeparator)+e.pathSeparator+s}}function at(t){return t}function ht(t,e,s){return e+t+s}function pt(t,e){let{relativePaths:s,includeBasePath:r}=e;return s&&t?ct(t,e):r?C:at}function ft(t){return function(e,s){s.push(e.substring(t.length)||".")}}function yt(t){return function(e,s,r){let i=e.substring(t.length)||".";r.every(o=>o(i,!0))&&s.push(i)}}var mt=(t,e)=>{e.push(t||".")},gt=(t,e,s)=>{let r=t||".";s.every(i=>i(r,!0))&&e.push(r)},dt=()=>{};function St(t,e){let{includeDirs:s,filters:r,relativePaths:i}=e;return s?i?r&&r.length?yt(t):ft(t):r&&r.length?gt:mt:dt}var wt=(t,e,s,r)=>{r.every(i=>i(t,!1))&&s.files++},Tt=(t,e,s,r)=>{r.every(i=>i(t,!1))&&e.push(t)},Et=(t,e,s,r)=>{s.files++},bt=(t,e)=>{e.push(t)},xt=()=>{};function At(t){let{excludeFiles:e,filters:s,onlyCounts:r}=t;return e?xt:s&&s.length?r?wt:Tt:r?Et:bt}var Ft=t=>t,kt=()=>[""].slice(0,0);function Dt(t){return t.group?kt:Ft}var Pt=(t,e,s)=>{t.push({directory:e,files:s,dir:e})},_t=()=>{};function vt(t){return t.group?Pt:_t}var It=function(t,e,s){let{queue:r,fs:i,options:{suppressErrors:o}}=e;r.enqueue(),i.realpath(t,(l,n)=>{if(l)return r.dequeue(o?null:l,e);i.stat(n,(u,h)=>{if(u)return r.dequeue(o?null:u,e);if(h.isDirectory()&&N(t,n,e))return r.dequeue(null,e);s(h,n),r.dequeue(null,e)})})},Rt=function(t,e,s){let{queue:r,fs:i,options:{suppressErrors:o}}=e;r.enqueue();try{let l=i.realpathSync(t),n=i.statSync(l);if(n.isDirectory()&&N(t,l,e))return;s(n,l)}catch(l){if(!o)throw l}};function Ct(t,e){return!t.resolveSymlinks||t.excludeSymlinks?null:e?Rt:It}function N(t,e,s){if(s.options.useRealPaths)return Nt(e,s);let r=k(t),i=1;for(;r!==s.root&&i<2;){let o=s.symlinks.get(r);!!o&&(o===e||o.startsWith(e)||e.startsWith(o))?i++:r=k(r)}return s.symlinks.set(t,e),i>1}function Nt(t,e){return e.visited.includes(t+e.options.pathSeparator)}var Mt=t=>t.counts,Wt=t=>t.groups,$t=t=>t.paths,Ot=t=>t.paths.slice(0,t.options.maxFiles),qt=(t,e,s)=>(T(e,s,t.counts,t.options.suppressErrors),null),Kt=(t,e,s)=>(T(e,s,t.paths,t.options.suppressErrors),null),Gt=(t,e,s)=>(T(e,s,t.paths.slice(0,t.options.maxFiles),t.options.suppressErrors),null),Bt=(t,e,s)=>(T(e,s,t.groups,t.options.suppressErrors),null);function T(t,e,s,r){e(t&&!r?t:null,s)}function Ht(t,e){let{onlyCounts:s,group:r,maxFiles:i}=t;return s?e?Mt:qt:r?e?Wt:Bt:i?e?Ot:Gt:e?$t:Kt}var M={withFileTypes:!0},jt=(t,e,s,r,i)=>{if(t.queue.enqueue(),r<0)return t.queue.dequeue(null,t);let{fs:o}=t;t.visited.push(e),t.counts.directories++,o.readdir(e||".",M,(l,n=[])=>{i(n,s,r),t.queue.dequeue(t.options.suppressErrors?null:l,t)})},Lt=(t,e,s,r,i)=>{let{fs:o}=t;if(r<0)return;t.visited.push(e),t.counts.directories++;let l=[];try{l=o.readdirSync(e||".",M)}catch(n){if(!t.options.suppressErrors)throw n}i(l,s,r)};function Yt(t){return t?Lt:jt}var Ut=class{constructor(t){c(this,"count",0);this.onQueueEmpty=t}enqueue(){return this.count++,this.count}dequeue(t,e){this.onQueueEmpty&&(--this.count<=0||t)&&(this.onQueueEmpty(t,e),t&&(e.controller.abort(),this.onQueueEmpty=void 0))}},zt=class{constructor(){c(this,"_files",0);c(this,"_directories",0)}set files(t){this._files=t}get files(){return this._files}set directories(t){this._directories=t}get directories(){return this._directories}get dirs(){return this._directories}},Qt=class{constructor(){c(this,"aborted",!1)}abort(){this.aborted=!0}},W=class{constructor(t,e,s){c(this,"root");c(this,"isSynchronous");c(this,"state");c(this,"joinPath");c(this,"pushDirectory");c(this,"pushFile");c(this,"getArray");c(this,"groupFiles");c(this,"resolveSymlink");c(this,"walkDirectory");c(this,"callbackInvoker");c(this,"walk",(t,e,s)=>{let{paths:r,options:{filters:i,resolveSymlinks:o,excludeSymlinks:l,exclude:n,maxFiles:u,signal:h,useRealPaths:p,pathSeparator:f},controller:E}=this.state;if(E.aborted||h&&h.aborted||u&&r.length>u)return;let b=this.getArray(this.state.paths);for(let x=0;x<t.length;++x){let g=t[x];if(g.isFile()||g.isSymbolicLink()&&!o&&!l){let y=this.joinPath(g.name,e);this.pushFile(y,b,this.state.counts,i)}else if(g.isDirectory()){let y=ht(g.name,e,this.state.options.pathSeparator);if(n&&n(g.name,y))continue;this.pushDirectory(y,r,i),this.walkDirectory(this.state,y,y,s-1,this.walk)}else if(this.resolveSymlink&&g.isSymbolicLink()){let y=C(g.name,e);this.resolveSymlink(y,this.state,(U,m)=>{if(U.isDirectory()){if(m=F(m,this.state.options),n&&n(g.name,p?m:y+f))return;this.walkDirectory(this.state,m,p?m:y+f,s-1,this.walk)}else{m=p?m:y;let z=tt(m),Q=F(k(m),this.state.options);m=this.joinPath(z,Q),this.pushFile(m,b,this.state.counts,i)}})}}this.groupFiles(this.state.groups,e,b)});this.isSynchronous=!s,this.callbackInvoker=Ht(e,this.isSynchronous),this.root=F(t,e),this.state={root:ut(this.root)?this.root:this.root.slice(0,-1),paths:[""].slice(0,0),groups:[],counts:new zt,options:e,queue:new Ut((r,i)=>this.callbackInvoker(i,r,s)),symlinks:new Map,visited:[""].slice(0,0),controller:new Qt,fs:e.fs||it},this.joinPath=pt(this.root,e),this.pushDirectory=St(this.root,e),this.pushFile=At(e),this.getArray=Dt(e),this.groupFiles=vt(e),this.resolveSymlink=Ct(e,this.isSynchronous),this.walkDirectory=Yt(this.isSynchronous)}start(){return this.pushDirectory(this.root,this.state.paths,this.state.options.filters),this.walkDirectory(this.state,this.root,this.root,this.state.options.maxDepth,this.walk),this.isSynchronous?this.callbackInvoker(this.state,null):null}};function Xt(t,e){return new Promise((s,r)=>{$(t,e,(i,o)=>{if(i)return r(i);s(o)})})}function $(t,e,s){new W(t,e,s).start()}function Zt(t,e){return new W(t,e).start()}var v=class{constructor(t,e){this.root=t,this.options=e}withPromise(){return Xt(this.root,this.options)}withCallback(t){$(this.root,this.options,t)}sync(){return Zt(this.root,this.options)}},O=null;try{_.resolve("picomatch"),O=_("picomatch")}catch(t){}var q=class{constructor(t){c(this,"globCache",{});c(this,"options",{maxDepth:1/0,suppressErrors:!0,pathSeparator:I,filters:[]});c(this,"globFunction");this.options=w(w({},this.options),t),this.globFunction=this.options.globFunction}group(){return this.options.group=!0,this}withPathSeparator(t){return this.options.pathSeparator=t,this}withBasePath(){return this.options.includeBasePath=!0,this}withRelativePaths(){return this.options.relativePaths=!0,this}withDirs(){return this.options.includeDirs=!0,this}withMaxDepth(t){return this.options.maxDepth=t,this}withMaxFiles(t){return this.options.maxFiles=t,this}withFullPaths(){return this.options.resolvePaths=!0,this.options.includeBasePath=!0,this}withErrors(){return this.options.suppressErrors=!1,this}withSymlinks({resolvePaths:t=!0}={}){return this.options.resolveSymlinks=!0,this.options.useRealPaths=t,this.withFullPaths()}withAbortSignal(t){return this.options.signal=t,this}normalize(){return this.options.normalizePath=!0,this}filter(t){return this.options.filters.push(t),this}onlyDirs(){return this.options.excludeFiles=!0,this.options.includeDirs=!0,this}exclude(t){return this.options.exclude=t,this}onlyCounts(){return this.options.onlyCounts=!0,this}crawl(t){return new v(t||".",this.options)}withGlobFunction(t){return this.globFunction=t,this}crawlWithOptions(t,e){return this.options=w(w({},this.options),e),new v(t||".",this.options)}glob(...t){return this.globFunction?this.globWithOptions(t):this.globWithOptions(t,{dot:!0})}globWithOptions(t,...e){let s=this.globFunction||O;if(!s)throw new Error("Please specify a glob function to use glob matching.");var r=this.globCache[t.join("\0")];return r||(r=s(t,...e),this.globCache[t.join("\0")]=r),this.options.filters.push(i=>r(i)),this}};import{stat as Jt}from"fs/promises";function K(t){return new q().withFullPaths().exclude(s=>s==="node_modules"||s==="dist"||s==="build"||s===".git").filter(s=>s.endsWith(".ts")||s.endsWith(".tsx")).crawl(t).sync()}function G(t,e){let s=[];for(let r=0;r<t.length;r+=e)s.push(t.slice(r,r+e));return s}function B(t){return S(this,null,function*(){let e;try{e=yield Jt(t)}catch(s){throw new Error(`Directory "${t}" does not exist.`)}if(!e.isDirectory())throw new Error(`"${t}" is not a directory.`)})}import{Project as ie}from"ts-morph";import{SyntaxKind as a}from"ts-morph";var d={CHUNK_SIZE:50,TYPE_DEBT_METRICS:{EXPLICIT_ANY_WEIGHT:2,IMPLICIT_ANY_WEIGHT:3,AS_ANY_WEIGHT:2,SUPPRESSION_WEIGHT:4,NON_NULL_ASSERTION_WEIGHT:1}};var te=/(?:\/\/|\/\*)\s*@ts-(ignore|expect-error|nocheck)/g;function H(t){let e={explicitAny:0,implicitAny:0,asAny:0,suppressions:0,nonNullAssertions:0,validTypes:0,score:0};t.forEachDescendant(i=>{var l;let o=i.getKind();if(o===a.AnyKeyword){let n=i.getParent();(n==null?void 0:n.getKind())!==a.AsExpression&&e.explicitAny++}else o===a.AsExpression&&((l=i.asKindOrThrow(a.AsExpression).getTypeNode())==null?void 0:l.getKind())===a.AnyKeyword&&e.asAny++;if(o===a.Parameter){let n=i.asKindOrThrow(a.Parameter);if(!n.getTypeNode()&&!n.getInitializer()){let u=n.getParent(),h=u==null?void 0:u.getParent();((u==null?void 0:u.getKind())===a.ArrowFunction||(u==null?void 0:u.getKind())===a.FunctionExpression)&&(h==null?void 0:h.getKind())===a.CallExpression||e.implicitAny++}}o===a.NonNullExpression?e.nonNullAssertions++:re(o)&&e.validTypes++});let r=t.getFullText().match(te);return e.suppressions=r?r.length:0,e.score=ee(e),e}function ee(t){let e=t.explicitAny+t.implicitAny+t.asAny+t.suppressions+t.nonNullAssertions,s=t.validTypes+e;if(s==0)return 100;{let r=t.validTypes/s*100,o=se(t)/s*100;return Math.max(0,Math.round(r-o))}}function se(t){return t.explicitAny*d.TYPE_DEBT_METRICS.EXPLICIT_ANY_WEIGHT+t.implicitAny*d.TYPE_DEBT_METRICS.IMPLICIT_ANY_WEIGHT+t.asAny*d.TYPE_DEBT_METRICS.AS_ANY_WEIGHT+t.nonNullAssertions*d.TYPE_DEBT_METRICS.NON_NULL_ASSERTION_WEIGHT+t.suppressions*d.TYPE_DEBT_METRICS.SUPPRESSION_WEIGHT}function re(t){return t===a.TypeReference||t===a.StringKeyword||t===a.NumberKeyword||t===a.BooleanKeyword||t===a.InterfaceDeclaration||t===a.TypeAliasDeclaration}function j(t){let e=new ie({skipAddingFilesFromTsConfig:!0});e.addSourceFilesAtPaths(t);let s=[];e.forgetNodesCreatedInBlock(()=>{for(let r of e.getSourceFiles())s.push({filePath:r.getFilePath(),typeDebtMetrics:H(r)})});for(let r of e.getSourceFiles())e.removeSourceFile(r);return s}import{promises as ne}from"fs";import L from"path";function Y(t,e,s="type-debt-report.md"){return S(this,null,function*(){if(t.length===0)return;let r=t.length,i=t.reduce((p,f)=>p+f.typeDebtMetrics.score,0),o=Math.round(i/r),l=[...t].sort((p,f)=>p.typeDebtMetrics.score-f.typeDebtMetrics.score),n=`Type Debt Report
|
|
3
|
+
|
|
4
|
+
`;n+=`> Generated on: ${new Date().toUTCString()}
|
|
5
|
+
|
|
6
|
+
`,n+=`## Summary
|
|
7
|
+
`,n+=`- Total Files Scanned: ${r}
|
|
8
|
+
`,n+=`- Average Score: ${o}/100
|
|
9
|
+
|
|
10
|
+
`,n+=`## Top 10 Worst Offenders
|
|
11
|
+
|
|
12
|
+
`,n+="| Score | File Path | Suppressions | Implicit `any` | Explicit `any` | `as any` | Non-Null (`!`) |\n",n+=`| :---: | :--- | :---: | :---: | :---: | :---: | :---: |
|
|
13
|
+
`;let u=l.slice(0,10),h=L.resolve(e);for(let p of u){let f=p.typeDebtMetrics,E=L.relative(h,p.filePath);n+=`| ${f.score} | \`${E}\` | ${f.suppressions} | ${f.implicitAny} | ${f.explicitAny} | ${f.asAny} | ${f.nonNullAssertions} |
|
|
14
|
+
`}try{yield ne.writeFile(s,n,"utf-8"),console.log(`
|
|
15
|
+
Markdown report successfully generated at: ${s}`)}catch(p){console.error(`
|
|
16
|
+
Failed to write Markdown report:`,p)}})}var D=process.argv.slice(2);(D.includes("--help")||D.includes("-h"))&&(ue(),process.exit(0));var oe=D[0]||".";le(oe).catch(t=>{console.error(`
|
|
17
|
+
\x1B[31m Error: ${t.message}\x1B[0m
|
|
18
|
+
`),process.exit(1)});function le(t){return S(this,null,function*(){yield B(t),console.log(`
|
|
19
|
+
Scanning directory: ${t}`);let e=K(t);if(e.length===0)throw new Error("No TypeScript files found to process.");let s=G(e,d.CHUNK_SIZE),r=[];for(let[i,o]of s.entries()){let l=j(o);r=r.concat(l),process.stdout.write(`\rProcessed chunk ${i+1}/${s.length}`)}yield Y(r,t)})}function ue(){console.log(`
|
|
20
|
+
Type Debt Analyzer
|
|
21
|
+
|
|
22
|
+
Usage: npx tsx index.ts [directory]
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
npx tsx index.ts .
|
|
26
|
+
npx tsx index.ts ./src
|
|
27
|
+
`),process.exit(0)}
|
package/index.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { getTargetFiles } from "./utils.js";
|
|
4
|
+
import { chunkArray, validateDirectory } from "./utils.js";
|
|
5
|
+
import { processChunk } from "./core/chunkProcessor.js";
|
|
6
|
+
import { FileReport } from "./types.js";
|
|
7
|
+
import { CONSTANTS } from "./constants.js";
|
|
8
|
+
import { generateMarkdownReport } from "./core/reporter.js";
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
|
|
14
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
15
|
+
printHelp();
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const target = args[0] || ".";
|
|
20
|
+
|
|
21
|
+
runCLI(target).catch((err) => {
|
|
22
|
+
console.error(`\n\x1b[31m Error: ${err.message}\x1b[0m\n`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async function runCLI(targetDir: string) {
|
|
28
|
+
|
|
29
|
+
await validateDirectory(targetDir);
|
|
30
|
+
|
|
31
|
+
console.log(`\nScanning directory: ${targetDir}`);
|
|
32
|
+
const allFiles = getTargetFiles(targetDir);
|
|
33
|
+
|
|
34
|
+
if (allFiles.length === 0) {
|
|
35
|
+
throw new Error(`No TypeScript files found to process.`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const fileChunks = chunkArray(allFiles, CONSTANTS.CHUNK_SIZE);
|
|
39
|
+
|
|
40
|
+
let allResults: FileReport[] = [];
|
|
41
|
+
|
|
42
|
+
for (const [index, chunk] of fileChunks.entries()) {
|
|
43
|
+
|
|
44
|
+
const chunkResults = processChunk(chunk);
|
|
45
|
+
allResults = allResults.concat(chunkResults);
|
|
46
|
+
process.stdout.write(`\rProcessed chunk ${index + 1}/${fileChunks.length}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await generateMarkdownReport(allResults, targetDir);
|
|
50
|
+
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function printHelp() {
|
|
54
|
+
console.log(`
|
|
55
|
+
Type Debt Analyzer
|
|
56
|
+
|
|
57
|
+
Usage: npx tsx index.ts [directory]
|
|
58
|
+
|
|
59
|
+
Examples:
|
|
60
|
+
npx tsx index.ts .
|
|
61
|
+
npx tsx index.ts ./src
|
|
62
|
+
`);
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@m4x_7/type-debt",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "A high-performance TypeScript static analyzer that calculates Type Debt and generates reports.",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"type-debt": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsup index.ts --format esm --minify --clean",
|
|
12
|
+
"test": "vitest",
|
|
13
|
+
"dev": "nodemon --ignore temp_repos/ --exec ts-node main.ts"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"typescript",
|
|
17
|
+
"analyzer",
|
|
18
|
+
"type-debt",
|
|
19
|
+
"cli"
|
|
20
|
+
],
|
|
21
|
+
"author": "",
|
|
22
|
+
"license": "ISC",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"cors": "^2.8.6",
|
|
25
|
+
"express": "^5.2.1",
|
|
26
|
+
"jscpd": "^4.0.8",
|
|
27
|
+
"simple-git": "^3.35.2",
|
|
28
|
+
"ts-morph": "^27.0.2"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/cors": "^2.8.19",
|
|
32
|
+
"@types/express": "^5.0.6",
|
|
33
|
+
"@types/node": "^25.5.2",
|
|
34
|
+
"nodemon": "^3.1.14",
|
|
35
|
+
"ts-node": "^10.9.2",
|
|
36
|
+
"tsup": "^8.5.1",
|
|
37
|
+
"typescript": "^6.0.2",
|
|
38
|
+
"vitest": "^4.1.9"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { Project } from "ts-morph";
|
|
3
|
+
import { calculatePenalty, calculateScore, getTypeDebt } from "../core/typeDebt";
|
|
4
|
+
import { TypeDebtMetrics } from "../types";
|
|
5
|
+
|
|
6
|
+
describe("Type Debt Analysis", () => {
|
|
7
|
+
it("should correctly identify all forms of type debt", () => {
|
|
8
|
+
|
|
9
|
+
const project = new Project({ useInMemoryFileSystem: true });
|
|
10
|
+
|
|
11
|
+
const sourceCode = `
|
|
12
|
+
|
|
13
|
+
interface ValidUser { id: number; }
|
|
14
|
+
|
|
15
|
+
// @ts-ignore
|
|
16
|
+
const id: any = 123;
|
|
17
|
+
|
|
18
|
+
const user = fetchUser() as any;
|
|
19
|
+
|
|
20
|
+
function process(payload) {
|
|
21
|
+
console.log(payload!.data);
|
|
22
|
+
}
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
const sourceFile = project.createSourceFile("test.ts", sourceCode);
|
|
26
|
+
|
|
27
|
+
const metrics = getTypeDebt(sourceFile);
|
|
28
|
+
|
|
29
|
+
expect(metrics.explicitAny).toBe(1);
|
|
30
|
+
expect(metrics.asAny).toBe(1);
|
|
31
|
+
expect(metrics.suppressions).toBe(1);
|
|
32
|
+
expect(metrics.implicitAny).toBe(1);
|
|
33
|
+
expect(metrics.nonNullAssertions).toBe(1);
|
|
34
|
+
expect(metrics.validTypes).toBeGreaterThan(0);
|
|
35
|
+
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should correctly calculate penalty", () => {
|
|
39
|
+
|
|
40
|
+
const metrics: TypeDebtMetrics = {
|
|
41
|
+
explicitAny: 2,
|
|
42
|
+
implicitAny: 3,
|
|
43
|
+
asAny: 2,
|
|
44
|
+
suppressions: 1,
|
|
45
|
+
nonNullAssertions: 1,
|
|
46
|
+
validTypes: 40,
|
|
47
|
+
score: 0
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const penalty = calculatePenalty(metrics);
|
|
51
|
+
expect(penalty).toBe(22);
|
|
52
|
+
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
it("should correctly calculate score for ordinary data", () => {
|
|
58
|
+
|
|
59
|
+
let metrics: TypeDebtMetrics = {
|
|
60
|
+
explicitAny: 2,
|
|
61
|
+
implicitAny: 3,
|
|
62
|
+
asAny: 2,
|
|
63
|
+
suppressions: 1,
|
|
64
|
+
nonNullAssertions: 1,
|
|
65
|
+
validTypes: 40,
|
|
66
|
+
score: 0
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
metrics.score = calculateScore(metrics);
|
|
70
|
+
expect(metrics.score).toBe(37);
|
|
71
|
+
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should correctly calculate score when no type nodes were found in the file", () => {
|
|
75
|
+
|
|
76
|
+
let metrics: TypeDebtMetrics = {
|
|
77
|
+
explicitAny: 0,
|
|
78
|
+
implicitAny: 0,
|
|
79
|
+
asAny: 0,
|
|
80
|
+
suppressions: 0,
|
|
81
|
+
nonNullAssertions: 0,
|
|
82
|
+
validTypes: 0,
|
|
83
|
+
score: 0
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
metrics.score = calculateScore(metrics);
|
|
87
|
+
expect(metrics.score).toBe(100);
|
|
88
|
+
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
})
|
|
93
|
+
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
// Visit https://aka.ms/tsconfig to read more about this file
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
// File Layout
|
|
5
|
+
// "rootDir": "./src",
|
|
6
|
+
// "outDir": "./dist",
|
|
7
|
+
|
|
8
|
+
// Environment Settings
|
|
9
|
+
// See also https://aka.ms/tsconfig/module
|
|
10
|
+
"module": "CommonJS",
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"target": "es2016",
|
|
13
|
+
"types": [],
|
|
14
|
+
// For nodejs:
|
|
15
|
+
// "lib": ["esnext"],
|
|
16
|
+
// "types": ["node"],
|
|
17
|
+
// and npm install -D @types/node
|
|
18
|
+
|
|
19
|
+
// Other Outputs
|
|
20
|
+
"sourceMap": true,
|
|
21
|
+
"declaration": true,
|
|
22
|
+
"declarationMap": true,
|
|
23
|
+
|
|
24
|
+
// Stricter Typechecking Options
|
|
25
|
+
"noUncheckedIndexedAccess": true,
|
|
26
|
+
"exactOptionalPropertyTypes": true,
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
// Style Options
|
|
30
|
+
// "noImplicitReturns": true,
|
|
31
|
+
// "noImplicitOverride": true,
|
|
32
|
+
// "noUnusedLocals": true,
|
|
33
|
+
// "noUnusedParameters": true,
|
|
34
|
+
// "noFallthroughCasesInSwitch": true,
|
|
35
|
+
// "noPropertyAccessFromIndexSignature": true,
|
|
36
|
+
|
|
37
|
+
// Recommended Options
|
|
38
|
+
"strict": true,
|
|
39
|
+
"jsx": "react-jsx",
|
|
40
|
+
"verbatimModuleSyntax": false,
|
|
41
|
+
"isolatedModules": true,
|
|
42
|
+
"noUncheckedSideEffectImports": true,
|
|
43
|
+
"moduleDetection": "force",
|
|
44
|
+
"skipLibCheck": true,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Type Debt Report
|
|
2
|
+
|
|
3
|
+
> Generated on: Mon, 29 Jun 2026 14:06:35 GMT
|
|
4
|
+
|
|
5
|
+
## Summary
|
|
6
|
+
- Total Files Scanned: 709
|
|
7
|
+
- Average Score: 73/100
|
|
8
|
+
|
|
9
|
+
## Top 10 Worst Offenders
|
|
10
|
+
|
|
11
|
+
| Score | File Path | Suppressions | Implicit `any` | Explicit `any` | `as any` | Non-Null (`!`) |
|
|
12
|
+
| :---: | :--- | :---: | :---: | :---: | :---: | :---: |
|
|
13
|
+
| 0 | `compiler/visitorPublic.ts` | 0 | 810 | 1 | 0 | 4 |
|
|
14
|
+
| 0 | `harness/harnessGlobals.ts` | 0 | 3 | 1 | 0 | 0 |
|
|
15
|
+
| 0 | `lib/es2017.intl.d.ts` | 0 | 0 | 11 | 0 | 0 |
|
|
16
|
+
| 0 | `server/moduleSpecifierCache.ts` | 0 | 22 | 0 | 0 | 0 |
|
|
17
|
+
| 0 | `services/codefixes/addEmptyExportDeclaration.ts` | 0 | 1 | 0 | 0 | 0 |
|
|
18
|
+
| 0 | `services/codefixes/addMissingDeclareProperty.ts` | 0 | 2 | 0 | 0 | 0 |
|
|
19
|
+
| 0 | `services/codefixes/addMissingInvocationForDecorator.ts` | 0 | 2 | 0 | 0 | 1 |
|
|
20
|
+
| 0 | `services/codefixes/addMissingResolutionModeImportAttribute.ts` | 0 | 2 | 0 | 0 | 1 |
|
|
21
|
+
| 0 | `services/codefixes/disableJsDiagnostics.ts` | 3 | 2 | 0 | 0 | 0 |
|
|
22
|
+
| 0 | `services/codefixes/fixAddMissingNewOperator.ts` | 0 | 2 | 0 | 0 | 0 |
|
package/types.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface FileReport {
|
|
2
|
+
filePath: string;
|
|
3
|
+
typeDebtMetrics: TypeDebtMetrics;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface TypeDebtMetrics {
|
|
7
|
+
explicitAny: number;
|
|
8
|
+
implicitAny: number;
|
|
9
|
+
asAny: number;
|
|
10
|
+
suppressions: number;
|
|
11
|
+
nonNullAssertions: number;
|
|
12
|
+
validTypes: number;
|
|
13
|
+
score: number;
|
|
14
|
+
}
|
package/utils.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { fdir } from "fdir";
|
|
2
|
+
import { stat } from "fs/promises";
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
export function getTargetFiles(directoryPath: string): string[] {
|
|
6
|
+
const crawler = new fdir()
|
|
7
|
+
.withFullPaths()
|
|
8
|
+
.exclude((dirName) =>
|
|
9
|
+
dirName === "node_modules" ||
|
|
10
|
+
dirName === "dist" ||
|
|
11
|
+
dirName === "build" ||
|
|
12
|
+
dirName === ".git"
|
|
13
|
+
)
|
|
14
|
+
.filter((path) => path.endsWith(".ts") || path.endsWith(".tsx"))
|
|
15
|
+
.crawl(directoryPath);
|
|
16
|
+
|
|
17
|
+
return crawler.sync() as string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
export function chunkArray<T>(array: T[], chunkSize: number): T[][] {
|
|
22
|
+
const chunks: T[][] = [];
|
|
23
|
+
for (let i = 0; i < array.length; i += chunkSize) {
|
|
24
|
+
chunks.push(array.slice(i, i + chunkSize));
|
|
25
|
+
}
|
|
26
|
+
return chunks;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
export async function validateDirectory(path: string) {
|
|
31
|
+
|
|
32
|
+
let stats;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
stats = await stat(path);
|
|
36
|
+
} catch {
|
|
37
|
+
throw new Error(`Directory "${path}" does not exist.`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!stats.isDirectory()) {
|
|
41
|
+
throw new Error(`"${path}" is not a directory.`);
|
|
42
|
+
}
|
|
43
|
+
}
|