@aiready/doc-drift 0.1.5 → 0.1.8
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 +10 -10
- package/.turbo/turbo-lint.log +5 -0
- package/.turbo/turbo-test.log +7 -6
- package/README.md +1 -1
- package/dist/chunk-5EFFNN6L.mjs +145 -0
- package/dist/chunk-BBGJNBVI.mjs +189 -0
- package/dist/chunk-VLBPAYS3.mjs +209 -0
- package/dist/cli.js +39 -56
- package/dist/cli.mjs +11 -3
- package/dist/index.js +29 -54
- package/dist/index.mjs +1 -1
- package/package.json +8 -8
- package/src/__tests__/analyzer.test.ts +11 -5
- package/src/analyzer.ts +71 -65
- package/src/cli.ts +17 -7
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
|
|
3
|
-
> @aiready/doc-drift@0.1.
|
|
3
|
+
> @aiready/doc-drift@0.1.8 build /Users/pengcao/projects/aiready/packages/doc-drift
|
|
4
4
|
> tsup src/index.ts src/cli.ts --format cjs,esm --dts
|
|
5
5
|
|
|
6
6
|
[34mCLI[39m Building entry: src/cli.ts, src/index.ts
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
[34mCJS[39m Build start
|
|
11
11
|
[34mESM[39m Build start
|
|
12
12
|
|
|
13
|
-
[90m[[
|
|
13
|
+
[90m[[90m12:55:25 AM[90m][39m [43m[30m WARN [39m[49m [33m▲ [43;33m[[43;30mWARNING[43;33m][0m [1mThe condition "types" here will never be used as it comes after both "import" and "require"[0m [package.json]
|
|
14
14
|
|
|
15
15
|
package.json:33:6:
|
|
16
16
|
[37m 33 │ [32m"types"[37m: "./dist/index.d.ts"
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
[90m[[
|
|
34
|
+
[90m[[90m12:55:25 AM[90m][39m [43m[30m WARN [39m[49m [33m▲ [43;33m[[43;30mWARNING[43;33m][0m [1mThe condition "types" here will never be used as it comes after both "import" and "require"[0m [package.json]
|
|
35
35
|
|
|
36
36
|
package.json:33:6:
|
|
37
37
|
[37m 33 │ [32m"types"[37m: "./dist/index.d.ts"
|
|
@@ -51,15 +51,15 @@
|
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
|
|
54
|
-
[32mCJS[39m [1mdist/
|
|
55
|
-
[32mCJS[39m [1mdist/
|
|
56
|
-
[32mCJS[39m ⚡️ Build success in
|
|
57
|
-
[32mESM[39m [1mdist/
|
|
54
|
+
[32mCJS[39m [1mdist/cli.js [22m[32m7.58 KB[39m
|
|
55
|
+
[32mCJS[39m [1mdist/index.js [22m[32m5.55 KB[39m
|
|
56
|
+
[32mCJS[39m ⚡️ Build success in 511ms
|
|
57
|
+
[32mESM[39m [1mdist/chunk-5EFFNN6L.mjs [22m[32m4.85 KB[39m
|
|
58
58
|
[32mESM[39m [1mdist/index.mjs [22m[32m88.00 B[39m
|
|
59
|
-
[32mESM[39m [1mdist/
|
|
60
|
-
[32mESM[39m ⚡️ Build success in
|
|
59
|
+
[32mESM[39m [1mdist/cli.mjs [22m[32m1.39 KB[39m
|
|
60
|
+
[32mESM[39m ⚡️ Build success in 495ms
|
|
61
61
|
DTS Build start
|
|
62
|
-
DTS ⚡️ Build success in
|
|
62
|
+
DTS ⚡️ Build success in 10279ms
|
|
63
63
|
DTS dist/cli.d.ts 108.00 B
|
|
64
64
|
DTS dist/index.d.ts 950.00 B
|
|
65
65
|
DTS dist/cli.d.mts 108.00 B
|
package/.turbo/turbo-test.log
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
|
|
2
2
|
|
|
3
|
-
> @aiready/doc-drift@0.1.
|
|
3
|
+
> @aiready/doc-drift@0.1.8 test /Users/pengcao/projects/aiready/packages/doc-drift
|
|
4
4
|
> vitest run
|
|
5
5
|
|
|
6
|
+
[?25l
|
|
7
|
+
[1m[46m RUN [49m[22m [36mv4.0.18 [39m[90m/Users/pengcao/projects/aiready/packages/doc-drift[39m
|
|
6
8
|
|
|
7
|
-
[
|
|
8
|
-
|
|
9
|
-
[32m✓[39m src/__tests__/analyzer.test.ts [2m ([22m[2m1 test[22m[2m)[22m[33m 777[2mms[22m[39m
|
|
9
|
+
[32m✓[39m src/__tests__/analyzer.test.ts [2m([22m[2m1 test[22m[2m)[22m[33m 364[2mms[22m[39m
|
|
10
|
+
[33m[2m✓[22m[39m detects missing param documentation and uncommented complexity [33m 361[2mms[22m[39m
|
|
10
11
|
|
|
11
12
|
[2m Test Files [22m [1m[32m1 passed[39m[22m[90m (1)[39m
|
|
12
13
|
[2m Tests [22m [1m[32m1 passed[39m[22m[90m (1)[39m
|
|
13
|
-
[2m Start at [22m
|
|
14
|
-
[2m Duration [22m
|
|
14
|
+
[2m Start at [22m 00:56:24
|
|
15
|
+
[2m Duration [22m 5.43s[2m (transform 957ms, setup 0ms, import 3.81s, tests 364ms, environment 0ms)[22m
|
|
15
16
|
|
|
16
17
|
[?25h
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
## Overview
|
|
9
9
|
|
|
10
|
-
The **Documentation Drift** analyzer combines AST parsing with git log traversal to identify instances where comments are likely lagging behind actual implementation logic.
|
|
10
|
+
The **Documentation Drift** analyzer combines AST parsing with git log traversal to identify instances where comments are likely lagging behind actual implementation logic.
|
|
11
11
|
|
|
12
12
|
## 🏛️ Architecture
|
|
13
13
|
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
5
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
// src/analyzer.ts
|
|
9
|
+
import {
|
|
10
|
+
scanFiles,
|
|
11
|
+
calculateDocDrift,
|
|
12
|
+
getFileCommitTimestamps,
|
|
13
|
+
getLineRangeLastModifiedCached
|
|
14
|
+
} from "@aiready/core";
|
|
15
|
+
import { readFileSync } from "fs";
|
|
16
|
+
import { parse } from "@typescript-eslint/typescript-estree";
|
|
17
|
+
async function analyzeDocDrift(options) {
|
|
18
|
+
const rootDir = options.rootDir;
|
|
19
|
+
const files = await scanFiles(options);
|
|
20
|
+
const issues = [];
|
|
21
|
+
const results = [];
|
|
22
|
+
const staleMonths = options.staleMonths ?? 6;
|
|
23
|
+
const staleSeconds = staleMonths * 30 * 24 * 60 * 60;
|
|
24
|
+
let uncommentedExports = 0;
|
|
25
|
+
let totalExports = 0;
|
|
26
|
+
let outdatedComments = 0;
|
|
27
|
+
let undocumentedComplexity = 0;
|
|
28
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
29
|
+
let processed = 0;
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
processed++;
|
|
32
|
+
options.onProgress?.(processed, files.length, `doc-drift: analyzing files`);
|
|
33
|
+
let code;
|
|
34
|
+
try {
|
|
35
|
+
code = readFileSync(file, "utf-8");
|
|
36
|
+
} catch {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
let ast;
|
|
40
|
+
try {
|
|
41
|
+
ast = parse(code, {
|
|
42
|
+
jsx: file.endsWith(".tsx") || file.endsWith(".jsx"),
|
|
43
|
+
loc: true,
|
|
44
|
+
comment: true
|
|
45
|
+
});
|
|
46
|
+
} catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const comments = ast.comments || [];
|
|
50
|
+
let fileLineStamps;
|
|
51
|
+
for (const node of ast.body) {
|
|
52
|
+
if (node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration") {
|
|
53
|
+
const decl = node.declaration;
|
|
54
|
+
if (!decl) continue;
|
|
55
|
+
if (decl.type === "FunctionDeclaration" || decl.type === "ClassDeclaration" || decl.type === "VariableDeclaration") {
|
|
56
|
+
totalExports++;
|
|
57
|
+
const nodeLine = node.loc.start.line;
|
|
58
|
+
const jsdocs = comments.filter(
|
|
59
|
+
(c) => c.type === "Block" && c.value.startsWith("*") && c.loc.end.line === nodeLine - 1
|
|
60
|
+
);
|
|
61
|
+
if (jsdocs.length === 0) {
|
|
62
|
+
uncommentedExports++;
|
|
63
|
+
if (decl.type === "FunctionDeclaration" && decl.body?.loc) {
|
|
64
|
+
const lines = decl.body.loc.end.line - decl.body.loc.start.line;
|
|
65
|
+
if (lines > 20) undocumentedComplexity++;
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
const jsdoc = jsdocs[0];
|
|
69
|
+
const jsdocText = jsdoc.value;
|
|
70
|
+
if (decl.type === "FunctionDeclaration") {
|
|
71
|
+
const params = decl.params.map((p) => p.name || p.left && p.left.name).filter(Boolean);
|
|
72
|
+
const paramTags = Array.from(
|
|
73
|
+
jsdocText.matchAll(/@param\s+(?:\{[^}]+\}\s+)?([a-zA-Z0-9_]+)/g)
|
|
74
|
+
).map((m) => m[1]);
|
|
75
|
+
const missingParams = params.filter(
|
|
76
|
+
(p) => !paramTags.includes(p)
|
|
77
|
+
);
|
|
78
|
+
if (missingParams.length > 0) {
|
|
79
|
+
outdatedComments++;
|
|
80
|
+
issues.push({
|
|
81
|
+
type: "doc-drift",
|
|
82
|
+
severity: "major",
|
|
83
|
+
message: `JSDoc @param mismatch: function has parameters (${missingParams.join(", ")}) not documented in JSDoc.`,
|
|
84
|
+
location: { file, line: nodeLine }
|
|
85
|
+
});
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!fileLineStamps) {
|
|
90
|
+
fileLineStamps = getFileCommitTimestamps(file);
|
|
91
|
+
}
|
|
92
|
+
const commentModified = getLineRangeLastModifiedCached(
|
|
93
|
+
fileLineStamps,
|
|
94
|
+
jsdoc.loc.start.line,
|
|
95
|
+
jsdoc.loc.end.line
|
|
96
|
+
);
|
|
97
|
+
const bodyModified = getLineRangeLastModifiedCached(
|
|
98
|
+
fileLineStamps,
|
|
99
|
+
decl.loc.start.line,
|
|
100
|
+
decl.loc.end.line
|
|
101
|
+
);
|
|
102
|
+
if (commentModified > 0 && bodyModified > 0) {
|
|
103
|
+
if (now - commentModified > staleSeconds && bodyModified - commentModified > staleSeconds / 2) {
|
|
104
|
+
outdatedComments++;
|
|
105
|
+
issues.push({
|
|
106
|
+
type: "doc-drift",
|
|
107
|
+
severity: "minor",
|
|
108
|
+
message: `JSDoc is significantly older than the function body implementation. Code may have drifted.`,
|
|
109
|
+
location: { file, line: jsdoc.loc.start.line }
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const riskResult = calculateDocDrift({
|
|
119
|
+
uncommentedExports,
|
|
120
|
+
totalExports,
|
|
121
|
+
outdatedComments,
|
|
122
|
+
undocumentedComplexity
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
summary: {
|
|
126
|
+
filesAnalyzed: files.length,
|
|
127
|
+
functionsAnalyzed: totalExports,
|
|
128
|
+
score: riskResult.score,
|
|
129
|
+
rating: riskResult.rating
|
|
130
|
+
},
|
|
131
|
+
issues,
|
|
132
|
+
rawData: {
|
|
133
|
+
uncommentedExports,
|
|
134
|
+
totalExports,
|
|
135
|
+
outdatedComments,
|
|
136
|
+
undocumentedComplexity
|
|
137
|
+
},
|
|
138
|
+
recommendations: riskResult.recommendations
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export {
|
|
143
|
+
__require,
|
|
144
|
+
analyzeDocDrift
|
|
145
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
5
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
// src/analyzer.ts
|
|
9
|
+
import { calculateDocDrift } from "@aiready/core";
|
|
10
|
+
import { readdirSync, statSync, readFileSync } from "fs";
|
|
11
|
+
import { join, extname } from "path";
|
|
12
|
+
import { parse } from "@typescript-eslint/typescript-estree";
|
|
13
|
+
import { execSync } from "child_process";
|
|
14
|
+
var SRC_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
15
|
+
var DEFAULT_EXCLUDES = [
|
|
16
|
+
"node_modules",
|
|
17
|
+
"dist",
|
|
18
|
+
".git",
|
|
19
|
+
"coverage",
|
|
20
|
+
".turbo",
|
|
21
|
+
"build"
|
|
22
|
+
];
|
|
23
|
+
function collectFiles(dir, options, depth = 0) {
|
|
24
|
+
if (depth > 20) return [];
|
|
25
|
+
const excludes = [...DEFAULT_EXCLUDES, ...options.exclude ?? []];
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = readdirSync(dir);
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
const files = [];
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
if (excludes.some((ex) => entry === ex || entry.includes(ex))) continue;
|
|
35
|
+
const full = join(dir, entry);
|
|
36
|
+
let stat;
|
|
37
|
+
try {
|
|
38
|
+
stat = statSync(full);
|
|
39
|
+
} catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (stat.isDirectory()) {
|
|
43
|
+
files.push(...collectFiles(full, options, depth + 1));
|
|
44
|
+
} else if (stat.isFile() && SRC_EXTENSIONS.has(extname(full))) {
|
|
45
|
+
if (!options.include || options.include.some((p) => full.includes(p))) {
|
|
46
|
+
files.push(full);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return files;
|
|
51
|
+
}
|
|
52
|
+
function getLineRangeLastModified(file, startLine, endLine) {
|
|
53
|
+
try {
|
|
54
|
+
const output = execSync(
|
|
55
|
+
`git log -1 --format=%ct -L ${startLine},${endLine}:"${file}"`,
|
|
56
|
+
{
|
|
57
|
+
encoding: "utf-8",
|
|
58
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
const match = output.trim().split("\n")[0];
|
|
62
|
+
if (match && !isNaN(parseInt(match, 10))) {
|
|
63
|
+
return parseInt(match, 10);
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
}
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
async function analyzeDocDrift(options) {
|
|
70
|
+
const rootDir = options.rootDir;
|
|
71
|
+
const files = collectFiles(rootDir, options);
|
|
72
|
+
const staleMonths = options.staleMonths ?? 6;
|
|
73
|
+
const staleSeconds = staleMonths * 30 * 24 * 60 * 60;
|
|
74
|
+
let uncommentedExports = 0;
|
|
75
|
+
let totalExports = 0;
|
|
76
|
+
let outdatedComments = 0;
|
|
77
|
+
let undocumentedComplexity = 0;
|
|
78
|
+
const issues = [];
|
|
79
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
80
|
+
for (const file of files) {
|
|
81
|
+
let code;
|
|
82
|
+
try {
|
|
83
|
+
code = readFileSync(file, "utf-8");
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
let ast;
|
|
88
|
+
try {
|
|
89
|
+
ast = parse(code, {
|
|
90
|
+
jsx: file.endsWith(".tsx") || file.endsWith(".jsx"),
|
|
91
|
+
loc: true,
|
|
92
|
+
comment: true
|
|
93
|
+
});
|
|
94
|
+
} catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const comments = ast.comments || [];
|
|
98
|
+
for (const node of ast.body) {
|
|
99
|
+
if (node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration") {
|
|
100
|
+
const decl = node.declaration;
|
|
101
|
+
if (!decl) continue;
|
|
102
|
+
if (decl.type === "FunctionDeclaration" || decl.type === "ClassDeclaration" || decl.type === "VariableDeclaration") {
|
|
103
|
+
totalExports++;
|
|
104
|
+
const nodeLine = node.loc.start.line;
|
|
105
|
+
const jsdocs = comments.filter(
|
|
106
|
+
(c) => c.type === "Block" && c.value.startsWith("*") && c.loc.end.line === nodeLine - 1
|
|
107
|
+
);
|
|
108
|
+
if (jsdocs.length === 0) {
|
|
109
|
+
uncommentedExports++;
|
|
110
|
+
if (decl.type === "FunctionDeclaration" && decl.body?.loc) {
|
|
111
|
+
const lines = decl.body.loc.end.line - decl.body.loc.start.line;
|
|
112
|
+
if (lines > 20) undocumentedComplexity++;
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
const jsdoc = jsdocs[0];
|
|
116
|
+
const jsdocText = jsdoc.value;
|
|
117
|
+
if (decl.type === "FunctionDeclaration") {
|
|
118
|
+
const params = decl.params.map((p) => p.name || p.left && p.left.name).filter(Boolean);
|
|
119
|
+
const paramTags = Array.from(
|
|
120
|
+
jsdocText.matchAll(/@param\s+(?:\{[^}]+\}\s+)?([a-zA-Z0-9_]+)/g)
|
|
121
|
+
).map((m) => m[1]);
|
|
122
|
+
const missingParams = params.filter(
|
|
123
|
+
(p) => !paramTags.includes(p)
|
|
124
|
+
);
|
|
125
|
+
if (missingParams.length > 0) {
|
|
126
|
+
outdatedComments++;
|
|
127
|
+
issues.push({
|
|
128
|
+
type: "doc-drift",
|
|
129
|
+
severity: "major",
|
|
130
|
+
message: `JSDoc @param mismatch: function has parameters (${missingParams.join(", ")}) not documented in JSDoc.`,
|
|
131
|
+
location: { file, line: nodeLine }
|
|
132
|
+
});
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const commentModified = getLineRangeLastModified(
|
|
137
|
+
file,
|
|
138
|
+
jsdoc.loc.start.line,
|
|
139
|
+
jsdoc.loc.end.line
|
|
140
|
+
);
|
|
141
|
+
const bodyModified = getLineRangeLastModified(
|
|
142
|
+
file,
|
|
143
|
+
decl.loc.start.line,
|
|
144
|
+
decl.loc.end.line
|
|
145
|
+
);
|
|
146
|
+
if (commentModified > 0 && bodyModified > 0) {
|
|
147
|
+
if (now - commentModified > staleSeconds && bodyModified - commentModified > staleSeconds / 2) {
|
|
148
|
+
outdatedComments++;
|
|
149
|
+
issues.push({
|
|
150
|
+
type: "doc-drift",
|
|
151
|
+
severity: "minor",
|
|
152
|
+
message: `JSDoc is significantly older than the function body implementation. Code may have drifted.`,
|
|
153
|
+
location: { file, line: jsdoc.loc.start.line }
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const riskResult = calculateDocDrift({
|
|
163
|
+
uncommentedExports,
|
|
164
|
+
totalExports,
|
|
165
|
+
outdatedComments,
|
|
166
|
+
undocumentedComplexity
|
|
167
|
+
});
|
|
168
|
+
return {
|
|
169
|
+
summary: {
|
|
170
|
+
filesAnalyzed: files.length,
|
|
171
|
+
functionsAnalyzed: totalExports,
|
|
172
|
+
score: riskResult.score,
|
|
173
|
+
rating: riskResult.rating
|
|
174
|
+
},
|
|
175
|
+
issues,
|
|
176
|
+
rawData: {
|
|
177
|
+
uncommentedExports,
|
|
178
|
+
totalExports,
|
|
179
|
+
outdatedComments,
|
|
180
|
+
undocumentedComplexity
|
|
181
|
+
},
|
|
182
|
+
recommendations: riskResult.recommendations
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export {
|
|
187
|
+
__require,
|
|
188
|
+
analyzeDocDrift
|
|
189
|
+
};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
5
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
// src/analyzer.ts
|
|
9
|
+
import { calculateDocDrift } from "@aiready/core";
|
|
10
|
+
import { readdirSync, statSync, readFileSync } from "fs";
|
|
11
|
+
import { join, extname } from "path";
|
|
12
|
+
import { parse } from "@typescript-eslint/typescript-estree";
|
|
13
|
+
import { execSync } from "child_process";
|
|
14
|
+
var SRC_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
15
|
+
var DEFAULT_EXCLUDES = [
|
|
16
|
+
"node_modules",
|
|
17
|
+
"dist",
|
|
18
|
+
".git",
|
|
19
|
+
"coverage",
|
|
20
|
+
".turbo",
|
|
21
|
+
"build"
|
|
22
|
+
];
|
|
23
|
+
function collectFiles(dir, options, depth = 0) {
|
|
24
|
+
if (depth > 20) return [];
|
|
25
|
+
const excludes = [...DEFAULT_EXCLUDES, ...options.exclude ?? []];
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = readdirSync(dir);
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
const files = [];
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
if (excludes.some((ex) => entry === ex || entry.includes(ex))) continue;
|
|
35
|
+
const full = join(dir, entry);
|
|
36
|
+
let stat;
|
|
37
|
+
try {
|
|
38
|
+
stat = statSync(full);
|
|
39
|
+
} catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (stat.isDirectory()) {
|
|
43
|
+
files.push(...collectFiles(full, options, depth + 1));
|
|
44
|
+
} else if (stat.isFile() && SRC_EXTENSIONS.has(extname(full))) {
|
|
45
|
+
if (!options.include || options.include.some((p) => full.includes(p))) {
|
|
46
|
+
files.push(full);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return files;
|
|
51
|
+
}
|
|
52
|
+
function getFileCommitTimestamps(file) {
|
|
53
|
+
const lineStamps = {};
|
|
54
|
+
try {
|
|
55
|
+
const output = execSync(`git blame -t "${file}"`, {
|
|
56
|
+
encoding: "utf-8",
|
|
57
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
58
|
+
});
|
|
59
|
+
const lines = output.split("\n");
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
if (!line) continue;
|
|
62
|
+
const match = line.match(/^\S+\s+\(.*?(\d{10,})\s+[-+]\d+\s+(\d+)\)/);
|
|
63
|
+
if (match) {
|
|
64
|
+
const ts = parseInt(match[1], 10);
|
|
65
|
+
const ln = parseInt(match[2], 10);
|
|
66
|
+
lineStamps[ln] = ts;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
}
|
|
71
|
+
return lineStamps;
|
|
72
|
+
}
|
|
73
|
+
function getLineRangeLastModifiedCached(lineStamps, startLine, endLine) {
|
|
74
|
+
let latest = 0;
|
|
75
|
+
for (let i = startLine; i <= endLine; i++) {
|
|
76
|
+
if (lineStamps[i] && lineStamps[i] > latest) {
|
|
77
|
+
latest = lineStamps[i];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return latest;
|
|
81
|
+
}
|
|
82
|
+
async function analyzeDocDrift(options) {
|
|
83
|
+
const rootDir = options.rootDir;
|
|
84
|
+
const files = collectFiles(rootDir, options);
|
|
85
|
+
const staleMonths = options.staleMonths ?? 6;
|
|
86
|
+
const staleSeconds = staleMonths * 30 * 24 * 60 * 60;
|
|
87
|
+
let uncommentedExports = 0;
|
|
88
|
+
let totalExports = 0;
|
|
89
|
+
let outdatedComments = 0;
|
|
90
|
+
let undocumentedComplexity = 0;
|
|
91
|
+
const issues = [];
|
|
92
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
93
|
+
let processed = 0;
|
|
94
|
+
for (const file of files) {
|
|
95
|
+
processed++;
|
|
96
|
+
options.onProgress?.(processed, files.length, `doc-drift: analyzing ${file.substring(rootDir.length + 1)}`);
|
|
97
|
+
let code;
|
|
98
|
+
try {
|
|
99
|
+
code = readFileSync(file, "utf-8");
|
|
100
|
+
} catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
let ast;
|
|
104
|
+
try {
|
|
105
|
+
ast = parse(code, {
|
|
106
|
+
jsx: file.endsWith(".tsx") || file.endsWith(".jsx"),
|
|
107
|
+
loc: true,
|
|
108
|
+
comment: true
|
|
109
|
+
});
|
|
110
|
+
} catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const comments = ast.comments || [];
|
|
114
|
+
let fileLineStamps;
|
|
115
|
+
for (const node of ast.body) {
|
|
116
|
+
if (node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration") {
|
|
117
|
+
const decl = node.declaration;
|
|
118
|
+
if (!decl) continue;
|
|
119
|
+
if (decl.type === "FunctionDeclaration" || decl.type === "ClassDeclaration" || decl.type === "VariableDeclaration") {
|
|
120
|
+
totalExports++;
|
|
121
|
+
const nodeLine = node.loc.start.line;
|
|
122
|
+
const jsdocs = comments.filter(
|
|
123
|
+
(c) => c.type === "Block" && c.value.startsWith("*") && c.loc.end.line === nodeLine - 1
|
|
124
|
+
);
|
|
125
|
+
if (jsdocs.length === 0) {
|
|
126
|
+
uncommentedExports++;
|
|
127
|
+
if (decl.type === "FunctionDeclaration" && decl.body?.loc) {
|
|
128
|
+
const lines = decl.body.loc.end.line - decl.body.loc.start.line;
|
|
129
|
+
if (lines > 20) undocumentedComplexity++;
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
const jsdoc = jsdocs[0];
|
|
133
|
+
const jsdocText = jsdoc.value;
|
|
134
|
+
if (decl.type === "FunctionDeclaration") {
|
|
135
|
+
const params = decl.params.map((p) => p.name || p.left && p.left.name).filter(Boolean);
|
|
136
|
+
const paramTags = Array.from(
|
|
137
|
+
jsdocText.matchAll(/@param\s+(?:\{[^}]+\}\s+)?([a-zA-Z0-9_]+)/g)
|
|
138
|
+
).map((m) => m[1]);
|
|
139
|
+
const missingParams = params.filter(
|
|
140
|
+
(p) => !paramTags.includes(p)
|
|
141
|
+
);
|
|
142
|
+
if (missingParams.length > 0) {
|
|
143
|
+
outdatedComments++;
|
|
144
|
+
issues.push({
|
|
145
|
+
type: "doc-drift",
|
|
146
|
+
severity: "major",
|
|
147
|
+
message: `JSDoc @param mismatch: function has parameters (${missingParams.join(", ")}) not documented in JSDoc.`,
|
|
148
|
+
location: { file, line: nodeLine }
|
|
149
|
+
});
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!fileLineStamps) {
|
|
154
|
+
fileLineStamps = getFileCommitTimestamps(file);
|
|
155
|
+
}
|
|
156
|
+
const commentModified = getLineRangeLastModifiedCached(
|
|
157
|
+
fileLineStamps,
|
|
158
|
+
jsdoc.loc.start.line,
|
|
159
|
+
jsdoc.loc.end.line
|
|
160
|
+
);
|
|
161
|
+
const bodyModified = getLineRangeLastModifiedCached(
|
|
162
|
+
fileLineStamps,
|
|
163
|
+
decl.loc.start.line,
|
|
164
|
+
decl.loc.end.line
|
|
165
|
+
);
|
|
166
|
+
if (commentModified > 0 && bodyModified > 0) {
|
|
167
|
+
if (now - commentModified > staleSeconds && bodyModified - commentModified > staleSeconds / 2) {
|
|
168
|
+
outdatedComments++;
|
|
169
|
+
issues.push({
|
|
170
|
+
type: "doc-drift",
|
|
171
|
+
severity: "minor",
|
|
172
|
+
message: `JSDoc is significantly older than the function body implementation. Code may have drifted.`,
|
|
173
|
+
location: { file, line: jsdoc.loc.start.line }
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const riskResult = calculateDocDrift({
|
|
183
|
+
uncommentedExports,
|
|
184
|
+
totalExports,
|
|
185
|
+
outdatedComments,
|
|
186
|
+
undocumentedComplexity
|
|
187
|
+
});
|
|
188
|
+
return {
|
|
189
|
+
summary: {
|
|
190
|
+
filesAnalyzed: files.length,
|
|
191
|
+
functionsAnalyzed: totalExports,
|
|
192
|
+
score: riskResult.score,
|
|
193
|
+
rating: riskResult.rating
|
|
194
|
+
},
|
|
195
|
+
issues,
|
|
196
|
+
rawData: {
|
|
197
|
+
uncommentedExports,
|
|
198
|
+
totalExports,
|
|
199
|
+
outdatedComments,
|
|
200
|
+
undocumentedComplexity
|
|
201
|
+
},
|
|
202
|
+
recommendations: riskResult.recommendations
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export {
|
|
207
|
+
__require,
|
|
208
|
+
analyzeDocDrift
|
|
209
|
+
};
|
package/dist/cli.js
CHANGED
|
@@ -38,66 +38,23 @@ var import_commander = require("commander");
|
|
|
38
38
|
// src/analyzer.ts
|
|
39
39
|
var import_core = require("@aiready/core");
|
|
40
40
|
var import_fs = require("fs");
|
|
41
|
-
var import_path = require("path");
|
|
42
41
|
var import_typescript_estree = require("@typescript-eslint/typescript-estree");
|
|
43
|
-
var import_child_process = require("child_process");
|
|
44
|
-
var SRC_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
45
|
-
var DEFAULT_EXCLUDES = ["node_modules", "dist", ".git", "coverage", ".turbo", "build"];
|
|
46
|
-
function collectFiles(dir, options, depth = 0) {
|
|
47
|
-
if (depth > 20) return [];
|
|
48
|
-
const excludes = [...DEFAULT_EXCLUDES, ...options.exclude ?? []];
|
|
49
|
-
let entries;
|
|
50
|
-
try {
|
|
51
|
-
entries = (0, import_fs.readdirSync)(dir);
|
|
52
|
-
} catch {
|
|
53
|
-
return [];
|
|
54
|
-
}
|
|
55
|
-
const files = [];
|
|
56
|
-
for (const entry of entries) {
|
|
57
|
-
if (excludes.some((ex) => entry === ex || entry.includes(ex))) continue;
|
|
58
|
-
const full = (0, import_path.join)(dir, entry);
|
|
59
|
-
let stat;
|
|
60
|
-
try {
|
|
61
|
-
stat = (0, import_fs.statSync)(full);
|
|
62
|
-
} catch {
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
if (stat.isDirectory()) {
|
|
66
|
-
files.push(...collectFiles(full, options, depth + 1));
|
|
67
|
-
} else if (stat.isFile() && SRC_EXTENSIONS.has((0, import_path.extname)(full))) {
|
|
68
|
-
if (!options.include || options.include.some((p) => full.includes(p))) {
|
|
69
|
-
files.push(full);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return files;
|
|
74
|
-
}
|
|
75
|
-
function getLineRangeLastModified(file, startLine, endLine) {
|
|
76
|
-
try {
|
|
77
|
-
const output = (0, import_child_process.execSync)(`git log -1 --format=%ct -L ${startLine},${endLine}:"${file}"`, {
|
|
78
|
-
encoding: "utf-8",
|
|
79
|
-
stdio: ["ignore", "pipe", "ignore"]
|
|
80
|
-
});
|
|
81
|
-
const match = output.trim().split("\n")[0];
|
|
82
|
-
if (match && !isNaN(parseInt(match, 10))) {
|
|
83
|
-
return parseInt(match, 10);
|
|
84
|
-
}
|
|
85
|
-
} catch {
|
|
86
|
-
}
|
|
87
|
-
return 0;
|
|
88
|
-
}
|
|
89
42
|
async function analyzeDocDrift(options) {
|
|
90
43
|
const rootDir = options.rootDir;
|
|
91
|
-
const files =
|
|
44
|
+
const files = await (0, import_core.scanFiles)(options);
|
|
45
|
+
const issues = [];
|
|
46
|
+
const results = [];
|
|
92
47
|
const staleMonths = options.staleMonths ?? 6;
|
|
93
48
|
const staleSeconds = staleMonths * 30 * 24 * 60 * 60;
|
|
94
49
|
let uncommentedExports = 0;
|
|
95
50
|
let totalExports = 0;
|
|
96
51
|
let outdatedComments = 0;
|
|
97
52
|
let undocumentedComplexity = 0;
|
|
98
|
-
const issues = [];
|
|
99
53
|
const now = Math.floor(Date.now() / 1e3);
|
|
54
|
+
let processed = 0;
|
|
100
55
|
for (const file of files) {
|
|
56
|
+
processed++;
|
|
57
|
+
options.onProgress?.(processed, files.length, `doc-drift: analyzing files`);
|
|
101
58
|
let code;
|
|
102
59
|
try {
|
|
103
60
|
code = (0, import_fs.readFileSync)(file, "utf-8");
|
|
@@ -115,6 +72,7 @@ async function analyzeDocDrift(options) {
|
|
|
115
72
|
continue;
|
|
116
73
|
}
|
|
117
74
|
const comments = ast.comments || [];
|
|
75
|
+
let fileLineStamps;
|
|
118
76
|
for (const node of ast.body) {
|
|
119
77
|
if (node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration") {
|
|
120
78
|
const decl = node.declaration;
|
|
@@ -122,7 +80,9 @@ async function analyzeDocDrift(options) {
|
|
|
122
80
|
if (decl.type === "FunctionDeclaration" || decl.type === "ClassDeclaration" || decl.type === "VariableDeclaration") {
|
|
123
81
|
totalExports++;
|
|
124
82
|
const nodeLine = node.loc.start.line;
|
|
125
|
-
const jsdocs = comments.filter(
|
|
83
|
+
const jsdocs = comments.filter(
|
|
84
|
+
(c) => c.type === "Block" && c.value.startsWith("*") && c.loc.end.line === nodeLine - 1
|
|
85
|
+
);
|
|
126
86
|
if (jsdocs.length === 0) {
|
|
127
87
|
uncommentedExports++;
|
|
128
88
|
if (decl.type === "FunctionDeclaration" && decl.body?.loc) {
|
|
@@ -134,8 +94,12 @@ async function analyzeDocDrift(options) {
|
|
|
134
94
|
const jsdocText = jsdoc.value;
|
|
135
95
|
if (decl.type === "FunctionDeclaration") {
|
|
136
96
|
const params = decl.params.map((p) => p.name || p.left && p.left.name).filter(Boolean);
|
|
137
|
-
const paramTags = Array.from(
|
|
138
|
-
|
|
97
|
+
const paramTags = Array.from(
|
|
98
|
+
jsdocText.matchAll(/@param\s+(?:\{[^}]+\}\s+)?([a-zA-Z0-9_]+)/g)
|
|
99
|
+
).map((m) => m[1]);
|
|
100
|
+
const missingParams = params.filter(
|
|
101
|
+
(p) => !paramTags.includes(p)
|
|
102
|
+
);
|
|
139
103
|
if (missingParams.length > 0) {
|
|
140
104
|
outdatedComments++;
|
|
141
105
|
issues.push({
|
|
@@ -147,8 +111,19 @@ async function analyzeDocDrift(options) {
|
|
|
147
111
|
continue;
|
|
148
112
|
}
|
|
149
113
|
}
|
|
150
|
-
|
|
151
|
-
|
|
114
|
+
if (!fileLineStamps) {
|
|
115
|
+
fileLineStamps = (0, import_core.getFileCommitTimestamps)(file);
|
|
116
|
+
}
|
|
117
|
+
const commentModified = (0, import_core.getLineRangeLastModifiedCached)(
|
|
118
|
+
fileLineStamps,
|
|
119
|
+
jsdoc.loc.start.line,
|
|
120
|
+
jsdoc.loc.end.line
|
|
121
|
+
);
|
|
122
|
+
const bodyModified = (0, import_core.getLineRangeLastModifiedCached)(
|
|
123
|
+
fileLineStamps,
|
|
124
|
+
decl.loc.start.line,
|
|
125
|
+
decl.loc.end.line
|
|
126
|
+
);
|
|
152
127
|
if (commentModified > 0 && bodyModified > 0) {
|
|
153
128
|
if (now - commentModified > staleSeconds && bodyModified - commentModified > staleSeconds / 2) {
|
|
154
129
|
outdatedComments++;
|
|
@@ -192,7 +167,13 @@ async function analyzeDocDrift(options) {
|
|
|
192
167
|
// src/cli.ts
|
|
193
168
|
var import_picocolors = __toESM(require("picocolors"));
|
|
194
169
|
function createCommand() {
|
|
195
|
-
const program = new import_commander.Command("doc-drift").description(
|
|
170
|
+
const program = new import_commander.Command("doc-drift").description(
|
|
171
|
+
"Scan for documentation drift (outdated comments, mismatched signatures)"
|
|
172
|
+
).option("--include <patterns...>", "File patterns to include").option("--exclude <patterns...>", "File patterns to exclude").option(
|
|
173
|
+
"--stale-months <number>",
|
|
174
|
+
"Months before a comment is considered potentially outdated",
|
|
175
|
+
"6"
|
|
176
|
+
).action(async (options) => {
|
|
196
177
|
console.log(import_picocolors.default.cyan("Analyzing documentation drift..."));
|
|
197
178
|
const report = await analyzeDocDrift({
|
|
198
179
|
rootDir: process.cwd(),
|
|
@@ -201,7 +182,9 @@ function createCommand() {
|
|
|
201
182
|
staleMonths: parseInt(options.staleMonths, 10)
|
|
202
183
|
});
|
|
203
184
|
console.log(import_picocolors.default.bold("Doc Drift Analysis Results:"));
|
|
204
|
-
console.log(
|
|
185
|
+
console.log(
|
|
186
|
+
`Rating: ${report.summary.rating.toUpperCase()} (Score: ${report.summary.score})`
|
|
187
|
+
);
|
|
205
188
|
if (report.issues.length > 0) {
|
|
206
189
|
console.log(import_picocolors.default.red(`
|
|
207
190
|
Found ${report.issues.length} drift issues.`));
|
package/dist/cli.mjs
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import {
|
|
2
2
|
__require,
|
|
3
3
|
analyzeDocDrift
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-5EFFNN6L.mjs";
|
|
5
5
|
|
|
6
6
|
// src/cli.ts
|
|
7
7
|
import { Command } from "commander";
|
|
8
8
|
import pc from "picocolors";
|
|
9
9
|
function createCommand() {
|
|
10
|
-
const program = new Command("doc-drift").description(
|
|
10
|
+
const program = new Command("doc-drift").description(
|
|
11
|
+
"Scan for documentation drift (outdated comments, mismatched signatures)"
|
|
12
|
+
).option("--include <patterns...>", "File patterns to include").option("--exclude <patterns...>", "File patterns to exclude").option(
|
|
13
|
+
"--stale-months <number>",
|
|
14
|
+
"Months before a comment is considered potentially outdated",
|
|
15
|
+
"6"
|
|
16
|
+
).action(async (options) => {
|
|
11
17
|
console.log(pc.cyan("Analyzing documentation drift..."));
|
|
12
18
|
const report = await analyzeDocDrift({
|
|
13
19
|
rootDir: process.cwd(),
|
|
@@ -16,7 +22,9 @@ function createCommand() {
|
|
|
16
22
|
staleMonths: parseInt(options.staleMonths, 10)
|
|
17
23
|
});
|
|
18
24
|
console.log(pc.bold("Doc Drift Analysis Results:"));
|
|
19
|
-
console.log(
|
|
25
|
+
console.log(
|
|
26
|
+
`Rating: ${report.summary.rating.toUpperCase()} (Score: ${report.summary.score})`
|
|
27
|
+
);
|
|
20
28
|
if (report.issues.length > 0) {
|
|
21
29
|
console.log(pc.red(`
|
|
22
30
|
Found ${report.issues.length} drift issues.`));
|
package/dist/index.js
CHANGED
|
@@ -27,66 +27,23 @@ module.exports = __toCommonJS(index_exports);
|
|
|
27
27
|
// src/analyzer.ts
|
|
28
28
|
var import_core = require("@aiready/core");
|
|
29
29
|
var import_fs = require("fs");
|
|
30
|
-
var import_path = require("path");
|
|
31
30
|
var import_typescript_estree = require("@typescript-eslint/typescript-estree");
|
|
32
|
-
var import_child_process = require("child_process");
|
|
33
|
-
var SRC_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
34
|
-
var DEFAULT_EXCLUDES = ["node_modules", "dist", ".git", "coverage", ".turbo", "build"];
|
|
35
|
-
function collectFiles(dir, options, depth = 0) {
|
|
36
|
-
if (depth > 20) return [];
|
|
37
|
-
const excludes = [...DEFAULT_EXCLUDES, ...options.exclude ?? []];
|
|
38
|
-
let entries;
|
|
39
|
-
try {
|
|
40
|
-
entries = (0, import_fs.readdirSync)(dir);
|
|
41
|
-
} catch {
|
|
42
|
-
return [];
|
|
43
|
-
}
|
|
44
|
-
const files = [];
|
|
45
|
-
for (const entry of entries) {
|
|
46
|
-
if (excludes.some((ex) => entry === ex || entry.includes(ex))) continue;
|
|
47
|
-
const full = (0, import_path.join)(dir, entry);
|
|
48
|
-
let stat;
|
|
49
|
-
try {
|
|
50
|
-
stat = (0, import_fs.statSync)(full);
|
|
51
|
-
} catch {
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
if (stat.isDirectory()) {
|
|
55
|
-
files.push(...collectFiles(full, options, depth + 1));
|
|
56
|
-
} else if (stat.isFile() && SRC_EXTENSIONS.has((0, import_path.extname)(full))) {
|
|
57
|
-
if (!options.include || options.include.some((p) => full.includes(p))) {
|
|
58
|
-
files.push(full);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return files;
|
|
63
|
-
}
|
|
64
|
-
function getLineRangeLastModified(file, startLine, endLine) {
|
|
65
|
-
try {
|
|
66
|
-
const output = (0, import_child_process.execSync)(`git log -1 --format=%ct -L ${startLine},${endLine}:"${file}"`, {
|
|
67
|
-
encoding: "utf-8",
|
|
68
|
-
stdio: ["ignore", "pipe", "ignore"]
|
|
69
|
-
});
|
|
70
|
-
const match = output.trim().split("\n")[0];
|
|
71
|
-
if (match && !isNaN(parseInt(match, 10))) {
|
|
72
|
-
return parseInt(match, 10);
|
|
73
|
-
}
|
|
74
|
-
} catch {
|
|
75
|
-
}
|
|
76
|
-
return 0;
|
|
77
|
-
}
|
|
78
31
|
async function analyzeDocDrift(options) {
|
|
79
32
|
const rootDir = options.rootDir;
|
|
80
|
-
const files =
|
|
33
|
+
const files = await (0, import_core.scanFiles)(options);
|
|
34
|
+
const issues = [];
|
|
35
|
+
const results = [];
|
|
81
36
|
const staleMonths = options.staleMonths ?? 6;
|
|
82
37
|
const staleSeconds = staleMonths * 30 * 24 * 60 * 60;
|
|
83
38
|
let uncommentedExports = 0;
|
|
84
39
|
let totalExports = 0;
|
|
85
40
|
let outdatedComments = 0;
|
|
86
41
|
let undocumentedComplexity = 0;
|
|
87
|
-
const issues = [];
|
|
88
42
|
const now = Math.floor(Date.now() / 1e3);
|
|
43
|
+
let processed = 0;
|
|
89
44
|
for (const file of files) {
|
|
45
|
+
processed++;
|
|
46
|
+
options.onProgress?.(processed, files.length, `doc-drift: analyzing files`);
|
|
90
47
|
let code;
|
|
91
48
|
try {
|
|
92
49
|
code = (0, import_fs.readFileSync)(file, "utf-8");
|
|
@@ -104,6 +61,7 @@ async function analyzeDocDrift(options) {
|
|
|
104
61
|
continue;
|
|
105
62
|
}
|
|
106
63
|
const comments = ast.comments || [];
|
|
64
|
+
let fileLineStamps;
|
|
107
65
|
for (const node of ast.body) {
|
|
108
66
|
if (node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration") {
|
|
109
67
|
const decl = node.declaration;
|
|
@@ -111,7 +69,9 @@ async function analyzeDocDrift(options) {
|
|
|
111
69
|
if (decl.type === "FunctionDeclaration" || decl.type === "ClassDeclaration" || decl.type === "VariableDeclaration") {
|
|
112
70
|
totalExports++;
|
|
113
71
|
const nodeLine = node.loc.start.line;
|
|
114
|
-
const jsdocs = comments.filter(
|
|
72
|
+
const jsdocs = comments.filter(
|
|
73
|
+
(c) => c.type === "Block" && c.value.startsWith("*") && c.loc.end.line === nodeLine - 1
|
|
74
|
+
);
|
|
115
75
|
if (jsdocs.length === 0) {
|
|
116
76
|
uncommentedExports++;
|
|
117
77
|
if (decl.type === "FunctionDeclaration" && decl.body?.loc) {
|
|
@@ -123,8 +83,12 @@ async function analyzeDocDrift(options) {
|
|
|
123
83
|
const jsdocText = jsdoc.value;
|
|
124
84
|
if (decl.type === "FunctionDeclaration") {
|
|
125
85
|
const params = decl.params.map((p) => p.name || p.left && p.left.name).filter(Boolean);
|
|
126
|
-
const paramTags = Array.from(
|
|
127
|
-
|
|
86
|
+
const paramTags = Array.from(
|
|
87
|
+
jsdocText.matchAll(/@param\s+(?:\{[^}]+\}\s+)?([a-zA-Z0-9_]+)/g)
|
|
88
|
+
).map((m) => m[1]);
|
|
89
|
+
const missingParams = params.filter(
|
|
90
|
+
(p) => !paramTags.includes(p)
|
|
91
|
+
);
|
|
128
92
|
if (missingParams.length > 0) {
|
|
129
93
|
outdatedComments++;
|
|
130
94
|
issues.push({
|
|
@@ -136,8 +100,19 @@ async function analyzeDocDrift(options) {
|
|
|
136
100
|
continue;
|
|
137
101
|
}
|
|
138
102
|
}
|
|
139
|
-
|
|
140
|
-
|
|
103
|
+
if (!fileLineStamps) {
|
|
104
|
+
fileLineStamps = (0, import_core.getFileCommitTimestamps)(file);
|
|
105
|
+
}
|
|
106
|
+
const commentModified = (0, import_core.getLineRangeLastModifiedCached)(
|
|
107
|
+
fileLineStamps,
|
|
108
|
+
jsdoc.loc.start.line,
|
|
109
|
+
jsdoc.loc.end.line
|
|
110
|
+
);
|
|
111
|
+
const bodyModified = (0, import_core.getLineRangeLastModifiedCached)(
|
|
112
|
+
fileLineStamps,
|
|
113
|
+
decl.loc.start.line,
|
|
114
|
+
decl.loc.end.line
|
|
115
|
+
);
|
|
141
116
|
if (commentModified > 0 && bodyModified > 0) {
|
|
142
117
|
if (now - commentModified > staleSeconds && bodyModified - commentModified > staleSeconds / 2) {
|
|
143
118
|
outdatedComments++;
|
package/dist/index.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiready/doc-drift",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "AI-Readiness: Documentation Drift Detection",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
8
|
"dependencies": {
|
|
9
|
-
"@typescript-eslint/typescript-estree": "^
|
|
10
|
-
"commander": "^
|
|
11
|
-
"glob": "^
|
|
9
|
+
"@typescript-eslint/typescript-estree": "^8.0.0",
|
|
10
|
+
"commander": "^14.0.0",
|
|
11
|
+
"glob": "^13.0.0",
|
|
12
12
|
"picocolors": "^1.0.0",
|
|
13
|
-
"@aiready/core": "0.9.
|
|
13
|
+
"@aiready/core": "0.9.35"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
|
-
"@types/node": "^
|
|
17
|
-
"@typescript-eslint/types": "^
|
|
16
|
+
"@types/node": "^24.0.0",
|
|
17
|
+
"@typescript-eslint/types": "^8.0.0",
|
|
18
18
|
"tsup": "^8.0.2",
|
|
19
19
|
"typescript": "^5.4.5",
|
|
20
|
-
"vitest": "^
|
|
20
|
+
"vitest": "^4.0.0"
|
|
21
21
|
},
|
|
22
22
|
"exports": {
|
|
23
23
|
".": {
|
|
@@ -13,7 +13,9 @@ describe('Doc Drift Analyzer', () => {
|
|
|
13
13
|
|
|
14
14
|
// File with signature mismatch
|
|
15
15
|
const file1 = join(tmpDir, 'file1.ts');
|
|
16
|
-
writeFileSync(
|
|
16
|
+
writeFileSync(
|
|
17
|
+
file1,
|
|
18
|
+
`
|
|
17
19
|
/**
|
|
18
20
|
* Adds numbers.
|
|
19
21
|
* @param a First number
|
|
@@ -21,11 +23,14 @@ describe('Doc Drift Analyzer', () => {
|
|
|
21
23
|
export function add(a: number, b: number) {
|
|
22
24
|
return a + b;
|
|
23
25
|
}
|
|
24
|
-
`
|
|
26
|
+
`
|
|
27
|
+
);
|
|
25
28
|
|
|
26
29
|
// File with undocumented complexity (simulated by lines > 20)
|
|
27
30
|
const file2 = join(tmpDir, 'file2.ts');
|
|
28
|
-
writeFileSync(
|
|
31
|
+
writeFileSync(
|
|
32
|
+
file2,
|
|
33
|
+
`
|
|
29
34
|
export function complexFunction(data: any) {
|
|
30
35
|
let result = 0;
|
|
31
36
|
for (let i = 0; i < 10; i++) {
|
|
@@ -51,7 +56,8 @@ export function complexFunction(data: any) {
|
|
|
51
56
|
}
|
|
52
57
|
return result;
|
|
53
58
|
}
|
|
54
|
-
`
|
|
59
|
+
`
|
|
60
|
+
);
|
|
55
61
|
});
|
|
56
62
|
|
|
57
63
|
afterAll(() => {
|
|
@@ -72,6 +78,6 @@ export function complexFunction(data: any) {
|
|
|
72
78
|
expect(report.rawData.undocumentedComplexity).toBe(1);
|
|
73
79
|
|
|
74
80
|
expect(report.issues.length).toBeGreaterThan(0);
|
|
75
|
-
expect(report.issues.some(i => i.message.includes('b'))).toBe(true);
|
|
81
|
+
expect(report.issues.some((i) => i.message.includes('b'))).toBe(true);
|
|
76
82
|
});
|
|
77
83
|
});
|
package/src/analyzer.ts
CHANGED
|
@@ -1,59 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
scanFiles,
|
|
3
|
+
readFileContent,
|
|
4
|
+
calculateDocDrift,
|
|
5
|
+
getFileCommitTimestamps,
|
|
6
|
+
getLineRangeLastModifiedCached,
|
|
7
|
+
} from '@aiready/core';
|
|
2
8
|
import type { DocDriftOptions, DocDriftReport, DocDriftIssue } from './types';
|
|
3
|
-
import {
|
|
4
|
-
import { join, extname } from 'path';
|
|
9
|
+
import { readFileSync } from 'fs';
|
|
5
10
|
import { parse } from '@typescript-eslint/typescript-estree';
|
|
6
11
|
import type { TSESTree } from '@typescript-eslint/types';
|
|
7
|
-
import { execSync } from 'child_process';
|
|
8
|
-
|
|
9
|
-
const SRC_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx']);
|
|
10
|
-
const DEFAULT_EXCLUDES = ['node_modules', 'dist', '.git', 'coverage', '.turbo', 'build'];
|
|
11
|
-
|
|
12
|
-
function collectFiles(dir: string, options: DocDriftOptions, depth = 0): string[] {
|
|
13
|
-
if (depth > 20) return [];
|
|
14
|
-
const excludes = [...DEFAULT_EXCLUDES, ...(options.exclude ?? [])];
|
|
15
|
-
let entries: string[];
|
|
16
|
-
try { entries = readdirSync(dir); } catch { return []; }
|
|
17
|
-
|
|
18
|
-
const files: string[] = [];
|
|
19
|
-
for (const entry of entries) {
|
|
20
|
-
if (excludes.some(ex => entry === ex || entry.includes(ex))) continue;
|
|
21
|
-
const full = join(dir, entry);
|
|
22
|
-
let stat;
|
|
23
|
-
try { stat = statSync(full); } catch { continue; }
|
|
24
|
-
if (stat.isDirectory()) {
|
|
25
|
-
files.push(...collectFiles(full, options, depth + 1));
|
|
26
|
-
} else if (stat.isFile() && SRC_EXTENSIONS.has(extname(full))) {
|
|
27
|
-
if (!options.include || options.include.some(p => full.includes(p))) {
|
|
28
|
-
files.push(full);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return files;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function getLineRangeLastModified(file: string, startLine: number, endLine: number): number {
|
|
36
|
-
try {
|
|
37
|
-
// format %ct is committer date, UNIX timestamp
|
|
38
|
-
const output = execSync(`git log -1 --format=%ct -L ${startLine},${endLine}:"${file}"`, {
|
|
39
|
-
encoding: 'utf-8',
|
|
40
|
-
stdio: ['ignore', 'pipe', 'ignore']
|
|
41
|
-
});
|
|
42
|
-
const match = output.trim().split('\n')[0];
|
|
43
|
-
if (match && !isNaN(parseInt(match, 10))) {
|
|
44
|
-
return parseInt(match, 10);
|
|
45
|
-
}
|
|
46
|
-
} catch {
|
|
47
|
-
// Ignore errors (file untracked, new file, etc)
|
|
48
|
-
}
|
|
49
|
-
return 0; // Unknown or not committed
|
|
50
|
-
}
|
|
51
12
|
|
|
52
13
|
export async function analyzeDocDrift(
|
|
53
|
-
options: DocDriftOptions
|
|
14
|
+
options: DocDriftOptions
|
|
54
15
|
): Promise<DocDriftReport> {
|
|
55
16
|
const rootDir = options.rootDir;
|
|
56
|
-
|
|
17
|
+
// Use core scanFiles which respects .gitignore recursively
|
|
18
|
+
const files = await scanFiles(options);
|
|
19
|
+
const issues: DocDriftIssue[] = [];
|
|
20
|
+
const results: DocDriftIssue[] = [];
|
|
57
21
|
const staleMonths = options.staleMonths ?? 6;
|
|
58
22
|
const staleSeconds = staleMonths * 30 * 24 * 60 * 60;
|
|
59
23
|
|
|
@@ -62,12 +26,19 @@ export async function analyzeDocDrift(
|
|
|
62
26
|
let outdatedComments = 0;
|
|
63
27
|
let undocumentedComplexity = 0;
|
|
64
28
|
|
|
65
|
-
const issues: DocDriftIssue[] = [];
|
|
66
29
|
const now = Math.floor(Date.now() / 1000);
|
|
67
30
|
|
|
31
|
+
let processed = 0;
|
|
68
32
|
for (const file of files) {
|
|
33
|
+
processed++;
|
|
34
|
+
options.onProgress?.(processed, files.length, `doc-drift: analyzing files`);
|
|
35
|
+
|
|
69
36
|
let code: string;
|
|
70
|
-
try {
|
|
37
|
+
try {
|
|
38
|
+
code = readFileSync(file, 'utf-8');
|
|
39
|
+
} catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
71
42
|
|
|
72
43
|
let ast: TSESTree.Program;
|
|
73
44
|
try {
|
|
@@ -76,22 +47,37 @@ export async function analyzeDocDrift(
|
|
|
76
47
|
loc: true,
|
|
77
48
|
comment: true,
|
|
78
49
|
});
|
|
79
|
-
} catch {
|
|
50
|
+
} catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
80
53
|
|
|
81
54
|
const comments = ast.comments || [];
|
|
55
|
+
let fileLineStamps: Record<number, number> | undefined;
|
|
82
56
|
|
|
83
57
|
for (const node of ast.body) {
|
|
84
|
-
if (
|
|
58
|
+
if (
|
|
59
|
+
node.type === 'ExportNamedDeclaration' ||
|
|
60
|
+
node.type === 'ExportDefaultDeclaration'
|
|
61
|
+
) {
|
|
85
62
|
const decl = (node as any).declaration;
|
|
86
63
|
if (!decl) continue;
|
|
87
64
|
|
|
88
65
|
// Count exports
|
|
89
|
-
if (
|
|
66
|
+
if (
|
|
67
|
+
decl.type === 'FunctionDeclaration' ||
|
|
68
|
+
decl.type === 'ClassDeclaration' ||
|
|
69
|
+
decl.type === 'VariableDeclaration'
|
|
70
|
+
) {
|
|
90
71
|
totalExports++;
|
|
91
72
|
|
|
92
73
|
// Find associated JSDoc comment (immediately preceding the export)
|
|
93
74
|
const nodeLine = node.loc.start.line;
|
|
94
|
-
const jsdocs = comments.filter(
|
|
75
|
+
const jsdocs = comments.filter(
|
|
76
|
+
(c: any) =>
|
|
77
|
+
c.type === 'Block' &&
|
|
78
|
+
c.value.startsWith('*') &&
|
|
79
|
+
c.loc.end.line === nodeLine - 1
|
|
80
|
+
);
|
|
95
81
|
|
|
96
82
|
if (jsdocs.length === 0) {
|
|
97
83
|
uncommentedExports++;
|
|
@@ -107,35 +93,55 @@ export async function analyzeDocDrift(
|
|
|
107
93
|
|
|
108
94
|
// Signature mismatch detection
|
|
109
95
|
if (decl.type === 'FunctionDeclaration') {
|
|
110
|
-
const params = decl.params
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
96
|
+
const params = decl.params
|
|
97
|
+
.map((p: any) => p.name || (p.left && p.left.name))
|
|
98
|
+
.filter(Boolean);
|
|
99
|
+
const paramTags = Array.from(
|
|
100
|
+
jsdocText.matchAll(/@param\s+(?:\{[^}]+\}\s+)?([a-zA-Z0-9_]+)/g)
|
|
101
|
+
).map((m: any) => m[1]);
|
|
102
|
+
|
|
103
|
+
const missingParams = params.filter(
|
|
104
|
+
(p: string) => !paramTags.includes(p)
|
|
105
|
+
);
|
|
114
106
|
if (missingParams.length > 0) {
|
|
115
107
|
outdatedComments++;
|
|
116
108
|
issues.push({
|
|
117
109
|
type: 'doc-drift',
|
|
118
110
|
severity: 'major',
|
|
119
111
|
message: `JSDoc @param mismatch: function has parameters (${missingParams.join(', ')}) not documented in JSDoc.`,
|
|
120
|
-
location: { file, line: nodeLine }
|
|
112
|
+
location: { file, line: nodeLine },
|
|
121
113
|
});
|
|
122
114
|
continue; // already counted as outdated
|
|
123
115
|
}
|
|
124
116
|
}
|
|
125
117
|
|
|
126
118
|
// Timestamp comparison
|
|
127
|
-
|
|
128
|
-
|
|
119
|
+
if (!fileLineStamps) {
|
|
120
|
+
fileLineStamps = getFileCommitTimestamps(file);
|
|
121
|
+
}
|
|
122
|
+
const commentModified = getLineRangeLastModifiedCached(
|
|
123
|
+
fileLineStamps,
|
|
124
|
+
jsdoc.loc.start.line,
|
|
125
|
+
jsdoc.loc.end.line
|
|
126
|
+
);
|
|
127
|
+
const bodyModified = getLineRangeLastModifiedCached(
|
|
128
|
+
fileLineStamps,
|
|
129
|
+
decl.loc.start.line,
|
|
130
|
+
decl.loc.end.line
|
|
131
|
+
);
|
|
129
132
|
|
|
130
133
|
if (commentModified > 0 && bodyModified > 0) {
|
|
131
134
|
// If body was modified much later than the comment, and comment is older than staleMonths
|
|
132
|
-
if (
|
|
135
|
+
if (
|
|
136
|
+
now - commentModified > staleSeconds &&
|
|
137
|
+
bodyModified - commentModified > staleSeconds / 2
|
|
138
|
+
) {
|
|
133
139
|
outdatedComments++;
|
|
134
140
|
issues.push({
|
|
135
141
|
type: 'doc-drift',
|
|
136
142
|
severity: 'minor',
|
|
137
143
|
message: `JSDoc is significantly older than the function body implementation. Code may have drifted.`,
|
|
138
|
-
location: { file, line: jsdoc.loc.start.line }
|
|
144
|
+
location: { file, line: jsdoc.loc.start.line },
|
|
139
145
|
});
|
|
140
146
|
}
|
|
141
147
|
}
|
|
@@ -149,7 +155,7 @@ export async function analyzeDocDrift(
|
|
|
149
155
|
uncommentedExports,
|
|
150
156
|
totalExports,
|
|
151
157
|
outdatedComments,
|
|
152
|
-
undocumentedComplexity
|
|
158
|
+
undocumentedComplexity,
|
|
153
159
|
});
|
|
154
160
|
|
|
155
161
|
return {
|
package/src/cli.ts
CHANGED
|
@@ -4,10 +4,16 @@ import pc from 'picocolors';
|
|
|
4
4
|
|
|
5
5
|
export function createCommand() {
|
|
6
6
|
const program = new Command('doc-drift')
|
|
7
|
-
.description(
|
|
7
|
+
.description(
|
|
8
|
+
'Scan for documentation drift (outdated comments, mismatched signatures)'
|
|
9
|
+
)
|
|
8
10
|
.option('--include <patterns...>', 'File patterns to include')
|
|
9
11
|
.option('--exclude <patterns...>', 'File patterns to exclude')
|
|
10
|
-
.option(
|
|
12
|
+
.option(
|
|
13
|
+
'--stale-months <number>',
|
|
14
|
+
'Months before a comment is considered potentially outdated',
|
|
15
|
+
'6'
|
|
16
|
+
)
|
|
11
17
|
.action(async (options) => {
|
|
12
18
|
console.log(pc.cyan('Analyzing documentation drift...'));
|
|
13
19
|
const report = await analyzeDocDrift({
|
|
@@ -18,7 +24,9 @@ export function createCommand() {
|
|
|
18
24
|
});
|
|
19
25
|
|
|
20
26
|
console.log(pc.bold('Doc Drift Analysis Results:'));
|
|
21
|
-
console.log(
|
|
27
|
+
console.log(
|
|
28
|
+
`Rating: ${report.summary.rating.toUpperCase()} (Score: ${report.summary.score})`
|
|
29
|
+
);
|
|
22
30
|
if (report.issues.length > 0) {
|
|
23
31
|
console.log(pc.red(`\nFound ${report.issues.length} drift issues.`));
|
|
24
32
|
} else {
|
|
@@ -30,8 +38,10 @@ export function createCommand() {
|
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
if (require.main === module) {
|
|
33
|
-
createCommand()
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
createCommand()
|
|
42
|
+
.parseAsync(process.argv)
|
|
43
|
+
.catch((err) => {
|
|
44
|
+
console.error(pc.red(err.message));
|
|
45
|
+
process.exit(1);
|
|
46
|
+
});
|
|
37
47
|
}
|