Skip to content

Modularize codebase and enhance test coverage#6

Merged
stixx merged 15 commits intomainfrom
dev
Feb 22, 2026
Merged

Modularize codebase and enhance test coverage#6
stixx merged 15 commits intomainfrom
dev

Conversation

@stixx
Copy link
Owner

@stixx stixx commented Feb 22, 2026

This PR refactors the core action logic into a modular architecture to improve maintainability, readability, and testability.

Key Changes:

  • Modularization: Split the monolithic src/action.ts into focused modules:
    • GitService: Encapsulates all Git operations.
    • LockfileParser: Handles parsing for various lockfile versions and package.json.
    • Comparer: Logic for identifying added, removed, and updated dependencies.
    • Formatter: Generates the Markdown output and comparison links.
  • Enhanced Testing: Added a comprehensive suite of unit tests for each new module, supplementing the existing functional tests.
  • Improved Link Generation: Updated npmdiff.dev links to use the correct comparison format (/package/old/new/).
  • Code Quality: Resolved all TypeScript linting and formatting issues, ensuring a clean and type-safe codebase.
  • Build: Updated the minified dist/index.js to include all architectural improvements

Summary by CodeRabbit

  • New Features

    • Action now exposes has_changes, diff, and added/removed/updated counts; option to include transitive dependencies; improved package-diff links and markdown output.
  • Bug Fixes

    • More robust handling for missing lockfiles, merge-base resolution failures, git/diff errors, and edge-case dependency diffs; more accurate upgrade detection/counting.
  • Documentation

    • README updated with revised package comparison link formats.
  • Tests

    • Expanded functional and unit tests covering parsing, comparison, formatting, git interactions, and runner scenarios.
  • Chores

    • Added repository configuration and editor settings.

@coderabbitai
Copy link

coderabbitai bot commented Feb 22, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Refactors the action into an exported NpmDiffRunner and extracts Git, lockfile parsing, comparison, and formatting into new modules; adds TypeScript types, tests, and config files, updates README links, and centralizes outputs and error/early-exit handling.

Changes

Cohort / File(s) Summary
Configuration & Docs
\.coderabbit\.yaml, \.editorconfig, README.md
Add CodeRabbit config and enable auto-review/auto-reply; set LF line endings; update README package links to use npmdiff.dev / npm pages.
Core Action
src/action.ts
Introduce exported NpmDiffRunner class with run(), getInputs(), setEmptyOutputs(); move run flow into instance methods; expose outputs has_changes, diff, added_count, removed_count, updated_count; switch to GitService and modular parsers/formatters.
Types
src/types.ts
Add PackageInfo, LockfileData, PackageChange, Changes, and ActionInputs interfaces.
Git Service
src/git.service.ts
Add GitService class with execute(), getMergeBase(), getChangedFiles(), getFileAtRevision() including input validation, ignoreErrors option, and increased maxBuffer.
Lockfile Parser
src/lockfile.parser.ts
Add parseLockfile(path, content, includeTransitive?) supporting package-lock v1/v2/v3 and optional inclusion of transitive deps; normalizes entries to PackageInfo.
Comparer
src/comparer.ts
Add comparePackages(base, head) producing Changes (added/removed/updated), handling node_modules paths, constraint vs version diffs, and deduplication.
Formatter
src/formatter.ts
Add getCompareLink(pathOrName, oldV?, newV?) and formatMarkdown(changes, includeTransitive?) to generate markdown tables and npm/npmdiff links.
Unit Tests
test/unit/...
Add/rework unit tests: comprehensive action.test.js (NpmDiffRunner with mocks) and new tests for git.service, comparer, formatter, and lockfile.parser.
Functional Tests
test/functional/action.test.js
Adjust expectations to new comparison semantics (updated_count increased; added/removed counts modified; textual descriptions updated).

Sequence Diagram(s)

sequenceDiagram
  participant Runner as NpmDiffRunner
  participant Git as GitService
  participant Parser as LockfileParser
  participant Comparer as comparePackages
  participant Formatter as formatMarkdown
  participant Core as ActionsCore

  Runner->>Git: getMergeBase(baseRef)
  alt merge-base found / fallback
    Git-->>Runner: baseRevision
  end
  Runner->>Git: getChangedFiles(baseRevision)
  Git-->>Runner: changedFiles[]
  Runner->>Git: getFileAtRevision(baseRevision, lockfilePath)
  Git-->>Runner: baseLockfileContent
  Runner->>Git: getFileAtRevision(HEAD, lockfilePath)
  Git-->>Runner: headLockfileContent
  Runner->>Parser: parseLockfile(baseLockfileContent, includeTransitive)
  Parser-->>Runner: basePackages
  Runner->>Parser: parseLockfile(headLockfileContent, includeTransitive)
  Parser-->>Runner: headPackages
  Runner->>Comparer: comparePackages(basePackages, headPackages)
  Comparer-->>Runner: Changes
  Runner->>Formatter: formatMarkdown(Changes, includeTransitive)
  Formatter-->>Runner: markdownDiff
  Runner->>Core: setOutput(has_changes, diff, added_count, removed_count, updated_count)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: modularizing the codebase into focused modules (GitService, LockfileParser, Comparer, Formatter) and adding comprehensive unit test coverage.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch dev

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

Copy link

@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

🧹 Nitpick comments (4)
src/comparer.ts (1)

31-41: Consider potential key collisions in resolvedChanges Map.

When multiple paths resolve to the same package name (e.g., node_modules/pkg and node_modules/other/node_modules/pkg), only the last occurrence is retained in the Map. This may be intentional to deduplicate, but could silently lose version information for nested dependencies with different versions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/comparer.ts` around lines 31 - 41, ResolvedChanges currently uses package
name as the Map key which causes collisions for nested packages sharing the same
name; change the data model so entries are uniquely keyed or allow multiple
entries per name (e.g., use a compound key including the package path like
`${name}::${path}` or make resolvedChanges a Map<string, Entry[]> and push each
occurrence). Update the place where entries are set (the block that calls
resolvedChanges.set(name, ...)) to include the package path in the stored object
and/or adjust the key creation, and update any consumers of resolvedChanges to
iterate arrays or split the compound key to recover name/path so nested versions
are preserved (refer to resolvedChanges, the set calls, and any readers of
resolvedChanges).
src/lockfile.parser.ts (2)

51-53: Unsafe type cast from LockfileData to PackageInfo.

The cast data as unknown as PackageInfo forces an incompatible type conversion. LockfileData has packages, dependencies, etc., while PackageInfo expects version, name, and dependency maps with string values. This works at runtime because downstream code only accesses specific properties, but it bypasses type safety.

♻️ Suggested improvement

Consider extracting only the relevant fields:

     } else if (data.dependencies || data.devDependencies || data.optionalDependencies) {
       core.debug('Detected lockfile v1 (dependencies found)');
-      packages[''] = data as unknown as PackageInfo;
+      packages[''] = {
+        dependencies: data.dependencies as Record<string, string> | undefined,
+        devDependencies: data.devDependencies as Record<string, string> | undefined,
+        optionalDependencies: data.optionalDependencies as Record<string, string> | undefined,
+      };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lockfile.parser.ts` around lines 51 - 53, The branch that handles
lockfile v1 currently does an unsafe cast "data as unknown as PackageInfo";
instead, construct a proper PackageInfo for the '' key by extracting the actual
fields you need (e.g., map data.version/name if present and transform
data.dependencies, data.devDependencies, data.optionalDependencies into
string-to-string maps) rather than forcing the type cast; update the else-if
handling that references data and packages[''] to build and assign a PackageInfo
object (preserving keys like version, name and normalized dependency maps) so
type safety is restored for PackageInfo consumers.

75-77: Silent error handling hinders debugging.

Returning an empty object on parse failure without logging makes troubleshooting lockfile issues difficult. Consider logging the error before returning.

♻️ Suggested improvement
-  } catch {
+  } catch (err: unknown) {
+    if (err instanceof Error) {
+      core.debug(`Failed to parse lockfile at ${path}: ${err.message}`);
+    }
     return {};
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lockfile.parser.ts` around lines 75 - 77, The catch block in the lockfile
parser that currently does "catch { return {} }" should capture the error (e.g.,
"catch (err)") and log it before returning so failures are visible; update the
catch to log the error (using the project's logger or console.error with a clear
message like "Failed to parse lockfile:" plus err) and then return the empty
object to preserve behavior.
test/functional/action.test.js (1)

9-15: Use unique temp dirs to avoid parallel test collisions.

Line 10 uses a fixed directory name. If tests run in parallel or cleanup fails, this can cause flakes. Prefer fs.mkdtempSync with os.tmpdir() (apply similarly to the second test’s temp dir).

♻️ Suggested refactor
-const path = require('path');
+const path = require('path');
+const os = require('os');

 ...
-const tempDir = path.join(root, 'temp-functional-test-pkgjson');
+const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-diff-pkgjson-'));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/functional/action.test.js` around lines 9 - 15, Replace the fixed temp
directory creation with unique OS temp dirs: instead of using the hardcoded
tempDir variable and fs.mkdirSync, use fs.mkdtempSync(path.join(os.tmpdir(),
'temp-functional-test-pkgjson-')) to create a unique temp directory (update any
variable names accordingly, e.g., tempDir). Apply the same change to the second
test's temp directory creation so both tests use os.tmpdir() + fs.mkdtempSync
and avoid collisions; ensure existing fs.existsSync/fs.rmSync cleanup logic
targets the newly created unique temp path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/formatter.ts`:
- Around line 10-16: The npmdiff and npmjs links use the raw package name so
scoped packages like `@types/node` break the URL; update the link generation in
the formatter to URL-encode the package name (use encodeURIComponent or
equivalent) when building the npmdiff link
(`https://npmdiff.dev/${name}/${v1}/${v2}/`) and the npm details link
(`https://www.npmjs.com/package/${name}?activeTab=versions`) so the '/' in
scoped names becomes %2F; keep the existing v1/v2 normalization but substitute
encodedName where `name` is used.

In `@src/git.service.ts`:
- Around line 45-47: The getFileAtRevision method interpolates the
user-controlled path into a shell command, creating a shell injection risk;
update getFileAtRevision to validate or sanitize the path parameter (or switch
to a safe exec API that accepts an args array) before calling this.execute:
ensure the path contains only allowed characters (e.g., alphanumerics, /, -, _,
., and no shell metacharacters like ; & | $ ` > < *) or perform proper escaping,
and reject/throw for invalid values so only safe paths reach
getFileAtRevision(revision: string, path: string) and the underlying
this.execute call.
- Around line 27-35: The getMergeBase method interpolates baseRef directly into
shell commands (in getMergeBase and the execute call), creating a
command-injection risk; validate baseRef against a restrictive pattern (e.g.
allow only letters, digits, underscore, dash, dot and slash such as
/^[\w\-./]+$/) and throw an error when it doesn't match, or refactor execute to
run git with argument array (no shell interpolation) and call it like
execute(['merge-base', `origin/${baseRef}`, 'HEAD']) so that baseRef is never
passed through a shell; implement the validation or switch to argument-based
execution in the getMergeBase function and/or the execute helper to mitigate
injection.

In `@test/unit/action.test.js`:
- Around line 50-66: The test overwrites GitService.prototype.getMergeBase,
GitService.prototype.getChangedFiles, GitService.prototype.getFileAtRevision and
module functions lockfileParser.parseLockfile, comparer.comparePackages,
formatter.formatMarkdown in beforeEach but only restores fs/core in afterEach;
save the original references for each of those functions (e.g.,
originalGetMergeBase, originalGetChangedFiles, originalGetFileAtRevision,
originalParseLockfile, originalComparePackages, originalFormatMarkdown) before
mocking and restore them in afterEach, or alternatively call
jest.restoreAllMocks if using jest spies, ensuring
GitService.prototype.getMergeBase/getChangedFiles/getFileAtRevision and
lockfileParser.parseLockfile, comparer.comparePackages, formatter.formatMarkdown
are returned to their originals to avoid leakage into other tests.

---

Nitpick comments:
In `@src/comparer.ts`:
- Around line 31-41: ResolvedChanges currently uses package name as the Map key
which causes collisions for nested packages sharing the same name; change the
data model so entries are uniquely keyed or allow multiple entries per name
(e.g., use a compound key including the package path like `${name}::${path}` or
make resolvedChanges a Map<string, Entry[]> and push each occurrence). Update
the place where entries are set (the block that calls resolvedChanges.set(name,
...)) to include the package path in the stored object and/or adjust the key
creation, and update any consumers of resolvedChanges to iterate arrays or split
the compound key to recover name/path so nested versions are preserved (refer to
resolvedChanges, the set calls, and any readers of resolvedChanges).

In `@src/lockfile.parser.ts`:
- Around line 51-53: The branch that handles lockfile v1 currently does an
unsafe cast "data as unknown as PackageInfo"; instead, construct a proper
PackageInfo for the '' key by extracting the actual fields you need (e.g., map
data.version/name if present and transform data.dependencies,
data.devDependencies, data.optionalDependencies into string-to-string maps)
rather than forcing the type cast; update the else-if handling that references
data and packages[''] to build and assign a PackageInfo object (preserving keys
like version, name and normalized dependency maps) so type safety is restored
for PackageInfo consumers.
- Around line 75-77: The catch block in the lockfile parser that currently does
"catch { return {} }" should capture the error (e.g., "catch (err)") and log it
before returning so failures are visible; update the catch to log the error
(using the project's logger or console.error with a clear message like "Failed
to parse lockfile:" plus err) and then return the empty object to preserve
behavior.

In `@test/functional/action.test.js`:
- Around line 9-15: Replace the fixed temp directory creation with unique OS
temp dirs: instead of using the hardcoded tempDir variable and fs.mkdirSync, use
fs.mkdtempSync(path.join(os.tmpdir(), 'temp-functional-test-pkgjson-')) to
create a unique temp directory (update any variable names accordingly, e.g.,
tempDir). Apply the same change to the second test's temp directory creation so
both tests use os.tmpdir() + fs.mkdtempSync and avoid collisions; ensure
existing fs.existsSync/fs.rmSync cleanup logic targets the newly created unique
temp path.

Copy link

@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: 2

🧹 Nitpick comments (5)
test/unit/git.service.test.js (1)

1-23: Consider adding tests for the remaining GitService methods.

The tests for execute() cover the core scenarios well. However, the GitService class has three additional public methods (getMergeBase, getChangedFiles, getFileAtRevision) that lack unit test coverage:

  • getMergeBase: fallback logic from origin to local ref
  • getChangedFiles: parsing and filtering of diff output
  • getFileAtRevision: ignoreErrors behavior for missing files

As per coding guidelines: test/**: "Verify that the tests are comprehensive and cover the changes."

Would you like me to generate unit tests for the remaining methods?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/git.service.test.js` around lines 1 - 23, Add unit tests covering
GitService.getMergeBase, GitService.getChangedFiles, and
GitService.getFileAtRevision: for getMergeBase, mock git.execute to return an
origin ref first and verify fallback to local ref when origin call fails; for
getChangedFiles, mock git.execute to return a diff --name-only string including
additions, deletions and subpaths and assert parsing/filtering produces the
expected array (including path filters and deduping); for getFileAtRevision,
test successful retrieval by mocking git.execute to return file contents and
test ignoreErrors=true returns empty string when git.execute errors while
ignoreErrors=false throws; use the existing test harness pattern (instantiate
new GitService()) and stub/spy on execute to control outputs.
src/lockfile.parser.ts (1)

75-77: Consider logging parse errors before returning empty object.

Silent error swallowing makes debugging difficult when lockfile parsing fails unexpectedly. Consider adding a debug log to help diagnose issues.

🔧 Suggested improvement
-  } catch {
+  } catch (err) {
+    core.debug(`Failed to parse lockfile at ${path}: ${err instanceof Error ? err.message : String(err)}`);
     return {};
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lockfile.parser.ts` around lines 75 - 77, The catch block currently
swallows parse errors and returns {} (the try/catch around lockfile parsing) —
change the catch to capture the error (e.g., catch (err)) and log the error
before returning, using the module’s existing logger (e.g., logger.debug or
processLogger.debug) or console.debug if no logger is available; keep the
existing return {} behavior after logging to preserve current flow.
test/unit/formatter.test.js (1)

6-33: Add a scoped-package link test.

Once link encoding is fixed, add a test to assert correct encoding for @scope/pkg.

✅ Suggested test
+test('getCompareLink encodes scoped package names', () => {
+  const link = getCompareLink('@scope/pkg', '1.0.0', '1.1.0');
+  assert.strictEqual(link, '[Compare](https://npmdiff.dev/%40scope%2Fpkg/1.0.0/1.1.0/)');
+});

As per coding guidelines, test/**: Verify that the tests are comprehensive and cover the changes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/formatter.test.js` around lines 6 - 33, Add a unit test that
verifies link encoding for scoped packages (e.g. "@scope/pkg") using
getCompareLink and formatMarkdown: call getCompareLink('@scope/pkg', '1.0.0',
'1.1.0') and assert the returned Compare URL encodes the slash (or uses the
expected encoded form), and add a case in the formatMarkdown test data with
added/updated/removed entries for '@scope/pkg' to assert the generated table
rows contain the correctly encoded npmjs/npmdiff links; update or add assertions
similar to existing tests referencing getCompareLink and formatMarkdown to
ensure scoped-package links are encoded properly.
test/unit/lockfile.parser.test.js (1)

6-98: Add coverage for nested transitive filtering and parse errors.

The parser explicitly drops nested .../node_modules/.../node_modules/... entries and returns {} on malformed JSON, but those branches aren't exercised here. Adding tests will guard those behaviors from regressions.

✅ Suggested tests
+test('parseLockfile excludes nested transitive deps', () => {
+  const content = JSON.stringify({
+    packages: {
+      '': { dependencies: { direct: '1.0.0' } },
+      'node_modules/direct': { version: '1.0.0' },
+      'node_modules/direct/node_modules/transitive': { version: '2.0.0' },
+    },
+  });
+
+  const packages = parseLockfile('package-lock.json', content);
+  assert.ok(packages['node_modules/direct']);
+  assert.strictEqual(packages['node_modules/direct/node_modules/transitive'], undefined);
+});
+
+test('parseLockfile returns empty on invalid JSON', () => {
+  const packages = parseLockfile('package-lock.json', '{');
+  assert.deepStrictEqual(packages, {});
+});

As per coding guidelines, test/**: Verify that the tests are comprehensive and cover the changes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/lockfile.parser.test.js` around lines 6 - 98, Add two unit tests to
test/unit/lockfile.parser.test.js targeting parseLockfile: (1) a test that
includes nested paths like "node_modules/pkg/node_modules/nested" in a
package-lock v3 "packages" object and asserts those nested entries are dropped
(not returned) to exercise the explicit filtering of nested node_modules; (2) a
test that passes malformed JSON (e.g. an invalid string) to parseLockfile and
asserts it returns an empty object {} to cover the error-handling branch. Use
the same test harness/assert style as the existing tests and reference
parseLockfile in both tests.
test/unit/comparer.test.js (1)

32-74: Add a test that locks in nested node_modules behavior.

To prevent regressions around nested transitive dependencies, add a case where the same package exists in both top-level and nested paths and only the nested one changes.

✅ Suggested test
+test('comparePackages keeps nested node_modules paths distinct', () => {
+  const base = {
+    'node_modules/dep': { version: '1.0.0' },
+    'node_modules/a/node_modules/dep': { version: '1.0.0' },
+  };
+  const head = {
+    'node_modules/dep': { version: '1.0.0' },
+    'node_modules/a/node_modules/dep': { version: '1.1.0' },
+  };
+
+  const changes = comparePackages(base, head);
+  assert.ok(changes.updated.some((c) => c.name === 'a/node_modules/dep'));
+});

As per coding guidelines, test/**: Verify that the tests are comprehensive and cover the changes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/comparer.test.js` around lines 32 - 74, Add a new unit test in
comparer.test.js that exercises comparePackages with a package present both at
top-level ('': dependencies) and in a nested path ('node_modules/pkg' and
'node_modules/some-module/node_modules/pkg'), where only the nested package
version or constraint changes; call comparePackages and assert that the reported
update refers only to the nested package (identify by path or by distinguishing
properties your comparePackages uses), and that the top-level package is not
reported as changed; reference the comparePackages function and the test file to
locate where to add this case.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/comparer.ts`:
- Around line 12-75: The current logic collapses nested node_modules by using
the stripped `name` (last node_modules suffix) as the map key (see
resolvedChanges, `name = key.replace(...)`) which merges distinct paths like
`node_modules/a/node_modules/dep` with `node_modules/dep`; change to use the
full `key` as the Map key (e.g., `resolvedChanges.set(key, ...)`) and introduce
a separate displayName (compute from `key` but do not drop nested segments) for
user-facing output so nested packages remain distinct; update all lookups and
dedup checks (e.g., `resolvedChanges.has(...)`, any checks that used `depName`
or `name`) to use the full-path key while preserving existing `name`/displayName
fields for presentation, and ensure the constraint-change logic that checks
`resolvedChanges.has(depName)` uses the corresponding full-path keys or a
mapping from dependency names to full paths.

In `@src/lockfile.parser.ts`:
- Around line 51-62: The root issue is that the code assigns the whole
LockfileData object to packages[''] by casting data as PackageInfo; instead,
construct and assign a proper PackageInfo-shaped object for the root package
(packages['']) rather than casting the whole lockfile, and normalize each v1
dependency entry when populating packages[name] from v1Deps so they conform to
the PackageInfo structure (e.g., ensure fields like version, name, and
dependencies are set or derived). Update the logic around packages[''], v1Deps
and the loop that assigns packages[name] to create/validate PackageInfo objects
instead of blindly copying LockfileData or raw dependency values.

---

Duplicate comments:
In `@src/formatter.ts`:
- Around line 3-16: getCompareLink builds URLs using the raw package name
(stored in the local variable name) and raw sanitized versions v1/v2, which
breaks for scoped packages containing "/" — fix by URL-encoding the path
components: pass name through encodeURIComponent when interpolating into both
the compare URL and the npm details URL, and also encode v1 and v2 (or at least
ensure they are safe) before concatenation; update getCompareLink to use
encodeURIComponent(name) and encodeURIComponent(v1)/encodeURIComponent(v2) when
constructing the links.

In `@src/git.service.ts`:
- Around line 27-35: The getMergeBase method interpolates baseRef into a shell
command, creating a command-injection risk; validate baseRef against the safe
pattern (/^[\w\-.\/]+$/) before calling this.execute and throw or return an
explicit error when it fails validation, then use the validated value in the
existing calls to this.execute (both the origin and local fallback branches) to
ensure only safe ref strings are passed to git merge-base.
- Around line 45-47: getFileAtRevision currently interpolates the
user-controlled path into a shell string, allowing command injection; change it
to stop using shell interpolation and instead call the underlying executor with
a safe argument list or sanitized path (e.g., use execute with an args array or
a child_process.execFile/spawn style call), and/or validate/normalize the path
to reject traversal or unsafe characters; update the getFileAtRevision
implementation (and adjust the execute helper if needed) so it passes revision
and path as separate, non-interpolated arguments to git instead of embedding
them into a single shell string.

In `@test/unit/action.test.js`:
- Around line 12-66: Save the original references to the mocked functions before
overwriting them and restore them in afterEach: capture
GitService.prototype.getMergeBase, GitService.prototype.getChangedFiles,
GitService.prototype.getFileAtRevision and the module exports
lockfileParser.parseLockfile, comparer.comparePackages, formatter.formatMarkdown
in variables (e.g., originalGetMergeBase, originalGetChangedFiles,
originalGetFileAtRevision, originalParseLockfile, originalComparePackages,
originalFormatMarkdown) and then reassign those originals back in afterEach so
the tests do not leak mocked implementations.

---

Nitpick comments:
In `@src/lockfile.parser.ts`:
- Around line 75-77: The catch block currently swallows parse errors and returns
{} (the try/catch around lockfile parsing) — change the catch to capture the
error (e.g., catch (err)) and log the error before returning, using the module’s
existing logger (e.g., logger.debug or processLogger.debug) or console.debug if
no logger is available; keep the existing return {} behavior after logging to
preserve current flow.

In `@test/unit/comparer.test.js`:
- Around line 32-74: Add a new unit test in comparer.test.js that exercises
comparePackages with a package present both at top-level ('': dependencies) and
in a nested path ('node_modules/pkg' and
'node_modules/some-module/node_modules/pkg'), where only the nested package
version or constraint changes; call comparePackages and assert that the reported
update refers only to the nested package (identify by path or by distinguishing
properties your comparePackages uses), and that the top-level package is not
reported as changed; reference the comparePackages function and the test file to
locate where to add this case.

In `@test/unit/formatter.test.js`:
- Around line 6-33: Add a unit test that verifies link encoding for scoped
packages (e.g. "@scope/pkg") using getCompareLink and formatMarkdown: call
getCompareLink('@scope/pkg', '1.0.0', '1.1.0') and assert the returned Compare
URL encodes the slash (or uses the expected encoded form), and add a case in the
formatMarkdown test data with added/updated/removed entries for '@scope/pkg' to
assert the generated table rows contain the correctly encoded npmjs/npmdiff
links; update or add assertions similar to existing tests referencing
getCompareLink and formatMarkdown to ensure scoped-package links are encoded
properly.

In `@test/unit/git.service.test.js`:
- Around line 1-23: Add unit tests covering GitService.getMergeBase,
GitService.getChangedFiles, and GitService.getFileAtRevision: for getMergeBase,
mock git.execute to return an origin ref first and verify fallback to local ref
when origin call fails; for getChangedFiles, mock git.execute to return a diff
--name-only string including additions, deletions and subpaths and assert
parsing/filtering produces the expected array (including path filters and
deduping); for getFileAtRevision, test successful retrieval by mocking
git.execute to return file contents and test ignoreErrors=true returns empty
string when git.execute errors while ignoreErrors=false throws; use the existing
test harness pattern (instantiate new GitService()) and stub/spy on execute to
control outputs.

In `@test/unit/lockfile.parser.test.js`:
- Around line 6-98: Add two unit tests to test/unit/lockfile.parser.test.js
targeting parseLockfile: (1) a test that includes nested paths like
"node_modules/pkg/node_modules/nested" in a package-lock v3 "packages" object
and asserts those nested entries are dropped (not returned) to exercise the
explicit filtering of nested node_modules; (2) a test that passes malformed JSON
(e.g. an invalid string) to parseLockfile and asserts it returns an empty object
{} to cover the error-handling branch. Use the same test harness/assert style as
the existing tests and reference parseLockfile in both tests.

Copy link

@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: 2

🧹 Nitpick comments (2)
src/git.service.ts (1)

10-10: Shell escaping may be incomplete for certain metacharacters.

The current escaping (arg.replace(/"/g, '\\"')) only handles double quotes but doesn't escape $, backticks, or backslashes, which can still be interpreted inside double-quoted strings. Since getMergeBase, getFileAtRevision, and their callers validate inputs, this is mitigated for those paths, but any future direct calls to execute() with untrusted input could be vulnerable.

Consider using a more complete escaping approach or switching to execFileSync with an array of arguments (no shell) for stronger guarantees.

♻️ Optional: Use execFileSync to avoid shell interpolation entirely
-import { execSync, StdioOptions } from 'child_process';
+import { execFileSync, StdioOptions } from 'child_process';
 import * as core from '@actions/core';
 
 export class GitService {
   private readonly maxBuffer = 50 * 1024 * 1024;
 
   execute(args: string[], options: { stdio?: StdioOptions; ignoreErrors?: boolean } = {}): string {
     core.debug(`Executing command: git ${args.join(' ')}`);
     try {
-      return execSync(`git ${args.map((arg) => `"${arg.replace(/"/g, '\\"')}"`).join(' ')}`, {
+      return execFileSync('git', args, {
         maxBuffer: this.maxBuffer,
         stdio: options.stdio || ['pipe', 'pipe', 'ignore'],
       })
         .toString()
         .trim();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/git.service.ts` at line 10, The current execute() call builds a shell
command using execSync with ad-hoc escaping (arg.replace(...)) which misses
metacharacters like $, backticks, and backslashes; update execute() to avoid
shell interpolation by switching to execFileSync (or spawnSync) and pass the git
binary and the args array directly, or implement a robust escaping routine that
covers $, backticks, backslashes, and quotes; update callers such as
getMergeBase and getFileAtRevision to continue passing raw args (no quoting) so
execute() can safely forward them as an argument array to
execFileSync/child_process.spawnSync.
test/unit/git.service.test.js (1)

1-66: Missing test coverage for getChangedFiles.

The test file covers execute, getMergeBase, and getFileAtRevision, but there's no test for getChangedFiles. Consider adding tests to verify:

  • Correct parsing of diff output into file list
  • Handling of empty diff output
  • Filtering of empty lines
📝 Example test to add
test('GitService.getChangedFiles returns array of changed files', () => {
  const git = new GitService();
  // This test would require a known commit range in the test repo
  // or mocking execute - consider integration test approach
  const files = git.getChangedFiles('HEAD~1');
  assert.ok(Array.isArray(files));
});

As per coding guidelines, tests should be comprehensive and cover the changes. The getChangedFiles method is part of the new GitService class but lacks dedicated test coverage.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/git.service.test.js` around lines 1 - 66, Add unit tests for
GitService.getChangedFiles: create tests that instantiate GitService and mock
its execute method to return representative git diff output (e.g., lines with
file paths separated by newlines, empty lines, and no output) and then assert
getChangedFiles returns an array of file paths, returns an empty array for empty
diff output, and filters out any empty lines; reference the GitService class and
the getChangedFiles and execute methods when implementing these tests and name
them clearly (e.g., "GitService.getChangedFiles returns array of changed files",
"GitService.getChangedFiles returns empty array for no changes",
"GitService.getChangedFiles filters empty lines").
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/git.service.ts`:
- Around line 49-54: The getFileAtRevision method validates path but not
revision, which allows injection into the git "show" argument; add the same
sanity check for revision (e.g., ensure it matches /^[\w\-./]+$/ and does not
contain ':' or other unsafe chars) and throw an Error with a clear message if
invalid, then only call this.execute(['show', `${revision}:${path}`], {
ignoreErrors: true }) after both revision and path are validated.
- Around line 41-47: getChangedFiles interpolates the user-controlled
baseRevision into a git command without validation; update getChangedFiles to
validate or sanitize baseRevision the same way getMergeBase and
getFileAtRevision do (reuse their validation helper or add the same checks)
before calling this.execute(['diff', '--name-only', baseRevision, 'HEAD']); if
validation fails throw an error or return an empty array to avoid passing
untrusted input to the git command.

---

Nitpick comments:
In `@src/git.service.ts`:
- Line 10: The current execute() call builds a shell command using execSync with
ad-hoc escaping (arg.replace(...)) which misses metacharacters like $,
backticks, and backslashes; update execute() to avoid shell interpolation by
switching to execFileSync (or spawnSync) and pass the git binary and the args
array directly, or implement a robust escaping routine that covers $, backticks,
backslashes, and quotes; update callers such as getMergeBase and
getFileAtRevision to continue passing raw args (no quoting) so execute() can
safely forward them as an argument array to
execFileSync/child_process.spawnSync.

In `@test/unit/git.service.test.js`:
- Around line 1-66: Add unit tests for GitService.getChangedFiles: create tests
that instantiate GitService and mock its execute method to return representative
git diff output (e.g., lines with file paths separated by newlines, empty lines,
and no output) and then assert getChangedFiles returns an array of file paths,
returns an empty array for empty diff output, and filters out any empty lines;
reference the GitService class and the getChangedFiles and execute methods when
implementing these tests and name them clearly (e.g.,
"GitService.getChangedFiles returns array of changed files",
"GitService.getChangedFiles returns empty array for no changes",
"GitService.getChangedFiles filters empty lines").

Copy link

@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: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/comparer.ts`:
- Around line 85-90: The current suppression logic uses hasResolvedChange =
(depNameToPaths.get(depName) || []).some(path => resolvedChanges.has(path))
which includes nested node_modules paths and lets transitive updates hide direct
dependency constraint changes; change this to only consider
non-nested/direct/workspace paths when computing hasResolvedChange (e.g., filter
depNameToPaths.get(depName) to exclude nested paths like those containing
multiple "node_modules" segments or use a helper isDirectPath(path) that only
returns true for top-level/workspace locations) so the if (baseConstraint !==
headConstraint && !hasResolvedChange) check only gets suppressed by
direct/workspace resolvedChanges, not transitive ones.

In `@src/lockfile.parser.ts`:
- Around line 54-71: The root PackageInfo construction and v1 dependency
handling are incorrect: set packages[''].name and .version from top-level
data.name and data.version (not data.packages['']) and normalize all v1
dependency constraints to strings so comparePackages sees plain version strings;
when iterating v1Deps (the v1Deps object and loop that assigns packages[name] =
info), replace object assignments with a PackageInfo-like entry where
dependency/version fields use info.version (or String(info) if needed) so
packages[name] becomes a string constraint (or contains a string .version)
rather than an object.

In `@test/unit/comparer.test.js`:
- Around line 71-92: Add a new unit test for comparePackages that simulates a
transitive package update inside a nested node_modules (e.g.,
'node_modules/other/node_modules/pkg' version changes) while the root package
constraint for 'pkg' (in the '' dependencies) also changes; assert that
comparePackages returns both the transitive update (updated entry for 'pkg' with
oldVersion/newVersion) and a separate reported constraint change for the root
dependency constraint, using the existing test pattern (see comparer.test.js
test that uses comparePackages and checks changes.updated) to follow naming and
assertion style.

In `@test/unit/git.service.test.js`:
- Around line 12-19: Update the test "GitService.execute handles arguments with
spaces" so it actually passes an argument containing spaces to exercise quoting
logic: call GitService.execute with the -c form and a value that includes spaces
(e.g., git.execute(['-c', 'user.name=Alice Smith', 'version'])) and keep the
assertion that output matches /git version/; this verifies GitService.execute
handles arguments with embedded spaces.

In `@test/unit/lockfile.parser.test.js`:
- Around line 46-59: The test assumes v1 root dependencies remain objects with a
.version field, but parseLockfile normalizes v1 root dependencies to string
version constraints; update the assertions in test('parseLockfile handles
lockfile v1 dependencies') to expect the string '1.1.0' for both packages['pkg']
and packages[''].dependencies.pkg (remove the .version access) so the test
reflects the normalized output from parseLockfile.

Copy link

@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.

🧹 Nitpick comments (3)
test/unit/git.service.test.js (2)

89-106: Consider adding happy path tests for getMergeBase, getChangedFiles, and getFileAtRevision.

The current tests cover input validation (negative cases) thoroughly, but there are no tests verifying correct behavior with valid inputs:

  • getMergeBase: No test for successful execution or the fallback behavior (tries origin/${baseRef} first, then falls back to local ref)
  • getChangedFiles: No test verifying it returns a properly split, trimmed, and filtered array of filenames
  • getFileAtRevision: No test verifying it returns file content or handles missing files gracefully (returns '')

As per coding guidelines, "test/**: Verify that the tests are comprehensive and cover the changes."

Would you like me to generate additional test cases for these happy paths?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/git.service.test.js` around lines 89 - 106, Add "happy path" unit
tests for getMergeBase, getChangedFiles, and getFileAtRevision: for getMergeBase
create tests that mock the underlying git calls to succeed when resolving
origin/${baseRef} and another test where origin/${baseRef} fails and the local
ref is used (verify returned merge base value and that fallback was attempted);
for getChangedFiles mock the git diff output and assert the method returns a
trimmed, split array with empty lines filtered and no surrounding whitespace
(include cases with trailing/leading spaces and blank lines); for
getFileAtRevision mock the git show/cat-file output to assert it returns file
content and another test where the file is missing to assert it returns an empty
string. Reference the GitService class methods getMergeBase, getChangedFiles,
and getFileAtRevision and use the same stubbing/mocking approach used elsewhere
in tests (e.g., stubbing child_process exec or the GitService helper that
executes git).

4-4: Inconsistent import: remove .ts extension.

Other test files (e.g., test/unit/action.test.js line 6) import the same module without the .ts extension:

const { GitService } = require('../../src/git.service');

Using the explicit .ts extension here is inconsistent and may fail depending on the test runner configuration.

🔧 Suggested fix
-const { GitService } = require('../../src/git.service.ts');
+const { GitService } = require('../../src/git.service');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/git.service.test.js` at line 4, The test imports GitService with an
explicit ".ts" extension which is inconsistent and may break some runners; in
the test that requires GitService, replace the require call that references
"git.service.ts" with the same require without the ".ts" extension so it uses
"git.service" (i.e., update the require for GitService to remove the ".ts"
suffix) to match other tests and ensure consistent module resolution.
src/lockfile.parser.ts (1)

89-91: Consider logging parse errors for debugging.

The empty catch silently returns an empty object, which is resilient but could hide issues during development or debugging.

💡 Optional: add debug logging
-  } catch {
+  } catch (error) {
+    core.debug(`Failed to parse lockfile ${path}: ${error}`);
     return {};
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lockfile.parser.ts` around lines 89 - 91, The catch block in
src/lockfile.parser.ts currently swallows errors and returns {} — update it to
capture the exception (e.g., catch (err)) and emit a debug/error log with
context (e.g., "Failed to parse lockfile") before returning {}; use the project
logger if available (or console.debug/console.error) so the parse failure is
visible while preserving the fallback return {}.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/lockfile.parser.ts`:
- Around line 54-61: The root PackageInfo construction assigns
data.dependencies/devDependencies/optionalDependencies directly but LockfileData
v1 may have values as PackageInfo objects, not strings; normalize these maps
before assigning to packages[''] so each dependency value is a string
version/specifier (e.g., map PackageInfo|string -> string by extracting .version
or appropriate field), then assign the normalized records to packages[''];
update the code paths that feed comparePackages to ensure it receives string
constraints (references: packages[''] and comparePackages, and the LockfileData
type).

In `@test/unit/git.service.test.js`:
- Around line 12-17: The test currently passes an argument with spaces to
GitService.execute but then runs a command that ignores the config, so it
doesn't verify quoting; update the test to run a git subcommand that echoes the
configured value (e.g., replace the 'version' argument with a command like
'config' and key 'user.name') so the call to GitService.execute(['-c',
'user.name=Alice Smith', 'config', 'user.name']) returns the configured string
and assert that the output equals or contains "Alice Smith"; this will exercise
GitService.execute's quoting logic and reference the GitService.execute method
to locate the test change.

---

Nitpick comments:
In `@src/lockfile.parser.ts`:
- Around line 89-91: The catch block in src/lockfile.parser.ts currently
swallows errors and returns {} — update it to capture the exception (e.g., catch
(err)) and emit a debug/error log with context (e.g., "Failed to parse
lockfile") before returning {}; use the project logger if available (or
console.debug/console.error) so the parse failure is visible while preserving
the fallback return {}.

In `@test/unit/git.service.test.js`:
- Around line 89-106: Add "happy path" unit tests for getMergeBase,
getChangedFiles, and getFileAtRevision: for getMergeBase create tests that mock
the underlying git calls to succeed when resolving origin/${baseRef} and another
test where origin/${baseRef} fails and the local ref is used (verify returned
merge base value and that fallback was attempted); for getChangedFiles mock the
git diff output and assert the method returns a trimmed, split array with empty
lines filtered and no surrounding whitespace (include cases with
trailing/leading spaces and blank lines); for getFileAtRevision mock the git
show/cat-file output to assert it returns file content and another test where
the file is missing to assert it returns an empty string. Reference the
GitService class methods getMergeBase, getChangedFiles, and getFileAtRevision
and use the same stubbing/mocking approach used elsewhere in tests (e.g.,
stubbing child_process exec or the GitService helper that executes git).
- Line 4: The test imports GitService with an explicit ".ts" extension which is
inconsistent and may break some runners; in the test that requires GitService,
replace the require call that references "git.service.ts" with the same require
without the ".ts" extension so it uses "git.service" (i.e., update the require
for GitService to remove the ".ts" suffix) to match other tests and ensure
consistent module resolution.

Copy link

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lockfile.parser.ts`:
- Around line 10-21: The normalizeV1Deps function misclassifies a PackageInfo
with an empty version string because the current truthy check on (info as
PackageInfo).version fails and the code casts the object to string producing
"[object Object]"; update the check inside normalizeV1Deps to detect the
presence of a version key regardless of its truthiness (e.g., use 'version' in
info or typeof (info as PackageInfo).version === 'string' or
hasOwnProperty('version')) and then assign normalized[name] = (info as
PackageInfo).version as string; otherwise keep the existing fallback that treats
info as a string.

@stixx stixx merged commit 304dc49 into main Feb 22, 2026
2 checks passed
@stixx stixx deleted the dev branch February 22, 2026 11:27
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.

1 participant