Files
Mert Koseoglu 4f58e4fea1 fix(hooks): commit message capture + symmetric rollup stamp (Bug 1+2)
Two coupled bugs reported via the platform-side handoff produced misleading
analytics:

Bug 1 — extractGit() returned data=match.operation ("commit"/"push"/"diff")
instead of the actual commit subject. Platform commit_message column
inherited the operation name, which is meaningless. Fix: hand-rolled
shell-like argv tokenizer (no regex) parses -m / -am / --message= /
--message ARG forms, returns the captured subject as event.data. When a
message is captured, the event surfaces as type='git_commit' so the rollup
aggregator can distinguish actual commits from other git ops. Falls back
to operation name when no message is present (--amend / --no-edit / -F).

Bug 2 — rollup query stamped has_commit=1 onto every event in a commit-
bearing session (via SUM(category='git')>0), but commit_message lived only
on per-event git rows. Asymmetric — ~92% of has_commit=1 rows on platform
arrived with commit_message=NULL. Fix: SessionDB.getSessionRollup now
includes the latest commit_message from session's type='git_commit' events
via a separate prepared statement; has_commit SQL changed from
category='git' to type='git_commit' for accurate semantics. session-loaders
enrichment gates per-event commit_message/has_commit on type='git_commit'
instead of category='git'. Spread order makes rollup authoritative for
non-git events while git_commit events stay marked.

ALGORITHMIC parsing, not regex:
- tokenizeCommand: pure char loop, single/double quotes, backslash escapes,
  POSIX-style adjacent merging (a"b c"d → "ab cd")
- extractCommitMessageFromCommand: linear scan for --message=value,
  --message value, or -m/-am/cluster-flag-ending-in-m followed by value
- isLowerAlphaRun: char-code check (97-122) for cluster validation

Tests (TDD, vertical slices, RED→GREEN per behavior):
- tests/session/extract-git-commit.test.ts (11 tests): tracer + 10 edge
  cases — single/double quotes, --message= attached, --message separate,
  -am combined flag, --amend fallback, empty -m "" fallback, non-commit
  unchanged, special chars, env-prefixed invocation, type discriminator.
- tests/integration/commit-message-symmetry.test.ts (1 test): session
  with 1 git_commit + 3 non-git events → all 4 captured POST bodies carry
  commit_message symmetric with has_commit=1.

Empirical proof: live E2E against real platform — 4/4 POSTs status 201,
4/4 has_commit=1, 4/4 commit_message populated symmetrically. Bug 1 and
Bug 2 both verified fixed end-to-end with rebuilt bundles.

Regression matrix: 1210 pass, 5 skipped, 1 pre-existing failure
(project-dir-strict — stale, unrelated). Existing tests using
type='git' updated to type='git_commit' where the fixture represents
an actual commit (session-extract test, seed-parity-coverage two fixtures).

Refs platform-bridge.mjs envelope ABI (PRD §5.4), bridge stays a dumb pipe;
all enrichment upstream in session-loaders. NO release — push next only.
2026-06-02 13:07:39 +03:00

88 lines
3.1 KiB
TypeScript

/**
* Bug 1 repro — extractGit() commit message capture.
*
* Symptom: `git commit -m "feat: x"` → event.data is `"commit"` (operation
* name) instead of `"feat: x"` (actual commit message). Downstream consumers
* (platform `commit_message` column) inherit the operation name as the
* commit message, which is meaningless.
*
* Per /diagnose discipline this file exists to REPRODUCE the bug at a
* deterministic vitest seam before any fix is applied. Each test below
* starts RED today; the fix will flip them to GREEN one tracer at a time.
*/
import { describe, test, expect } from "vitest";
import { extractEvents } from "../../src/session/extract.js";
function dataOf(cmd: string): string | undefined {
const events = extractEvents({
tool_name: "Bash",
tool_input: { command: cmd },
tool_response: "",
tool_output: undefined,
});
return events.find((e) => e.category === "git")?.data;
}
describe("extractGit — Bug 1 repro: commit message capture", () => {
test("tracer: `git commit -m \"feat: x\"` → event.data is the message", () => {
expect(dataOf(`git commit -m "feat: x"`)).toBe("feat: x");
});
test("single-quoted message survives", () => {
expect(dataOf(`git commit -m 'single quoted'`)).toBe("single quoted");
});
test("combined flag cluster `-am` recognised", () => {
expect(dataOf(`git commit -am "stage+commit"`)).toBe("stage+commit");
});
test("long form `--message=value` (attached)", () => {
expect(dataOf(`git commit --message=longform`)).toBe("longform");
});
test("long form `--message value` (separate token)", () => {
expect(dataOf(`git commit --message "separate"`)).toBe("separate");
});
test("--amend without -m falls back to operation name", () => {
expect(dataOf(`git commit --amend --no-edit`)).toBe("commit");
});
test("empty `-m \"\"` falls back to operation name", () => {
expect(dataOf(`git commit -m ""`)).toBe("commit");
});
test("non-commit operations carry the operation name unchanged", () => {
expect(dataOf(`git push origin main`)).toBe("push");
expect(dataOf(`git diff HEAD~1`)).toBe("diff");
expect(dataOf(`git status`)).toBe("status");
});
test("messages containing colons and special chars are preserved verbatim", () => {
expect(dataOf(`git commit -m "fix: navbar — handle long URLs"`))
.toBe("fix: navbar — handle long URLs");
});
test("env-prefixed invocation is tolerated", () => {
expect(dataOf(`GIT_AUTHOR_DATE=now git commit -m "with env"`))
.toBe("with env");
});
test("type discriminator: commits get type='git_commit', other ops keep type='git'", () => {
function typeOf(cmd: string): string | undefined {
const events = extractEvents({
tool_name: "Bash",
tool_input: { command: cmd },
tool_response: "",
tool_output: undefined,
});
return events.find((e) => e.category === "git")?.type;
}
expect(typeOf(`git commit -m "x"`)).toBe("git_commit");
expect(typeOf(`git commit --amend`)).toBe("git"); // no -m → not promoted
expect(typeOf(`git push`)).toBe("git");
expect(typeOf(`git diff HEAD`)).toBe("git");
});
});