@dailephd/my-dev-kit-lab 0.2.2 → 0.3.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.
Files changed (42) hide show
  1. package/README.md +17 -7
  2. package/dist/scripts/audits/runAudit.js +102 -0
  3. package/dist/src/audits/codeRot/codeRotDetectors.js +17 -0
  4. package/dist/src/audits/codeRot/detectors/architectureDriftDetector.js +271 -0
  5. package/dist/src/audits/codeRot/detectors/crossPlatformRotDetector.js +214 -0
  6. package/dist/src/audits/codeRot/detectors/deadCodeCandidateDetector.js +377 -0
  7. package/dist/src/audits/codeRot/detectors/dependencyEnvironmentRotDetector.js +244 -0
  8. package/dist/src/audits/codeRot/detectors/docsCodeMismatchDetector.js +219 -0
  9. package/dist/src/audits/codeRot/detectors/duplicateImplementationDetector.js +350 -0
  10. package/dist/src/audits/codeRot/detectors/packageReleaseRotDetector.js +325 -0
  11. package/dist/src/audits/codeRot/detectors/securityValidationAssumptionRotDetector.js +385 -0
  12. package/dist/src/audits/codeRot/detectors/staleCommandReferenceDetector.js +135 -0
  13. package/dist/src/audits/codeRot/detectors/testRotDetector.js +410 -0
  14. package/dist/src/audits/codeRot/utils/boundedRead.js +23 -0
  15. package/dist/src/audits/codeRot/utils/docClaimPatterns.js +62 -0
  16. package/dist/src/audits/codeRot/utils/docCommandReferences.js +64 -0
  17. package/dist/src/audits/codeRot/utils/filePatternUtils.js +79 -0
  18. package/dist/src/audits/codeRot/utils/issueFactories.js +38 -0
  19. package/dist/src/audits/codeRot/utils/sourceFactsLookup.js +53 -0
  20. package/dist/src/audits/codeRot/utils/textLines.js +12 -0
  21. package/dist/src/audits/core/auditConfig.js +125 -0
  22. package/dist/src/audits/core/auditExitCode.js +75 -0
  23. package/dist/src/audits/core/auditIssue.js +20 -0
  24. package/dist/src/audits/core/auditRegistry.js +40 -0
  25. package/dist/src/audits/core/auditRunner.js +74 -0
  26. package/dist/src/audits/core/auditTarget.js +19 -0
  27. package/dist/src/audits/core/auditTypes.js +30 -0
  28. package/dist/src/audits/core/collectSourceFacts.js +142 -0
  29. package/dist/src/audits/core/languageAnalyzerRegistry.js +33 -0
  30. package/dist/src/audits/core/projectInventory.js +458 -0
  31. package/dist/src/audits/core/sourceFacts.js +17 -0
  32. package/dist/src/audits/core/sourceOfTruth.js +339 -0
  33. package/dist/src/audits/core/textLines.js +24 -0
  34. package/dist/src/audits/core/typescriptJavaScriptAnalyzer.js +299 -0
  35. package/dist/src/audits/report/auditReportModel.js +253 -0
  36. package/dist/src/audits/report/auditReportPaths.js +13 -0
  37. package/dist/src/audits/report/renderAuditJsonReport.js +30 -0
  38. package/dist/src/audits/report/renderAuditTextReport.js +181 -0
  39. package/dist/src/audits/report/sanitizeAuditText.js +39 -0
  40. package/dist/src/audits/report/writeAuditReports.js +27 -0
  41. package/dist/src/core/resolveCommand.js +53 -2
  42. package/package.json +10 -6
@@ -0,0 +1,350 @@
1
+ import { readBoundedFileText } from "../utils/boundedRead.js";
2
+ import { firstPathSegment, GENERIC_DECLARATION_NAMES, GENERIC_INFRA_BASENAMES, baseNameNoExt } from "../utils/filePatternUtils.js";
3
+ import { deduplicateIssuesById, makeCodeRotIssue } from "../utils/issueFactories.js";
4
+ import { indexSourceFactsByPath } from "../utils/sourceFactsLookup.js";
5
+ // ---------------------------------------------------------------------------
6
+ // v0.3.0 Batch 4 — duplicate/parallel implementation candidate detector.
7
+ //
8
+ // Every check here is a *candidate* signal, never proof of duplication --
9
+ // title/description wording is deliberately hedged throughout. Bounded,
10
+ // deterministic, no fuzzy text similarity and no large file reads (only a
11
+ // cheap regex scan of already-small detector source files for the
12
+ // registry-id-duplicate sub-check).
13
+ //
14
+ // Simplifications made relative to the full Batch 4 spec bullet list (see
15
+ // docs handed to this batch for the original wording):
16
+ // - "multiple report renderers for same feature family" is folded into the
17
+ // generic same-basename-across-unrelated-roots check (below) rather than
18
+ // a separate report-specific check -- a dedicated report-directory scan
19
+ // would duplicate that same logic for one category of directory.
20
+ // - "multiple validation runners for same command surface" and "duplicated
21
+ // script families where only one is documented" are SKIPPED: both
22
+ // require inferring "same command surface"/"same family" from script
23
+ // naming, which risks false positives without semantic judgment (out of
24
+ // scope). Left for a future batch if a cheap deterministic signal is
25
+ // found.
26
+ // - The registry-duplicate-id check is intentionally narrow: it scans
27
+ // source text of files under src/audits/codeRot/detectors/ for
28
+ // `id: "..."` string literals via a cheap regex (not a semantic parse),
29
+ // per the spec's own suggested simplification for that bullet.
30
+ // ---------------------------------------------------------------------------
31
+ const DETECTOR_ID = "duplicate-implementation-candidate";
32
+ const MAX_READ_BYTES = 200_000;
33
+ // Directory-name pairs commonly conflated in real projects (singular vs.
34
+ // plural, or synonym pairs). Flagged only when BOTH variants exist as a
35
+ // first path segment among inventoried files.
36
+ const PARALLEL_DIR_PAIRS = [
37
+ ["report", "reports"],
38
+ ["test", "tests"],
39
+ ["util", "utils"],
40
+ ["script", "scripts"],
41
+ ["doc", "docs"],
42
+ ];
43
+ export const DUPLICATE_IMPLEMENTATION_DETECTOR = {
44
+ id: DETECTOR_ID,
45
+ auditType: "code-rot",
46
+ title: "Duplicate/parallel implementation candidate",
47
+ description: "Detects candidate (not proven) duplicate or parallel implementations: overlapping npm script entrypoints, parallel singular/plural directories, duplicate detector-registry ids, and repeated basenames across unrelated source roots.",
48
+ supportedIncludeAreas: ["cli", "package", "architecture"],
49
+ shouldSkip: (ctx) => {
50
+ if (!ctx.config.include.includes("package")) {
51
+ return { skip: true, reason: "--include does not select package; this detector's primary signal is package.json scripts." };
52
+ }
53
+ return { skip: false };
54
+ },
55
+ run: (ctx) => {
56
+ const issues = [];
57
+ const pkg = ctx.sourceOfTruth.package;
58
+ if (pkg) {
59
+ issues.push(...findDuplicateScriptEntrypoints(pkg.scripts));
60
+ }
61
+ if (ctx.config.include.includes("architecture")) {
62
+ issues.push(...findParallelDirectories(ctx.inventory.files.map((f) => f.relativePath)));
63
+ issues.push(...findDuplicateBasenamesAcrossRoots(ctx.inventory.sourceFiles.map((f) => f.relativePath)));
64
+ issues.push(...findDuplicateDeclarationCandidates(ctx));
65
+ }
66
+ issues.push(...findDuplicateDetectorRegistryIds(ctx));
67
+ return deduplicateIssuesById(issues);
68
+ },
69
+ };
70
+ // Groups scripts by the file path they invoke (first "scripts/..." token in
71
+ // the command) and flags when 2+ distinct script names invoke the exact
72
+ // same target file -- a strong, low-noise duplicate-entrypoint signal.
73
+ function findDuplicateScriptEntrypoints(scripts) {
74
+ const byTarget = new Map();
75
+ for (const [name, command] of Object.entries(scripts)) {
76
+ const match = command.match(/\b(scripts\/[\w./-]+\.[cm]?[jt]sx?)\b/);
77
+ if (!match)
78
+ continue;
79
+ const target = match[1];
80
+ const names = byTarget.get(target) ?? [];
81
+ names.push(name);
82
+ byTarget.set(target, names);
83
+ }
84
+ const issues = [];
85
+ for (const [target, names] of byTarget) {
86
+ if (names.length < 2)
87
+ continue;
88
+ const sortedNames = [...names].sort((a, b) => a.localeCompare(b));
89
+ issues.push(makeCodeRotIssue({
90
+ auditType: "code-rot",
91
+ detectorId: DETECTOR_ID,
92
+ idCues: ["script-entrypoint-candidate", target],
93
+ title: `Multiple npm scripts may be duplicate entrypoints for "${target}"`,
94
+ description: `Scripts ${sortedNames.map((n) => `"${n}"`).join(", ")} all invoke "${target}" -- this may indicate duplicate/parallel CLI entrypoints for the same feature, or may be intentional aliasing. Inspect manually before consolidating.`,
95
+ severity: "medium",
96
+ confidence: "medium",
97
+ falsePositiveRisk: "medium",
98
+ category: "duplicate-implementation-candidate",
99
+ recommendedAction: "Manually confirm whether these scripts are intentional aliases or accidental duplicates before consolidating or removing any of them.",
100
+ suggestedFixStrategy: "If duplicated unintentionally, keep one canonical script name and remove or redirect the others.",
101
+ validationCommands: ["npm run audit -- --types code-rot --include package"],
102
+ releaseBlocking: false,
103
+ implementationBlocking: false,
104
+ autoFixEligible: false,
105
+ evidence: [
106
+ {
107
+ kind: "reference",
108
+ message: `${sortedNames.length} package.json scripts invoke the same target file.`,
109
+ filePath: "package.json",
110
+ excerpt: sortedNames.join(", "),
111
+ source: DETECTOR_ID,
112
+ confidence: "medium",
113
+ },
114
+ ],
115
+ affectedFiles: ["package.json", target],
116
+ }));
117
+ }
118
+ return issues;
119
+ }
120
+ function findParallelDirectories(relativePaths) {
121
+ const topSegments = new Set(relativePaths.map((p) => firstPathSegment(p)).filter((s) => s !== null));
122
+ // Also consider the file itself as a "segment" for root-level layout
123
+ // (rare, but keeps this check symmetric for flat projects).
124
+ for (const p of relativePaths) {
125
+ if (!p.includes("/"))
126
+ topSegments.add(p);
127
+ }
128
+ const issues = [];
129
+ for (const [a, b] of PARALLEL_DIR_PAIRS) {
130
+ if (topSegments.has(a) && topSegments.has(b)) {
131
+ issues.push(makeCodeRotIssue({
132
+ auditType: "code-rot",
133
+ detectorId: DETECTOR_ID,
134
+ idCues: ["parallel-directory-candidate", a, b],
135
+ title: `Both "${a}/" and "${b}/" exist as top-level directories`,
136
+ description: `Both "${a}/" and "${b}/" are present at the project root -- this may indicate parallel/overlapping directories for the same responsibility. Inspect manually before consolidating.`,
137
+ severity: "low",
138
+ confidence: "low",
139
+ falsePositiveRisk: "medium",
140
+ category: "duplicate-implementation-candidate",
141
+ recommendedAction: `Confirm whether "${a}/" and "${b}/" serve distinct purposes; if not, consolidate into one.`,
142
+ suggestedFixStrategy: `Merge "${a}/" and "${b}/" or rename one to make the distinction explicit.`,
143
+ validationCommands: ["npm run audit -- --types code-rot --include architecture"],
144
+ releaseBlocking: false,
145
+ implementationBlocking: false,
146
+ autoFixEligible: false,
147
+ evidence: [
148
+ {
149
+ kind: "observation",
150
+ message: `Both "${a}/" and "${b}/" exist as top-level project directories.`,
151
+ source: DETECTOR_ID,
152
+ confidence: "low",
153
+ },
154
+ ],
155
+ affectedFiles: [`${a}/`, `${b}/`],
156
+ }));
157
+ }
158
+ }
159
+ return issues;
160
+ }
161
+ // Weak/low-confidence signal: the same basename repeated across 2-3
162
+ // unrelated top-level source roots, excluding generic infra names (index,
163
+ // types, config, etc.) that legitimately repeat by convention.
164
+ function findDuplicateBasenamesAcrossRoots(sourceRelativePaths) {
165
+ const byBasename = new Map();
166
+ for (const relativePath of sourceRelativePaths) {
167
+ const base = baseNameNoExt(relativePath).toLowerCase();
168
+ if (GENERIC_INFRA_BASENAMES.has(base))
169
+ continue;
170
+ const top = firstTopLevelUnderSrc(relativePath);
171
+ if (top === null)
172
+ continue;
173
+ const roots = byBasename.get(base) ?? new Set();
174
+ roots.add(top);
175
+ byBasename.set(base, roots);
176
+ }
177
+ const issues = [];
178
+ for (const [base, roots] of byBasename) {
179
+ if (roots.size < 2 || roots.size > 3)
180
+ continue;
181
+ const sortedRoots = [...roots].sort((a, b) => a.localeCompare(b));
182
+ issues.push(makeCodeRotIssue({
183
+ auditType: "code-rot",
184
+ detectorId: DETECTOR_ID,
185
+ idCues: ["basename-repeat-candidate", base, ...sortedRoots],
186
+ title: `Basename "${base}" repeats across unrelated source roots (weak signal)`,
187
+ description: `A file named "${base}.*" exists under multiple unrelated top-level source directories: ${sortedRoots.join(", ")}. This is a weak candidate signal for parallel implementations -- inspect manually.`,
188
+ severity: "info",
189
+ confidence: "low",
190
+ falsePositiveRisk: "high",
191
+ category: "duplicate-implementation-candidate",
192
+ recommendedAction: "Manually confirm whether these files serve genuinely different purposes before taking any action.",
193
+ suggestedFixStrategy: "No automatic action -- rename or consolidate only after manual review.",
194
+ validationCommands: ["npm run audit -- --types code-rot --include architecture"],
195
+ releaseBlocking: false,
196
+ implementationBlocking: false,
197
+ autoFixEligible: false,
198
+ evidence: [
199
+ {
200
+ kind: "observation",
201
+ message: `Basename "${base}" appears under ${sortedRoots.length} different top-level source directories.`,
202
+ source: DETECTOR_ID,
203
+ confidence: "low",
204
+ },
205
+ ],
206
+ affectedFiles: sortedRoots,
207
+ }));
208
+ }
209
+ return issues;
210
+ }
211
+ function firstTopLevelUnderSrc(relativePath) {
212
+ const parts = relativePath.split("/");
213
+ if (parts.length < 3)
214
+ return null; // need at least src/<dir>/file
215
+ if (parts[0] !== "src" && parts[0] !== "scripts")
216
+ return null;
217
+ return `${parts[0]}/${parts[1]}`;
218
+ }
219
+ // v0.3.1 Batch 4 -- source-facts-aware duplicate-declaration candidate
220
+ // check. Weak/low-confidence signal, same spirit as
221
+ // findDuplicateBasenamesAcrossRoots(): the same exported declaration name
222
+ // and kind (e.g. two exported `class Logger`) appears in 2-4 distinct
223
+ // "source"-role files that a registered analyzer actually parsed. Bounded
224
+ // to declaration kinds that carry real implementation weight
225
+ // (function/class/interface/type/enum) -- "variable"/"constant"/"method"/
226
+ // "unknown" are excluded as too noisy (methods repeat across unrelated
227
+ // classes by convention; module-scope variables/constants repeat even more
228
+ // often). Generic declaration names (run, init, handler, ...) are excluded
229
+ // via GENERIC_DECLARATION_NAMES for the same reason
230
+ // GENERIC_INFRA_BASENAMES excludes generic file basenames above. Requires
231
+ // ctx.sourceFacts -- returns no issues when it is absent (e.g. existing
232
+ // detector unit tests that build a literal AuditDetectorContext without
233
+ // it), so this check is purely additive.
234
+ const NOISY_DECLARATION_KINDS = new Set(["variable", "constant", "method", "unknown"]);
235
+ function findDuplicateDeclarationCandidates(ctx) {
236
+ if (!ctx.sourceFacts)
237
+ return [];
238
+ const index = indexSourceFactsByPath(ctx.sourceFacts);
239
+ const byKey = new Map();
240
+ for (const facts of index.values()) {
241
+ if (facts.role !== "source")
242
+ continue;
243
+ if (facts.parseStatus !== "parsed")
244
+ continue;
245
+ for (const decl of facts.declarations) {
246
+ if (!decl.exported)
247
+ continue;
248
+ if (NOISY_DECLARATION_KINDS.has(decl.kind))
249
+ continue;
250
+ const nameLower = decl.name.toLowerCase();
251
+ if (GENERIC_DECLARATION_NAMES.has(nameLower))
252
+ continue;
253
+ const key = `${decl.kind}:${nameLower}`;
254
+ const entry = byKey.get(key) ?? { name: decl.name, kind: decl.kind, paths: new Set() };
255
+ entry.paths.add(facts.relativePath);
256
+ byKey.set(key, entry);
257
+ }
258
+ }
259
+ const issues = [];
260
+ for (const entry of byKey.values()) {
261
+ if (entry.paths.size < 2 || entry.paths.size > 4)
262
+ continue;
263
+ const sortedPaths = [...entry.paths].sort((a, b) => a.localeCompare(b));
264
+ issues.push(makeCodeRotIssue({
265
+ auditType: "code-rot",
266
+ detectorId: DETECTOR_ID,
267
+ idCues: ["duplicate-declaration-candidate", entry.kind, entry.name.toLowerCase(), ...sortedPaths],
268
+ title: `Exported ${entry.kind} "${entry.name}" is declared in ${sortedPaths.length} unrelated files (weak signal)`,
269
+ description: `An exported ${entry.kind} named "${entry.name}" is declared in: ${sortedPaths.join(", ")}. This is a source-facts-derived candidate signal (same declaration name and kind, not a semantic-equivalence check) for parallel implementations -- inspect manually before consolidating.`,
270
+ severity: "info",
271
+ confidence: "low",
272
+ falsePositiveRisk: "high",
273
+ category: "duplicate-implementation-candidate",
274
+ recommendedAction: "Manually confirm whether these declarations serve genuinely different purposes before taking any action.",
275
+ suggestedFixStrategy: "No automatic action -- rename or consolidate only after manual review.",
276
+ validationCommands: ["npm run audit -- --types code-rot --include architecture"],
277
+ releaseBlocking: false,
278
+ implementationBlocking: false,
279
+ autoFixEligible: false,
280
+ evidence: [
281
+ {
282
+ kind: "reference",
283
+ message: `Source facts: an exported ${entry.kind} named "${entry.name}" was parsed in ${sortedPaths.length} distinct files.`,
284
+ excerpt: sortedPaths.join(", "),
285
+ source: DETECTOR_ID,
286
+ confidence: "low",
287
+ },
288
+ ],
289
+ affectedFiles: sortedPaths,
290
+ }));
291
+ }
292
+ return issues;
293
+ }
294
+ // Cheap regex scan (not a semantic parse) of already-small detector source
295
+ // files for exported `id: "..."` string literals. Flags duplicates only if
296
+ // genuinely found via this file-based check -- per the spec's own allowed
297
+ // simplification, this does not attempt to reconcile against
298
+ // DEFAULT_AUDIT_REGISTRY at runtime (that would be circular).
299
+ function findDuplicateDetectorRegistryIds(ctx) {
300
+ const detectorFiles = ctx.inventory.sourceFiles.filter((f) => /\/detectors\/[\w-]+\.ts$/.test(f.relativePath));
301
+ if (detectorFiles.length === 0)
302
+ return [];
303
+ const idsToFiles = new Map();
304
+ for (const file of detectorFiles) {
305
+ const content = readBoundedFileText(ctx.target.rootPath, file.relativePath, file.sizeBytes, MAX_READ_BYTES);
306
+ if (content === null)
307
+ continue;
308
+ const match = content.match(/\bid:\s*DETECTOR_ID\b/) ? content.match(/const DETECTOR_ID\s*=\s*"([^"]+)"/) : content.match(/\bid:\s*"([^"]+)"/);
309
+ if (!match)
310
+ continue;
311
+ const id = match[1];
312
+ const files = idsToFiles.get(id) ?? [];
313
+ files.push(file.relativePath);
314
+ idsToFiles.set(id, files);
315
+ }
316
+ const issues = [];
317
+ for (const [id, files] of idsToFiles) {
318
+ if (files.length < 2)
319
+ continue;
320
+ const sortedFiles = [...files].sort((a, b) => a.localeCompare(b));
321
+ issues.push(makeCodeRotIssue({
322
+ auditType: "code-rot",
323
+ detectorId: DETECTOR_ID,
324
+ idCues: ["duplicate-detector-id", id],
325
+ title: `Multiple detector files declare the same detector id "${id}"`,
326
+ description: `Files ${sortedFiles.map((f) => `"${f}"`).join(", ")} appear to declare the same detector id "${id}". Registry construction rejects true duplicate ids at runtime; this file-based scan is a defensive early signal.`,
327
+ severity: "medium",
328
+ confidence: "medium",
329
+ falsePositiveRisk: "low",
330
+ category: "duplicate-implementation-candidate",
331
+ recommendedAction: "Rename one of the conflicting detector ids.",
332
+ suggestedFixStrategy: `Give each detector a unique id string.`,
333
+ validationCommands: ["npm run audit -- --types code-rot --include architecture"],
334
+ releaseBlocking: false,
335
+ implementationBlocking: false,
336
+ autoFixEligible: false,
337
+ evidence: [
338
+ {
339
+ kind: "reference",
340
+ message: `Detector id "${id}" found in ${sortedFiles.length} files.`,
341
+ excerpt: sortedFiles.join(", "),
342
+ source: DETECTOR_ID,
343
+ confidence: "medium",
344
+ },
345
+ ],
346
+ affectedFiles: sortedFiles,
347
+ }));
348
+ }
349
+ return issues;
350
+ }
@@ -0,0 +1,325 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { CURRENT_PACKAGE_VERSION_PATTERNS, PUBLISHED_OR_RELEASED_CLAIM_PATTERN, isHedgedNearby } from "../utils/docClaimPatterns.js";
4
+ import { deduplicateIssuesById, makeCodeRotIssue } from "../utils/issueFactories.js";
5
+ import { splitLines } from "../utils/textLines.js";
6
+ // ---------------------------------------------------------------------------
7
+ // v0.3.0 Batch 3 — package/release rot detector.
8
+ //
9
+ // Local-only: never runs npm pack, npm view, or any network/registry call
10
+ // (Batch 3 scope). Owns the publish/release-claim check and the doc-vs-
11
+ // package version-mismatch check (both also listed under
12
+ // docsCodeMismatchDetector.ts's Batch 3 spec section, but implemented here
13
+ // only, to avoid the same underlying signal producing two issues from two
14
+ // detectors -- see that file's header comment for the same note from the
15
+ // other side).
16
+ // ---------------------------------------------------------------------------
17
+ const DETECTOR_ID = "package-release-rot";
18
+ const MAX_DOC_READ_BYTES = 200_000;
19
+ // Paths that should never appear in package.json's "files" allowlist --
20
+ // each is either generated content or private/local state, per this
21
+ // project's own .gitignore/security-package-content conventions.
22
+ const RISKY_FILES_ENTRY_PATTERN = /^(reports|lab-output|\.my-dev-kit|\.env)/i;
23
+ export const PACKAGE_RELEASE_ROT_DETECTOR = {
24
+ id: DETECTOR_ID,
25
+ auditType: "code-rot",
26
+ title: "Package/release rot",
27
+ description: "Detects package.json/package-lock.json version or name drift, a missing CHANGELOG section for the current version, stale doc version mentions, unhedged publish/release claims, risky package.json files entries, and self-referencing script commands.",
28
+ supportedIncludeAreas: ["package", "docs", "cli"],
29
+ run: (ctx) => {
30
+ const issues = [];
31
+ const pkg = ctx.sourceOfTruth.package;
32
+ const lockfile = ctx.sourceOfTruth.lockfile;
33
+ if (ctx.config.include.includes("package") && pkg) {
34
+ if (lockfile.present && lockfile.packageManagerConsistent === false) {
35
+ if (lockfile.rootVersion !== null && lockfile.rootVersion !== pkg.version) {
36
+ issues.push(makeVersionMismatchIssue(pkg.version, lockfile.rootVersion));
37
+ }
38
+ else if (lockfile.rootName !== null && lockfile.rootName !== pkg.name) {
39
+ issues.push(makeNameMismatchIssue(pkg.name, lockfile.rootName));
40
+ }
41
+ }
42
+ if (pkg.files) {
43
+ for (const entry of pkg.files) {
44
+ if (RISKY_FILES_ENTRY_PATTERN.test(entry)) {
45
+ issues.push(makeRiskyFilesEntryIssue(entry));
46
+ }
47
+ }
48
+ }
49
+ for (const [scriptName, command] of Object.entries(pkg.scripts)) {
50
+ for (const match of command.matchAll(/npm run ([\w:.-]+)/g)) {
51
+ const referenced = match[1];
52
+ if (referenced !== scriptName && !(referenced in pkg.scripts)) {
53
+ issues.push(makeSelfReferenceIssue(scriptName, referenced));
54
+ }
55
+ }
56
+ }
57
+ }
58
+ if (ctx.config.include.includes("docs") && pkg) {
59
+ const changelogFile = ctx.inventory.docsFiles.find((f) => path.basename(f.relativePath).toLowerCase() === "changelog.md");
60
+ if (changelogFile) {
61
+ const content = readBoundedText(ctx.target.rootPath, changelogFile.relativePath, changelogFile.sizeBytes);
62
+ if (content !== null && pkg.version) {
63
+ const hasCurrentVersionSection = new RegExp(`^##\\s*\\[${escapeRegExp(pkg.version)}\\]`, "m").test(content);
64
+ if (!hasCurrentVersionSection) {
65
+ issues.push(makeMissingChangelogSectionIssue(changelogFile.relativePath, pkg.version));
66
+ }
67
+ }
68
+ }
69
+ for (const docFile of ctx.inventory.docsFiles) {
70
+ const content = readBoundedText(ctx.target.rootPath, docFile.relativePath, docFile.sizeBytes);
71
+ if (content === null)
72
+ continue;
73
+ const lines = splitLines(content);
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const line = lines[i];
76
+ if (pkg.version) {
77
+ for (const pattern of CURRENT_PACKAGE_VERSION_PATTERNS) {
78
+ const match = line.match(pattern);
79
+ if (match && match[1] !== pkg.version) {
80
+ issues.push(makeStaleDocVersionIssue(docFile.relativePath, i + 1, line, match[1], pkg.version));
81
+ }
82
+ }
83
+ }
84
+ if (PUBLISHED_OR_RELEASED_CLAIM_PATTERN.test(line)) {
85
+ const windowStart = Math.max(0, i - 1);
86
+ const window = lines.slice(windowStart, i + 1).join(" ");
87
+ if (!isHedgedNearby(window)) {
88
+ issues.push(makeUnsupportedPublishClaimIssue(docFile.relativePath, i + 1, line));
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+ return deduplicateIssuesById(issues);
95
+ },
96
+ };
97
+ function makeVersionMismatchIssue(packageVersion, lockfileVersion) {
98
+ return makeCodeRotIssue({
99
+ auditType: "code-rot",
100
+ detectorId: DETECTOR_ID,
101
+ idCues: ["version-mismatch", packageVersion ?? "unknown", lockfileVersion],
102
+ title: "package.json and package-lock.json version mismatch",
103
+ description: `package.json version is "${packageVersion}" but package-lock.json's root version is "${lockfileVersion}".`,
104
+ severity: "blocker",
105
+ confidence: "high",
106
+ falsePositiveRisk: "low",
107
+ category: "package-release-rot",
108
+ recommendedAction: "Run npm install to regenerate package-lock.json so both versions match.",
109
+ suggestedFixStrategy: "npm install (lockfile-only update is sufficient if dependencies did not change).",
110
+ validationCommands: ["npm install --package-lock-only"],
111
+ releaseBlocking: true,
112
+ implementationBlocking: false,
113
+ autoFixEligible: false,
114
+ evidence: [
115
+ {
116
+ kind: "file",
117
+ message: "Version field mismatch between package.json and package-lock.json.",
118
+ filePath: "package-lock.json",
119
+ expected: packageVersion ?? undefined,
120
+ observed: lockfileVersion,
121
+ source: DETECTOR_ID,
122
+ confidence: "high",
123
+ },
124
+ ],
125
+ affectedFiles: ["package.json", "package-lock.json"],
126
+ });
127
+ }
128
+ function makeNameMismatchIssue(packageName, lockfileName) {
129
+ return makeCodeRotIssue({
130
+ auditType: "code-rot",
131
+ detectorId: DETECTOR_ID,
132
+ idCues: ["name-mismatch", packageName ?? "unknown", lockfileName],
133
+ title: "package.json and package-lock.json name mismatch",
134
+ description: `package.json name is "${packageName}" but package-lock.json's root name is "${lockfileName}".`,
135
+ severity: "high",
136
+ confidence: "high",
137
+ falsePositiveRisk: "low",
138
+ category: "package-release-rot",
139
+ recommendedAction: "Run npm install to regenerate package-lock.json so both names match.",
140
+ suggestedFixStrategy: "npm install (lockfile-only update is sufficient if dependencies did not change).",
141
+ validationCommands: ["npm install --package-lock-only"],
142
+ releaseBlocking: false,
143
+ implementationBlocking: false,
144
+ autoFixEligible: false,
145
+ evidence: [
146
+ {
147
+ kind: "file",
148
+ message: "Name field mismatch between package.json and package-lock.json.",
149
+ filePath: "package-lock.json",
150
+ expected: packageName ?? undefined,
151
+ observed: lockfileName,
152
+ source: DETECTOR_ID,
153
+ confidence: "high",
154
+ },
155
+ ],
156
+ affectedFiles: ["package.json", "package-lock.json"],
157
+ });
158
+ }
159
+ function makeRiskyFilesEntryIssue(entry) {
160
+ return makeCodeRotIssue({
161
+ auditType: "code-rot",
162
+ detectorId: DETECTOR_ID,
163
+ idCues: ["risky-files-entry", entry],
164
+ title: `package.json "files" field includes a risky-looking path: "${entry}"`,
165
+ description: `package.json's "files" allowlist includes "${entry}", which looks like generated or private content rather than intentional package content.`,
166
+ severity: "medium",
167
+ confidence: "medium",
168
+ falsePositiveRisk: "low",
169
+ category: "package-release-rot",
170
+ recommendedAction: `Remove "${entry}" from package.json's "files" field unless it is intentionally published.`,
171
+ suggestedFixStrategy: `Edit the "files" array in package.json to drop "${entry}".`,
172
+ validationCommands: ["npm pack --dry-run"],
173
+ releaseBlocking: false,
174
+ implementationBlocking: false,
175
+ autoFixEligible: false,
176
+ evidence: [
177
+ {
178
+ kind: "file",
179
+ message: `"files" entry matches a known risky pattern.`,
180
+ filePath: "package.json",
181
+ excerpt: entry,
182
+ source: DETECTOR_ID,
183
+ confidence: "medium",
184
+ },
185
+ ],
186
+ affectedFiles: ["package.json"],
187
+ });
188
+ }
189
+ function makeSelfReferenceIssue(scriptName, referenced) {
190
+ return makeCodeRotIssue({
191
+ auditType: "code-rot",
192
+ detectorId: DETECTOR_ID,
193
+ idCues: ["script-references-missing-script", scriptName, referenced],
194
+ title: `Script "${scriptName}" references undefined script "${referenced}"`,
195
+ description: `package.json script "${scriptName}" runs "npm run ${referenced}", but "${referenced}" is not itself a defined script.`,
196
+ severity: "high",
197
+ confidence: "high",
198
+ falsePositiveRisk: "low",
199
+ category: "package-release-rot",
200
+ recommendedAction: `Add the missing "${referenced}" script, or fix "${scriptName}" to reference an existing script.`,
201
+ suggestedFixStrategy: `Edit package.json's "${scriptName}" script.`,
202
+ validationCommands: [`npm run ${scriptName}`],
203
+ releaseBlocking: false,
204
+ implementationBlocking: false,
205
+ autoFixEligible: false,
206
+ evidence: [
207
+ {
208
+ kind: "file",
209
+ message: `Script command references a script name that does not exist.`,
210
+ filePath: "package.json",
211
+ excerpt: `"${scriptName}": "... npm run ${referenced} ..."`,
212
+ source: DETECTOR_ID,
213
+ confidence: "high",
214
+ },
215
+ ],
216
+ affectedFiles: ["package.json"],
217
+ });
218
+ }
219
+ function makeMissingChangelogSectionIssue(relativePath, currentVersion) {
220
+ return makeCodeRotIssue({
221
+ auditType: "code-rot",
222
+ detectorId: DETECTOR_ID,
223
+ idCues: [relativePath, "missing-current-version-section", currentVersion],
224
+ title: `${relativePath} has no section for the current package version (${currentVersion})`,
225
+ description: `package.json version is "${currentVersion}", but ${relativePath} has no "## [${currentVersion}]" heading.`,
226
+ severity: "high",
227
+ confidence: "high",
228
+ falsePositiveRisk: "low",
229
+ category: "package-release-rot",
230
+ recommendedAction: `Add a "## [${currentVersion}]" section to ${relativePath} describing this release.`,
231
+ suggestedFixStrategy: `Convert the top Unreleased/placeholder section in ${relativePath} into a "## [${currentVersion}]" entry.`,
232
+ validationCommands: ["npm run audit -- --types code-rot --include package,docs"],
233
+ releaseBlocking: false,
234
+ implementationBlocking: false,
235
+ autoFixEligible: false,
236
+ evidence: [
237
+ {
238
+ kind: "file",
239
+ message: `No "## [${currentVersion}]" heading found.`,
240
+ filePath: relativePath,
241
+ expected: `## [${currentVersion}]`,
242
+ source: DETECTOR_ID,
243
+ confidence: "high",
244
+ },
245
+ ],
246
+ affectedFiles: [relativePath],
247
+ });
248
+ }
249
+ function makeStaleDocVersionIssue(relativePath, lineNumber, excerpt, docVersion, currentVersion) {
250
+ return makeCodeRotIssue({
251
+ auditType: "code-rot",
252
+ detectorId: DETECTOR_ID,
253
+ idCues: [relativePath, "stale-doc-version", docVersion],
254
+ title: `${relativePath} states an outdated current version (${docVersion})`,
255
+ description: `${relativePath}:${lineNumber} describes "${docVersion}" as the current package version, but package.json says "${currentVersion}".`,
256
+ severity: "medium",
257
+ confidence: "medium",
258
+ falsePositiveRisk: "medium",
259
+ category: "package-release-rot",
260
+ recommendedAction: `Update ${relativePath} to reference the current version (${currentVersion}).`,
261
+ suggestedFixStrategy: `Replace "${docVersion}" with "${currentVersion}" in ${relativePath}.`,
262
+ validationCommands: ["npm run audit -- --types code-rot --include package,docs"],
263
+ releaseBlocking: false,
264
+ implementationBlocking: false,
265
+ autoFixEligible: false,
266
+ evidence: [
267
+ {
268
+ kind: "file",
269
+ message: `Doc-stated current version does not match package.json.`,
270
+ filePath: relativePath,
271
+ line: lineNumber,
272
+ excerpt: excerpt.trim().slice(0, 200),
273
+ expected: currentVersion,
274
+ observed: docVersion,
275
+ source: DETECTOR_ID,
276
+ confidence: "medium",
277
+ },
278
+ ],
279
+ affectedFiles: [relativePath],
280
+ });
281
+ }
282
+ function makeUnsupportedPublishClaimIssue(relativePath, lineNumber, excerpt) {
283
+ return makeCodeRotIssue({
284
+ auditType: "code-rot",
285
+ detectorId: DETECTOR_ID,
286
+ idCues: [relativePath, "unsupported-publish-claim", String(lineNumber)],
287
+ title: `${relativePath} claims publication/release/tag without local support`,
288
+ description: `${relativePath}:${lineNumber} contains an unhedged publish/release/tag claim. This is a local-only tool with no npm registry or GitHub API access, so this claim cannot be verified and is flagged for manual confirmation.`,
289
+ severity: "blocker",
290
+ confidence: "medium",
291
+ falsePositiveRisk: "medium",
292
+ category: "package-release-rot",
293
+ recommendedAction: "Confirm whether publication has actually happened; if not, correct the claim to say it has not yet been published.",
294
+ suggestedFixStrategy: `Rephrase the claim in ${relativePath} to accurately reflect current release state.`,
295
+ validationCommands: ["npm run audit -- --types code-rot --include package,docs"],
296
+ releaseBlocking: true,
297
+ implementationBlocking: false,
298
+ autoFixEligible: false,
299
+ evidence: [
300
+ {
301
+ kind: "file",
302
+ message: "Matched an unhedged publish/release/tag claim pattern with no nearby hedge word.",
303
+ filePath: relativePath,
304
+ line: lineNumber,
305
+ excerpt: excerpt.trim().slice(0, 200),
306
+ source: DETECTOR_ID,
307
+ confidence: "medium",
308
+ },
309
+ ],
310
+ affectedFiles: [relativePath],
311
+ });
312
+ }
313
+ function escapeRegExp(value) {
314
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
315
+ }
316
+ function readBoundedText(root, relativePath, sizeBytes) {
317
+ if (sizeBytes > MAX_DOC_READ_BYTES)
318
+ return null;
319
+ try {
320
+ return fs.readFileSync(path.join(root, relativePath), "utf8");
321
+ }
322
+ catch {
323
+ return null;
324
+ }
325
+ }