From 42a26444c0fb858a293f0972d57fc7df50c79eaf Mon Sep 17 00:00:00 2001 From: razakadam74 Date: Sat, 13 Jun 2026 00:56:43 -0700 Subject: [PATCH] feat(analyze): generate specific subjects for npm dependency changes --- .changeset/smart-dependency-subjects.md | 5 + docs/heuristics.md | 4 +- src/analyze/dependencies.ts | 114 ++++++++++++++++++ src/analyze/subject.ts | 3 +- tests/analyze-dependencies.test.ts | 113 +++++++++++++++++ tests/fixtures/chore-deps-add.diff | 25 ++++ tests/fixtures/chore-deps-add.expected.txt | 1 + tests/fixtures/chore-deps-multiple.diff | 29 +++++ .../fixtures/chore-deps-multiple.expected.txt | 1 + tests/fixtures/chore-deps-override.diff | 18 +++ .../fixtures/chore-deps-override.expected.txt | 1 + tests/fixtures/chore-deps-remove.diff | 25 ++++ tests/fixtures/chore-deps-remove.expected.txt | 1 + tests/fixtures/chore-deps.expected.txt | 2 +- 14 files changed, 339 insertions(+), 3 deletions(-) create mode 100644 .changeset/smart-dependency-subjects.md create mode 100644 src/analyze/dependencies.ts create mode 100644 tests/analyze-dependencies.test.ts create mode 100644 tests/fixtures/chore-deps-add.diff create mode 100644 tests/fixtures/chore-deps-add.expected.txt create mode 100644 tests/fixtures/chore-deps-multiple.diff create mode 100644 tests/fixtures/chore-deps-multiple.expected.txt create mode 100644 tests/fixtures/chore-deps-override.diff create mode 100644 tests/fixtures/chore-deps-override.expected.txt create mode 100644 tests/fixtures/chore-deps-remove.diff create mode 100644 tests/fixtures/chore-deps-remove.expected.txt diff --git a/.changeset/smart-dependency-subjects.md b/.changeset/smart-dependency-subjects.md new file mode 100644 index 0000000..64fd604 --- /dev/null +++ b/.changeset/smart-dependency-subjects.md @@ -0,0 +1,5 @@ +--- +'@razakadam74/gitmsg': minor +--- + +Generate specific commit subjects for unambiguous npm dependency changes. diff --git a/docs/heuristics.md b/docs/heuristics.md index 280e73f..eb32321 100644 --- a/docs/heuristics.md +++ b/docs/heuristics.md @@ -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 workflow` | `update CI configuration` | | `build` | `update 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 `; 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 to ` for a new override, `bump from to ` for a version change, `add `, or `remove `. Multiple unambiguous package changes produce `update and ` 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: diff --git a/src/analyze/dependencies.ts b/src/analyze/dependencies.ts new file mode 100644 index 0000000..be35798 --- /dev/null +++ b/src/analyze/dependencies.ts @@ -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 { + const result = new Map(); + + 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}`; +} diff --git a/src/analyze/subject.ts b/src/analyze/subject.ts index 079238b..2570fac 100644 --- a/src/analyze/subject.ts +++ b/src/analyze/subject.ts @@ -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 { @@ -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'; } diff --git a/tests/analyze-dependencies.test.ts b/tests/analyze-dependencies.test.ts new file mode 100644 index 0000000..43108a1 --- /dev/null +++ b/tests/analyze-dependencies.test.ts @@ -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 => ({ + 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(); + }); +}); diff --git a/tests/fixtures/chore-deps-add.diff b/tests/fixtures/chore-deps-add.diff new file mode 100644 index 0000000..3eb97ed --- /dev/null +++ b/tests/fixtures/chore-deps-add.diff @@ -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" ++ } diff --git a/tests/fixtures/chore-deps-add.expected.txt b/tests/fixtures/chore-deps-add.expected.txt new file mode 100644 index 0000000..f7c2777 --- /dev/null +++ b/tests/fixtures/chore-deps-add.expected.txt @@ -0,0 +1 @@ +chore(deps): add zod 4.0.0 diff --git a/tests/fixtures/chore-deps-multiple.diff b/tests/fixtures/chore-deps-multiple.diff new file mode 100644 index 0000000..e45754e --- /dev/null +++ b/tests/fixtures/chore-deps-multiple.diff @@ -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", diff --git a/tests/fixtures/chore-deps-multiple.expected.txt b/tests/fixtures/chore-deps-multiple.expected.txt new file mode 100644 index 0000000..ce9c721 --- /dev/null +++ b/tests/fixtures/chore-deps-multiple.expected.txt @@ -0,0 +1 @@ +chore(deps): update vitest and prettier diff --git a/tests/fixtures/chore-deps-override.diff b/tests/fixtures/chore-deps-override.diff new file mode 100644 index 0000000..c2565b8 --- /dev/null +++ b/tests/fixtures/chore-deps-override.diff @@ -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", diff --git a/tests/fixtures/chore-deps-override.expected.txt b/tests/fixtures/chore-deps-override.expected.txt new file mode 100644 index 0000000..78987df --- /dev/null +++ b/tests/fixtures/chore-deps-override.expected.txt @@ -0,0 +1 @@ +chore(deps): pin esbuild to 0.28.1 diff --git a/tests/fixtures/chore-deps-remove.diff b/tests/fixtures/chore-deps-remove.diff new file mode 100644 index 0000000..752752a --- /dev/null +++ b/tests/fixtures/chore-deps-remove.diff @@ -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" +- } diff --git a/tests/fixtures/chore-deps-remove.expected.txt b/tests/fixtures/chore-deps-remove.expected.txt new file mode 100644 index 0000000..ddd66b8 --- /dev/null +++ b/tests/fixtures/chore-deps-remove.expected.txt @@ -0,0 +1 @@ +chore(deps): remove lodash diff --git a/tests/fixtures/chore-deps.expected.txt b/tests/fixtures/chore-deps.expected.txt index f2b5ff1..9bd7000 100644 --- a/tests/fixtures/chore-deps.expected.txt +++ b/tests/fixtures/chore-deps.expected.txt @@ -1 +1 @@ -chore(deps): update dependencies +chore(deps): bump lodash from 4.17.20 to 4.17.21