* 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>
This commit is contained in:
@@ -19,9 +19,19 @@
|
||||
* 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.
|
||||
@@ -57,39 +67,87 @@ const BOOLEAN_FLAGS = new Set([
|
||||
* 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) {
|
||||
const tokens = [];
|
||||
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;
|
||||
const len = cmd.length;
|
||||
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 < len) {
|
||||
// Skip whitespace
|
||||
while (i < len && /\s/.test(cmd[i])) i++;
|
||||
if (i >= len) break;
|
||||
|
||||
let token = '';
|
||||
while (i < len && !/\s/.test(cmd[i])) {
|
||||
if (cmd[i] === "'") {
|
||||
// Single-quoted string: take everything until closing '
|
||||
i++;
|
||||
while (i < len && cmd[i] !== "'") token += cmd[i++];
|
||||
if (i < len) i++; // consume closing '
|
||||
} else if (cmd[i] === '"') {
|
||||
// Double-quoted string: take everything until closing " (no escape handling)
|
||||
i++;
|
||||
while (i < len && cmd[i] !== '"') token += cmd[i++];
|
||||
if (i < len) i++; // consume closing "
|
||||
} else {
|
||||
token += cmd[i++];
|
||||
}
|
||||
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 (token) tokens.push(token);
|
||||
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;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
if (sub === 'branch') {
|
||||
for (let j = subIdx + 1; j < tokens.length; j++) {
|
||||
if (!tokens[j].startsWith('-')) return tokens[j];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,49 +160,15 @@ function tokenize(cmd) {
|
||||
function isGitSubcommand(cmd, sub) {
|
||||
if (!cmd || !sub) return false;
|
||||
|
||||
const tokens = tokenize(cmd);
|
||||
let i = 0;
|
||||
|
||||
// Phase 1: skip leading VAR=VALUE environment assignments
|
||||
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) {
|
||||
i++;
|
||||
}
|
||||
|
||||
// Phase 2: the next token must be the git executable
|
||||
if (i >= tokens.length) return false;
|
||||
const gitToken = tokens[i++];
|
||||
if (path.basename(gitToken) !== 'git') return false;
|
||||
|
||||
// Phase 3: consume git global options
|
||||
while (i < tokens.length) {
|
||||
const t = tokens[i];
|
||||
|
||||
// --flag=value form for argument-taking flags
|
||||
const eqIdx = t.indexOf('=');
|
||||
const flagName = eqIdx !== -1 ? t.slice(0, eqIdx) : t;
|
||||
if (ARGUMENT_TAKING_FLAGS.has(flagName)) {
|
||||
if (eqIdx !== -1) {
|
||||
// consumed as one token: --git-dir=.git
|
||||
i++;
|
||||
} else {
|
||||
// consumed as two tokens: -C /path
|
||||
i += 2;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (BOOLEAN_FLAGS.has(t)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not a global option — this is the subcommand
|
||||
break;
|
||||
}
|
||||
// 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 (i >= tokens.length) return false;
|
||||
return tokens[i] === sub;
|
||||
if (subIdx === -1 || subIdx >= tokens.length) return false;
|
||||
return tokens[subIdx] === sub;
|
||||
}
|
||||
|
||||
module.exports = { isGitSubcommand, tokenize };
|
||||
module.exports = { isGitSubcommand, tokenize, extractBranchArgument };
|
||||
|
||||
Reference in New Issue
Block a user