@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.
@@ -1,6 +1,6 @@
1
1
 
2
2
  
3
- > @aiready/doc-drift@0.1.5 build /Users/pengcao/projects/aiready/packages/doc-drift
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
  CLI Building entry: src/cli.ts, src/index.ts
@@ -10,7 +10,7 @@
10
10
  CJS Build start
11
11
  ESM Build start
12
12
 
13
- [2:33:41 PM]  WARN  ▲ [WARNING] The condition "types" here will never be used as it comes after both "import" and "require" [package.json]
13
+ [12:55:25 AM]  WARN  ▲ [WARNING] The condition "types" here will never be used as it comes after both "import" and "require" [package.json]
14
14
 
15
15
  package.json:33:6:
16
16
   33 │ "types": "./dist/index.d.ts"
@@ -31,7 +31,7 @@
31
31
 
32
32
 
33
33
 
34
- [2:33:42 PM]  WARN  ▲ [WARNING] The condition "types" here will never be used as it comes after both "import" and "require" [package.json]
34
+ [12:55:25 AM]  WARN  ▲ [WARNING] The condition "types" here will never be used as it comes after both "import" and "require" [package.json]
35
35
 
36
36
  package.json:33:6:
37
37
   33 │ "types": "./dist/index.d.ts"
@@ -51,15 +51,15 @@
51
51
 
52
52
 
53
53
 
54
- CJS dist/index.js 6.50 KB
55
- CJS dist/cli.js 8.49 KB
56
- CJS ⚡️ Build success in 2383ms
57
- ESM dist/cli.mjs 1.36 KB
54
+ CJS dist/cli.js 7.58 KB
55
+ CJS dist/index.js 5.55 KB
56
+ CJS ⚡️ Build success in 511ms
57
+ ESM dist/chunk-5EFFNN6L.mjs 4.85 KB
58
58
  ESM dist/index.mjs 88.00 B
59
- ESM dist/chunk-TSLAGWBV.mjs 5.71 KB
60
- ESM ⚡️ Build success in 2526ms
59
+ ESM dist/cli.mjs 1.39 KB
60
+ ESM ⚡️ Build success in 495ms
61
61
  DTS Build start
62
- DTS ⚡️ Build success in 72201ms
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
@@ -0,0 +1,5 @@
1
+
2
+ 
3
+ > @aiready/doc-drift@0.1.6 lint /Users/pengcao/projects/aiready/packages/doc-drift
4
+ > eslint src --ext .ts
5
+
@@ -1,16 +1,17 @@
1
1
 
2
2
  
3
- > @aiready/doc-drift@0.1.5 test /Users/pengcao/projects/aiready/packages/doc-drift
3
+ > @aiready/doc-drift@0.1.8 test /Users/pengcao/projects/aiready/packages/doc-drift
4
4
  > vitest run
5
5
 
6
+ [?25l
7
+  RUN  v4.0.18 /Users/pengcao/projects/aiready/packages/doc-drift
6
8
 
7
-  RUN  v1.6.1 /Users/pengcao/projects/aiready/packages/doc-drift
8
-
9
- ✓ src/__tests__/analyzer.test.ts  (1 test) 777ms
9
+ ✓ src/__tests__/analyzer.test.ts (1 test) 364ms
10
+ ✓ detects missing param documentation and uncommented complexity  361ms
10
11
 
11
12
   Test Files  1 passed (1)
12
13
   Tests  1 passed (1)
13
-  Start at  14:38:44
14
-  Duration  33.39s (transform 5.68s, setup 0ms, collect 23.73s, tests 777ms, environment 0ms, prepare 4.26s)
14
+  Start at  00:56:24
15
+  Duration  5.43s (transform 957ms, setup 0ms, import 3.81s, tests 364ms, environment 0ms)
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 = collectFiles(rootDir, options);
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((c) => c.type === "Block" && c.value.startsWith("*") && c.loc.end.line === nodeLine - 1);
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(jsdocText.matchAll(/@param\s+(?:\{[^}]+\}\s+)?([a-zA-Z0-9_]+)/g)).map((m) => m[1]);
138
- const missingParams = params.filter((p) => !paramTags.includes(p));
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
- const commentModified = getLineRangeLastModified(file, jsdoc.loc.start.line, jsdoc.loc.end.line);
151
- const bodyModified = getLineRangeLastModified(file, decl.loc.start.line, decl.loc.end.line);
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("Scan for documentation drift (outdated comments, mismatched signatures)").option("--include <patterns...>", "File patterns to include").option("--exclude <patterns...>", "File patterns to exclude").option("--stale-months <number>", "Months before a comment is considered potentially outdated", "6").action(async (options) => {
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(`Rating: ${report.summary.rating.toUpperCase()} (Score: ${report.summary.score})`);
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-TSLAGWBV.mjs";
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("Scan for documentation drift (outdated comments, mismatched signatures)").option("--include <patterns...>", "File patterns to include").option("--exclude <patterns...>", "File patterns to exclude").option("--stale-months <number>", "Months before a comment is considered potentially outdated", "6").action(async (options) => {
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(`Rating: ${report.summary.rating.toUpperCase()} (Score: ${report.summary.score})`);
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 = collectFiles(rootDir, options);
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((c) => c.type === "Block" && c.value.startsWith("*") && c.loc.end.line === nodeLine - 1);
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(jsdocText.matchAll(/@param\s+(?:\{[^}]+\}\s+)?([a-zA-Z0-9_]+)/g)).map((m) => m[1]);
127
- const missingParams = params.filter((p) => !paramTags.includes(p));
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
- const commentModified = getLineRangeLastModified(file, jsdoc.loc.start.line, jsdoc.loc.end.line);
140
- const bodyModified = getLineRangeLastModified(file, decl.loc.start.line, decl.loc.end.line);
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
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  analyzeDocDrift
3
- } from "./chunk-TSLAGWBV.mjs";
3
+ } from "./chunk-5EFFNN6L.mjs";
4
4
  export {
5
5
  analyzeDocDrift
6
6
  };
package/package.json CHANGED
@@ -1,23 +1,23 @@
1
1
  {
2
2
  "name": "@aiready/doc-drift",
3
- "version": "0.1.5",
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": "^7.8.0",
10
- "commander": "^12.0.0",
11
- "glob": "^10.3.12",
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.32"
13
+ "@aiready/core": "0.9.35"
14
14
  },
15
15
  "devDependencies": {
16
- "@types/node": "^20.12.7",
17
- "@typescript-eslint/types": "^7.8.0",
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": "^1.6.0"
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(file1, `
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(file2, `
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 { calculateDocDrift } from '@aiready/core';
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 { readdirSync, statSync, readFileSync } from 'fs';
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
- const files = collectFiles(rootDir, options);
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 { code = readFileSync(file, 'utf-8'); } catch { continue; }
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 { continue; }
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 (node.type === 'ExportNamedDeclaration' || node.type === 'ExportDefaultDeclaration') {
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 (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration' || decl.type === 'VariableDeclaration') {
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((c: any) => c.type === 'Block' && c.value.startsWith('*') && c.loc.end.line === nodeLine - 1);
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.map((p: any) => p.name || (p.left && p.left.name)).filter(Boolean);
111
- const paramTags = Array.from(jsdocText.matchAll(/@param\s+(?:\{[^}]+\}\s+)?([a-zA-Z0-9_]+)/g)).map((m: any) => m[1]);
112
-
113
- const missingParams = params.filter((p: string) => !paramTags.includes(p));
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
- const commentModified = getLineRangeLastModified(file, jsdoc.loc.start.line, jsdoc.loc.end.line);
128
- const bodyModified = getLineRangeLastModified(file, decl.loc.start.line, decl.loc.end.line);
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 (now - commentModified > staleSeconds && bodyModified - commentModified > staleSeconds / 2) {
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('Scan for documentation drift (outdated comments, mismatched signatures)')
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('--stale-months <number>', 'Months before a comment is considered potentially outdated', '6')
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(`Rating: ${report.summary.rating.toUpperCase()} (Score: ${report.summary.score})`);
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().parseAsync(process.argv).catch(err => {
34
- console.error(pc.red(err.message));
35
- process.exit(1);
36
- });
41
+ createCommand()
42
+ .parseAsync(process.argv)
43
+ .catch((err) => {
44
+ console.error(pc.red(err.message));
45
+ process.exit(1);
46
+ });
37
47
  }