Files
msd-core/hooks/lib/git-cmd.js
Tom Boucher 470389f3a2 chore(#3212): tokenizer-first for stateful grammars — a shared scanner — Phase 3 (#3424)
* feat(#3414): promote git-cmd.js token-walk into a shared scanner, fix #3169

Phase 3 of epic #3212 (ADR-3212 §4). New src/token-scanner.cts generalizes
hooks/lib/git-cmd.js's proven token-walk (#3129 — "has not re-opened"):
tokenizeShellLike (quote-aware shell tokenizer, byte-identical port) and
indentWidth (bullet-nesting depth).

git-cmd.js migrates onto tokenizeShellLike with zero behavior change
(parity-asserted against every existing #3129 fixture in
tests/worktree-safety.test.cjs's folded block); isGitSubcommand's phases
1-3 (env-prefix skip, executable check, global-option consume) extracted
into skipToSubcommand, shared with the new extractBranchArgument (git
checkout -b / git branch <name>) — a new capability exercising the seam
on the domain the ADR names, not a migration of existing duplicated logic
(none existed).

Fixes #3169: src/decisions.cts's parseDecisionLines couldn't distinguish
a cross-reference bullet nested under an open decision from a fresh
malformed declaration attempt. An earlier bold-run-content-classification
design was tried and disproven against the repo's own existing FIX-B
fixtures (D-02, "no colon no dash") before being adopted — both have
identical shape under any content-only rule. Nesting depth (via
indentWidth) is the actual distinguishing signal: a bullet indented
deeper than the currently-open decision's own bullet is elaboration,
folded into its text like a continuation line, never tested against the
parse-miss guard. A bullet at the same-or-shallower indent is unchanged.

Scope-narrowing disclosed, not silent: of the ADR's four named bugs
(#3197, #3169, #2570, #2528), three no longer need this phase's work.
were independently fixed and closed since the ADR was authored — #2570's
fix is already a correctly-bounded regex per the ADR's own decidability
test (no scanner needed); #2528's fix is a deliberate, twice-reviewed
non-scanner design (its own code comment records a scanner-based attempt
that regressed a symmetric case and was reverted) that this phase does
not disturb. Only #3169 required new work.

get_impact: isGitSubcommand CRITICAL/196 affected symbols,
parseDecisionLines CRITICAL/164 affected symbols (ADR §6 due diligence).

Six-gate ripple: .gitignore, eslint.config.mjs, docs/INVENTORY.md,
docs/INVENTORY-MANIFEST.json (regenerated), CONTEXT.md glossary.

Design: .gsd/phase/chore-3414-tokenizer-first-seam/40-design.md
Test matrix: .gsd/phase/chore-3414-tokenizer-first-seam/50-test-matrix.md

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3414): add required fast-check property tests per code review

TESTING-STANDARDS.md:169 requires at least one fast-check property test
for any module that implements parsing — src/token-scanner.cts had none,
an orthogonal Standards-axis review finding. Adds two seeded property
tests (mirroring Phase 1/2's fast-check-setup.cjs convention):
indentWidth counts exactly a generated leading-space run; tokenizeShellLike
round-trips a generated array of whitespace/quote-free words joined with
single spaces.

The design doc's own "no property test needed" rationale was wrong — it
argued no algebraic law applied, but the standard is unconditional for
parsing modules regardless of whether one "feels" applicable. Corrected
in .gsd/phase/chore-3414-tokenizer-first-seam/50-test-matrix.md.

Also fixes two Spec-axis wording drifts the same review found between
the design doc and the shipped code (doc-only, no behavior change):
extractBranchArgument's documented signature dropped an unused
subVariants parameter that was never implemented, and the #3169
fail-first fixture description corrected from "15-decision plan via
cmdDecisionCoverageVerify" to the actual compact 3-decision analog via
the real blocking gate, check.decision-coverage-plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#3414): add changeset for #3169 fix

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#3414): backfill changeset pr number to 3424

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 23:08:34 -04:00

175 lines
5.9 KiB
JavaScript

'use strict';
/**
* git-cmd.js — token-walk git command classifier.
*
* Determines whether a shell command string invokes a specific git
* subcommand. Handles the four forms that a naive `^git\s+commit` regex
* misses:
*
* bare: git commit -m "..." ✓
* -C path: git -C /some/path commit -m "..." ✓ (missed by regex)
* env-prefix: GIT_AUTHOR_NAME=x git commit "..." ✓ (missed by regex)
* full-path: /usr/bin/git commit -m "..." ✓ (missed by regex)
*
* This module is the single source of truth for git-commit detection so all
* hooks that need to gate on git commits share one implementation.
*
* Exported by the hooks/lib/ directory — require via a path relative to the
* hook's own __dirname:
*
* const { isGitSubcommand } = require(path.join(__dirname, 'lib', 'git-cmd.js'));
*
* `tokenize()` delegates to the shared `src/token-scanner.cts` seam (ADR-3212
* §4, epic #3212 Phase 3, #3414) — the built `gsd-core/bin/lib/token-scanner.cjs`
* artifact, not a sibling hooks/-tree file, because hook scripts are staged as
* standalone files at install time and a sibling require is a staging
* dependency that can fail silently (see gsd-workflow-guard.js's own
* KIMI_TOOL_NAMES comment for the precedent this follows). Re-exported here
* unchanged — every existing caller's behavior is identical (parity-asserted
* in tests/token-scanner.test.cjs row 5).
*/
const path = require('path');
const { tokenizeShellLike } = require(path.join(__dirname, '..', '..', 'gsd-core', 'bin', 'lib', 'token-scanner.cjs'));
/**
* Git global options that take a following argument.
* These must be consumed as (option, argument) pairs when walking tokens.
*/
const ARGUMENT_TAKING_FLAGS = new Set([
'-C', // working directory
'--git-dir', // path to git repository
'--work-tree', // path to working tree
'--namespace', // git namespace
'--super-prefix', // superproject-relative prefix
'--exec-path', // path to core git programs (when given an arg)
'--html-path',
'--man-path',
'--info-path',
'--list-cmds',
]);
/**
* Git global flags that consume no extra argument.
*/
const BOOLEAN_FLAGS = new Set([
'-p', '--paginate', '--no-pager',
'--no-replace-objects', '--bare',
'--literal-pathspecs', '--glob-pathspecs', '--noglob-pathspecs',
'--icase-pathspecs', '--no-optional-locks',
'-P', '--no-lazy-fetch',
'--version', '--help',
]);
/**
* Tokenize a shell command string.
* Handles single-quoted strings, double-quoted strings, and unquoted tokens.
* Does NOT perform variable expansion or brace expansion.
*
* Delegates to the shared `src/token-scanner.cts` seam — see the module
* header comment for why the built artifact, not a sibling require, is used.
*
* @param {string} cmd
* @returns {string[]}
*/
function tokenize(cmd) {
return tokenizeShellLike(cmd);
}
/**
* Walk past leading env-prefix assignments and global git options, same as
* `isGitSubcommand`'s phases 1-3. Returns the index of the subcommand token,
* or -1 if the command does not resolve to a git invocation at all.
*
* @param {string[]} tokens
* @returns {number}
*/
function skipToSubcommand(tokens) {
let i = 0;
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) {
i++;
}
if (i >= tokens.length) return -1;
const gitToken = tokens[i++];
if (path.basename(gitToken) !== 'git') return -1;
while (i < tokens.length) {
const t = tokens[i];
const eqIdx = t.indexOf('=');
const flagName = eqIdx !== -1 ? t.slice(0, eqIdx) : t;
if (ARGUMENT_TAKING_FLAGS.has(flagName)) {
i += eqIdx !== -1 ? 1 : 2;
continue;
}
if (BOOLEAN_FLAGS.has(t)) {
i++;
continue;
}
break;
}
return i;
}
/**
* Extract the branch-name argument from a git command line that creates or
* references one — `git checkout -b <name>` or `git branch <name>`. Returns
* null for any other command, including plain `git checkout <ref>` (switches
* branches, does not create one) and commands where a checkout/branch-shaped
* substring appears only inside a quoted argument (e.g. a commit message).
*
* New capability (ADR-3212 §4, epic #3212 Phase 3, #3414) exercising the
* shared scanner on the domain the ADR names ("a branch name... [is] not
* regular") — not a migration of existing duplicated logic; no prior
* implementation of this existed in the repo (design doc §1.2).
*
* @param {string} cmd
* @returns {string | null}
*/
function extractBranchArgument(cmd) {
if (!cmd) return null;
const tokens = tokenizeShellLike(cmd);
const subIdx = skipToSubcommand(tokens);
if (subIdx === -1 || subIdx >= tokens.length) return null;
const sub = tokens[subIdx];
if (sub === 'checkout') {
for (let j = subIdx + 1; j < tokens.length; j++) {
if (tokens[j] === '-b' && j + 1 < tokens.length) return tokens[j + 1];
}
return null;
}
if (sub === 'branch') {
for (let j = subIdx + 1; j < tokens.length; j++) {
if (!tokens[j].startsWith('-')) return tokens[j];
}
return null;
}
return null;
}
/**
* Return true if `cmd` invokes the git subcommand `sub`.
*
* @param {string} cmd - Full shell command string (may include env vars, full paths)
* @param {string} sub - Subcommand to test for, e.g. 'commit'
* @returns {boolean}
*/
function isGitSubcommand(cmd, sub) {
if (!cmd || !sub) return false;
// Phases 1-3 (env-prefix skip, git-executable check, global-option consume)
// extracted verbatim into skipToSubcommand — byte-identical logic, shared
// with extractBranchArgument rather than a second copy (#3212 Phase 3).
const tokens = tokenizeShellLike(cmd);
const subIdx = skipToSubcommand(tokens);
// Phase 4: check the subcommand
if (subIdx === -1 || subIdx >= tokens.length) return false;
return tokens[subIdx] === sub;
}
module.exports = { isGitSubcommand, tokenize, extractBranchArgument };