@aiready/doc-drift 0.13.4 → 0.13.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiready/doc-drift",
3
- "version": "0.13.4",
3
+ "version": "0.13.6",
4
4
  "description": "AI-Readiness: Documentation Drift Detection",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -10,7 +10,7 @@
10
10
  "commander": "^14.0.0",
11
11
  "glob": "^13.0.0",
12
12
  "picocolors": "^1.0.0",
13
- "@aiready/core": "0.23.4"
13
+ "@aiready/core": "0.23.7"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@types/node": "^24.0.0",
@@ -76,6 +76,7 @@ export function complexFunction(data: any) {
76
76
  expect(report.rawData.uncommentedExports).toBe(1);
77
77
  expect(report.rawData.outdatedComments).toBe(1);
78
78
  expect(report.rawData.undocumentedComplexity).toBe(1);
79
+ expect(report.rawData.actualDrift).toBe(0);
79
80
 
80
81
  expect(report.issues.length).toBeGreaterThan(0);
81
82
  expect(report.issues.some((i) => i.message.includes('b'))).toBe(true);
@@ -21,6 +21,7 @@ describe('Doc Drift Provider', () => {
21
21
  totalExports: 5,
22
22
  outdatedComments: 0,
23
23
  undocumentedComplexity: 0,
24
+ actualDrift: 0,
24
25
  },
25
26
  recommendations: [],
26
27
  });
@@ -17,6 +17,7 @@ describe('Doc Drift Scoring', () => {
17
17
  totalExports: 50,
18
18
  outdatedComments: 2,
19
19
  undocumentedComplexity: 1,
20
+ actualDrift: 0,
20
21
  },
21
22
  recommendations: [
22
23
  'Update or remove 2 outdated comments that contradict the code.',
package/src/analyzer.ts CHANGED
@@ -11,21 +11,31 @@ import {
11
11
  import type { DocDriftOptions, DocDriftReport, DocDriftIssue } from './types';
12
12
  import { readFileSync } from 'fs';
13
13
 
14
+ /**
15
+ * Analyzes documentation drift across a set of files.
16
+ * This tool detects:
17
+ * 1. Missing documentation for complex functions/classes.
18
+ * 2. Signature mismatches (parameters not mentioned in docs).
19
+ * 3. Temporal drift (logic changed after documentation was last updated).
20
+ *
21
+ * @param options - Analysis configuration including include/exclude patterns and drift thresholds.
22
+ * @returns A comprehensive report with drift scores and specific issues.
23
+ */
14
24
  export async function analyzeDocDrift(
15
25
  options: DocDriftOptions
16
26
  ): Promise<DocDriftReport> {
17
27
  // Use core scanFiles which respects .gitignore recursively
18
28
  const files = await scanFiles(options);
19
29
  const issues: DocDriftIssue[] = [];
20
- const staleMonths = options.staleMonths ?? 6;
21
- const staleSeconds = staleMonths * 30 * 24 * 60 * 60;
30
+ // const staleSeconds = staleMonths * 30 * 24 * 60 * 60; // Unused, removed
22
31
 
23
32
  let uncommentedExports = 0;
24
33
  let totalExports = 0;
25
34
  let outdatedComments = 0;
26
35
  let undocumentedComplexity = 0;
36
+ let actualDrift = 0;
27
37
 
28
- const now = Math.floor(Date.now() / 1000);
38
+ // const now = Math.floor(Date.now() / 1000); // Unused, removed
29
39
 
30
40
  let processed = 0;
31
41
  for (const file of files) {
@@ -76,7 +86,6 @@ export async function analyzeDocDrift(
76
86
  if (exp.type === 'function' && exp.parameters) {
77
87
  const params = exp.parameters;
78
88
  // Check if params mentioned in doc (standard @param or simple mention)
79
- // Use regex with word boundaries to avoid partial matches (e.g. 'b' in 'numbers')
80
89
  const missingParams = params.filter((p) => {
81
90
  const regex = new RegExp(`\\b${p}\\b`, 'i');
82
91
  return !regex.test(docContent);
@@ -90,36 +99,39 @@ export async function analyzeDocDrift(
90
99
  message: `Documentation mismatch: function parameters (${missingParams.join(', ')}) are not mentioned in the docs.`,
91
100
  location: { file, line: exp.loc?.start.line || 1 },
92
101
  });
93
- continue;
94
102
  }
95
103
  }
96
104
 
97
- // Timestamp comparison
98
- if (exp.loc) {
105
+ // Timestamp comparison for temporal drift
106
+ if (exp.loc && doc.loc) {
99
107
  if (!fileLineStamps) {
100
108
  fileLineStamps = getFileCommitTimestamps(file);
101
109
  }
102
110
 
103
- // We don't have exact lines for the doc node in ExportInfo yet,
104
- // but we know it precedes the export. Using export start as a proxy for drift check.
105
111
  const bodyModified = getLineRangeLastModifiedCached(
106
112
  fileLineStamps,
107
113
  exp.loc.start.line,
108
114
  exp.loc.end.line
109
115
  );
110
116
 
111
- if (bodyModified > 0) {
112
- // If body was modified much later than the "stale" threshold
113
- if (
114
- now - bodyModified < staleSeconds / 4 &&
115
- exp.documentation.isStale === true
116
- ) {
117
- // This would require isStale to be set by the parser if it knew history
118
- // For now, we compare body modification vs current time if docs look very old (heuristic)
119
- }
117
+ const docModified = getLineRangeLastModifiedCached(
118
+ fileLineStamps,
119
+ doc.loc.start.line,
120
+ doc.loc.end.line
121
+ );
120
122
 
121
- // If the file itself is very old but has no issues, it's fine.
122
- // Doc-drift is really about implementation changing without doc updates.
123
+ if (bodyModified > 0 && docModified > 0) {
124
+ // If body was modified more than 1 day AFTER the documentation
125
+ const DRIFT_THRESHOLD_SECONDS = 24 * 60 * 60;
126
+ if (bodyModified - docModified > DRIFT_THRESHOLD_SECONDS) {
127
+ actualDrift++;
128
+ issues.push({
129
+ type: IssueType.DocDrift,
130
+ severity: Severity.Major,
131
+ message: `Documentation drift: logic was modified on ${new Date(bodyModified * 1000).toLocaleDateString()} but documentation was last updated on ${new Date(docModified * 1000).toLocaleDateString()}.`,
132
+ location: { file, line: doc.loc.start.line },
133
+ });
134
+ }
123
135
  }
124
136
  }
125
137
  }
@@ -136,6 +148,7 @@ export async function analyzeDocDrift(
136
148
  totalExports,
137
149
  outdatedComments,
138
150
  undocumentedComplexity,
151
+ actualDrift,
139
152
  });
140
153
 
141
154
  return {
@@ -151,6 +164,7 @@ export async function analyzeDocDrift(
151
164
  totalExports,
152
165
  outdatedComments,
153
166
  undocumentedComplexity,
167
+ actualDrift,
154
168
  },
155
169
  recommendations: riskResult.recommendations,
156
170
  };
package/src/scoring.ts CHANGED
@@ -3,7 +3,11 @@ import type { ToolScoringOutput } from '@aiready/core';
3
3
  import type { DocDriftReport } from './types';
4
4
 
5
5
  /**
6
- * Convert doc-drift report into a ToolScoringOutput.
6
+ * Convert doc-drift report into a standardized ToolScoringOutput.
7
+ *
8
+ * @param report - The detailed doc-drift report including raw metrics.
9
+ * @returns Standardized scoring and risk factor breakdown.
10
+ * @lastUpdated 2026-03-18
7
11
  */
8
12
  export function calculateDocDriftScore(
9
13
  report: DocDriftReport
@@ -16,28 +20,41 @@ export function calculateDocDriftScore(
16
20
  totalExports: rawData.totalExports,
17
21
  outdatedComments: rawData.outdatedComments,
18
22
  undocumentedComplexity: rawData.undocumentedComplexity,
23
+ actualDrift: rawData.actualDrift,
19
24
  });
20
25
 
21
26
  const factors: ToolScoringOutput['factors'] = [
22
27
  {
23
- name: 'Uncommented Exports',
28
+ name: 'Undocumented Complexity',
24
29
  impact: -Math.min(
25
- 20,
26
- (rawData.uncommentedExports / Math.max(1, rawData.totalExports)) *
30
+ 50,
31
+ (rawData.undocumentedComplexity /
32
+ Math.max(1, rawData.totalExports) /
33
+ 0.2) *
27
34
  100 *
28
- 0.2
35
+ 0.5
29
36
  ),
30
- description: `${rawData.uncommentedExports} uncommented exports`,
37
+ description: `${rawData.undocumentedComplexity} complex functions lack docs (high risk)`,
31
38
  },
32
39
  {
33
- name: 'Outdated Comments',
34
- impact: -Math.min(60, rawData.outdatedComments * 15 * 0.6),
35
- description: `${rawData.outdatedComments} outdated comments`,
40
+ name: 'Outdated/Incomplete Comments',
41
+ impact: -Math.min(
42
+ 30,
43
+ (rawData.outdatedComments / Math.max(1, rawData.totalExports) / 0.4) *
44
+ 100 *
45
+ 0.3
46
+ ),
47
+ description: `${rawData.outdatedComments} functions with parameter-mismatch in docs`,
36
48
  },
37
49
  {
38
- name: 'Undocumented Complexity',
39
- impact: -Math.min(20, rawData.undocumentedComplexity * 10 * 0.2),
40
- description: `${rawData.undocumentedComplexity} complex functions lack docs`,
50
+ name: 'Uncommented Exports',
51
+ impact: -Math.min(
52
+ 20,
53
+ (rawData.uncommentedExports / Math.max(1, rawData.totalExports) / 0.8) *
54
+ 100 *
55
+ 0.2
56
+ ),
57
+ description: `${rawData.uncommentedExports} uncommented exports`,
41
58
  },
42
59
  ];
43
60
 
package/src/types.ts CHANGED
@@ -50,6 +50,8 @@ export interface DocDriftReport {
50
50
  outdatedComments: number;
51
51
  /** Count of complex functions without sufficient documentation */
52
52
  undocumentedComplexity: number;
53
+ /** Number of functions where code changed after docs */
54
+ actualDrift: number;
53
55
  };
54
56
  /** AI-generated remediation advice */
55
57
  recommendations: string[];
@@ -1,145 +0,0 @@
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
- };
@@ -1,189 +0,0 @@
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
- };