@dailephd/my-dev-kit-lab 0.3.1 → 0.3.3

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 (29) hide show
  1. package/README.md +24 -18
  2. package/dist/scripts/audits/runAudit.js +8 -2
  3. package/dist/scripts/security/validate.js +16 -47
  4. package/dist/src/audits/codeRot/detectors/deadCodeCandidateDetector.js +191 -1
  5. package/dist/src/audits/codeRot/detectors/docsCodeMismatchDetector.js +202 -4
  6. package/dist/src/audits/codeRot/detectors/duplicateImplementationDetector.js +42 -9
  7. package/dist/src/audits/codeRot/detectors/testRotDetector.js +241 -1
  8. package/dist/src/audits/codeRot/utils/filePatternUtils.js +19 -1
  9. package/dist/src/audits/codeRot/utils/jvmSourceFactsUtils.js +153 -0
  10. package/dist/src/audits/core/auditConfig.js +2 -2
  11. package/dist/src/audits/core/auditRunner.js +19 -0
  12. package/dist/src/audits/core/auditTypes.js +14 -7
  13. package/dist/src/audits/core/javaAnalyzer.js +282 -0
  14. package/dist/src/audits/core/jvmProjectMetadata.js +106 -0
  15. package/dist/src/audits/core/kotlinAnalyzer.js +280 -0
  16. package/dist/src/audits/core/languageAnalyzerRegistry.js +12 -3
  17. package/dist/src/audits/core/projectInventory.js +76 -3
  18. package/dist/src/audits/core/pythonAnalyzer.js +328 -0
  19. package/dist/src/audits/core/pythonProjectMetadata.js +80 -0
  20. package/dist/src/audits/report/auditReportModel.js +3 -0
  21. package/dist/src/audits/report/renderAuditJsonReport.js +2 -0
  22. package/dist/src/audits/report/renderAuditTextReport.js +40 -0
  23. package/dist/src/audits/security/mapSecurityFindingToAuditIssue.js +70 -0
  24. package/dist/src/audits/security/securityAuditAdapter.js +70 -0
  25. package/dist/src/audits/security/securityAuditTypes.js +17 -0
  26. package/dist/src/securityValidation/report/buildSecurityReport.js +31 -0
  27. package/dist/src/securityValidation/report/writeSecurityReportFiles.js +28 -0
  28. package/dist/src/securityValidation/validate/resolveTarget.js +9 -0
  29. package/package.json +6 -8
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { collectJvmProjectMetadata } from "../../core/jvmProjectMetadata.js";
3
4
  import { currentClaimPatternFor, plannedClaimPatternFor, FULL_RELEASE_GATE_FOR_SCOPED_CHECKS_PATTERN, isNegatedNearby, } from "../utils/docClaimPatterns.js";
4
5
  import { deduplicateIssuesById, makeCodeRotIssue } from "../utils/issueFactories.js";
5
6
  import { splitLines } from "../utils/textLines.js";
@@ -27,20 +28,95 @@ const FEATURE_CHECKS = [
27
28
  featureId: "security-validation",
28
29
  label: "security validation",
29
30
  subjectPattern: "security validation",
30
- isImplemented: (sot) => sot.security.hasSecurityValidationSourceDir && sot.security.hasSecurityValidateScript,
31
+ isImplemented: (ctx) => ctx.sourceOfTruth.security.hasSecurityValidationSourceDir && ctx.sourceOfTruth.security.hasSecurityValidateScript,
31
32
  },
32
33
  {
33
34
  featureId: "experiment-framework",
34
35
  label: "experiment framework",
35
36
  subjectPattern: "experiment (?:framework|plugin)",
36
- isImplemented: (sot) => sot.experiment.hasExperimentsSourceDir && sot.experiment.hasExperimentListScript,
37
+ isImplemented: (ctx) => ctx.sourceOfTruth.experiment.hasExperimentsSourceDir && ctx.sourceOfTruth.experiment.hasExperimentListScript,
38
+ },
39
+ // v0.3.3 Batch 3 -- Gradle/Maven/Android feature-claim checks, using only
40
+ // static JVM metadata (see jvmProjectMetadata.ts). Never runs Gradle,
41
+ // Maven, or any Android tooling.
42
+ {
43
+ featureId: "gradle-support",
44
+ label: "Gradle",
45
+ subjectPattern: "Gradle",
46
+ isImplemented: (ctx) => ctx.jvmMetadata.hasGradleBuild || ctx.jvmMetadata.hasGradleSettings,
47
+ },
48
+ {
49
+ featureId: "maven-support",
50
+ label: "Maven",
51
+ subjectPattern: "Maven",
52
+ isImplemented: (ctx) => ctx.jvmMetadata.hasMavenPom,
53
+ },
54
+ {
55
+ featureId: "android-validation",
56
+ label: "Android validation",
57
+ // Negative lookbehind excludes "(non-Android)"-style negated mentions
58
+ // (this project's own docs legitimately describe things as
59
+ // "the general (non-Android) ... adapter", which is a claim about the
60
+ // *adapter*, not about Android -- without this guard, "is implemented"
61
+ // appearing later in the same sentence about the adapter would
62
+ // falsely match as an Android claim).
63
+ subjectPattern: "(?<!non-)Android(?: validation| support)?",
64
+ // Android automated validation is not implemented anywhere in this
65
+ // project (see the v0.3.x/v0.4.x roadmap) -- this is a permanently
66
+ // false signal, not a metadata lookup, so a claim that it is
67
+ // implemented/current is always a genuine mismatch until a future
68
+ // version actually builds it. This does not implement any Android
69
+ // detection itself (no AndroidManifest.xml/Gradle-Android-plugin/
70
+ // Compose/AGP/SDK/emulator awareness) -- it only reuses the existing
71
+ // current-vs-planned claim mechanism for a feature this project
72
+ // already knows, out of band, does not exist.
73
+ isImplemented: () => false,
37
74
  },
38
75
  ];
76
+ // v0.3.3 Batch 3 -- backtick-quoted-only Java/Kotlin FQCN-shaped symbol
77
+ // scan (T1-T4). Requiring the mention to be inside an inline code span
78
+ // (`` `com.example.Foo` ``) is what keeps this conservative: ordinary prose
79
+ // mentioning a capitalized word is extremely common and would be far too
80
+ // noisy to scan directly, but a backtick-quoted, fully-dotted,
81
+ // lowercase-package-then-Capitalized-name token is specifically how a
82
+ // real symbol reference is conventionally written in markdown docs.
83
+ const BACKTICK_SPAN_PATTERN = /`([^`]+)`/g;
84
+ const JVM_FQCN_PATTERN = /^[a-z][\w]*(?:\.[a-z][\w]*)*\.[A-Z]\w*$/;
85
+ // Reused by both the symbol-claim check and the Gradle/Maven command-claim
86
+ // check below: a broader set of future/negative-boundary hedge phrases than
87
+ // docClaimPatterns.ts's own HEDGE_PATTERN (which is narrowly scoped to
88
+ // "not yet published"-style wording) -- this project's own docs
89
+ // legitimately say things like "Android support is planned" or "does not
90
+ // support Gradle yet", which must never be treated as a current-state claim.
91
+ const JVM_FUTURE_OR_NEGATIVE_HEDGE_PATTERN = /\b(planned|future|deferred|not yet|out of scope|does not support|doesn't support|will support|not implemented|not supported)\b/i;
92
+ const GRADLE_COMMAND_PATTERN = /\b(?:\.\/)?gradlew(?:\.bat)?\b|\bgradle\b/i;
93
+ const MAVEN_COMMAND_PATTERN = /\bmvnw(?:\.cmd)?\b|\bmvn\b/i;
94
+ // Declaration kinds treated as safe, implementation-weight symbol claims --
95
+ // same conservative kind allowlist Batch 2's duplicate/dead-code checks
96
+ // already use (methods/variables/constants excluded as too noisy/ambiguous
97
+ // for a name-only, no-package-tracking cross-check).
98
+ const JVM_SYMBOL_DECLARATION_KINDS = new Set(["class", "interface", "enum", "function"]);
99
+ function collectKnownJvmDeclarationNames(ctx) {
100
+ const names = new Set();
101
+ if (!ctx.sourceFacts)
102
+ return names;
103
+ for (const file of ctx.sourceFacts.files) {
104
+ if (file.language !== "java" && file.language !== "kotlin")
105
+ continue;
106
+ if (file.parseStatus !== "parsed")
107
+ continue;
108
+ for (const decl of file.declarations) {
109
+ if (JVM_SYMBOL_DECLARATION_KINDS.has(decl.kind))
110
+ names.add(decl.name);
111
+ }
112
+ }
113
+ return names;
114
+ }
39
115
  export const DOCS_CODE_MISMATCH_DETECTOR = {
40
116
  id: DETECTOR_ID,
41
117
  auditType: "code-rot",
42
118
  title: "Documentation/code mismatch",
43
- description: "Detects deterministic mismatches between documentation claims and source-of-truth data: feature-implementation claims and full-release-gate-for-scoped-checks wording.",
119
+ description: "Detects deterministic mismatches between documentation claims and source-of-truth data: feature-implementation claims, full-release-gate-for-scoped-checks wording, and (v0.3.3) Java/Kotlin symbol claims plus Gradle/Maven feature/command claims against static JVM metadata.",
44
120
  supportedIncludeAreas: ["docs", "architecture", "cli", "package"],
45
121
  shouldSkip: (ctx) => {
46
122
  if (!ctx.config.include.includes("docs")) {
@@ -50,6 +126,13 @@ export const DOCS_CODE_MISMATCH_DETECTOR = {
50
126
  },
51
127
  run: (ctx) => {
52
128
  const issues = [];
129
+ // v0.3.3 Batch 3 -- computed once per run, same "cheap, static,
130
+ // presence-only" treatment as pythonMetadata/jvmMetadata elsewhere in
131
+ // this codebase (see testRotDetector.ts). Never runs Gradle/Maven/any
132
+ // JVM tooling.
133
+ const jvmMetadata = collectJvmProjectMetadata(ctx.target.rootPath, ctx.inventory);
134
+ const featureCtx = { sourceOfTruth: ctx.sourceOfTruth, jvmMetadata };
135
+ const knownJvmDeclarationNames = collectKnownJvmDeclarationNames(ctx);
53
136
  for (const docFile of ctx.inventory.docsFiles) {
54
137
  const content = readBoundedText(ctx.target.rootPath, docFile.relativePath, docFile.sizeBytes);
55
138
  if (content === null)
@@ -58,9 +141,12 @@ export const DOCS_CODE_MISMATCH_DETECTOR = {
58
141
  for (let i = 0; i < lines.length; i++) {
59
142
  const line = lines[i];
60
143
  for (const feature of FEATURE_CHECKS) {
144
+ if (feature.featureId === "security-validation" && isAndroidScopedSecurityValidationClaim(line)) {
145
+ continue;
146
+ }
61
147
  const currentMatch = currentClaimPatternFor(feature.subjectPattern).test(line);
62
148
  const plannedMatch = plannedClaimPatternFor(feature.subjectPattern).test(line);
63
- const implemented = feature.isImplemented(ctx.sourceOfTruth);
149
+ const implemented = feature.isImplemented(featureCtx);
64
150
  if (currentMatch && plannedMatch) {
65
151
  // Same line claims both current and planned for the same
66
152
  // subject -- genuinely ambiguous, reported as low-confidence
@@ -78,11 +164,120 @@ export const DOCS_CODE_MISMATCH_DETECTOR = {
78
164
  if (FULL_RELEASE_GATE_FOR_SCOPED_CHECKS_PATTERN.test(line) && !isNegatedNearby(line)) {
79
165
  issues.push(makeFullReleaseGateIssue(docFile.relativePath, i + 1, line));
80
166
  }
167
+ if (!JVM_FUTURE_OR_NEGATIVE_HEDGE_PATTERN.test(line)) {
168
+ issues.push(...findJvmSymbolClaimMismatches(docFile.relativePath, i + 1, line, jvmMetadata, knownJvmDeclarationNames));
169
+ issues.push(...findJvmBuildCommandClaimMismatches(docFile.relativePath, i + 1, line, jvmMetadata));
170
+ }
81
171
  }
82
172
  }
83
173
  return deduplicateIssuesById(issues);
84
174
  },
85
175
  };
176
+ // v0.3.3 Batch 3 -- T1/T2/T3/T4. Gated on the project having ANY real
177
+ // Java/Kotlin source at all (jvmMetadata.hasJavaSource/hasKotlinSource) --
178
+ // without this gate, a project with no JVM code whatsoever (e.g. this repo
179
+ // itself) would have every backtick-quoted FQCN-shaped mention trivially
180
+ // "fail" to match anything, which would be pure noise rather than a real
181
+ // signal. Matches by simple declaration name only (see
182
+ // collectKnownJvmDeclarationNames()'s header comment) -- this cannot verify
183
+ // the claimed package prefix actually matches, only that some declaration
184
+ // with that name exists somewhere in the scanned Java/Kotlin source, which
185
+ // is why this is always a "weak signal" candidate, never a proof.
186
+ function findJvmSymbolClaimMismatches(relativePath, lineNumber, line, jvmMetadata, knownJvmDeclarationNames) {
187
+ if (!jvmMetadata.hasJavaSource && !jvmMetadata.hasKotlinSource)
188
+ return [];
189
+ const issues = [];
190
+ BACKTICK_SPAN_PATTERN.lastIndex = 0;
191
+ for (const match of line.matchAll(BACKTICK_SPAN_PATTERN)) {
192
+ const candidate = match[1];
193
+ if (!JVM_FQCN_PATTERN.test(candidate))
194
+ continue;
195
+ const simpleName = candidate.slice(candidate.lastIndexOf(".") + 1);
196
+ if (knownJvmDeclarationNames.has(simpleName))
197
+ continue;
198
+ issues.push(makeCodeRotIssue({
199
+ auditType: "code-rot",
200
+ detectorId: DETECTOR_ID,
201
+ idCues: [relativePath, "jvm-symbol-claim-mismatch", String(lineNumber), candidate],
202
+ title: `Docs mention a Java/Kotlin symbol not found in static source facts: "${candidate}"`,
203
+ description: `${relativePath}:${lineNumber} mentions \`${candidate}\`, but no scanned Java/Kotlin declaration named "${simpleName}" was found. This is a static source-facts signal from a conservative, name-only scan -- it cannot verify the claimed package prefix, only that no declaration with this simple name exists anywhere in the scanned Java/Kotlin file set, so this is a candidate docs-code mismatch (weak signal), not proof.`,
204
+ severity: "info",
205
+ confidence: "low",
206
+ falsePositiveRisk: "high",
207
+ category: "docs-feature-mismatch",
208
+ recommendedAction: `Confirm "${candidate}" exists (it may be defined outside the scanned source set) before treating this as stale documentation.`,
209
+ suggestedFixStrategy: `Update ${relativePath} to reference an existing Java/Kotlin symbol, or add the missing declaration.`,
210
+ validationCommands: ["npm run audit -- --types code-rot --include docs"],
211
+ releaseBlocking: false,
212
+ implementationBlocking: false,
213
+ autoFixEligible: false,
214
+ evidence: [
215
+ {
216
+ kind: "file",
217
+ message: `Static source-facts check did not find a Java/Kotlin declaration named "${simpleName}" in the scanned file set.`,
218
+ filePath: relativePath,
219
+ line: lineNumber,
220
+ excerpt: line.trim().slice(0, 200),
221
+ source: DETECTOR_ID,
222
+ confidence: "low",
223
+ },
224
+ ],
225
+ affectedFiles: [relativePath],
226
+ }));
227
+ }
228
+ return issues;
229
+ }
230
+ // v0.3.3 Batch 3 -- T6/T7/T8/T9. Only matches a Gradle/Maven command token
231
+ // inside a backtick-quoted inline code span, for the same "conventionally
232
+ // how a real command example is written" reasoning as the symbol check
233
+ // above. This never claims the command would actually succeed -- only that
234
+ // docs present a command example implying a build tool this project's
235
+ // static metadata does not show any trace of.
236
+ function findJvmBuildCommandClaimMismatches(relativePath, lineNumber, line, jvmMetadata) {
237
+ const issues = [];
238
+ BACKTICK_SPAN_PATTERN.lastIndex = 0;
239
+ for (const match of line.matchAll(BACKTICK_SPAN_PATTERN)) {
240
+ const candidate = match[1];
241
+ if (GRADLE_COMMAND_PATTERN.test(candidate) && !jvmMetadata.hasGradleBuild && !jvmMetadata.hasGradleSettings && !jvmMetadata.hasGradleWrapper) {
242
+ issues.push(makeBuildCommandIssue(relativePath, lineNumber, line, candidate, "Gradle", "no Gradle execution was performed"));
243
+ }
244
+ if (MAVEN_COMMAND_PATTERN.test(candidate) && !jvmMetadata.hasMavenPom) {
245
+ issues.push(makeBuildCommandIssue(relativePath, lineNumber, line, candidate, "Maven", "no Maven execution was performed"));
246
+ }
247
+ }
248
+ return issues;
249
+ }
250
+ function makeBuildCommandIssue(relativePath, lineNumber, line, commandExcerpt, toolLabel, executionNote) {
251
+ return makeCodeRotIssue({
252
+ auditType: "code-rot",
253
+ detectorId: DETECTOR_ID,
254
+ idCues: [relativePath, `${toolLabel.toLowerCase()}-command-claim-mismatch`, String(lineNumber), commandExcerpt],
255
+ title: `Docs include a ${toolLabel} command example, but no ${toolLabel} metadata was found: "${commandExcerpt}"`,
256
+ description: `${relativePath}:${lineNumber} shows the command \`${commandExcerpt}\`, but no ${toolLabel} build/config metadata was found in the project (see jvmProjectMetadata.ts's static presence checks). This is a candidate docs-code mismatch (weak signal), not proof -- ${executionNote}, so this scan cannot confirm the command would actually run.`,
257
+ severity: "info",
258
+ confidence: "low",
259
+ falsePositiveRisk: "high",
260
+ category: "docs-feature-mismatch",
261
+ recommendedAction: `Confirm ${toolLabel} metadata exists (it may live outside the scanned inventory) before treating this as stale documentation.`,
262
+ suggestedFixStrategy: `Update ${relativePath} to remove or correct the ${toolLabel} command example, or add the corresponding ${toolLabel} project files.`,
263
+ validationCommands: ["npm run audit -- --types code-rot --include docs"],
264
+ releaseBlocking: false,
265
+ implementationBlocking: false,
266
+ autoFixEligible: false,
267
+ evidence: [
268
+ {
269
+ kind: "file",
270
+ message: `Static source-facts/metadata check did not find ${toolLabel} build metadata; ${executionNote}.`,
271
+ filePath: relativePath,
272
+ line: lineNumber,
273
+ excerpt: line.trim().slice(0, 200),
274
+ source: DETECTOR_ID,
275
+ confidence: "low",
276
+ },
277
+ ],
278
+ affectedFiles: [relativePath],
279
+ });
280
+ }
86
281
  function makeCurrentButMissingIssue(relativePath, lineNumber, excerpt, feature) {
87
282
  return makeCodeRotIssue({
88
283
  auditType: "code-rot",
@@ -217,3 +412,6 @@ function readBoundedText(root, relativePath, sizeBytes) {
217
412
  return null;
218
413
  }
219
414
  }
415
+ function isAndroidScopedSecurityValidationClaim(line) {
416
+ return /\bAndroid\b[^.\n]{0,60}\bsecurity validation\b/i.test(line) || /\bsecurity validation\b[^.\n]{0,30}\bfor Android\b/i.test(line);
417
+ }
@@ -1,7 +1,8 @@
1
1
  import { readBoundedFileText } from "../utils/boundedRead.js";
2
- import { firstPathSegment, GENERIC_DECLARATION_NAMES, GENERIC_INFRA_BASENAMES, baseNameNoExt } from "../utils/filePatternUtils.js";
2
+ import { firstPathSegment, GENERIC_INFRA_BASENAMES, baseNameNoExt, isGenericOrTestPrefixedDeclarationName, } from "../utils/filePatternUtils.js";
3
3
  import { deduplicateIssuesById, makeCodeRotIssue } from "../utils/issueFactories.js";
4
4
  import { indexSourceFactsByPath } from "../utils/sourceFactsLookup.js";
5
+ import { isJvmCommonLowSignalDeclarationName } from "../utils/jvmSourceFactsUtils.js";
5
6
  // ---------------------------------------------------------------------------
6
7
  // v0.3.0 Batch 4 — duplicate/parallel implementation candidate detector.
7
8
  //
@@ -225,12 +226,12 @@ function firstTopLevelUnderSrc(relativePath) {
225
226
  // (function/class/interface/type/enum) -- "variable"/"constant"/"method"/
226
227
  // "unknown" are excluded as too noisy (methods repeat across unrelated
227
228
  // 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.
229
+ // often). Generic/lifecycle declaration names (run, init, handler, __init__,
230
+ // test_*, ...) are excluded via isGenericOrTestPrefixedDeclarationName() for
231
+ // the same reason GENERIC_INFRA_BASENAMES excludes generic file basenames
232
+ // above. Requires ctx.sourceFacts -- returns no issues when it is absent
233
+ // (e.g. existing detector unit tests that build a literal
234
+ // AuditDetectorContext without it), so this check is purely additive.
234
235
  const NOISY_DECLARATION_KINDS = new Set(["variable", "constant", "method", "unknown"]);
235
236
  function findDuplicateDeclarationCandidates(ctx) {
236
237
  if (!ctx.sourceFacts)
@@ -248,9 +249,41 @@ function findDuplicateDeclarationCandidates(ctx) {
248
249
  if (NOISY_DECLARATION_KINDS.has(decl.kind))
249
250
  continue;
250
251
  const nameLower = decl.name.toLowerCase();
251
- if (GENERIC_DECLARATION_NAMES.has(nameLower))
252
+ if (isGenericOrTestPrefixedDeclarationName(nameLower))
252
253
  continue;
253
- const key = `${decl.kind}:${nameLower}`;
254
+ // v0.3.3 Batch 2 -- Java/Kotlin-specific common/low-signal name
255
+ // suppression (Main, App, Config, Service, Controller, test*, ...).
256
+ // Gated to java/kotlin so existing TS/JS/Python behavior is
257
+ // unaffected -- a TS/JS file exporting `class Service {}` (a very
258
+ // ordinary, non-generic name in that ecosystem) must keep being
259
+ // considered, unlike the JVM ecosystem where these names are
260
+ // conventionally near-meaningless boilerplate labels.
261
+ if ((facts.language === "java" || facts.language === "kotlin") && isJvmCommonLowSignalDeclarationName(nameLower)) {
262
+ continue;
263
+ }
264
+ // v0.3.2 Batch 2 -- the key is scoped by `facts.analyzerId` (not
265
+ // `facts.language`) so a Python declaration is only ever compared
266
+ // against other Python declarations, never against a same-named
267
+ // TypeScript/JavaScript one. `analyzerId`, not `language`, is the
268
+ // right scope here: TypeScript and JavaScript are two different
269
+ // NormalizedLanguage values but share one analyzer
270
+ // (typescript-javascript-analyzer) and were already compared together
271
+ // before this batch -- scoping by `language` instead would have
272
+ // silently split that existing, still-desired TS/JS pairing in two
273
+ // (each side then below the "2+ files" threshold, so both would be
274
+ // dropped entirely), which is exactly the "preserve existing
275
+ // TypeScript/JavaScript duplicate behavior" regression this batch
276
+ // must not introduce. Scoping by `analyzerId` fixes the real
277
+ // cross-language bug (Python vs TS/JS) without touching TS/JS's own
278
+ // existing behavior at all. v0.3.3 Batch 2 -- this same scoping is
279
+ // why Java ("java-analyzer") and Kotlin ("kotlin-analyzer") never
280
+ // group with each other, TS/JS, or Python: they are two distinct
281
+ // analyzers with two distinct ids, deliberately not unified into one
282
+ // "JVM" scope in this batch (a same-named Java class and Kotlin
283
+ // class are not the same declaration in the same language, and
284
+ // conflating them risks a false "duplicate" signal across languages
285
+ // that don't even share syntax).
286
+ const key = `${facts.analyzerId}:${decl.kind}:${nameLower}`;
254
287
  const entry = byKey.get(key) ?? { name: decl.name, kind: decl.kind, paths: new Set() };
255
288
  entry.paths.add(facts.relativePath);
256
289
  byKey.set(key, entry);
@@ -1,9 +1,13 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { collectPythonProjectMetadata } from "../../core/pythonProjectMetadata.js";
4
+ import { collectJvmProjectMetadata } from "../../core/jvmProjectMetadata.js";
3
5
  import { readBoundedFileText } from "../utils/boundedRead.js";
6
+ import { baseNameNoExt } from "../utils/filePatternUtils.js";
4
7
  import { deduplicateIssuesById, makeCodeRotIssue } from "../utils/issueFactories.js";
5
8
  import { getParsedSourceFacts, indexSourceFactsByPath } from "../utils/sourceFactsLookup.js";
6
9
  import { splitLines } from "../utils/textLines.js";
10
+ import { jvmImportCandidatePaths, jvmImportLooksPossiblyLocal, jvmImportSimpleName, } from "../utils/jvmSourceFactsUtils.js";
7
11
  // ---------------------------------------------------------------------------
8
12
  // v0.3.0 Batch 4 — test-rot detector.
9
13
  //
@@ -44,6 +48,15 @@ export const TEST_ROT_DETECTOR = {
44
48
  const scriptNames = new Set(ctx.sourceOfTruth.commands.allScriptNames);
45
49
  const currentVersion = ctx.sourceOfTruth.package?.version ?? null;
46
50
  const sourceFactsIndex = indexSourceFactsByPath(ctx.sourceFacts);
51
+ // v0.3.2 Batch 2 -- computed once per run, not per test file. Presence-
52
+ // only/simple-text-extraction (see pythonProjectMetadata.ts); used below
53
+ // purely as weak supporting context on Python findings, never as a
54
+ // required condition -- its absence must not change whether a finding
55
+ // is emitted, only add or omit one extra evidence line.
56
+ const pythonMetadata = collectPythonProjectMetadata(ctx.target.rootPath, ctx.inventory);
57
+ // v0.3.3 Batch 2 -- same "computed once, weak supporting context only"
58
+ // treatment as pythonMetadata above.
59
+ const jvmMetadata = collectJvmProjectMetadata(ctx.target.rootPath, ctx.inventory);
47
60
  for (const testFile of ctx.inventory.testFiles) {
48
61
  const content = readBoundedFileText(ctx.target.rootPath, testFile.relativePath, testFile.sizeBytes, MAX_READ_BYTES);
49
62
  if (content === null)
@@ -71,7 +84,25 @@ export const TEST_ROT_DETECTOR = {
71
84
  // (testRelativePath, "missing-source-import", specifier), so an
72
85
  // identical id is deduplicated by deduplicateIssuesById() below.
73
86
  const parsedFacts = getParsedSourceFacts(sourceFactsIndex, testFile.relativePath);
74
- if (parsedFacts) {
87
+ if (parsedFacts?.language === "python") {
88
+ // v0.3.2 Batch 2 -- Python relative imports use dotted-module
89
+ // notation (".module", "..pkg.module"), not TS/JS's path-like
90
+ // "./module" syntax. Routing Python facts through the TS/JS
91
+ // resolver below would misresolve every relative Python import and
92
+ // flag it as missing regardless of whether it actually exists --
93
+ // this guard is the fix for that latent cross-language bug.
94
+ issues.push(...findMissingPythonSourceImports(ctx, testFile.relativePath, parsedFacts, pythonMetadata));
95
+ }
96
+ else if (parsedFacts?.language === "java" || parsedFacts?.language === "kotlin") {
97
+ // v0.3.3 Batch 2 -- Java/Kotlin imports are fully-qualified dotted
98
+ // package names ("com.example.Foo"), neither TS/JS's path-like
99
+ // "./module" syntax nor Python's dotted-relative-import syntax.
100
+ // Routing them through either existing resolver would misresolve
101
+ // every import -- this guard is the JVM analogue of the Python
102
+ // guard immediately above.
103
+ issues.push(...findMissingJvmSourceImports(ctx, testFile.relativePath, parsedFacts, jvmMetadata));
104
+ }
105
+ else if (parsedFacts) {
75
106
  issues.push(...findMissingSourceImportsFromSourceFacts(ctx, testFile.relativePath, parsedFacts));
76
107
  }
77
108
  issues.push(...findStaleNpmRunReferences(testFile.relativePath, content, scriptNames));
@@ -177,6 +208,215 @@ function findMissingSourceImportsFromSourceFacts(ctx, testRelativePath, facts) {
177
208
  }
178
209
  return issues;
179
210
  }
211
+ // v0.3.2 Batch 2 -- resolves a Python relative-import source (".module",
212
+ // "..pkg.sub", or bare "."/".." paired with `from . import name`-style
213
+ // imported names) to candidate local file paths, using Python's own
214
+ // relative-import level semantics (one leading dot = the importing file's
215
+ // own directory, each additional dot = one directory further up). This is a
216
+ // conservative, path-based approximation -- it does not resolve namespace
217
+ // packages, `sys.path` manipulation, editable installs, or anything beyond
218
+ // simple on-disk file/package presence, so returning zero matches is a
219
+ // candidate "may indicate a missing module" signal, not proof.
220
+ function resolvePythonRelativeImportCandidatePaths(testDir, importSource, importedNames) {
221
+ if (!importSource.startsWith("."))
222
+ return [];
223
+ let dotCount = 0;
224
+ while (dotCount < importSource.length && importSource[dotCount] === ".")
225
+ dotCount += 1;
226
+ const remainder = importSource.slice(dotCount);
227
+ const upLevels = dotCount - 1;
228
+ let baseDir = testDir;
229
+ for (let i = 0; i < upLevels; i++)
230
+ baseDir = path.dirname(baseDir);
231
+ const candidates = [];
232
+ const pushCandidatesFor = (moduleSegment) => {
233
+ candidates.push(path.join(baseDir, `${moduleSegment}.py`).replace(/\\/g, "/"));
234
+ candidates.push(path.join(baseDir, moduleSegment, "__init__.py").replace(/\\/g, "/"));
235
+ };
236
+ if (remainder) {
237
+ pushCandidatesFor(remainder.split(".").join("/"));
238
+ }
239
+ else {
240
+ // Bare "." / ".." (e.g. `from . import sibling`) -- the module lives
241
+ // in `importedNames`, not in `importSource` itself.
242
+ for (const name of importedNames ?? [])
243
+ pushCandidatesFor(name);
244
+ }
245
+ return candidates;
246
+ }
247
+ // v0.3.2 Batch 2 -- Python-specific counterpart to
248
+ // findMissingSourceImportsFromSourceFacts() above. Only ever called for
249
+ // facts with `language === "python"` (see caller) -- the two functions are
250
+ // kept fully separate rather than parameterized by language, because their
251
+ // resolution algorithms are genuinely different (dotted-module relative-
252
+ // import-level semantics vs. path-like specifier semantics), and conflating
253
+ // them risked silently misresolving one language or the other. Deliberately
254
+ // lower severity/confidence than the TS/JS counterpart: this scan has no
255
+ // real package/sys.path awareness, so it is weaker evidence than the
256
+ // TS/JS analyzer's near-exact relative-path resolution.
257
+ function findMissingPythonSourceImports(ctx, testRelativePath, facts, pythonMetadata) {
258
+ const issues = [];
259
+ const testDir = path.dirname(testRelativePath);
260
+ for (const imp of facts.imports) {
261
+ if (!imp.source.startsWith("."))
262
+ continue; // conservative: only relative Python imports are checked
263
+ const candidates = resolvePythonRelativeImportCandidatePaths(testDir, imp.source, imp.importedNames);
264
+ if (candidates.length === 0)
265
+ continue; // nothing checkable (e.g. bare "." with no imported names)
266
+ const resolved = candidates.some((candidate) => fs.existsSync(path.join(ctx.target.rootPath, candidate)));
267
+ if (resolved)
268
+ continue;
269
+ const evidence = [
270
+ {
271
+ kind: "file",
272
+ message: "Analyzer-recorded relative Python import does not resolve to a candidate local module/package file. This is a best-effort, path-based check -- it does not resolve Python's real package/sys.path rules, so confirm manually before treating it as stale.",
273
+ filePath: testRelativePath,
274
+ excerpt: imp.source,
275
+ source: DETECTOR_ID,
276
+ confidence: "low",
277
+ },
278
+ ];
279
+ if (pythonMetadata.hasPytestConfiguration) {
280
+ evidence.push({
281
+ kind: "observation",
282
+ message: "Weak supporting context: this project has a detected pytest configuration (pytest.ini, or a pyproject.toml/setup.cfg pytest section), consistent with pytest-style test conventions -- not a requirement for this finding.",
283
+ source: DETECTOR_ID,
284
+ confidence: "low",
285
+ });
286
+ }
287
+ issues.push(makeCodeRotIssue({
288
+ auditType: "code-rot",
289
+ detectorId: DETECTOR_ID,
290
+ idCues: [testRelativePath, "missing-python-source-import", imp.source],
291
+ title: `Test "${testRelativePath}" imports a Python module that may not exist: "${imp.source}"`,
292
+ description: `${testRelativePath} imports "${imp.source}" (source-facts-derived via the Python analyzer). No candidate local module or package file was found relative to the test file. This is a deterministic source-facts signal from a conservative, non-semantic scan -- it does not resolve Python's real package/sys.path rules, namespace packages, or editable installs, so this may indicate a stale import but is not proof.`,
293
+ severity: "low",
294
+ confidence: "low",
295
+ falsePositiveRisk: "medium",
296
+ category: "test-rot",
297
+ recommendedAction: "Confirm the imported Python module exists (it may resolve via a path this scan cannot see) before treating this as stale.",
298
+ suggestedFixStrategy: `Update or remove the import "${imp.source}" in ${testRelativePath} if confirmed stale.`,
299
+ validationCommands: [`npx vitest run ${testRelativePath}`],
300
+ releaseBlocking: false,
301
+ implementationBlocking: false,
302
+ autoFixEligible: false,
303
+ evidence,
304
+ affectedFiles: [testRelativePath],
305
+ }));
306
+ }
307
+ return issues;
308
+ }
309
+ // v0.3.3 Batch 2 -- Java/Kotlin-specific counterpart to
310
+ // findMissingSourceImportsFromSourceFacts()/findMissingPythonSourceImports()
311
+ // above. Only ever called for facts with `language === "java"` or
312
+ // `"kotlin"` (see caller) -- kept fully separate from both existing
313
+ // resolvers for the same reason the Python resolver is kept separate from
314
+ // the TS/JS one: the resolution algorithm (fully-qualified dotted package
315
+ // names -> conventional Gradle/Maven source-set paths) is genuinely
316
+ // different from either.
317
+ //
318
+ // Deliberately lower severity/confidence than the TS/JS counterpart, same
319
+ // tier as the Python counterpart: this scan has no real classpath/build
320
+ // awareness (no Gradle/Maven execution, no compiler), so it is weaker
321
+ // evidence than the TS/JS analyzer's near-exact relative-path resolution.
322
+ // Standard-library, common third-party test-framework, and wildcard
323
+ // imports are skipped entirely via jvmImportLooksPossiblyLocal() -- see
324
+ // jvmSourceFactsUtils.ts for the full skip-prefix list and rationale.
325
+ function findMissingJvmSourceImports(ctx, testRelativePath, facts, jvmMetadata) {
326
+ const issues = [];
327
+ const isKotlin = facts.language === "kotlin";
328
+ const languageLabel = isKotlin ? "Kotlin" : "Java";
329
+ // Kotlin may legitimately import Java source in a mixed JVM project;
330
+ // Java never imports Kotlin-only source through a plain `import`
331
+ // statement in the way this conservative scan can see, so a Java test
332
+ // only ever checks .java candidates.
333
+ const extensions = isKotlin ? ["kt", "java"] : ["java"];
334
+ // Both recognized main and test source-set directories are checked --
335
+ // the latter specifically covers the "importing a test-scoped helper
336
+ // from another test-set file" case (the importing file here is, by
337
+ // definition, already test-scoped, since this function is only ever
338
+ // called from the per-test-file loop above).
339
+ const candidateDirs = [...jvmMetadata.recognizedSourceDirectories, ...jvmMetadata.recognizedTestDirectories];
340
+ const inventoryBasenameIndex = buildJvmInventoryBasenameIndex(ctx, extensions);
341
+ for (const imp of facts.imports) {
342
+ if (!jvmImportLooksPossiblyLocal(imp))
343
+ continue;
344
+ // A Java `import static com.example.Foo.bar;` (or a Kotlin top-level
345
+ // member import) has its *member* name as the last dotted segment, not
346
+ // a class name -- this analyzer's ImportFact model (shared across
347
+ // every language) does not distinguish "static import of a member"
348
+ // from "plain class import", so both interpretations are tried: the
349
+ // full dotted path as-is (the common, plain-class-import case), and
350
+ // with its last segment dropped (the static/member-import case). This
351
+ // is a detector-side fix, not a change to javaAnalyzer.ts/
352
+ // kotlinAnalyzer.ts's ImportFact shape.
353
+ const parentSource = imp.source.includes(".") ? imp.source.slice(0, imp.source.lastIndexOf(".")) : null;
354
+ const conventionalCandidates = [
355
+ ...jvmImportCandidatePaths(imp.source, candidateDirs, extensions),
356
+ ...(parentSource ? jvmImportCandidatePaths(parentSource, candidateDirs, extensions) : []),
357
+ ];
358
+ const resolved = conventionalCandidates.some((candidate) => fs.existsSync(path.join(ctx.target.rootPath, candidate))) ||
359
+ inventoryBasenameIndex.has(jvmImportSimpleName(imp.source)) ||
360
+ (parentSource !== null && inventoryBasenameIndex.has(jvmImportSimpleName(parentSource)));
361
+ if (resolved)
362
+ continue;
363
+ const evidence = [
364
+ {
365
+ kind: "file",
366
+ message: `Analyzer-recorded ${languageLabel} import does not resolve to a candidate local source file under a recognized source-set directory, nor to any inventoried file with a matching basename. This is a best-effort, path-based check -- no compiler/classpath analysis was performed, and no Gradle/Maven execution was performed, so confirm manually before treating it as stale.`,
367
+ filePath: testRelativePath,
368
+ excerpt: imp.source,
369
+ source: DETECTOR_ID,
370
+ confidence: "low",
371
+ },
372
+ ];
373
+ if (candidateDirs.length > 0) {
374
+ evidence.push({
375
+ kind: "observation",
376
+ message: `Weak supporting context: recognized source-set directories for this project are: ${candidateDirs.join(", ")}.`,
377
+ source: DETECTOR_ID,
378
+ confidence: "low",
379
+ });
380
+ }
381
+ issues.push(makeCodeRotIssue({
382
+ auditType: "code-rot",
383
+ detectorId: DETECTOR_ID,
384
+ idCues: [testRelativePath, `missing-${facts.language}-source-import`, imp.source],
385
+ title: `Test "${testRelativePath}" imports a ${languageLabel} class that may not exist: "${imp.source}"`,
386
+ description: `${testRelativePath} imports "${imp.source}" (source-facts-derived via the ${languageLabel} analyzer). No candidate local source file was found under a recognized source-set directory, and no inventoried file shares its simple name. This is a deterministic source-facts signal from a conservative, non-semantic scan -- no compiler/classpath analysis was performed, and no Gradle/Maven execution was performed, so this does not resolve the real classpath, package visibility, or multi-module project layout. It may indicate a stale import but is not proof.`,
387
+ severity: "low",
388
+ confidence: "low",
389
+ falsePositiveRisk: "medium",
390
+ category: "test-rot",
391
+ recommendedAction: "Confirm the imported class exists (it may resolve via a module/classpath this scan cannot see) before treating this as stale.",
392
+ suggestedFixStrategy: `Update or remove the import "${imp.source}" in ${testRelativePath} if confirmed stale.`,
393
+ validationCommands: [`npx vitest run ${testRelativePath}`],
394
+ releaseBlocking: false,
395
+ implementationBlocking: false,
396
+ autoFixEligible: false,
397
+ evidence,
398
+ affectedFiles: [testRelativePath],
399
+ }));
400
+ }
401
+ return issues;
402
+ }
403
+ // Lowercased-basename index (without extension) of every inventoried
404
+ // Java/Kotlin file matching the given extensions -- the lenient,
405
+ // last-resort fallback used by findMissingJvmSourceImports() above so a
406
+ // real (if unconventionally placed) local file is never flagged as
407
+ // missing merely because the project doesn't follow the conventional
408
+ // src/main/{java,kotlin} source-set layout (mirrors
409
+ // jvmProjectMetadata.ts's own "non-conventional layout" reasoning).
410
+ function buildJvmInventoryBasenameIndex(ctx, extensions) {
411
+ const extSet = new Set(extensions.map((e) => `.${e}`));
412
+ const basenames = new Set();
413
+ for (const file of ctx.inventory.files) {
414
+ if (!extSet.has(file.extension))
415
+ continue;
416
+ basenames.add(baseNameNoExt(file.relativePath));
417
+ }
418
+ return basenames;
419
+ }
180
420
  function findStaleNpmRunReferences(testRelativePath, content, scriptNames) {
181
421
  const issues = [];
182
422
  const seen = new Set();
@@ -49,13 +49,17 @@ export const GENERIC_INFRA_BASENAMES = new Set([
49
49
  // expected to repeat across unrelated files legitimately (CLI entrypoints,
50
50
  // handler/lifecycle conventions, etc.) -- excluded from the source-facts
51
51
  // duplicate-declaration-candidate heuristic in duplicateImplementationDetector.ts
52
- // to keep false positives near zero.
52
+ // to keep false positives near zero. v0.3.2 Batch 2 adds Python lifecycle/
53
+ // dunder names ("teardown", "__init__", "__call__") for the same reason,
54
+ // reused by both duplicateImplementationDetector.ts and
55
+ // deadCodeCandidateDetector.ts's new Python-aware checks.
53
56
  export const GENERIC_DECLARATION_NAMES = new Set([
54
57
  "run",
55
58
  "main",
56
59
  "init",
57
60
  "initialize",
58
61
  "setup",
62
+ "teardown",
59
63
  "handler",
60
64
  "handle",
61
65
  "execute",
@@ -76,4 +80,18 @@ export const GENERIC_DECLARATION_NAMES = new Set([
76
80
  "get",
77
81
  "set",
78
82
  "default",
83
+ "__init__",
84
+ "__call__",
79
85
  ]);
86
+ // v0.3.2 Batch 2 -- true for a generic/lifecycle declaration name (see
87
+ // GENERIC_DECLARATION_NAMES above) or a pytest-style `test_*` name. Shared by
88
+ // the Python-aware dead-code and duplicate-implementation checks so a
89
+ // `test_*` helper/fixture function is never flagged as a candidate dead-code
90
+ // symbol or a duplicate-implementation candidate merely for repeating across
91
+ // unrelated test modules, a legitimate and extremely common pytest
92
+ // convention. Comparison is case-insensitive; callers pass the already-
93
+ // lowercased name where convenient.
94
+ export function isGenericOrTestPrefixedDeclarationName(name) {
95
+ const lower = name.toLowerCase();
96
+ return GENERIC_DECLARATION_NAMES.has(lower) || lower.startsWith("test_");
97
+ }