@aiready/doc-drift 0.1.1
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 +66 -0
- package/.turbo/turbo-test.log +16 -0
- package/README.md +44 -0
- package/dist/chunk-TSLAGWBV.mjs +165 -0
- package/dist/cli.d.mts +5 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +223 -0
- package/dist/cli.mjs +37 -0
- package/dist/index.d.mts +31 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +183 -0
- package/dist/index.mjs +6 -0
- package/package.json +36 -0
- package/src/__tests__/analyzer.test.ts +77 -0
- package/src/analyzer.ts +171 -0
- package/src/cli.ts +37 -0
- package/src/index.ts +2 -0
- package/src/types.ts +29 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
> @aiready/doc-drift@0.1.1 build /Users/pengcao/projects/aiready/packages/doc-drift
|
|
4
|
+
> tsup src/index.ts src/cli.ts --format cjs,esm --dts
|
|
5
|
+
|
|
6
|
+
[34mCLI[39m Building entry: src/cli.ts, src/index.ts
|
|
7
|
+
[34mCLI[39m Using tsconfig: tsconfig.json
|
|
8
|
+
[34mCLI[39m tsup v8.5.1
|
|
9
|
+
[34mCLI[39m Target: es2020
|
|
10
|
+
[34mCJS[39m Build start
|
|
11
|
+
[34mESM[39m Build start
|
|
12
|
+
|
|
13
|
+
[90m[[90m9:41:37 PM[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
|
+
|
|
15
|
+
package.json:33:6:
|
|
16
|
+
[37m 33 │ [32m"types"[37m: "./dist/index.d.ts"
|
|
17
|
+
╵ [32m~~~~~~~[0m
|
|
18
|
+
|
|
19
|
+
The "import" condition comes earlier and will be used for all "import" statements:
|
|
20
|
+
|
|
21
|
+
package.json:31:6:
|
|
22
|
+
[37m 31 │ [32m"import"[37m: "./dist/index.mjs",
|
|
23
|
+
╵ [32m~~~~~~~~[0m
|
|
24
|
+
|
|
25
|
+
The "require" condition comes earlier and will be used for all "require" calls:
|
|
26
|
+
|
|
27
|
+
package.json:32:6:
|
|
28
|
+
[37m 32 │ [32m"require"[37m: "./dist/index.js",
|
|
29
|
+
╵ [32m~~~~~~~~~[0m
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
[90m[[90m9:41:37 PM[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
|
+
|
|
36
|
+
package.json:33:6:
|
|
37
|
+
[37m 33 │ [32m"types"[37m: "./dist/index.d.ts"
|
|
38
|
+
╵ [32m~~~~~~~[0m
|
|
39
|
+
|
|
40
|
+
The "import" condition comes earlier and will be used for all "import" statements:
|
|
41
|
+
|
|
42
|
+
package.json:31:6:
|
|
43
|
+
[37m 31 │ [32m"import"[37m: "./dist/index.mjs",
|
|
44
|
+
╵ [32m~~~~~~~~[0m
|
|
45
|
+
|
|
46
|
+
The "require" condition comes earlier and will be used for all "require" calls:
|
|
47
|
+
|
|
48
|
+
package.json:32:6:
|
|
49
|
+
[37m 32 │ [32m"require"[37m: "./dist/index.js",
|
|
50
|
+
╵ [32m~~~~~~~~~[0m
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
[32mCJS[39m [1mdist/cli.js [22m[32m8.49 KB[39m
|
|
55
|
+
[32mCJS[39m [1mdist/index.js [22m[32m6.50 KB[39m
|
|
56
|
+
[32mCJS[39m ⚡️ Build success in 180ms
|
|
57
|
+
[32mESM[39m [1mdist/chunk-TSLAGWBV.mjs [22m[32m5.71 KB[39m
|
|
58
|
+
[32mESM[39m [1mdist/cli.mjs [22m[32m1.36 KB[39m
|
|
59
|
+
[32mESM[39m [1mdist/index.mjs [22m[32m88.00 B[39m
|
|
60
|
+
[32mESM[39m ⚡️ Build success in 200ms
|
|
61
|
+
DTS Build start
|
|
62
|
+
DTS ⚡️ Build success in 3656ms
|
|
63
|
+
DTS dist/cli.d.ts 108.00 B
|
|
64
|
+
DTS dist/index.d.ts 950.00 B
|
|
65
|
+
DTS dist/cli.d.mts 108.00 B
|
|
66
|
+
DTS dist/index.d.mts 950.00 B
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
> @aiready/doc-drift@0.1.1 test /Users/pengcao/projects/aiready/packages/doc-drift
|
|
4
|
+
> vitest run
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
[7m[1m[36m RUN [39m[22m[27m [36mv1.6.1[39m [90m/Users/pengcao/projects/aiready/packages/doc-drift[39m
|
|
8
|
+
|
|
9
|
+
[32m✓[39m src/__tests__/analyzer.test.ts [2m ([22m[2m1 test[22m[2m)[22m[90m 19[2mms[22m[39m
|
|
10
|
+
|
|
11
|
+
[2m Test Files [22m [1m[32m1 passed[39m[22m[90m (1)[39m
|
|
12
|
+
[2m Tests [22m [1m[32m1 passed[39m[22m[90m (1)[39m
|
|
13
|
+
[2m Start at [22m 21:42:03
|
|
14
|
+
[2m Duration [22m 2.17s[2m (transform 267ms, setup 0ms, collect 1.29s, tests 19ms, environment 0ms, prepare 239ms)[22m
|
|
15
|
+
|
|
16
|
+
[?25h
|
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @aiready/doc-drift
|
|
2
|
+
|
|
3
|
+
> AIReady Spoke: Tracks documentation freshness versus code churn to pinpoint outdated comments that confuse AI models.
|
|
4
|
+
|
|
5
|
+
[](https://npmjs.com/package/@aiready/doc-drift)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
AI models rely heavily on inline documentation and function signatures. When code changes but comments don't, AI models often hallucinate based on the stale documentation. The **Documentation Drift** analyzer combines AST parsing with git log traversal to identify instances where comments are likely lagging behind actual implementation logic.
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- **Drift Detection**: Detects documentation older than the code it describes based on git history timestamps.
|
|
15
|
+
- **Signature Mismatches**: Finds missing documented `@param` tags when new arguments are added to functions.
|
|
16
|
+
- **Complexity Guardrails**: Identifies long or complex functions that completely lack documentation.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -g @aiready/cli @aiready/doc-drift
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
This tool is designed to be run through the unified AIReady CLI.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Scan a codebase for documentation drift
|
|
30
|
+
aiready scan . --tools doc-drift
|
|
31
|
+
|
|
32
|
+
# Output detailed JSON report
|
|
33
|
+
aiready scan . --tools doc-drift --output json
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## How It Works
|
|
37
|
+
|
|
38
|
+
1. Parses your codebase into an Abstract Syntax Tree (AST).
|
|
39
|
+
2. Uses `git log` to find the last modified timestamp for the code body limits vs the associated comment block.
|
|
40
|
+
3. Calculates a freshness ratio. If the comment trails the code body by several months (`--stale-months`), it flags the function as having a high risk of documentary drift.
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
|
|
44
|
+
MIT
|
|
@@ -0,0 +1,165 @@
|
|
|
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 = ["node_modules", "dist", ".git", "coverage", ".turbo", "build"];
|
|
16
|
+
function collectFiles(dir, options, depth = 0) {
|
|
17
|
+
if (depth > 20) return [];
|
|
18
|
+
const excludes = [...DEFAULT_EXCLUDES, ...options.exclude ?? []];
|
|
19
|
+
let entries;
|
|
20
|
+
try {
|
|
21
|
+
entries = readdirSync(dir);
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const files = [];
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
if (excludes.some((ex) => entry === ex || entry.includes(ex))) continue;
|
|
28
|
+
const full = join(dir, entry);
|
|
29
|
+
let stat;
|
|
30
|
+
try {
|
|
31
|
+
stat = statSync(full);
|
|
32
|
+
} catch {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (stat.isDirectory()) {
|
|
36
|
+
files.push(...collectFiles(full, options, depth + 1));
|
|
37
|
+
} else if (stat.isFile() && SRC_EXTENSIONS.has(extname(full))) {
|
|
38
|
+
if (!options.include || options.include.some((p) => full.includes(p))) {
|
|
39
|
+
files.push(full);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return files;
|
|
44
|
+
}
|
|
45
|
+
function getLineRangeLastModified(file, startLine, endLine) {
|
|
46
|
+
try {
|
|
47
|
+
const output = execSync(`git log -1 --format=%ct -L ${startLine},${endLine}:"${file}"`, {
|
|
48
|
+
encoding: "utf-8",
|
|
49
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
50
|
+
});
|
|
51
|
+
const match = output.trim().split("\n")[0];
|
|
52
|
+
if (match && !isNaN(parseInt(match, 10))) {
|
|
53
|
+
return parseInt(match, 10);
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
}
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
async function analyzeDocDrift(options) {
|
|
60
|
+
const rootDir = options.rootDir;
|
|
61
|
+
const files = collectFiles(rootDir, options);
|
|
62
|
+
const staleMonths = options.staleMonths ?? 6;
|
|
63
|
+
const staleSeconds = staleMonths * 30 * 24 * 60 * 60;
|
|
64
|
+
let uncommentedExports = 0;
|
|
65
|
+
let totalExports = 0;
|
|
66
|
+
let outdatedComments = 0;
|
|
67
|
+
let undocumentedComplexity = 0;
|
|
68
|
+
const issues = [];
|
|
69
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
let code;
|
|
72
|
+
try {
|
|
73
|
+
code = readFileSync(file, "utf-8");
|
|
74
|
+
} catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
let ast;
|
|
78
|
+
try {
|
|
79
|
+
ast = parse(code, {
|
|
80
|
+
jsx: file.endsWith(".tsx") || file.endsWith(".jsx"),
|
|
81
|
+
loc: true,
|
|
82
|
+
comment: true
|
|
83
|
+
});
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const comments = ast.comments || [];
|
|
88
|
+
for (const node of ast.body) {
|
|
89
|
+
if (node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration") {
|
|
90
|
+
const decl = node.declaration;
|
|
91
|
+
if (!decl) continue;
|
|
92
|
+
if (decl.type === "FunctionDeclaration" || decl.type === "ClassDeclaration" || decl.type === "VariableDeclaration") {
|
|
93
|
+
totalExports++;
|
|
94
|
+
const nodeLine = node.loc.start.line;
|
|
95
|
+
const jsdocs = comments.filter((c) => c.type === "Block" && c.value.startsWith("*") && c.loc.end.line === nodeLine - 1);
|
|
96
|
+
if (jsdocs.length === 0) {
|
|
97
|
+
uncommentedExports++;
|
|
98
|
+
if (decl.type === "FunctionDeclaration" && decl.body?.loc) {
|
|
99
|
+
const lines = decl.body.loc.end.line - decl.body.loc.start.line;
|
|
100
|
+
if (lines > 20) undocumentedComplexity++;
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
const jsdoc = jsdocs[0];
|
|
104
|
+
const jsdocText = jsdoc.value;
|
|
105
|
+
if (decl.type === "FunctionDeclaration") {
|
|
106
|
+
const params = decl.params.map((p) => p.name || p.left && p.left.name).filter(Boolean);
|
|
107
|
+
const paramTags = Array.from(jsdocText.matchAll(/@param\s+(?:\{[^}]+\}\s+)?([a-zA-Z0-9_]+)/g)).map((m) => m[1]);
|
|
108
|
+
const missingParams = params.filter((p) => !paramTags.includes(p));
|
|
109
|
+
if (missingParams.length > 0) {
|
|
110
|
+
outdatedComments++;
|
|
111
|
+
issues.push({
|
|
112
|
+
type: "doc-drift",
|
|
113
|
+
severity: "major",
|
|
114
|
+
message: `JSDoc @param mismatch: function has parameters (${missingParams.join(", ")}) not documented in JSDoc.`,
|
|
115
|
+
location: { file, line: nodeLine }
|
|
116
|
+
});
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const commentModified = getLineRangeLastModified(file, jsdoc.loc.start.line, jsdoc.loc.end.line);
|
|
121
|
+
const bodyModified = getLineRangeLastModified(file, decl.loc.start.line, decl.loc.end.line);
|
|
122
|
+
if (commentModified > 0 && bodyModified > 0) {
|
|
123
|
+
if (now - commentModified > staleSeconds && bodyModified - commentModified > staleSeconds / 2) {
|
|
124
|
+
outdatedComments++;
|
|
125
|
+
issues.push({
|
|
126
|
+
type: "doc-drift",
|
|
127
|
+
severity: "minor",
|
|
128
|
+
message: `JSDoc is significantly older than the function body implementation. Code may have drifted.`,
|
|
129
|
+
location: { file, line: jsdoc.loc.start.line }
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const riskResult = calculateDocDrift({
|
|
139
|
+
uncommentedExports,
|
|
140
|
+
totalExports,
|
|
141
|
+
outdatedComments,
|
|
142
|
+
undocumentedComplexity
|
|
143
|
+
});
|
|
144
|
+
return {
|
|
145
|
+
summary: {
|
|
146
|
+
filesAnalyzed: files.length,
|
|
147
|
+
functionsAnalyzed: totalExports,
|
|
148
|
+
score: riskResult.score,
|
|
149
|
+
rating: riskResult.rating
|
|
150
|
+
},
|
|
151
|
+
issues,
|
|
152
|
+
rawData: {
|
|
153
|
+
uncommentedExports,
|
|
154
|
+
totalExports,
|
|
155
|
+
outdatedComments,
|
|
156
|
+
undocumentedComplexity
|
|
157
|
+
},
|
|
158
|
+
recommendations: riskResult.recommendations
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export {
|
|
163
|
+
__require,
|
|
164
|
+
analyzeDocDrift
|
|
165
|
+
};
|
package/dist/cli.d.mts
ADDED
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/cli.ts
|
|
31
|
+
var cli_exports = {};
|
|
32
|
+
__export(cli_exports, {
|
|
33
|
+
createCommand: () => createCommand
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(cli_exports);
|
|
36
|
+
var import_commander = require("commander");
|
|
37
|
+
|
|
38
|
+
// src/analyzer.ts
|
|
39
|
+
var import_core = require("@aiready/core");
|
|
40
|
+
var import_fs = require("fs");
|
|
41
|
+
var import_path = require("path");
|
|
42
|
+
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
|
+
async function analyzeDocDrift(options) {
|
|
90
|
+
const rootDir = options.rootDir;
|
|
91
|
+
const files = collectFiles(rootDir, options);
|
|
92
|
+
const staleMonths = options.staleMonths ?? 6;
|
|
93
|
+
const staleSeconds = staleMonths * 30 * 24 * 60 * 60;
|
|
94
|
+
let uncommentedExports = 0;
|
|
95
|
+
let totalExports = 0;
|
|
96
|
+
let outdatedComments = 0;
|
|
97
|
+
let undocumentedComplexity = 0;
|
|
98
|
+
const issues = [];
|
|
99
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
100
|
+
for (const file of files) {
|
|
101
|
+
let code;
|
|
102
|
+
try {
|
|
103
|
+
code = (0, import_fs.readFileSync)(file, "utf-8");
|
|
104
|
+
} catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
let ast;
|
|
108
|
+
try {
|
|
109
|
+
ast = (0, import_typescript_estree.parse)(code, {
|
|
110
|
+
jsx: file.endsWith(".tsx") || file.endsWith(".jsx"),
|
|
111
|
+
loc: true,
|
|
112
|
+
comment: true
|
|
113
|
+
});
|
|
114
|
+
} catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const comments = ast.comments || [];
|
|
118
|
+
for (const node of ast.body) {
|
|
119
|
+
if (node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration") {
|
|
120
|
+
const decl = node.declaration;
|
|
121
|
+
if (!decl) continue;
|
|
122
|
+
if (decl.type === "FunctionDeclaration" || decl.type === "ClassDeclaration" || decl.type === "VariableDeclaration") {
|
|
123
|
+
totalExports++;
|
|
124
|
+
const nodeLine = node.loc.start.line;
|
|
125
|
+
const jsdocs = comments.filter((c) => c.type === "Block" && c.value.startsWith("*") && c.loc.end.line === nodeLine - 1);
|
|
126
|
+
if (jsdocs.length === 0) {
|
|
127
|
+
uncommentedExports++;
|
|
128
|
+
if (decl.type === "FunctionDeclaration" && decl.body?.loc) {
|
|
129
|
+
const lines = decl.body.loc.end.line - decl.body.loc.start.line;
|
|
130
|
+
if (lines > 20) undocumentedComplexity++;
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
const jsdoc = jsdocs[0];
|
|
134
|
+
const jsdocText = jsdoc.value;
|
|
135
|
+
if (decl.type === "FunctionDeclaration") {
|
|
136
|
+
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));
|
|
139
|
+
if (missingParams.length > 0) {
|
|
140
|
+
outdatedComments++;
|
|
141
|
+
issues.push({
|
|
142
|
+
type: "doc-drift",
|
|
143
|
+
severity: "major",
|
|
144
|
+
message: `JSDoc @param mismatch: function has parameters (${missingParams.join(", ")}) not documented in JSDoc.`,
|
|
145
|
+
location: { file, line: nodeLine }
|
|
146
|
+
});
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
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);
|
|
152
|
+
if (commentModified > 0 && bodyModified > 0) {
|
|
153
|
+
if (now - commentModified > staleSeconds && bodyModified - commentModified > staleSeconds / 2) {
|
|
154
|
+
outdatedComments++;
|
|
155
|
+
issues.push({
|
|
156
|
+
type: "doc-drift",
|
|
157
|
+
severity: "minor",
|
|
158
|
+
message: `JSDoc is significantly older than the function body implementation. Code may have drifted.`,
|
|
159
|
+
location: { file, line: jsdoc.loc.start.line }
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const riskResult = (0, import_core.calculateDocDrift)({
|
|
169
|
+
uncommentedExports,
|
|
170
|
+
totalExports,
|
|
171
|
+
outdatedComments,
|
|
172
|
+
undocumentedComplexity
|
|
173
|
+
});
|
|
174
|
+
return {
|
|
175
|
+
summary: {
|
|
176
|
+
filesAnalyzed: files.length,
|
|
177
|
+
functionsAnalyzed: totalExports,
|
|
178
|
+
score: riskResult.score,
|
|
179
|
+
rating: riskResult.rating
|
|
180
|
+
},
|
|
181
|
+
issues,
|
|
182
|
+
rawData: {
|
|
183
|
+
uncommentedExports,
|
|
184
|
+
totalExports,
|
|
185
|
+
outdatedComments,
|
|
186
|
+
undocumentedComplexity
|
|
187
|
+
},
|
|
188
|
+
recommendations: riskResult.recommendations
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/cli.ts
|
|
193
|
+
var import_picocolors = __toESM(require("picocolors"));
|
|
194
|
+
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) => {
|
|
196
|
+
console.log(import_picocolors.default.cyan("Analyzing documentation drift..."));
|
|
197
|
+
const report = await analyzeDocDrift({
|
|
198
|
+
rootDir: process.cwd(),
|
|
199
|
+
include: options.include,
|
|
200
|
+
exclude: options.exclude,
|
|
201
|
+
staleMonths: parseInt(options.staleMonths, 10)
|
|
202
|
+
});
|
|
203
|
+
console.log(import_picocolors.default.bold("Doc Drift Analysis Results:"));
|
|
204
|
+
console.log(`Rating: ${report.summary.rating.toUpperCase()} (Score: ${report.summary.score})`);
|
|
205
|
+
if (report.issues.length > 0) {
|
|
206
|
+
console.log(import_picocolors.default.red(`
|
|
207
|
+
Found ${report.issues.length} drift issues.`));
|
|
208
|
+
} else {
|
|
209
|
+
console.log(import_picocolors.default.green("\nNo documentation drift detected."));
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
return program;
|
|
213
|
+
}
|
|
214
|
+
if (require.main === module) {
|
|
215
|
+
createCommand().parseAsync(process.argv).catch((err) => {
|
|
216
|
+
console.error(import_picocolors.default.red(err.message));
|
|
217
|
+
process.exit(1);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
221
|
+
0 && (module.exports = {
|
|
222
|
+
createCommand
|
|
223
|
+
});
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__require,
|
|
3
|
+
analyzeDocDrift
|
|
4
|
+
} from "./chunk-TSLAGWBV.mjs";
|
|
5
|
+
|
|
6
|
+
// src/cli.ts
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import pc from "picocolors";
|
|
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) => {
|
|
11
|
+
console.log(pc.cyan("Analyzing documentation drift..."));
|
|
12
|
+
const report = await analyzeDocDrift({
|
|
13
|
+
rootDir: process.cwd(),
|
|
14
|
+
include: options.include,
|
|
15
|
+
exclude: options.exclude,
|
|
16
|
+
staleMonths: parseInt(options.staleMonths, 10)
|
|
17
|
+
});
|
|
18
|
+
console.log(pc.bold("Doc Drift Analysis Results:"));
|
|
19
|
+
console.log(`Rating: ${report.summary.rating.toUpperCase()} (Score: ${report.summary.score})`);
|
|
20
|
+
if (report.issues.length > 0) {
|
|
21
|
+
console.log(pc.red(`
|
|
22
|
+
Found ${report.issues.length} drift issues.`));
|
|
23
|
+
} else {
|
|
24
|
+
console.log(pc.green("\nNo documentation drift detected."));
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
return program;
|
|
28
|
+
}
|
|
29
|
+
if (__require.main === module) {
|
|
30
|
+
createCommand().parseAsync(process.argv).catch((err) => {
|
|
31
|
+
console.error(pc.red(err.message));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export {
|
|
36
|
+
createCommand
|
|
37
|
+
};
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Issue, ScanOptions } from '@aiready/core';
|
|
2
|
+
|
|
3
|
+
interface DocDriftOptions extends ScanOptions {
|
|
4
|
+
/** Maximum commit distance to check for drift */
|
|
5
|
+
maxCommits?: number;
|
|
6
|
+
/** Consider comments older than this many months as outdated */
|
|
7
|
+
staleMonths?: number;
|
|
8
|
+
}
|
|
9
|
+
interface DocDriftIssue extends Issue {
|
|
10
|
+
type: 'doc-drift';
|
|
11
|
+
}
|
|
12
|
+
interface DocDriftReport {
|
|
13
|
+
summary: {
|
|
14
|
+
filesAnalyzed: number;
|
|
15
|
+
functionsAnalyzed: number;
|
|
16
|
+
score: number;
|
|
17
|
+
rating: 'minimal' | 'low' | 'moderate' | 'high' | 'severe';
|
|
18
|
+
};
|
|
19
|
+
issues: DocDriftIssue[];
|
|
20
|
+
rawData: {
|
|
21
|
+
uncommentedExports: number;
|
|
22
|
+
totalExports: number;
|
|
23
|
+
outdatedComments: number;
|
|
24
|
+
undocumentedComplexity: number;
|
|
25
|
+
};
|
|
26
|
+
recommendations: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
declare function analyzeDocDrift(options: DocDriftOptions): Promise<DocDriftReport>;
|
|
30
|
+
|
|
31
|
+
export { type DocDriftIssue, type DocDriftOptions, type DocDriftReport, analyzeDocDrift };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Issue, ScanOptions } from '@aiready/core';
|
|
2
|
+
|
|
3
|
+
interface DocDriftOptions extends ScanOptions {
|
|
4
|
+
/** Maximum commit distance to check for drift */
|
|
5
|
+
maxCommits?: number;
|
|
6
|
+
/** Consider comments older than this many months as outdated */
|
|
7
|
+
staleMonths?: number;
|
|
8
|
+
}
|
|
9
|
+
interface DocDriftIssue extends Issue {
|
|
10
|
+
type: 'doc-drift';
|
|
11
|
+
}
|
|
12
|
+
interface DocDriftReport {
|
|
13
|
+
summary: {
|
|
14
|
+
filesAnalyzed: number;
|
|
15
|
+
functionsAnalyzed: number;
|
|
16
|
+
score: number;
|
|
17
|
+
rating: 'minimal' | 'low' | 'moderate' | 'high' | 'severe';
|
|
18
|
+
};
|
|
19
|
+
issues: DocDriftIssue[];
|
|
20
|
+
rawData: {
|
|
21
|
+
uncommentedExports: number;
|
|
22
|
+
totalExports: number;
|
|
23
|
+
outdatedComments: number;
|
|
24
|
+
undocumentedComplexity: number;
|
|
25
|
+
};
|
|
26
|
+
recommendations: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
declare function analyzeDocDrift(options: DocDriftOptions): Promise<DocDriftReport>;
|
|
30
|
+
|
|
31
|
+
export { type DocDriftIssue, type DocDriftOptions, type DocDriftReport, analyzeDocDrift };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
analyzeDocDrift: () => analyzeDocDrift
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
|
|
27
|
+
// src/analyzer.ts
|
|
28
|
+
var import_core = require("@aiready/core");
|
|
29
|
+
var import_fs = require("fs");
|
|
30
|
+
var import_path = require("path");
|
|
31
|
+
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
|
+
async function analyzeDocDrift(options) {
|
|
79
|
+
const rootDir = options.rootDir;
|
|
80
|
+
const files = collectFiles(rootDir, options);
|
|
81
|
+
const staleMonths = options.staleMonths ?? 6;
|
|
82
|
+
const staleSeconds = staleMonths * 30 * 24 * 60 * 60;
|
|
83
|
+
let uncommentedExports = 0;
|
|
84
|
+
let totalExports = 0;
|
|
85
|
+
let outdatedComments = 0;
|
|
86
|
+
let undocumentedComplexity = 0;
|
|
87
|
+
const issues = [];
|
|
88
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
89
|
+
for (const file of files) {
|
|
90
|
+
let code;
|
|
91
|
+
try {
|
|
92
|
+
code = (0, import_fs.readFileSync)(file, "utf-8");
|
|
93
|
+
} catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
let ast;
|
|
97
|
+
try {
|
|
98
|
+
ast = (0, import_typescript_estree.parse)(code, {
|
|
99
|
+
jsx: file.endsWith(".tsx") || file.endsWith(".jsx"),
|
|
100
|
+
loc: true,
|
|
101
|
+
comment: true
|
|
102
|
+
});
|
|
103
|
+
} catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const comments = ast.comments || [];
|
|
107
|
+
for (const node of ast.body) {
|
|
108
|
+
if (node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration") {
|
|
109
|
+
const decl = node.declaration;
|
|
110
|
+
if (!decl) continue;
|
|
111
|
+
if (decl.type === "FunctionDeclaration" || decl.type === "ClassDeclaration" || decl.type === "VariableDeclaration") {
|
|
112
|
+
totalExports++;
|
|
113
|
+
const nodeLine = node.loc.start.line;
|
|
114
|
+
const jsdocs = comments.filter((c) => c.type === "Block" && c.value.startsWith("*") && c.loc.end.line === nodeLine - 1);
|
|
115
|
+
if (jsdocs.length === 0) {
|
|
116
|
+
uncommentedExports++;
|
|
117
|
+
if (decl.type === "FunctionDeclaration" && decl.body?.loc) {
|
|
118
|
+
const lines = decl.body.loc.end.line - decl.body.loc.start.line;
|
|
119
|
+
if (lines > 20) undocumentedComplexity++;
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
const jsdoc = jsdocs[0];
|
|
123
|
+
const jsdocText = jsdoc.value;
|
|
124
|
+
if (decl.type === "FunctionDeclaration") {
|
|
125
|
+
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));
|
|
128
|
+
if (missingParams.length > 0) {
|
|
129
|
+
outdatedComments++;
|
|
130
|
+
issues.push({
|
|
131
|
+
type: "doc-drift",
|
|
132
|
+
severity: "major",
|
|
133
|
+
message: `JSDoc @param mismatch: function has parameters (${missingParams.join(", ")}) not documented in JSDoc.`,
|
|
134
|
+
location: { file, line: nodeLine }
|
|
135
|
+
});
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
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);
|
|
141
|
+
if (commentModified > 0 && bodyModified > 0) {
|
|
142
|
+
if (now - commentModified > staleSeconds && bodyModified - commentModified > staleSeconds / 2) {
|
|
143
|
+
outdatedComments++;
|
|
144
|
+
issues.push({
|
|
145
|
+
type: "doc-drift",
|
|
146
|
+
severity: "minor",
|
|
147
|
+
message: `JSDoc is significantly older than the function body implementation. Code may have drifted.`,
|
|
148
|
+
location: { file, line: jsdoc.loc.start.line }
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const riskResult = (0, import_core.calculateDocDrift)({
|
|
158
|
+
uncommentedExports,
|
|
159
|
+
totalExports,
|
|
160
|
+
outdatedComments,
|
|
161
|
+
undocumentedComplexity
|
|
162
|
+
});
|
|
163
|
+
return {
|
|
164
|
+
summary: {
|
|
165
|
+
filesAnalyzed: files.length,
|
|
166
|
+
functionsAnalyzed: totalExports,
|
|
167
|
+
score: riskResult.score,
|
|
168
|
+
rating: riskResult.rating
|
|
169
|
+
},
|
|
170
|
+
issues,
|
|
171
|
+
rawData: {
|
|
172
|
+
uncommentedExports,
|
|
173
|
+
totalExports,
|
|
174
|
+
outdatedComments,
|
|
175
|
+
undocumentedComplexity
|
|
176
|
+
},
|
|
177
|
+
recommendations: riskResult.recommendations
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
181
|
+
0 && (module.exports = {
|
|
182
|
+
analyzeDocDrift
|
|
183
|
+
});
|
package/dist/index.mjs
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aiready/doc-drift",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "AI-Readiness: Documentation Drift Detection",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@typescript-eslint/typescript-estree": "^7.8.0",
|
|
10
|
+
"commander": "^12.0.0",
|
|
11
|
+
"glob": "^10.3.12",
|
|
12
|
+
"picocolors": "^1.0.0",
|
|
13
|
+
"@aiready/core": "0.9.28"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/node": "^20.12.7",
|
|
17
|
+
"@typescript-eslint/types": "^7.8.0",
|
|
18
|
+
"tsup": "^8.0.2",
|
|
19
|
+
"typescript": "^5.4.5",
|
|
20
|
+
"vitest": "^1.6.0"
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"import": "./dist/index.mjs",
|
|
25
|
+
"require": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsup src/index.ts src/cli.ts --format cjs,esm --dts",
|
|
31
|
+
"dev": "tsup src/index.ts src/cli.ts --format cjs,esm --watch",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:watch": "vitest",
|
|
34
|
+
"lint": "eslint src --ext .ts"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { analyzeDocDrift } from '../analyzer';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { writeFileSync, mkdirSync, rmSync } from 'fs';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
6
|
+
|
|
7
|
+
describe('Doc Drift Analyzer', () => {
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
|
|
10
|
+
beforeAll(() => {
|
|
11
|
+
tmpDir = join(tmpdir(), `doc-drift-test-${Date.now()}`);
|
|
12
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
13
|
+
|
|
14
|
+
// File with signature mismatch
|
|
15
|
+
const file1 = join(tmpDir, 'file1.ts');
|
|
16
|
+
writeFileSync(file1, `
|
|
17
|
+
/**
|
|
18
|
+
* Adds numbers.
|
|
19
|
+
* @param a First number
|
|
20
|
+
*/
|
|
21
|
+
export function add(a: number, b: number) {
|
|
22
|
+
return a + b;
|
|
23
|
+
}
|
|
24
|
+
`);
|
|
25
|
+
|
|
26
|
+
// File with undocumented complexity (simulated by lines > 20)
|
|
27
|
+
const file2 = join(tmpDir, 'file2.ts');
|
|
28
|
+
writeFileSync(file2, `
|
|
29
|
+
export function complexFunction(data: any) {
|
|
30
|
+
let result = 0;
|
|
31
|
+
for (let i = 0; i < 10; i++) {
|
|
32
|
+
if (data && data.includes(i)) result += i;
|
|
33
|
+
}
|
|
34
|
+
for (let j = 0; j < 15; j++) {
|
|
35
|
+
result -= j;
|
|
36
|
+
}
|
|
37
|
+
for (let j = 0; j < 15; j++) {
|
|
38
|
+
result -= j;
|
|
39
|
+
}
|
|
40
|
+
for (let j = 0; j < 15; j++) {
|
|
41
|
+
result -= j;
|
|
42
|
+
}
|
|
43
|
+
for (let j = 0; j < 15; j++) {
|
|
44
|
+
result -= j;
|
|
45
|
+
}
|
|
46
|
+
for (let j = 0; j < 15; j++) {
|
|
47
|
+
result -= j;
|
|
48
|
+
}
|
|
49
|
+
for (let k = 0; k < 5; k++) {
|
|
50
|
+
result += k;
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
`);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(() => {
|
|
58
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('detects missing param documentation and uncommented complexity', async () => {
|
|
62
|
+
const report = await analyzeDocDrift({
|
|
63
|
+
rootDir: tmpDir,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// totalExports = 2 (add, complexFunction)
|
|
67
|
+
expect(report.summary.functionsAnalyzed).toBe(2);
|
|
68
|
+
|
|
69
|
+
// "add" has a JSDoc, but missing "b" param. "complexFunction" has NO JSDoc.
|
|
70
|
+
expect(report.rawData.uncommentedExports).toBe(1);
|
|
71
|
+
expect(report.rawData.outdatedComments).toBe(1);
|
|
72
|
+
expect(report.rawData.undocumentedComplexity).toBe(1);
|
|
73
|
+
|
|
74
|
+
expect(report.issues.length).toBeGreaterThan(0);
|
|
75
|
+
expect(report.issues.some(i => i.message.includes('b'))).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
package/src/analyzer.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { calculateDocDrift } from '@aiready/core';
|
|
2
|
+
import type { DocDriftOptions, DocDriftReport, DocDriftIssue } from './types';
|
|
3
|
+
import { readdirSync, statSync, readFileSync } from 'fs';
|
|
4
|
+
import { join, extname } from 'path';
|
|
5
|
+
import { parse } from '@typescript-eslint/typescript-estree';
|
|
6
|
+
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
|
+
|
|
52
|
+
export async function analyzeDocDrift(
|
|
53
|
+
options: DocDriftOptions,
|
|
54
|
+
): Promise<DocDriftReport> {
|
|
55
|
+
const rootDir = options.rootDir;
|
|
56
|
+
const files = collectFiles(rootDir, options);
|
|
57
|
+
const staleMonths = options.staleMonths ?? 6;
|
|
58
|
+
const staleSeconds = staleMonths * 30 * 24 * 60 * 60;
|
|
59
|
+
|
|
60
|
+
let uncommentedExports = 0;
|
|
61
|
+
let totalExports = 0;
|
|
62
|
+
let outdatedComments = 0;
|
|
63
|
+
let undocumentedComplexity = 0;
|
|
64
|
+
|
|
65
|
+
const issues: DocDriftIssue[] = [];
|
|
66
|
+
const now = Math.floor(Date.now() / 1000);
|
|
67
|
+
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
let code: string;
|
|
70
|
+
try { code = readFileSync(file, 'utf-8'); } catch { continue; }
|
|
71
|
+
|
|
72
|
+
let ast: TSESTree.Program;
|
|
73
|
+
try {
|
|
74
|
+
ast = parse(code, {
|
|
75
|
+
jsx: file.endsWith('.tsx') || file.endsWith('.jsx'),
|
|
76
|
+
loc: true,
|
|
77
|
+
comment: true,
|
|
78
|
+
});
|
|
79
|
+
} catch { continue; }
|
|
80
|
+
|
|
81
|
+
const comments = ast.comments || [];
|
|
82
|
+
|
|
83
|
+
for (const node of ast.body) {
|
|
84
|
+
if (node.type === 'ExportNamedDeclaration' || node.type === 'ExportDefaultDeclaration') {
|
|
85
|
+
const decl = (node as any).declaration;
|
|
86
|
+
if (!decl) continue;
|
|
87
|
+
|
|
88
|
+
// Count exports
|
|
89
|
+
if (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration' || decl.type === 'VariableDeclaration') {
|
|
90
|
+
totalExports++;
|
|
91
|
+
|
|
92
|
+
// Find associated JSDoc comment (immediately preceding the export)
|
|
93
|
+
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);
|
|
95
|
+
|
|
96
|
+
if (jsdocs.length === 0) {
|
|
97
|
+
uncommentedExports++;
|
|
98
|
+
|
|
99
|
+
// Check for undocumented complexity (e.g., function body > 20 lines)
|
|
100
|
+
if (decl.type === 'FunctionDeclaration' && decl.body?.loc) {
|
|
101
|
+
const lines = decl.body.loc.end.line - decl.body.loc.start.line;
|
|
102
|
+
if (lines > 20) undocumentedComplexity++;
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
const jsdoc = jsdocs[0];
|
|
106
|
+
const jsdocText = jsdoc.value;
|
|
107
|
+
|
|
108
|
+
// Signature mismatch detection
|
|
109
|
+
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));
|
|
114
|
+
if (missingParams.length > 0) {
|
|
115
|
+
outdatedComments++;
|
|
116
|
+
issues.push({
|
|
117
|
+
type: 'doc-drift',
|
|
118
|
+
severity: 'major',
|
|
119
|
+
message: `JSDoc @param mismatch: function has parameters (${missingParams.join(', ')}) not documented in JSDoc.`,
|
|
120
|
+
location: { file, line: nodeLine }
|
|
121
|
+
});
|
|
122
|
+
continue; // already counted as outdated
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 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);
|
|
129
|
+
|
|
130
|
+
if (commentModified > 0 && bodyModified > 0) {
|
|
131
|
+
// If body was modified much later than the comment, and comment is older than staleMonths
|
|
132
|
+
if (now - commentModified > staleSeconds && bodyModified - commentModified > staleSeconds / 2) {
|
|
133
|
+
outdatedComments++;
|
|
134
|
+
issues.push({
|
|
135
|
+
type: 'doc-drift',
|
|
136
|
+
severity: 'minor',
|
|
137
|
+
message: `JSDoc is significantly older than the function body implementation. Code may have drifted.`,
|
|
138
|
+
location: { file, line: jsdoc.loc.start.line }
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const riskResult = calculateDocDrift({
|
|
149
|
+
uncommentedExports,
|
|
150
|
+
totalExports,
|
|
151
|
+
outdatedComments,
|
|
152
|
+
undocumentedComplexity
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
summary: {
|
|
157
|
+
filesAnalyzed: files.length,
|
|
158
|
+
functionsAnalyzed: totalExports,
|
|
159
|
+
score: riskResult.score,
|
|
160
|
+
rating: riskResult.rating,
|
|
161
|
+
},
|
|
162
|
+
issues,
|
|
163
|
+
rawData: {
|
|
164
|
+
uncommentedExports,
|
|
165
|
+
totalExports,
|
|
166
|
+
outdatedComments,
|
|
167
|
+
undocumentedComplexity,
|
|
168
|
+
},
|
|
169
|
+
recommendations: riskResult.recommendations,
|
|
170
|
+
};
|
|
171
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { analyzeDocDrift } from './analyzer';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
|
|
5
|
+
export function createCommand() {
|
|
6
|
+
const program = new Command('doc-drift')
|
|
7
|
+
.description('Scan for documentation drift (outdated comments, mismatched signatures)')
|
|
8
|
+
.option('--include <patterns...>', 'File patterns to include')
|
|
9
|
+
.option('--exclude <patterns...>', 'File patterns to exclude')
|
|
10
|
+
.option('--stale-months <number>', 'Months before a comment is considered potentially outdated', '6')
|
|
11
|
+
.action(async (options) => {
|
|
12
|
+
console.log(pc.cyan('Analyzing documentation drift...'));
|
|
13
|
+
const report = await analyzeDocDrift({
|
|
14
|
+
rootDir: process.cwd(),
|
|
15
|
+
include: options.include,
|
|
16
|
+
exclude: options.exclude,
|
|
17
|
+
staleMonths: parseInt(options.staleMonths, 10),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
console.log(pc.bold('Doc Drift Analysis Results:'));
|
|
21
|
+
console.log(`Rating: ${report.summary.rating.toUpperCase()} (Score: ${report.summary.score})`);
|
|
22
|
+
if (report.issues.length > 0) {
|
|
23
|
+
console.log(pc.red(`\nFound ${report.issues.length} drift issues.`));
|
|
24
|
+
} else {
|
|
25
|
+
console.log(pc.green('\nNo documentation drift detected.'));
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return program;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (require.main === module) {
|
|
33
|
+
createCommand().parseAsync(process.argv).catch(err => {
|
|
34
|
+
console.error(pc.red(err.message));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
});
|
|
37
|
+
}
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ScanOptions, Issue } from '@aiready/core';
|
|
2
|
+
|
|
3
|
+
export interface DocDriftOptions extends ScanOptions {
|
|
4
|
+
/** Maximum commit distance to check for drift */
|
|
5
|
+
maxCommits?: number;
|
|
6
|
+
/** Consider comments older than this many months as outdated */
|
|
7
|
+
staleMonths?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DocDriftIssue extends Issue {
|
|
11
|
+
type: 'doc-drift';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DocDriftReport {
|
|
15
|
+
summary: {
|
|
16
|
+
filesAnalyzed: number;
|
|
17
|
+
functionsAnalyzed: number;
|
|
18
|
+
score: number;
|
|
19
|
+
rating: 'minimal' | 'low' | 'moderate' | 'high' | 'severe';
|
|
20
|
+
};
|
|
21
|
+
issues: DocDriftIssue[];
|
|
22
|
+
rawData: {
|
|
23
|
+
uncommentedExports: number;
|
|
24
|
+
totalExports: number;
|
|
25
|
+
outdatedComments: number;
|
|
26
|
+
undocumentedComplexity: number;
|
|
27
|
+
};
|
|
28
|
+
recommendations: string[];
|
|
29
|
+
}
|