Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/smart-dependency-subjects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@razakadam74/gitmsg': minor
---

Generate specific commit subjects for unambiguous npm dependency changes.
4 changes: 3 additions & 1 deletion docs/heuristics.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,14 @@ Operates on the commit type plus the `SymbolDelta` from language extractors plus
| `test` | (depends on whether the file names share a subject) — see below | `add tests` / `update tests` |
| `ci` | `update <name> workflow` | `update CI configuration` |
| `build` | `update <name> build config` | `update build configuration` |
| `chore` (deps) | `update dependencies` | `update dependencies` |
| `chore` (deps) | Specific npm change when unambiguous; otherwise `update dependencies` | `update dependencies` |
| `chore` (other) | `misc maintenance` | `misc maintenance` |
| `style` | `apply formatting` | `apply formatting` |

For `test`: the base names are stripped of `.test`/`.spec` suffixes and `test_`/`test-` prefixes. If all paths reduce to the same name, the subject is `add tests for <name>`; otherwise it falls back to `add tests` (if any file is new) or `update tests`.

For npm dependency changes, `package.json` is the source of truth and generated lockfile entries are ignored. A single unambiguous change produces `pin <name> to <version>` for a new override, `bump <name> from <old> to <new>` for a version change, `add <name> <version>`, or `remove <name>`. Multiple unambiguous package changes produce `update <name> and <name>` with compact list wording for larger groups. Root package metadata, scripts, lockfile-only changes, and multiple manifests fall back to `update dependencies`.

### Symbol-driven phrasing (`feat` / `fix` / `refactor` / `perf`)

When language extractors produced a symbol delta:
Expand Down
114 changes: 114 additions & 0 deletions src/analyze/dependencies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { FileChange } from '../types.js';

type DependencyAction = 'add' | 'remove' | 'update' | 'pin';

interface DependencyChange {
name: string;
action: DependencyAction;
from?: string;
to?: string;
}

const PACKAGE_JSON_PATTERN = /(^|\/)package\.json$/;
const ENTRY_PATTERN = /^\s*"([^"]+)"\s*:\s*"([^"]+)"\s*,?\s*$/;
const OVERRIDES_PATTERN = /^\s*"overrides"\s*:\s*\{/;
// A dependency value is version-shaped; this gate keeps script and config lines
// (e.g. "test": "vitest run") from masquerading as dependency entries.
const VERSION_PATTERN = /^(?:[~^<>=*]|\d|v\d|workspace:|npm:|file:|git(?:\+|:)|https?:)/;

// Top-level package.json fields — these are package metadata, never dependencies,
// so a value like a bumped "version" must not register as a change.
const ROOT_FIELDS = new Set([
'author',
'description',
'homepage',
'license',
'main',
'module',
'name',
'node',
'npm',
'packageManager',
'pnpm',
'private',
'type',
'types',
'version',
'yarn',
]);

function displayVersion(version: string): string {
return version.replace(/^[~^]+/, '');
}

function dependencyEntries(lines: string[]): Map<string, string> {
const result = new Map<string, string>();

for (const line of lines) {
const match = line.match(ENTRY_PATTERN);
if (!match) continue;

const [, name, version] = match;
if (!name || !version || ROOT_FIELDS.has(name) || !VERSION_PATTERN.test(version)) continue;
result.set(name, version);
}

return result;
}

function npmManifest(files: FileChange[]): FileChange | undefined {
// Exactly one manifest — multiple package.json files make the change ambiguous.
const manifests = files.filter((file) => PACKAGE_JSON_PATTERN.test(file.path));
return manifests.length === 1 ? manifests[0] : undefined;
}

function detectDependencyChanges(files: FileChange[]): DependencyChange[] {
const manifest = npmManifest(files);
if (!manifest) return [];

const added = dependencyEntries(manifest.addedLines);
const removed = dependencyEntries(manifest.removedLines);
const names = new Set([...added.keys(), ...removed.keys()]);
// A newly-added overrides block reads as a pin, not a plain dependency add.
const isOverride = manifest.addedLines.some((line) => OVERRIDES_PATTERN.test(line));
const changes: DependencyChange[] = [];

for (const name of names) {
const from = removed.get(name);
const to = added.get(name);

if (from && to && from !== to) {
changes.push({ name, action: 'update', from, to });
} else if (to && !from) {
changes.push({ name, action: isOverride ? 'pin' : 'add', to });
} else if (from && !to) {
changes.push({ name, action: 'remove', from });
}
}

return changes;
}

function formatNames(names: string[]): string {
if (names.length === 2) return `${names[0]!} and ${names[1]!}`;
if (names.length === 3) return `${names[0]!}, ${names[1]!}, and ${names[2]!}`;
return `${names[0]!}, ${names[1]!}, and others`;
}

export function detectDependencySubject(files: FileChange[]): string | undefined {
const changes = detectDependencyChanges(files);
if (changes.length === 0) return undefined;
if (changes.length > 1) return `update ${formatNames(changes.map((change) => change.name))}`;

const change = changes[0]!;
if (change.action === 'pin') {
return `pin ${change.name} to ${displayVersion(change.to!)}`;
}
if (change.action === 'update') {
return `bump ${change.name} from ${displayVersion(change.from!)} to ${displayVersion(change.to!)}`;
}
if (change.action === 'add') {
return `add ${change.name} ${displayVersion(change.to!)}`;
}
return `remove ${change.name}`;
}
3 changes: 2 additions & 1 deletion src/analyze/subject.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { basename } from 'node:path';
import type { CommitType, FileChange, SymbolDelta } from '../types.js';
import { detectDependencySubject } from './dependencies.js';
import { DEPS_PATTERN } from './patterns.js';

function baseName(p: string): string {
Expand Down Expand Up @@ -50,7 +51,7 @@ export function detectSubject({ type, files, symbols }: SubjectInput): string {

if (type === 'chore') {
const isDeps = files.every((f) => DEPS_PATTERN.test(f.path));
if (isDeps) return 'update dependencies';
if (isDeps) return detectDependencySubject(files) ?? 'update dependencies';
return 'misc maintenance';
}

Expand Down
113 changes: 113 additions & 0 deletions tests/analyze-dependencies.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest';
import { detectDependencySubject } from '../src/analyze/dependencies.js';
import type { FileChange } from '../src/types.js';

const file = (overrides: Partial<FileChange> = {}): FileChange => ({
path: 'package.json',
kind: 'modify',
addedLines: [],
removedLines: [],
...overrides,
});

describe('detectDependencySubject', () => {
it('detects an added npm override as a pin', () => {
expect(
detectDependencySubject([
file({
addedLines: [' "overrides": {', ' "esbuild": "0.28.1"', ' }'],
}),
file({ path: 'package-lock.json' }),
]),
).toBe('pin esbuild to 0.28.1');
});

it('detects a single dependency version bump and strips range operators', () => {
expect(
detectDependencySubject([
file({
removedLines: [' "vitest": "^4.1.6"'],
addedLines: [' "vitest": "^4.1.8"'],
}),
file({ path: 'package-lock.json' }),
]),
).toBe('bump vitest from 4.1.6 to 4.1.8');
});

it('detects a dependency addition', () => {
expect(
detectDependencySubject([
file({
addedLines: [' "zod": "4.0.0"'],
}),
]),
).toBe('add zod 4.0.0');
});

it('detects a dependency removal', () => {
expect(
detectDependencySubject([
file({
removedLines: [' "lodash": "^4.17.21"'],
}),
]),
).toBe('remove lodash');
});

it('ignores the root package version', () => {
expect(
detectDependencySubject([
file({
removedLines: [' "version": "1.0.0"'],
addedLines: [' "version": "1.0.1"'],
}),
file({ path: 'package-lock.json' }),
]),
).toBeUndefined();
});

it('ignores Node engine changes', () => {
expect(
detectDependencySubject([
file({
removedLines: [' "node": ">=20.0.0"'],
addedLines: [' "node": ">=22.0.0"'],
}),
]),
).toBeUndefined();
});

it('returns undefined for lockfile-only changes', () => {
expect(
detectDependencySubject([
file({
path: 'package-lock.json',
removedLines: [' "version": "1.0.0"'],
addedLines: [' "version": "1.0.1"'],
}),
]),
).toBeUndefined();
});

it('names multiple dependency changes in manifest order', () => {
expect(
detectDependencySubject([
file({
removedLines: [' "vitest": "4.1.6"', ' "prettier": "3.8.3"'],
addedLines: [' "vitest": "4.1.8"', ' "prettier": "3.8.4"'],
}),
]),
).toBe('update vitest and prettier');
});

it('ignores script changes that look like package entries', () => {
expect(
detectDependencySubject([
file({
removedLines: [' "test": "vitest run"'],
addedLines: [' "test": "vitest run --coverage"'],
}),
]),
).toBeUndefined();
});
});
25 changes: 25 additions & 0 deletions tests/fixtures/chore-deps-add.diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
diff --git a/package.json b/package.json
index aaa1111..bbb2222 100644
--- a/package.json
+++ b/package.json
@@ -20,5 +20,6 @@
"dependencies": {
"commander": "14.0.0",
+ "zod": "4.0.0"
}
}
diff --git a/package-lock.json b/package-lock.json
index ccc3333..ddd4444 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,5 +12,6 @@
"dependencies": {
"commander": "14.0.0",
+ "zod": "4.0.0"
}
@@ -100,0 +102,5 @@
+ "node_modules/zod": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.0.tgz",
+ "integrity": "sha512-example"
+ }
1 change: 1 addition & 0 deletions tests/fixtures/chore-deps-add.expected.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
chore(deps): add zod 4.0.0
29 changes: 29 additions & 0 deletions tests/fixtures/chore-deps-multiple.diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
diff --git a/package.json b/package.json
index aaa1111..bbb2222 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,6 @@
"devDependencies": {
- "vitest": "^4.1.6",
- "prettier": "3.8.3"
+ "vitest": "^4.1.8",
+ "prettier": "3.8.4"
}
}
diff --git a/package-lock.json b/package-lock.json
index ccc3333..ddd4444 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,6 @@
"devDependencies": {
- "vitest": "^4.1.6",
- "prettier": "3.8.3"
+ "vitest": "^4.1.8",
+ "prettier": "3.8.4"
}
@@ -100,2 +100,2 @@
- "version": "3.8.3",
+ "version": "3.8.4",
@@ -200,2 +200,2 @@
- "version": "4.1.6",
+ "version": "4.1.8",
1 change: 1 addition & 0 deletions tests/fixtures/chore-deps-multiple.expected.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
chore(deps): update vitest and prettier
18 changes: 18 additions & 0 deletions tests/fixtures/chore-deps-override.diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
diff --git a/package.json b/package.json
index 66e24ad..85a4e6f 100644
--- a/package.json
+++ b/package.json
@@ -64,5 +64,8 @@
"vitest": "^4.1.6"
+ },
+ "overrides": {
+ "esbuild": "0.28.1"
}
}
diff --git a/package-lock.json b/package-lock.json
index 0e102b2..811813b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2699,3 +2699,3 @@
- "version": "0.27.7",
+ "version": "0.28.1",
1 change: 1 addition & 0 deletions tests/fixtures/chore-deps-override.expected.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
chore(deps): pin esbuild to 0.28.1
25 changes: 25 additions & 0 deletions tests/fixtures/chore-deps-remove.diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
diff --git a/package.json b/package.json
index aaa1111..bbb2222 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,5 @@
"dependencies": {
"commander": "14.0.0",
- "lodash": "^4.17.21"
}
}
diff --git a/package-lock.json b/package-lock.json
index ccc3333..ddd4444 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,5 @@
"dependencies": {
"commander": "14.0.0",
- "lodash": "^4.17.21"
}
@@ -100,5 +98,0 @@
- "node_modules/lodash": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-example"
- }
1 change: 1 addition & 0 deletions tests/fixtures/chore-deps-remove.expected.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
chore(deps): remove lodash
2 changes: 1 addition & 1 deletion tests/fixtures/chore-deps.expected.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
chore(deps): update dependencies
chore(deps): bump lodash from 4.17.20 to 4.17.21
Loading