Skip to content

feat: migrate to TypeScript#110

Open
hawkeyexl wants to merge 3 commits intomainfrom
typescript
Open

feat: migrate to TypeScript#110
hawkeyexl wants to merge 3 commits intomainfrom
typescript

Conversation

@hawkeyexl
Copy link
Contributor

@hawkeyexl hawkeyexl commented Jan 28, 2026

Summary

  • Migrates all 9 source files from JavaScript to TypeScript
  • Adds dual CJS/ESM module support with proper exports
  • Keeps tests in JavaScript (importing from compiled dist/)

Changes

  • TypeScript Infrastructure

    • tsconfig.json: ES2022 target, Node16 module resolution, strict mode
    • scripts/createEsmWrapper.js: Creates ESM wrapper for dual module support
    • src/types.ts: Shared interfaces for Config, FileType, DetectedSpec, etc.
    • src/doc-detective-common.d.ts: Type declarations for doc-detective-common
  • Converted Files

    • src/index.ts - Main entry point with detectTests, resolveTests, detectAndResolveTests
    • src/config.ts - Configuration management with setConfig, resolveConcurrentRunners
    • src/utils.ts - Core utilities including parseTests, qualifyFiles, log, replaceEnvs
    • src/resolve.ts - Test resolution with resolveDetectedTests
    • src/openapi.ts - OpenAPI handling with loadDescription, getOperation
    • src/arazzo.ts - Arazzo workflow translation
    • src/heretto.ts - Heretto CMS integration
    • src/sanitize.ts - URI/path sanitization
    • src/telem.ts - Telemetry with PostHog
  • Package Updates

    • Added TypeScript 5.7+ and @types/node devDependencies
    • Added build scripts: compile, build, prebuild, postbuild
    • Updated exports with proper CJS/ESM/types paths
  • CI/CD Updates

    • Updated workflow to run npm run build before test and publish

Testing

All existing tests pass against the compiled TypeScript output.

Summary by CodeRabbit

  • New Features

    • Expanded public API for test detection/resolution, OpenAPI operation handling, telemetry reporting, and Heretto integration utilities.
  • Build & Infrastructure

    • TypeScript build now enabled; build runs before tests and publishing. Project emits compiled distribution and type declarations.
  • Refactor

    • Complete migration to ES modules and comprehensive TypeScript rewrites of core utilities and resolver logic.

✏️ Tip: You can customize this high-level summary in your review settings.

- Convert all 9 source files to TypeScript (.ts)
- Add tsconfig.json with ES2022 target and strict mode
- Add type declarations for doc-detective-common module
- Add shared types in src/types.ts
- Create ESM wrapper script for dual CJS/ESM support
- Update package.json with TypeScript build scripts and exports
- Update test imports to use compiled dist/ output
- Update CI workflow to run build before test/publish

Tests remain in JavaScript and import from the compiled output.
Copilot AI review requested due to automatic review settings January 28, 2026 00:00
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 28, 2026

Walkthrough

Migrates the codebase from CommonJS JavaScript to TypeScript/ESM, adds a dist build pipeline (compile/build/pre/post scripts + ESM wrapper), replaces many JS modules with typed TS equivalents (types, utils, openapi, resolve, heretto, sanitizers, telemetry), and updates CI/tests to build and consume the dist output.

Changes

Cohort / File(s) Summary
Build & Packaging
package.json, tsconfig.json, .github/workflows/npm-test.yaml, .github/workflows/integration-tests.yml, scripts/createEsmWrapper.js
Added TypeScript build config, changed package entry points to dist/*, added compile/build/prebuild/postbuild scripts, inserted build steps into CI workflows, and added script to generate dist/index.mjs.
Type System & Declarations
src/types.ts, src/doc-detective-common.d.ts
Introduced comprehensive TypeScript types and a declaration module for doc-detective-common describing validation, path resolution, transforms, and file reading APIs.
Core index & exports
src/index.ts
Rebuilt root API as an ES module; re-exports typed APIs (config, resolve, utils, openapi, arazzo, telem, sanitize) and adds typed public functions: detectTests, resolveTests, detectAndResolveTests.
Config & environment
src/config.ts, src/config.test.js
Rewrote config handling in TS with typed deepMerge, environment normalization, OpenAPI loading attachments, exported setConfig and resolveConcurrentRunners. Tests updated to reference dist artifacts.
Utilities
src/utils.ts (added), src/utils.js (deleted)
Replaced large JS utils with a typed TS implementation covering file qualification, parsing, env replacement, logging, spawning commands, fetching files, DITA processing, Heretto integration helpers, and result output.
OpenAPI handling
src/openapi.ts (added), src/openapi.js (deleted)
New TS OpenAPI loader/dereferencer with setReplaceEnvs, loadDescription, and getOperation (example/schema generation using JSONSchemaFaker). Old JS implementation removed.
Resolution pipeline
src/resolve.ts (added), src/resolve.js (deleted)
New typed resolution pipeline: resolveDetectedTests (default export), context normalization/expansion, OpenAPI document fetching/merging, driver requirement detection, ID generation and logging hook. Old JS resolver removed.
Arazzo / test translation
src/arazzo.ts
Converted to TS with typed workflowToTest and internal ArazzoTestSpec, improved typing and error handling.
Sanitizers
src/sanitize.ts (added), src/sanitize.js (deleted)
New typed sanitizeUri and sanitizePath implementations in TS; old JS removed.
Heretto integration
src/heretto.ts, src/heretto.test.js, src/heretto.integration.test.js
Rewritten as typed TS module with many exported functions/types for API client, auth, job trigger/poll/download, asset handling, uploads and resource dependency resolution. Tests updated to import from dist.
Telemetry
src/telem.ts
Reworked telemetry into TS with telemetryNotice and sendTelemetry, typed payloads, dynamic PostHog import and flattened property generation.
Tests -> dist
src/index.test.js, other tests
Multiple tests updated to require the ../dist/* build artifacts and adjusted proxyquire stubs accordingly.

Sequence Diagram(s)

sequenceDiagram
  participant CLI as CLI / Caller
  participant Config as setConfig
  participant Resolver as resolveDetectedTests
  participant OpenAPI as openapi.loadDescription
  participant HTTP as External API (OpenAPI / Heretto)
  participant FS as Filesystem

  CLI->>Config: setConfig(config)
  CLI->>Resolver: resolveDetectedTests({config, detectedTests})
  Resolver->>OpenAPI: loadDescription(descriptionPath) for each openApi source
  OpenAPI->>HTTP: fetch OpenAPI document
  HTTP-->>OpenAPI: OpenAPI document
  OpenAPI-->>Resolver: dereferenced definition
  Resolver->>Resolver: expand contexts, isDriverRequired checks
  Resolver->>FS: read local files (if needed)
  Resolver->>CLI: returned ResolvedTests
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰
From hops in JS to TypeScript’s bright field,
I stitched the types and built what you yield,
Dist wrapped in ESM, CI now will sing,
A rabbit’s small cheer for the tidy new spring! 🎉

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: migrate to TypeScript' directly and clearly summarizes the primary change in this changeset—converting JavaScript source files to TypeScript with comprehensive type safety and configuration.
Docstring Coverage ✅ Passed Docstring coverage is 91.14% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

The TypeScript compiled output uses strict mode which throws
TypeError when trying to set properties on a string. The original
JavaScript code silently ignored this case due to non-strict mode.

When regex patterns overlap (e.g., 'test ignore start' matches both
testStart and ignoreStart patterns), parseObject returns a string
instead of an object. Now we explicitly check for this and skip
processing non-object values.
@promptless
Copy link

promptless bot commented Jan 28, 2026

📝 Documentation updates detected!

New suggestion: Add doc-detective-resolver repository documentation

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
src/heretto.integration.test.js (1)

17-40: Guard dist import to prevent hard failure when build hasn't run

The top-level require("../dist/heretto") at line 17 throws immediately if dist/ hasn't been built, preventing the skip logic from executing. The test:integration script has no dependency on build, so this can fail even when tests should skip. Move the import into the before hook with a guard so tests can skip gracefully when the build output is missing.

🔧 Proposed change (guard import + skip if dist missing)
-const heretto = require("../dist/heretto");
+let heretto;

 const fs = require("fs");
 const path = require("path");
 const os = require("os");
 
+const distEntry = path.join(__dirname, "..", "dist", "heretto.js");
+const hasDist = fs.existsSync(distEntry);
 
 const isCI = process.env.CI === "true";
 const hasCredentials =
   process.env.HERETTO_ORGANIZATION_ID &&
   process.env.HERETTO_USERNAME &&
   process.env.HERETTO_API_TOKEN;
 
-const shouldRunIntegrationTests = isCI && hasCredentials;
+const shouldRunIntegrationTests = isCI && hasCredentials && hasDist;
 
 // Log why tests are being skipped
 if (!shouldRunIntegrationTests) {
   console.log("\n⏭️  Heretto integration tests skipped:");
   if (!isCI) {
     console.log("   - Not running in CI environment (CI !== 'true')");
   }
   if (!hasCredentials) {
     console.log("   - Missing required environment variables:");
     if (!process.env.HERETTO_ORGANIZATION_ID)
       console.log("     - HERETTO_ORGANIZATION_ID");
     if (!process.env.HERETTO_USERNAME) console.log("     - HERETTO_USERNAME");
     if (!process.env.HERETTO_API_TOKEN) console.log("     - HERETTO_API_TOKEN");
   }
+  if (!hasDist) {
+    console.log("   - Missing build output. Run `npm run build` before integration tests.");
+  }
   console.log("");
 }
 
 describeIntegration("Heretto Integration Tests (CI Only)", function () {
+  before(function () {
+    heretto = require(distEntry);
+  });
src/config.ts (1)

744-764: Mutating filter during iteration may skip elements.

In loadDescriptions, filtering config.integrations.openApi while iterating over it via for...of could lead to unexpected behavior if multiple consecutive definitions fail to load.

🐛 Proposed fix - collect failures and filter after iteration
 async function loadDescriptions(config: Config): Promise<void> {
   if (config?.integrations?.openApi) {
+    const failedConfigs: OpenApiConfigWithDefinition[] = [];
     for (const openApiConfig of config.integrations
       .openApi as OpenApiConfigWithDefinition[]) {
       try {
         openApiConfig.definition = await loadDescription(
           openApiConfig.descriptionPath!
         );
       } catch (error) {
         log(
           config,
           "error",
           `Failed to load OpenAPI description from ${openApiConfig.descriptionPath}: ${(error as Error).message}`
         );
-        // Remove the failed OpenAPI configuration
-        config.integrations.openApi = config.integrations.openApi!.filter(
-          (item) => item !== openApiConfig
-        );
+        failedConfigs.push(openApiConfig);
       }
     }
+    // Remove failed configurations after iteration
+    if (failedConfigs.length > 0) {
+      config.integrations.openApi = config.integrations.openApi!.filter(
+        (item) => !failedConfigs.includes(item as OpenApiConfigWithDefinition)
+      );
+    }
   }
 }
src/index.test.js (1)

27-35: Incorrect proxyquire stub paths causing test failures.

The proxyquire paths ../dist/config and ../dist/utils are incorrect. When proxyquire intercepts requires from within the compiled dist/index.js, the require paths in that file are ./config and ./utils (relative to the dist folder), not ../dist/config.

This is the likely root cause of the pipeline failure where 21 tests are failing with "TypeError: Cannot create property 'testId' on string 'end'" - the stubs aren't being applied, so real modules are being used.

🐛 Proposed fix
     detectTests = proxyquire("../dist/index", {
-      "../dist/config": { setConfig: setConfigStub },
-      "../dist/utils": {
+      "./config": { setConfig: setConfigStub },
+      "./utils": {
         qualifyFiles: qualifyFilesStub,
         parseTests: parseTestsStub,
         log: logStub,
       },
     }).detectTests;
src/heretto.ts (2)

166-226: Guard against missing scenarioParameters.content.
If the API omits content, .find(...) will throw and fall into the generic catch, masking the real issue. Consider a safe default.

🐛 Suggested fix
-    // Make sure that scenarioParameters.content has an object with name="transtype" and options[0].value="dita"
-    const transtypeParam = scenarioParameters.content.find(
+    const content = scenarioParameters.content ?? [];
+    // Make sure that scenarioParameters.content has an object with name="transtype" and options[0].value="dita"
+    const transtypeParam = content.find(
       (param) => param.name === "transtype"
     );
 ...
-    const toolKitParam = scenarioParameters.content.find(
+    const toolKitParam = content.find(
       (param) => param.name === "tool-kit-name"
     );
 ...
-    const fileUuidPickerParam = scenarioParameters.content.find(
+    const fileUuidPickerParam = content.find(
       (param) => param.type === "file_uuid_picker"
     );

434-505: Ensure ZIP cleanup on failure paths.
If extraction fails after the ZIP is written, the temp ZIP is left behind.

🐛 Suggested fix
-    const zipPath = path.join(tempDir, `heretto_${hash}.zip`);
-    fs.writeFileSync(zipPath, Buffer.from(response.data));
-
-    // Extract ZIP contents with path traversal protection
-    log(config, "debug", `Extracting output to ${outputDir}...`);
-    const zip = new AdmZip(zipPath);
-    const resolvedOutputDir = path.resolve(outputDir);
+    const zipPath = path.join(tempDir, `heretto_${hash}.zip`);
+    fs.writeFileSync(zipPath, Buffer.from(response.data));
+
+    try {
+      // Extract ZIP contents with path traversal protection
+      log(config, "debug", `Extracting output to ${outputDir}...`);
+      const zip = new AdmZip(zipPath);
+      const resolvedOutputDir = path.resolve(outputDir);
 
-    // Validate and extract entries safely to prevent zip slip attacks
-    for (const entry of zip.getEntries()) {
-      const entryPath = path.join(outputDir, entry.entryName);
-      const resolvedPath = path.resolve(entryPath);
+      // Validate and extract entries safely to prevent zip slip attacks
+      for (const entry of zip.getEntries()) {
+        const entryPath = path.join(outputDir, entry.entryName);
+        const resolvedPath = path.resolve(entryPath);
 
-      // Ensure the resolved path is within outputDir
-      if (
-        !resolvedPath.startsWith(resolvedOutputDir + path.sep) &&
-        resolvedPath !== resolvedOutputDir
-      ) {
-        log(config, "warning", `Skipping potentially malicious ZIP entry: ${entry.entryName}`);
-        continue;
-      }
+        // Ensure the resolved path is within outputDir
+        if (
+          !resolvedPath.startsWith(resolvedOutputDir + path.sep) &&
+          resolvedPath !== resolvedOutputDir
+        ) {
+          log(config, "warning", `Skipping potentially malicious ZIP entry: ${entry.entryName}`);
+          continue;
+        }
 
-      if (entry.isDirectory) {
-        fs.mkdirSync(resolvedPath, { recursive: true });
-      } else {
-        fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
-        fs.writeFileSync(resolvedPath, entry.getData());
+        if (entry.isDirectory) {
+          fs.mkdirSync(resolvedPath, { recursive: true });
+        } else {
+          fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
+          fs.writeFileSync(resolvedPath, entry.getData());
+        }
       }
-    }
-
-    // Clean up ZIP file
-    fs.unlinkSync(zipPath);
+    } finally {
+      if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
+    }
🤖 Fix all issues with AI agents
In `@src/arazzo.ts`:
- Around line 104-116: The status-code extraction for
workflowStep.successCriteria is fragile: update the parsing inside the loop over
successCriteria (where criterion.condition is inspected and
docDetectiveStep.statusCodes is set) to defensively handle malformed conditions
by first checking that criterion.condition exists and contains '==' (or match
with a regex like /^\s*\$statusCode\s*==\s*(\d+)\s*$/), extract the numeric part
safely, attempt Number/parseInt and verify !isNaN before assigning to
docDetectiveStep.statusCodes, and otherwise skip the entry (or log/warn) so
malformed conditions do not throw or produce NaN.

In `@src/config.test.js`:
- Around line 27-31: The proxyquire call that sets setConfig is stubbing the
wrong module paths; update the proxyquire stubs in the test to match how the
compiled module requires them by replacing "../dist/utils" and "../dist/openapi"
with "./utils" and "./openapi" respectively so the proxyquire for setConfig
(assigned from require("../dist/config")) correctly intercepts calls to log,
loadEnvs, replaceEnvs and loadDescription.

In `@src/config.ts`:
- Around line 491-500: resolveConcurrentRunners currently can return NaN if
config.concurrentRunners is a non-numeric string; update
resolveConcurrentRunners to coerce and validate numeric input before returning:
if config.concurrentRunners === true keep the existing boolean path; otherwise
attempt to parse Number(config.concurrentRunners) (or parseInt) and use
Number.isFinite to check validity, returning the validated numeric value when
valid or falling back to 1 when invalid; reference the resolveConcurrentRunners
function and the config.concurrentRunners property when making this change.

In `@src/heretto.ts`:
- Around line 518-675: getResourceDependencies currently builds pathToUuidMap
containing both HerettoResourceInfo objects and metadata string entries
(_ditamapPath/_ditamapId/_ditamapParentFolderId) but is being treated elsewhere
as Record<string, HerettoResourceInfo>, which hides the union and can cause
runtime errors; fix by changing the function's return shape to a clear typed
structure (e.g., an object with two properties: data: Record<string,
HerettoResourceInfo> and metadata: { ditamapPath?: string; ditamapId?: string;
ditamapParentFolderId?: string }) or by updating the shared HerettoConfig type
to accept the mixed map type, then update all call sites that consume
getResourceDependencies (and any HerettoConfig references) to use the new
structured fields instead of assuming every key has .uuid; ensure you modify
references to pathToUuidMap and any unsafe casts so consumers read metadata from
the dedicated metadata field or handle the union type safely.

In `@src/index.ts`:
- Around line 1-4: Import and call the resolve module's setLogFunction with the
existing log function so resolveDetectedTests has a real logger: add an import
for setLogFunction from "./resolve" and invoke setLogFunction(log) during module
initialization (before any calls to resolveDetectedTests) to wire the logger
into resolve.ts.

In `@src/telem.ts`:
- Around line 83-98: The string replacement calls inside the telemetry
flattening loop only replace the first space (calls to replace(" ", "_"));
update all occurrences — the parentKey, key, and key2 usages inside the
Object.entries(results.summary) nested loops (and the final else branch
assigning telemetryData[parentKey...]) — to use a global replacement (e.g.,
.replace(/ /g, "_") or .replaceAll(" ", "_")) so keys with multiple spaces are
fully normalized; ensure you change the three locations where replace(" ", "_")
is used.
- Around line 56-59: The code reads process.env["DOC_DETECTIVE_META"] and calls
JSON.parse directly into telemetryData (type TelemetryData), which can throw if
the env var contains invalid JSON; modify the logic around telemetryData
assignment to catch JSON.parse errors: when DOC_DETECTIVE_META is set, attempt
JSON.parse in a try/catch, log or handle the parse error (e.g., warn via console
or a logger) and fall back to an empty object (or a safe default) instead of
letting the exception propagate; keep the variable name telemetryData and its
TelemetryData type and ensure downstream code still receives a valid object.

In `@src/utils.ts`:
- Around line 215-223: cleanTemp currently assumes every entry under tempDir is
a file and calls fs.unlinkSync, which throws if an entry is a directory; update
the cleanTemp implementation to handle both files and directories by checking
each entry (e.g., fs.statSync or fs.lstatSync on the path) and removing
directories recursively (or use fs.rmSync(curPath, { recursive: true, force:
true })) instead of always calling fs.unlinkSync; ensure you still only target
entries inside tempDir and preserve the tempDir itself.
- Around line 627-639: The code constructs RegExp from potentially
user-controlled patterns (fileType.inlineStatements) using new RegExp in the
loop around patterns and can be vulnerable to ReDoS; validate each pattern
before constructing the RegExp (e.g., run patterns through a safe-regex check or
a whitelist of allowed patterns) and handle failures by skipping/logging the
offending pattern (wrap new RegExp(...) in a try/catch and avoid executing
matchAll on unsafe patterns), or document that fileType.inlineStatements are
trusted configuration only; update the logic around patterns.forEach / new
RegExp / matches handling to skip invalid/unsafe regexes and surface an explicit
error/log when a pattern is rejected so StatementMatch creation and
statements.push only run for validated regexes.
🧹 Nitpick comments (11)
package.json (1)

17-17: Cross-platform compatibility issue with prebuild script.

The rm -rf dist command won't work on Windows without additional tooling. Consider using a cross-platform approach.

Suggested fix using rimraf or node

Option 1: Add rimraf as a devDependency:

-    "prebuild": "rm -rf dist",
+    "prebuild": "rimraf dist",

Option 2: Use Node.js directly (no extra dependency):

-    "prebuild": "rm -rf dist",
+    "prebuild": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"",
src/utils.ts (3)

683-724: Wrap switch case declarations in blocks to prevent scope leakage.

Per static analysis, variables declared in switch cases (testWithV2, testWithDetectSteps) can be accessed from other cases, which can lead to bugs.

Wrap case blocks
       case "testStart":
+      {
         statementContent = statement[1] || statement[0];
         test = parseObject({ stringifiedObject: statementContent }) as DetectedTest;
         // ... rest of case logic ...
         tests.push(test);
         break;
+      }

       case "testEnd":
+      {
         testId = `${crypto.randomUUID()}`;
         ignore = false;
         break;
+      }

Apply similar wrapping to detectedStep and step cases.


234-258: HTTP request in fetchFile lacks timeout configuration.

The axios request has no timeout, which could cause the process to hang indefinitely on network issues.

Add timeout to axios request
 export async function fetchFile(fileURL: string): Promise<FetchFileResult> {
   try {
-    const response = await axios.get(fileURL);
+    const response = await axios.get(fileURL, { timeout: 30000 });
     let data: string;

1158-1212: spawnCommand returns redundant code and exitCode fields.

The return type includes both code and exitCode with the same value. Consider keeping only one for clarity.

Simplify return type
-  return { stdout, stderr, code: exitCode, exitCode };
+  return { stdout, stderr, code: exitCode };

And update SpawnResult type accordingly, or keep exitCode and remove code depending on which name is preferred throughout the codebase.

src/openapi.ts (1)

129-135: Consider returning null instead of throwing when no responses are defined.

The getSchemas function throws an error when no responses are defined, but getOperation (which calls it) is expected to return null when operations aren't found. This inconsistency could cause unexpected crashes for operations without responses.

Return empty schemas instead of throwing
   if (!responseCode) {
     if (definition.responses && Object.keys(definition.responses).length > 0) {
       responseCode = Object.keys(definition.responses)[0];
     } else {
-      throw new Error("No responses defined for the operation.");
+      return schemas; // Return partial schemas without response
     }
   }
src/resolve.ts (4)

18-30: Potential runtime error if setLogFunction is never called.

The log variable is declared but not initialized. If any resolution function is called before setLogFunction, and the conditional if (log) checks pass due to a truthy value somehow being assigned, it could cause issues. More importantly, the module expects consumers to call setLogFunction but there's no enforcement or documentation in the public exports.

Consider providing a default no-op function or logging a warning:

♻️ Proposed fix
 // Forward declaration for log function - will be set during initialization
-let log: LogFunction;
+let log: LogFunction = () => {}; // No-op default to prevent runtime errors

51-60: Unused function isDriverRequired.

The isDriverRequired function is defined but never called. The same logic is duplicated inline in resolveContexts (lines 89-95).

♻️ Proposed fix - use the existing function
   // Check if current test requires a browser
-  let browserRequired = false;
-  test.steps.forEach((step) => {
-    // Check if test includes actions that require a driver.
-    driverActions.forEach((action) => {
-      if (typeof step[action] !== "undefined") browserRequired = true;
-    });
-  });
+  const browserRequired = isDriverRequired({ test });

145-156: JSON.stringify comparison for browser deduplication may have ordering issues.

Using JSON.stringify for object comparison can produce different results if object property order differs. Consider a more robust comparison or use a dedicated deep-equal utility.


339-339: Deleting steps from resolvedTest may cause TypeScript issues.

The delete operation on line 339 modifies the type at runtime. While this works, it's a type-unsafe pattern. Consider excluding steps during object construction instead.

src/heretto.ts (2)

92-130: Prefer destructuring herettoConfig for readability and consistency.

♻️ Suggested refactor
-export function createApiClient(herettoConfig: HerettoConfig): AxiosInstance {
-  const authHeader = createAuthHeader(
-    herettoConfig.username,
-    herettoConfig.apiToken
-  );
+export function createApiClient({ username, apiToken, organizationId }: HerettoConfig): AxiosInstance {
+  const authHeader = createAuthHeader(username, apiToken);
   return axios.create({
-    baseURL: getBaseUrl(herettoConfig.organizationId),
+    baseURL: getBaseUrl(organizationId),
     timeout: API_REQUEST_TIMEOUT_MS,
     headers: {
       Authorization: `Basic ${authHeader}`,
       "Content-Type": "application/json",
     },
   });
 }
 
-export function createRestApiClient(herettoConfig: HerettoConfig): AxiosInstance {
-  const authHeader = createAuthHeader(
-    herettoConfig.username,
-    herettoConfig.apiToken
-  );
+export function createRestApiClient({ username, apiToken, organizationId }: HerettoConfig): AxiosInstance {
+  const authHeader = createAuthHeader(username, apiToken);
   return axios.create({
-    baseURL: `https://${herettoConfig.organizationId}.heretto.com`,
+    baseURL: `https://${organizationId}.heretto.com`,
     timeout: API_REQUEST_TIMEOUT_MS,
     headers: {
       Authorization: `Basic ${authHeader}`,
       Accept: "application/xml, text/xml, */*",
     },
   });
 }

As per coding guidelines, prefer destructuring for function parameters.


685-759: Prefer destructuring for the frequently-used herettoConfig fields.

♻️ Suggested refactor
-export async function loadHerettoContent(
-  herettoConfig: HerettoConfig,
-  log: LogFunction,
-  config: Config
-): Promise<string | null> {
-  log(config, "info", `Loading content from Heretto "${herettoConfig.name}"...`);
+export async function loadHerettoContent(
+  herettoConfig: HerettoConfig,
+  log: LogFunction,
+  config: Config
+): Promise<string | null> {
+  const { name, scenarioName, uploadOnChange } = herettoConfig;
+  log(config, "info", `Loading content from Heretto "${name}"...`);
 ...
-    const scenarioName = herettoConfig.scenarioName || DEFAULT_SCENARIO_NAME;
-    const scenario = await findScenario(client, log, config, scenarioName);
+    const resolvedScenarioName = scenarioName || DEFAULT_SCENARIO_NAME;
+    const scenario = await findScenario(client, log, config, resolvedScenarioName);
 ...
-    if (herettoConfig.uploadOnChange) {
+    if (uploadOnChange) {
       log(config, "debug", `Fetching resource dependencies for ditamap ${scenario.fileId}...`);
 ...
-    if (outputPath && herettoConfig.uploadOnChange) {
+    if (outputPath && uploadOnChange) {
       const fileMapping = await buildFileMapping(outputPath, herettoConfig, log, config);
       herettoConfig.fileMapping = fileMapping;
     }

As per coding guidelines, prefer destructuring for function parameters.

Comment on lines 104 to 116
if (workflowStep.successCriteria) {
docDetectiveStep.responseData = {};
docDetectiveStep.responseData = {} as Record<string, unknown>;
workflowStep.successCriteria.forEach((criterion) => {
if (criterion.condition.startsWith("$statusCode")) {
docDetectiveStep.statusCodes = [
parseInt(criterion.condition.split("==")[1].trim()),
];
} else if (criterion.context === "$response.body") {
// This is a simplification; actual JSONPath translation would be more complex
docDetectiveStep.responseData[criterion.condition] = true;
(docDetectiveStep.responseData as Record<string, unknown>)[criterion.condition] = true;
}
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fragile status code parsing could fail on malformed conditions.

The parsing logic criterion.condition.split("==")[1].trim() assumes a specific format. If the condition doesn't contain == or has unexpected whitespace, this could throw or produce NaN.

Suggested defensive fix
         if (criterion.condition.startsWith("$statusCode")) {
-          docDetectiveStep.statusCodes = [
-            parseInt(criterion.condition.split("==")[1].trim()),
-          ];
+          const parts = criterion.condition.split("==");
+          if (parts.length >= 2) {
+            const statusCode = parseInt(parts[1].trim(), 10);
+            if (!isNaN(statusCode)) {
+              docDetectiveStep.statusCodes = [statusCode];
+            }
+          }
         } else if (criterion.context === "$response.body") {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (workflowStep.successCriteria) {
docDetectiveStep.responseData = {};
docDetectiveStep.responseData = {} as Record<string, unknown>;
workflowStep.successCriteria.forEach((criterion) => {
if (criterion.condition.startsWith("$statusCode")) {
docDetectiveStep.statusCodes = [
parseInt(criterion.condition.split("==")[1].trim()),
];
} else if (criterion.context === "$response.body") {
// This is a simplification; actual JSONPath translation would be more complex
docDetectiveStep.responseData[criterion.condition] = true;
(docDetectiveStep.responseData as Record<string, unknown>)[criterion.condition] = true;
}
});
}
if (workflowStep.successCriteria) {
docDetectiveStep.responseData = {} as Record<string, unknown>;
workflowStep.successCriteria.forEach((criterion) => {
if (criterion.condition.startsWith("$statusCode")) {
const parts = criterion.condition.split("==");
if (parts.length >= 2) {
const statusCode = parseInt(parts[1].trim(), 10);
if (!isNaN(statusCode)) {
docDetectiveStep.statusCodes = [statusCode];
}
}
} else if (criterion.context === "$response.body") {
// This is a simplification; actual JSONPath translation would be more complex
(docDetectiveStep.responseData as Record<string, unknown>)[criterion.condition] = true;
}
});
}
🤖 Prompt for AI Agents
In `@src/arazzo.ts` around lines 104 - 116, The status-code extraction for
workflowStep.successCriteria is fragile: update the parsing inside the loop over
successCriteria (where criterion.condition is inspected and
docDetectiveStep.statusCodes is set) to defensively handle malformed conditions
by first checking that criterion.condition exists and contains '==' (or match
with a regex like /^\s*\$statusCode\s*==\s*(\d+)\s*$/), extract the numeric part
safely, attempt Number/parseInt and verify !isNaN before assigning to
docDetectiveStep.statusCodes, and otherwise skip the entry (or log/warn) so
malformed conditions do not throw or produce NaN.

Comment on lines +27 to 31
setConfig = proxyquire("../dist/config", {
"doc-detective-common": { validate: validStub },
"./utils": { log: logStub, loadEnvs: loadEnvsStub, replaceEnvs: replaceEnvsStub },
"./openapi": { loadDescription: sinon.stub().resolves({}) }
"../dist/utils": { log: logStub, loadEnvs: loadEnvsStub, replaceEnvs: replaceEnvsStub },
"../dist/openapi": { loadDescription: sinon.stub().resolves({}) }
}).setConfig;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Incorrect proxyquire stub paths - same issue as index.test.js.

The proxyquire paths should match how the compiled dist/config.js requires its dependencies. The compiled file uses ./utils and ./openapi, not ../dist/utils and ../dist/openapi.

🐛 Proposed fix
     setConfig = proxyquire("../dist/config", {
       "doc-detective-common": { validate: validStub },
-      "../dist/utils": { log: logStub, loadEnvs: loadEnvsStub, replaceEnvs: replaceEnvsStub },
-      "../dist/openapi": { loadDescription: sinon.stub().resolves({}) }
+      "./utils": { log: logStub, loadEnvs: loadEnvsStub, replaceEnvs: replaceEnvsStub },
+      "./openapi": { loadDescription: sinon.stub().resolves({}) }
     }).setConfig;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setConfig = proxyquire("../dist/config", {
"doc-detective-common": { validate: validStub },
"./utils": { log: logStub, loadEnvs: loadEnvsStub, replaceEnvs: replaceEnvsStub },
"./openapi": { loadDescription: sinon.stub().resolves({}) }
"../dist/utils": { log: logStub, loadEnvs: loadEnvsStub, replaceEnvs: replaceEnvsStub },
"../dist/openapi": { loadDescription: sinon.stub().resolves({}) }
}).setConfig;
setConfig = proxyquire("../dist/config", {
"doc-detective-common": { validate: validStub },
"./utils": { log: logStub, loadEnvs: loadEnvsStub, replaceEnvs: replaceEnvsStub },
"./openapi": { loadDescription: sinon.stub().resolves({}) }
}).setConfig;
🤖 Prompt for AI Agents
In `@src/config.test.js` around lines 27 - 31, The proxyquire call that sets
setConfig is stubbing the wrong module paths; update the proxyquire stubs in the
test to match how the compiled module requires them by replacing "../dist/utils"
and "../dist/openapi" with "./utils" and "./openapi" respectively so the
proxyquire for setConfig (assigned from require("../dist/config")) correctly
intercepts calls to log, loadEnvs, replaceEnvs and loadDescription.

Comment on lines +491 to 500
export function resolveConcurrentRunners(
config: Config
): number {
if (config.concurrentRunners === true) {
// Cap at 4 only for the boolean convenience option
return Math.min(os.cpus().length, 4);
}
// Respect explicit numeric values and default
return config.concurrentRunners || 1;
return (config.concurrentRunners as number) || 1;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Edge case: resolveConcurrentRunners returns NaN for non-numeric strings.

If config.concurrentRunners is set to a non-numeric string (e.g., from environment variable parsing), the function will return NaN via the || 1 fallback not catching it.

🐛 Proposed fix
 export function resolveConcurrentRunners(
   config: Config
 ): number {
   if (config.concurrentRunners === true) {
     // Cap at 4 only for the boolean convenience option
     return Math.min(os.cpus().length, 4);
   }
   // Respect explicit numeric values and default
-  return (config.concurrentRunners as number) || 1;
+  const value = Number(config.concurrentRunners);
+  return Number.isFinite(value) && value > 0 ? value : 1;
 }
🤖 Prompt for AI Agents
In `@src/config.ts` around lines 491 - 500, resolveConcurrentRunners currently can
return NaN if config.concurrentRunners is a non-numeric string; update
resolveConcurrentRunners to coerce and validate numeric input before returning:
if config.concurrentRunners === true keep the existing boolean path; otherwise
attempt to parse Number(config.concurrentRunners) (or parseInt) and use
Number.isFinite to check validity, returning the validated numeric value when
valid or falling back to 1 when invalid; reference the resolveConcurrentRunners
function and the config.concurrentRunners property when making this change.

Comment on lines +518 to 675
export async function getResourceDependencies(
restClient: AxiosInstance,
ditamapId: string,
log: LogFunction,
config: Config
): Promise<Record<string, HerettoResourceInfo | string>> {
const pathToUuidMap: Record<string, HerettoResourceInfo | string> = {};

const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
});

// First, try to get the ditamap's own info (this is more reliable than the dependencies endpoint)
try {
log(config, "debug", `Fetching ditamap info for: ${ditamapId}`);
const ditamapInfo = await restClient.get(`${REST_API_PATH}/${ditamapId}`);
const ditamapParsed = xmlParser.parse(ditamapInfo.data);

const ditamapUri = ditamapParsed.resource?.["xmldb-uri"] || ditamapParsed["@_uri"];
const ditamapName = ditamapParsed.resource?.name || ditamapParsed["@_name"];
const ditamapParentFolder = ditamapParsed.resource?.["folder-uuid"] ||
ditamapParsed.resource?.["@_folder-uuid"] ||
ditamapParsed["@_folder-uuid"];

log(config, "debug", `Ditamap info: uri=${ditamapUri}, name=${ditamapName}, parentFolder=${ditamapParentFolder}`);


const resource = ditamapParsed.resource as Record<string, unknown> | undefined;
const ditamapUri = resource?.["xmldb-uri"] || ditamapParsed["@_uri"];
const ditamapName = resource?.name || ditamapParsed["@_name"];
const ditamapParentFolder =
resource?.["folder-uuid"] ||
resource?.["@_folder-uuid"] ||
ditamapParsed["@_folder-uuid"];

log(
config,
"debug",
`Ditamap info: uri=${ditamapUri}, name=${ditamapName}, parentFolder=${ditamapParentFolder}`
);

if (ditamapUri) {
let relativePath = ditamapUri;
let relativePath = ditamapUri as string;
const orgPathMatch = relativePath?.match(/\/db\/organizations\/[^/]+\/(.+)/);
if (orgPathMatch) {
relativePath = orgPathMatch[1];
}

pathToUuidMap[relativePath] = {
uuid: ditamapId,
fullPath: ditamapUri,
name: ditamapName,
parentFolderId: ditamapParentFolder,
fullPath: ditamapUri as string,
name: ditamapName as string,
parentFolderId: ditamapParentFolder as string,
isDitamap: true,
};

// Store the ditamap info as reference points for creating new files
pathToUuidMap._ditamapPath = relativePath;
pathToUuidMap._ditamapId = ditamapId;
pathToUuidMap._ditamapParentFolderId = ditamapParentFolder;
pathToUuidMap._ditamapParentFolderId = ditamapParentFolder as string;

log(config, "debug", `Ditamap path: ${relativePath}, parent folder: ${ditamapParentFolder}`);
}
} catch (ditamapError) {
log(config, "warning", `Could not get ditamap info: ${ditamapError.message}`);
log(config, "warning", `Could not get ditamap info: ${(ditamapError as Error).message}`);
}

// Then try to get the full dependencies list (this endpoint may not be available)
try {
log(config, "debug", `Fetching resource dependencies for ditamap: ${ditamapId}`);

const response = await restClient.get(`${REST_API_PATH}/${ditamapId}/dependencies`);
const xmlData = response.data;

const parsed = xmlParser.parse(xmlData);

// Extract dependencies from the response
// Response format: <dependencies><dependency id="uuid" uri="path">...</dependency>...</dependencies>
const extractDependencies = (obj, parentPath = "") => {
interface DependencyNode {
"@_id"?: string;
"@_uuid"?: string;
"@_uri"?: string;
"@_path"?: string;
"@_name"?: string;
"@_folder-uuid"?: string;
"@_parent"?: string;
id?: string;
uuid?: string;
uri?: string;
path?: string;
name?: string;
"xmldb-uri"?: string;
"folder-uuid"?: string;
dependencies?: { dependency?: DependencyNode | DependencyNode[] };
dependency?: DependencyNode | DependencyNode[];
}

const extractDependencies = (obj: DependencyNode | Record<string, unknown>): void => {
if (!obj) return;


const node = obj as DependencyNode;
// Handle single dependency or array of dependencies
let dependencies = obj.dependencies?.dependency || obj.dependency;
let dependencies: DependencyNode | DependencyNode[] | undefined =
node.dependencies?.dependency || node.dependency;
if (!dependencies) {
// Try to extract from root-level response
if (obj["@_id"] && obj["@_uri"]) {
dependencies = [obj];
if (node["@_id"] && node["@_uri"]) {
dependencies = [node];
} else if (Array.isArray(obj)) {
dependencies = obj;
dependencies = obj as DependencyNode[];
}
}

if (!dependencies) return;
if (!Array.isArray(dependencies)) {
dependencies = [dependencies];
}

for (const dep of dependencies) {
const uuid = dep["@_id"] || dep["@_uuid"] || dep.id || dep.uuid;
const uri = dep["@_uri"] || dep["@_path"] || dep.uri || dep.path || dep["xmldb-uri"];
const name = dep["@_name"] || dep.name;
const parentFolderId = dep["@_folder-uuid"] || dep["@_parent"] || dep["folder-uuid"];

if (uuid && (uri || name)) {
// Extract the relative path from the full URI
// URI format: /db/organizations/{org}/{path}
let relativePath = uri || name;
let relativePath = (uri || name) as string;
const orgPathMatch = relativePath?.match(/\/db\/organizations\/[^/]+\/(.+)/);
if (orgPathMatch) {
relativePath = orgPathMatch[1];
}

pathToUuidMap[relativePath] = {
uuid,
fullPath: uri,
name: name || path.basename(relativePath || ""),
parentFolderId,
uuid: uuid as string,
fullPath: uri as string,
name: (name || path.basename(relativePath || "")) as string,
parentFolderId: parentFolderId as string,
};

log(config, "debug", `Mapped: ${relativePath} -> ${uuid}`);
}

// Recursively process nested dependencies
if (dep.dependencies || dep.dependency) {
extractDependencies(dep);
}
}
};
extractDependencies(parsed);

extractDependencies(parsed as Record<string, unknown>);

log(config, "info", `Retrieved ${Object.keys(pathToUuidMap).length} resource dependencies from Heretto`);

} catch (error) {
// Log more details about the error for debugging
const statusCode = error.response?.status;
log(config, "debug", `Dependencies endpoint not available (${statusCode}), will use ditamap info as fallback`);
const axiosError = error as { response?: { status?: number } };
const statusCode = axiosError.response?.status;
log(
config,
"debug",
`Dependencies endpoint not available (${statusCode}), will use ditamap info as fallback`
);
// Continue with ditamap info only - the fallback will create files in the ditamap's parent folder
}

return pathToUuidMap;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all usages of resourceDependencies and special _ditamap* keys
rg -n -C3 '_ditamap(Path|Id|ParentFolderId)' -g '*.ts'

Repository: doc-detective/resolver

Length of output: 597


🏁 Script executed:

#!/bin/bash
# Find where getResourceDependencies is called
rg -n 'getResourceDependencies' -g '*.ts'

Repository: doc-detective/resolver

Length of output: 216


🏁 Script executed:

#!/bin/bash
# Find how the return value is used - look for usages of the result
rg -n -B2 -A5 'getResourceDependencies' -g '*.ts' | head -100

Repository: doc-detective/resolver

Length of output: 927


🏁 Script executed:

#!/bin/bash
# Check what happens with resourceDependencies after line 717
sed -n '717,800p' src/heretto.ts

Repository: doc-detective/resolver

Length of output: 3003


🏁 Script executed:

#!/bin/bash
# Find uses of herettoConfig.resourceDependencies
rg -n 'resourceDependencies' -g '*.ts'

Repository: doc-detective/resolver

Length of output: 352


🏁 Script executed:

#!/bin/bash
# Find HerettoConfig type definition
rg -n 'type HerettoConfig|interface HerettoConfig' -g '*.ts' -A20

Repository: doc-detective/resolver

Length of output: 892


🏁 Script executed:

#!/bin/bash
# Check if herettoConfig.resourceDependencies is accessed/iterated anywhere
rg -n 'herettoConfig\.resourceDependencies|\.resourceDependencies' -g '*.ts' -B2 -A3

Repository: doc-detective/resolver

Length of output: 344


Unsafe cast masks mixed return type in getResourceDependencies.

The function returns Promise<Record<string, HerettoResourceInfo | string>> because it includes metadata keys (_ditamapPath, _ditamapId, _ditamapParentFolderId) as strings alongside HerettoResourceInfo objects. However, at line 718, this is cast to Record<string, HerettoResourceInfo>, hiding the union type from consumers. If code later accesses this map and attempts to read .uuid from metadata keys, it will fail. Either return a structured object with separate metadata and data fields, or update the HerettoConfig type to reflect the mixed shape and remove the unsafe cast.

🤖 Prompt for AI Agents
In `@src/heretto.ts` around lines 518 - 675, getResourceDependencies currently
builds pathToUuidMap containing both HerettoResourceInfo objects and metadata
string entries (_ditamapPath/_ditamapId/_ditamapParentFolderId) but is being
treated elsewhere as Record<string, HerettoResourceInfo>, which hides the union
and can cause runtime errors; fix by changing the function's return shape to a
clear typed structure (e.g., an object with two properties: data: Record<string,
HerettoResourceInfo> and metadata: { ditamapPath?: string; ditamapId?: string;
ditamapParentFolderId?: string }) or by updating the shared HerettoConfig type
to accept the mixed map type, then update all call sites that consume
getResourceDependencies (and any HerettoConfig references) to use the new
structured fields instead of assuming every key has .uuid; ensure you modify
references to pathToUuidMap and any unsafe casts so consumers read metadata from
the dedicated metadata field or handle the union type safely.

Comment on lines +1 to +4
import { setConfig } from "./config";
import { qualifyFiles, parseTests, log } from "./utils";
import { resolveDetectedTests } from "./resolve";
import type { Config, DetectedSpec, ResolvedTests } from "./types";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing setLogFunction call for resolve module.

The resolve.ts module exports setLogFunction to receive the log function, but this is never called during module initialization. This means logging in resolveDetectedTests will silently fail (or use the no-op if the suggested fix is applied to resolve.ts).

🐛 Proposed fix
 import { setConfig } from "./config";
 import { qualifyFiles, parseTests, log } from "./utils";
-import { resolveDetectedTests } from "./resolve";
+import { resolveDetectedTests, setLogFunction } from "./resolve";
 import type { Config, DetectedSpec, ResolvedTests } from "./types";

+// Initialize the log function for the resolve module
+setLogFunction(log);
+
 // Re-export types for consumers
🤖 Prompt for AI Agents
In `@src/index.ts` around lines 1 - 4, Import and call the resolve module's
setLogFunction with the existing log function so resolveDetectedTests has a real
logger: add an import for setLogFunction from "./resolve" and invoke
setLogFunction(log) during module initialization (before any calls to
resolveDetectedTests) to wire the logger into resolve.ts.

Comment on lines +56 to 59
const telemetryData: TelemetryData =
process.env["DOC_DETECTIVE_META"] !== undefined
? JSON.parse(process.env["DOC_DETECTIVE_META"])
: {};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing error handling for JSON.parse of environment variable.

If DOC_DETECTIVE_META contains invalid JSON, JSON.parse will throw an unhandled exception.

🐛 Proposed fix
   // Assemble telemetry data
-  const telemetryData: TelemetryData =
-    process.env["DOC_DETECTIVE_META"] !== undefined
-      ? JSON.parse(process.env["DOC_DETECTIVE_META"])
-      : {};
+  let telemetryData: TelemetryData = {};
+  if (process.env["DOC_DETECTIVE_META"] !== undefined) {
+    try {
+      telemetryData = JSON.parse(process.env["DOC_DETECTIVE_META"]);
+    } catch {
+      // Invalid JSON in DOC_DETECTIVE_META, use empty object
+    }
+  }
🤖 Prompt for AI Agents
In `@src/telem.ts` around lines 56 - 59, The code reads
process.env["DOC_DETECTIVE_META"] and calls JSON.parse directly into
telemetryData (type TelemetryData), which can throw if the env var contains
invalid JSON; modify the logic around telemetryData assignment to catch
JSON.parse errors: when DOC_DETECTIVE_META is set, attempt JSON.parse in a
try/catch, log or handle the parse error (e.g., warn via console or a logger)
and fall back to an empty object (or a safe default) instead of letting the
exception propagate; keep the variable name telemetryData and its TelemetryData
type and ensure downstream code still receives a valid object.

Comment on lines 83 to 98
Object.entries(results.summary).forEach(([parentKey, value]) => {
if (typeof value === "object") {
Object.entries(value).forEach(([key, value]) => {
if (typeof value === "object") {
Object.entries(value).forEach(([key2, value2]) => {
telemetryData[`${parentKey.replace(" ","_")}_${key.replace(" ","_")}_${key2.replace(" ","_")}`] = value2;
if (typeof value === "object" && value !== null) {
Object.entries(value as Record<string, unknown>).forEach(([key, val]) => {
if (typeof val === "object" && val !== null) {
Object.entries(val as Record<string, unknown>).forEach(([key2, value2]) => {
telemetryData[`${parentKey.replace(" ", "_")}_${key.replace(" ", "_")}_${key2.replace(" ", "_")}`] = value2;
});
} else {
telemetryData[`${parentKey.replace(" ","_")}_${key.replace(" ","_")}`] = value;
telemetryData[`${parentKey.replace(" ", "_")}_${key.replace(" ", "_")}`] = val;
}
});
} else {
telemetryData[parentKey.replace(" ","_")] = value;
telemetryData[parentKey.replace(" ", "_")] = value;
}
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

String.replace only replaces the first occurrence.

The replace(" ", "_") calls only replace the first space. Use replaceAll or a regex with global flag for keys with multiple spaces.

🐛 Proposed fix
-          telemetryData[`${parentKey.replace(" ", "_")}_${key.replace(" ", "_")}_${key2.replace(" ", "_")}`] = value2;
+          telemetryData[`${parentKey.replaceAll(" ", "_")}_${key.replaceAll(" ", "_")}_${key2.replaceAll(" ", "_")}`] = value2;

Apply similar changes to lines 91 and 95.

🤖 Prompt for AI Agents
In `@src/telem.ts` around lines 83 - 98, The string replacement calls inside the
telemetry flattening loop only replace the first space (calls to replace(" ",
"_")); update all occurrences — the parentKey, key, and key2 usages inside the
Object.entries(results.summary) nested loops (and the final else branch
assigning telemetryData[parentKey...]) — to use a global replacement (e.g.,
.replace(/ /g, "_") or .replaceAll(" ", "_")) so keys with multiple spaces are
fully normalized; ensure you change the three locations where replace(" ", "_")
is used.

Comment on lines +215 to +223
export function cleanTemp(): void {
const tempDir = `${os.tmpdir()}/doc-detective`;
if (fs.existsSync(tempDir)) {
fs.readdirSync(tempDir).forEach((file) => {
const curPath = `${tempDir}/${file}`;
fs.unlinkSync(curPath);
});
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

cleanTemp will fail if temp directory contains subdirectories.

fs.unlinkSync() cannot remove directories. If the temp directory contains subdirectories, this will throw an error.

Suggested fix using recursive removal
 export function cleanTemp(): void {
   const tempDir = `${os.tmpdir()}/doc-detective`;
   if (fs.existsSync(tempDir)) {
-    fs.readdirSync(tempDir).forEach((file) => {
-      const curPath = `${tempDir}/${file}`;
-      fs.unlinkSync(curPath);
-    });
+    fs.rmSync(tempDir, { recursive: true, force: true });
+    fs.mkdirSync(tempDir, { recursive: true });
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function cleanTemp(): void {
const tempDir = `${os.tmpdir()}/doc-detective`;
if (fs.existsSync(tempDir)) {
fs.readdirSync(tempDir).forEach((file) => {
const curPath = `${tempDir}/${file}`;
fs.unlinkSync(curPath);
});
}
}
export function cleanTemp(): void {
const tempDir = `${os.tmpdir()}/doc-detective`;
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
fs.mkdirSync(tempDir, { recursive: true });
}
}
🤖 Prompt for AI Agents
In `@src/utils.ts` around lines 215 - 223, cleanTemp currently assumes every entry
under tempDir is a file and calls fs.unlinkSync, which throws if an entry is a
directory; update the cleanTemp implementation to handle both files and
directories by checking each entry (e.g., fs.statSync or fs.lstatSync on the
path) and removing directories recursively (or use fs.rmSync(curPath, {
recursive: true, force: true })) instead of always calling fs.unlinkSync; ensure
you still only target entries inside tempDir and preserve the tempDir itself.

Comment on lines +627 to +639
patterns.forEach((statementRegex) => {
const regex = new RegExp(statementRegex, "g");
const matches = [...content.matchAll(regex)];
matches.forEach((match) => {
const statementMatch: StatementMatch = {
type: statementType,
sortIndex: match[1] ? match.index! + match[1].length : match.index!,
...match,
};
statements.push(statementMatch);
});
});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential ReDoS vulnerability from user-controlled regex patterns.

The regex patterns in fileType.inlineStatements come from configuration and are used directly in new RegExp(). Malicious or poorly written patterns could cause catastrophic backtracking.

Consider validating regex patterns or using a timeout mechanism. If patterns are only from trusted config files, document this assumption. Otherwise, consider using a library like safe-regex to validate patterns before use.

#!/bin/bash
# Check if there are any built-in/default fileType patterns that could indicate pattern source
rg -n "inlineStatements" --type=ts --type=js -C3
🧰 Tools
🪛 ast-grep (0.40.5)

[warning] 627-627: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(statementRegex, "g")
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)

🤖 Prompt for AI Agents
In `@src/utils.ts` around lines 627 - 639, The code constructs RegExp from
potentially user-controlled patterns (fileType.inlineStatements) using new
RegExp in the loop around patterns and can be vulnerable to ReDoS; validate each
pattern before constructing the RegExp (e.g., run patterns through a safe-regex
check or a whitelist of allowed patterns) and handle failures by
skipping/logging the offending pattern (wrap new RegExp(...) in a try/catch and
avoid executing matchAll on unsafe patterns), or document that
fileType.inlineStatements are trusted configuration only; update the logic
around patterns.forEach / new RegExp / matches handling to skip invalid/unsafe
regexes and surface an explicit error/log when a pattern is rejected so
StatementMatch creation and statements.push only run for validated regexes.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Migrates doc-detective-resolver from JavaScript to TypeScript, updates packaging to publish compiled dist/ output with dual CJS/ESM support, and adjusts CI/tests to build before executing tests/publishing.

Changes:

  • Added TypeScript build infrastructure (tsconfig.json, shared src/types.ts, dist/ output with declarations).
  • Converted core modules from .js to .ts and rewired cross-module interactions.
  • Updated package exports/scripts and CI workflow to build before running tests/publishing; tests now import from dist/.

Reviewed changes

Copilot reviewed 22 out of 25 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
tsconfig.json Adds TS compiler config to emit dist/ + declaration files.
src/utils.ts TypeScript port of utilities (file qualification, parsing, logging, spawning, etc.).
src/utils.js Removed JS implementation (replaced by TS).
src/types.ts Introduces shared public/internal TypeScript types/interfaces.
src/telem.ts TypeScript port of telemetry module.
src/sanitize.ts TypeScript port of URI/path sanitization helpers.
src/sanitize.js Removed JS implementation (replaced by TS).
src/resolve.ts TypeScript port of test resolution pipeline.
src/resolve.js Removed JS implementation (replaced by TS).
src/openapi.ts TypeScript port of OpenAPI loading/example compilation utilities.
src/openapi.js Removed JS implementation (replaced by TS).
src/index.ts Updates main entrypoint, exports, and re-exports for TS build output.
src/index.test.js Updates tests to import from compiled dist/.
src/heretto.ts TypeScript port of Heretto integration logic and APIs.
src/heretto.test.js Updates tests to import from compiled dist/.
src/heretto.integration.test.js Updates integration test to import from compiled dist/.
src/doc-detective-common.d.ts Adds local type declarations for doc-detective-common.
src/config.ts TypeScript port of config processing and defaults.
src/config.test.js Updates tests to import from compiled dist/.
src/arazzo.ts TypeScript port of Arazzo workflow translation.
scripts/createEsmWrapper.js Adds post-compile script to generate an ESM wrapper entrypoint.
package.json Updates main/types/exports + build scripts for TS compilation and dual module support.
package-lock.json Locks newly added dev dependencies (typescript, @types/*).
.github/workflows/npm-test.yaml Ensures CI builds before running tests/publishing.
Comments suppressed due to low confidence (3)

src/telem.ts:33

  • telemetryNotice now requires a log argument. This is a breaking API change compared to the previous telemetryNotice(config) shape and also inconsistent with how it’s re-exported from src/index.ts. Consider restoring the original signature (and importing/using the shared log), or make the log parameter optional with a safe default implementation.
    src/telem.ts:59
  • DOC_DETECTIVE_META is parsed with JSON.parse without error handling. If the env var contains invalid JSON, this will throw and crash normal execution. Wrap the parse in a try/catch and fall back to {} on failure.
    src/heretto.ts:718
  • getResourceDependencies intentionally stores sentinel string entries like _ditamapPath/_ditamapId alongside HerettoResourceInfo objects, but here it’s cast to Record<string, HerettoResourceInfo> and assigned to herettoConfig.resourceDependencies. This hides the mixed value types and can cause runtime/type errors for callers expecting only HerettoResourceInfo. Consider separating sentinel metadata from the mapping or widening the resourceDependencies type accordingly.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1042 to +1046
}
}
}
return specs;
}
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

outputResults is async but it doesn't await the file write; it schedules fs.writeFile and immediately continues. This can lead to callers proceeding (or the process exiting) before results are written. Consider using fs.promises.writeFile (or awaiting a Promise wrapper) and handling errors via rejection instead of throwing inside the callback.

Copilot uses AI. Check for mistakes.
Comment on lines +1229 to +1233
const result = await spawnCommand(`grep -sq "docker\\|lxc\\|kubepods" /proc/1/cgroup`);
if (result.exitCode === 0) return true;
}
return false;
}
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If both inputs are empty strings, maxLength becomes 0 and (distance / maxLength) yields NaN, so this returns "NaN". Handle the maxLength === 0 case explicitly (e.g., return "0.00").

Copilot uses AI. Check for mistakes.

const esmContent = `// ESM wrapper for CommonJS output
import cjsModule from './index.js';
export const { detectTests, resolveTests, detectAndResolveTests } = cjsModule;
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated dist/index.mjs only re-exports { detectTests, resolveTests, detectAndResolveTests }, but src/index.ts exports many more runtime symbols (e.g., setConfig, resolveDetectedTests, log, loadDescription, etc.). This will break ESM consumers (and TypeScript users relying on dist/index.d.ts) because the runtime exports won’t match the type declarations. Update the wrapper to export all intended public runtime exports from dist/index.js.

Suggested change
export const { detectTests, resolveTests, detectAndResolveTests } = cjsModule;
// Re-export all named exports from the CommonJS bundle
export * from './index.js';

Copilot uses AI. Check for mistakes.
Comment on lines +18 to 21
"postbuild": "npm run test",
"test": "mocha src/*.test.js --ignore src/*.integration.test.js",
"test:integration": "mocha src/*.integration.test.js --timeout 600000",
"test:all": "mocha src/*.test.js --timeout 600000",
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With tests now importing from dist/, npm test will fail unless a build has already run. At the same time, postbuild runs npm run test, and the workflow also runs npm test after npm run build, causing tests to run twice. Consider moving compilation to pretest (or updating the test script to build first) and removing postbuild to avoid duplicate test runs.

Copilot uses AI. Check for mistakes.
export interface MarkupPattern {
name: string;
regex: string[];
actions?: Record<string, unknown>[];
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MarkupPattern.actions is typed as Record<string, unknown>[], but the default fileTypes in src/config.ts use string actions (e.g., actions: ["click"]). Update the type to allow string actions (and any other fields you rely on like batchMatches) so downstream code doesn’t need casts and type mismatches don’t get hidden.

Suggested change
actions?: Record<string, unknown>[];
/**
* Actions associated with this markup pattern.
* Can be simple string identifiers (e.g., ["click"]) or
* structured action descriptors.
*/
actions?: (string | Record<string, unknown>)[];
/**
* When true, indicates this pattern should be applied in batch
* to accumulate multiple matches at once.
*/
batchMatches?: boolean;

Copilot uses AI. Check for mistakes.
const logMessage = `(${level.toUpperCase()})`;
console.log(logMessage);
console.log(JSON.stringify(message, null, 2));
}
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable 'stringOrObject' is of type date, object or regular expression, but it is compared to an expression of type null.

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +61
/**
* Checks if a test requires a browser driver.
* @param test - The test to check
* @returns True if the test requires a driver
*/
function isDriverRequired({ test }: { test: DetectedTest }): boolean {
let driverRequired = false;
test.steps.forEach((step) => {
// Check if test includes actions that require a driver.
driverActions.forEach((action) => {
if (typeof step[action] !== "undefined") driverRequired = true;
});
});
return driverRequired;
}

Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused function isDriverRequired.

Suggested change
/**
* Checks if a test requires a browser driver.
* @param test - The test to check
* @returns True if the test requires a driver
*/
function isDriverRequired({ test }: { test: DetectedTest }): boolean {
let driverRequired = false;
test.steps.forEach((step) => {
// Check if test includes actions that require a driver.
driverActions.forEach((action) => {
if (typeof step[action] !== "undefined") driverRequired = true;
});
});
return driverRequired;
}

Copilot uses AI. Check for mistakes.
const proxyquire = require("proxyquire");
const fs = require("fs");
const { detectTests, resolveTests, detectAndResolveTests } = require("./index");
const { detectTests, resolveTests, detectAndResolveTests } = require("../dist/index");
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable detectTests.

Suggested change
const { detectTests, resolveTests, detectAndResolveTests } = require("../dist/index");
const { resolveTests, detectAndResolveTests } = require("../dist/index");

Copilot uses AI. Check for mistakes.
const proxyquire = require("proxyquire");
const fs = require("fs");
const { detectTests, resolveTests, detectAndResolveTests } = require("./index");
const { detectTests, resolveTests, detectAndResolveTests } = require("../dist/index");
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable resolveTests.

Suggested change
const { detectTests, resolveTests, detectAndResolveTests } = require("../dist/index");
const { detectTests, detectAndResolveTests } = require("../dist/index");

Copilot uses AI. Check for mistakes.
if (testWithV2.id || testWithV2.file || testWithV2.setup || testWithV2.cleanup) {
if (!test.steps) {
test.steps = [{ action: "goTo", url: "https://doc-detective.com" } as Step];
stepsCleanup = true;
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The value assigned to stepsCleanup here is unused.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@src/utils.ts`:
- Around line 1010-1013: The code handling a test with a runShell step pushes
the test into spec.tests then hits a continue which skips validation and never
adds the spec to the specs array; fix by ensuring the spec is pushed into specs
after handling runShell (either remove the premature continue or move the logic
that validates and pushes spec into specs to execute before the continue).
Specifically locate the block that manipulates spec.tests (and checks for
runShell), remove or adjust the continue so that the subsequent spec validation
and the push to the specs collection (the specs array) always runs for specs
containing runShell tests.
- Around line 1238-1243: The function calculatePercentageDifference can divide
by zero when both inputs are empty; update it to check maxLength === 0 and
return "0.00" (or other appropriate zero-percentage string) immediately in that
case, otherwise compute percentage as before using levenshteinDistance(text1,
text2) and return percentage.toFixed(2); ensure the early-return preserves the
function's string return type and reference calculatePercentageDifference and
levenshteinDistance when locating the code.
- Around line 346-350: The code calls fs.statSync(source) without checking that
source exists, so non-existent local paths will throw; update the logic around
fs.statSync/source to first verify the path (e.g., use fs.existsSync(source)) or
wrap the fs.statSync(source) call in a try-catch, and on missing path return a
clear error or fallback instead of letting an uncaught exception propagate;
ensure isFile and isDir are only derived when stat succeeds and that any error
path produces a descriptive message referencing the source value.
- Around line 1051-1055: The outputResults function uses callback-style
fs.writeFile and can return before the write finishes; change it to use the
promise-based API and await the write so callers only proceed once the file is
persisted. Specifically, in outputResults replace the callback fs.writeFile
usage with fs.promises.writeFile (or import writeFile from fs/promises) and
await that call, allowing any errors to be thrown and propagated from the async
function.
🧹 Nitpick comments (3)
src/utils.ts (3)

683-867: Wrap switch case declarations in blocks to prevent scope leakage.

Biome flagged multiple const declarations inside switch cases that can be erroneously accessed by other cases. Wrap each case body with declarations in a block.

Example fix for the testStart case
       case "testStart":
+      {
         statementContent = statement[1] || statement[0];
         const parsedTest = parseObject({ stringifiedObject: statementContent });
         
         // Skip if parseObject returned a non-object...
         if (typeof parsedTest !== "object" || parsedTest === null) {
           break;
         }
         test = parsedTest as DetectedTest;
         // ... rest of case logic ...
         tests.push(test);
         break;
+      }

Apply similar block wrapping to cases at lines 698 (testWithV2), 722 (testWithDetectSteps), 750 (testWithDetect), 754 (markupWithActions), 855 (step), and 856-860 (validation).


1067-1079: Function is declared async but contains no asynchronous operations.

loadEnvs can be simplified to a synchronous function since it only uses synchronous fs.existsSync and require.

Suggested simplification
-export async function loadEnvs(envsFile: string): Promise<LoadEnvsResult> {
+export function loadEnvs(envsFile: string): LoadEnvsResult {

1196-1198: Empty error handler silently swallows spawn errors.

The error event handler on line 1197 does nothing, which means spawn failures (e.g., command not found on some platforms) are silently ignored. Consider logging or propagating the error.

Suggested improvement
-  runCommand.on("error", (_error) => {});
+  let spawnError: Error | null = null;
+  runCommand.on("error", (error) => {
+    spawnError = error;
+  });

Then check spawnError before returning and include it in the result if needed.

Comment on lines +346 to +350

// Check if source is a file or directory
const stat = fs.statSync(source);
const isFile = stat.isFile();
const isDir = stat.isDirectory();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing error handling for non-existent paths.

If a source path doesn't exist (not a URL, not heretto:), fs.statSync(source) will throw an uncaught exception. Consider wrapping with try-catch or checking existence first.

Suggested fix
     // Check if source is a file or directory
+    if (!fs.existsSync(source)) {
+      log(config, "warning", `Source path not found: ${source}. Skipping.`);
+      continue;
+    }
     const stat = fs.statSync(source);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Check if source is a file or directory
const stat = fs.statSync(source);
const isFile = stat.isFile();
const isDir = stat.isDirectory();
// Check if source is a file or directory
if (!fs.existsSync(source)) {
log(config, "warning", `Source path not found: ${source}. Skipping.`);
continue;
}
const stat = fs.statSync(source);
const isFile = stat.isFile();
const isDir = stat.isDirectory();
🤖 Prompt for AI Agents
In `@src/utils.ts` around lines 346 - 350, The code calls fs.statSync(source)
without checking that source exists, so non-existent local paths will throw;
update the logic around fs.statSync/source to first verify the path (e.g., use
fs.existsSync(source)) or wrap the fs.statSync(source) call in a try-catch, and
on missing path return a clear error or fallback instead of letting an uncaught
exception propagate; ensure isFile and isDir are only derived when stat succeeds
and that any error path produces a descriptive message referencing the source
value.

Comment on lines +1010 to +1013

spec.tests.push(test);
continue;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Spec with runShell step is not pushed to specs array.

After creating a test with runShell step and pushing it to spec.tests, the continue statement skips the remaining logic including the spec validation and push to specs. The spec is never added to the result.

Suggested fix
         spec.tests.push(test);
-        continue;
+        specs.push(spec);
+        continue;
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
spec.tests.push(test);
continue;
}
spec.tests.push(test);
specs.push(spec);
continue;
}
🤖 Prompt for AI Agents
In `@src/utils.ts` around lines 1010 - 1013, The code handling a test with a
runShell step pushes the test into spec.tests then hits a continue which skips
validation and never adds the spec to the specs array; fix by ensuring the spec
is pushed into specs after handling runShell (either remove the premature
continue or move the logic that validates and pushes spec into specs to execute
before the continue). Specifically locate the block that manipulates spec.tests
(and checks for runShell), remove or adjust the continue so that the subsequent
spec validation and the push to the specs collection (the specs array) always
runs for specs containing runShell tests.

Comment on lines +1051 to +1055
export async function outputResults(outputPath: string, results: unknown, config: Config): Promise<void> {
const data = JSON.stringify(results, null, 2);
fs.writeFile(outputPath, data, (err) => {
if (err) throw err;
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

fs.writeFile callback is not awaited; function may return before write completes.

The function is declared async but uses callback-style fs.writeFile without awaiting. This can cause issues if callers expect the file to be written when the function returns.

Suggested fix using fs.promises
 export async function outputResults(outputPath: string, results: unknown, config: Config): Promise<void> {
   const data = JSON.stringify(results, null, 2);
-  fs.writeFile(outputPath, data, (err) => {
-    if (err) throw err;
-  });
+  await fs.promises.writeFile(outputPath, data);
   log(config, "info", "RESULTS:");

As per coding guidelines: "Use async/await for asynchronous operations".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function outputResults(outputPath: string, results: unknown, config: Config): Promise<void> {
const data = JSON.stringify(results, null, 2);
fs.writeFile(outputPath, data, (err) => {
if (err) throw err;
});
export async function outputResults(outputPath: string, results: unknown, config: Config): Promise<void> {
const data = JSON.stringify(results, null, 2);
await fs.promises.writeFile(outputPath, data);
🤖 Prompt for AI Agents
In `@src/utils.ts` around lines 1051 - 1055, The outputResults function uses
callback-style fs.writeFile and can return before the write finishes; change it
to use the promise-based API and await the write so callers only proceed once
the file is persisted. Specifically, in outputResults replace the callback
fs.writeFile usage with fs.promises.writeFile (or import writeFile from
fs/promises) and await that call, allowing any errors to be thrown and
propagated from the async function.

Comment on lines +1238 to +1243
export function calculatePercentageDifference(text1: string, text2: string): string {
const distance = levenshteinDistance(text1, text2);
const maxLength = Math.max(text1.length, text2.length);
const percentageDiff = (distance / maxLength) * 100;
return percentageDiff.toFixed(2);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential division by zero when both strings are empty.

If both text1 and text2 are empty strings, maxLength will be 0, causing a division by zero.

Suggested fix
 export function calculatePercentageDifference(text1: string, text2: string): string {
   const distance = levenshteinDistance(text1, text2);
   const maxLength = Math.max(text1.length, text2.length);
+  if (maxLength === 0) return "0.00";
   const percentageDiff = (distance / maxLength) * 100;
   return percentageDiff.toFixed(2);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function calculatePercentageDifference(text1: string, text2: string): string {
const distance = levenshteinDistance(text1, text2);
const maxLength = Math.max(text1.length, text2.length);
const percentageDiff = (distance / maxLength) * 100;
return percentageDiff.toFixed(2);
}
export function calculatePercentageDifference(text1: string, text2: string): string {
const distance = levenshteinDistance(text1, text2);
const maxLength = Math.max(text1.length, text2.length);
if (maxLength === 0) return "0.00";
const percentageDiff = (distance / maxLength) * 100;
return percentageDiff.toFixed(2);
}
🤖 Prompt for AI Agents
In `@src/utils.ts` around lines 1238 - 1243, The function
calculatePercentageDifference can divide by zero when both inputs are empty;
update it to check maxLength === 0 and return "0.00" (or other appropriate
zero-percentage string) immediately in that case, otherwise compute percentage
as before using levenshteinDistance(text1, text2) and return
percentage.toFixed(2); ensure the early-return preserves the function's string
return type and reference calculatePercentageDifference and levenshteinDistance
when locating the code.

@jstjoe jstjoe self-requested a review January 29, 2026 21:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants