@opengsd/gsd-core 1.12.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +12 -0
- package/agents/gsd-executor.md +63 -35
- package/agents/gsd-plan-checker.md +76 -57
- package/agents/gsd-planner.md +14 -0
- package/agents/gsd-ui-checker.md +19 -3
- package/agents/gsd-ui-researcher.md +29 -0
- package/agents/gsd-verifier.md +23 -1
- package/bin/install.js +239 -67
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/ns-workflow.md +2 -1
- package/commands/gsd/phase.md +1 -1
- package/commands/gsd/quick-batch.md +105 -0
- package/commands/gsd/surface.md +18 -8
- package/gsd-core/bin/gsd-tools.cjs +195 -50
- package/gsd-core/bin/lib/capability-activation.cjs +27 -0
- package/gsd-core/bin/lib/capability-registry.cjs +514 -114
- package/gsd-core/bin/lib/capability-state.cjs +7 -1
- package/gsd-core/bin/lib/capability-validator.cjs +120 -4
- package/gsd-core/bin/lib/capability-writer.cjs +14 -4
- package/gsd-core/bin/lib/check-command-router.cjs +85 -2
- package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
- package/gsd-core/bin/lib/clusters.cjs +1 -0
- package/gsd-core/bin/lib/command-aliases.cjs +16 -0
- package/gsd-core/bin/lib/commands.cjs +337 -13
- package/gsd-core/bin/lib/config-loader.cjs +3 -0
- package/gsd-core/bin/lib/core-utils.cjs +34 -7
- package/gsd-core/bin/lib/decisions.cjs +213 -1
- package/gsd-core/bin/lib/edge-probe.cjs +14 -1
- package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
- package/gsd-core/bin/lib/frontmatter.cjs +137 -23
- package/gsd-core/bin/lib/gap-checker.cjs +22 -13
- package/gsd-core/bin/lib/git-base-branch.cjs +10 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +54 -11
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
- package/gsd-core/bin/lib/host-integration.cjs +57 -5
- package/gsd-core/bin/lib/init-command-router.cjs +14 -0
- package/gsd-core/bin/lib/init.cjs +132 -15
- package/gsd-core/bin/lib/install-engine.cjs +184 -12
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +45 -0
- package/gsd-core/bin/lib/install-profiles.cjs +22 -14
- package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
- package/gsd-core/bin/lib/io.cjs +35 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
- package/gsd-core/bin/lib/markdown-table.cjs +123 -0
- package/gsd-core/bin/lib/milestone.cjs +22 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
- package/gsd-core/bin/lib/phase-id.cjs +251 -9
- package/gsd-core/bin/lib/phase.cjs +774 -35
- package/gsd-core/bin/lib/plan-document.cjs +10 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +147 -20
- package/gsd-core/bin/lib/planning-workspace.cjs +103 -28
- package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
- package/gsd-core/bin/lib/quick-batch.cjs +840 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +53 -5
- package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
- package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
- package/gsd-core/bin/lib/roadmap-parser.cjs +499 -26
- package/gsd-core/bin/lib/roadmap.cjs +187 -58
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +233 -33
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +286 -108
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -43
- package/gsd-core/bin/lib/shell-command-projection.cjs +4 -0
- package/gsd-core/bin/lib/smart-entry.cjs +7 -9
- package/gsd-core/bin/lib/state-document.cjs +30 -5
- package/gsd-core/bin/lib/state-md-schema.cjs +23 -13
- package/gsd-core/bin/lib/state-transition.cjs +333 -44
- package/gsd-core/bin/lib/state.cjs +684 -125
- package/gsd-core/bin/lib/surface.cjs +23 -8
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
- package/gsd-core/bin/lib/uat.cjs +1419 -515
- package/gsd-core/bin/lib/update-context.cjs +6 -2
- package/gsd-core/bin/lib/validate.cjs +230 -12
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +273 -12
- package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
- package/gsd-core/bin/lib/verify.cjs +346 -16
- package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +8 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +8 -0
- package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
- package/gsd-core/references/agent-contracts.md +3 -3
- package/gsd-core/references/edge-probe.md +17 -13
- package/gsd-core/references/execute-mvp-tdd.md +18 -16
- package/gsd-core/references/execute-phase-response-language.md +6 -0
- package/gsd-core/references/executor-examples.md +42 -0
- package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
- package/gsd-core/references/mvp-concepts.md +2 -2
- package/gsd-core/references/plan-checker-examples.md +41 -0
- package/gsd-core/references/planner-antipatterns.md +25 -0
- package/gsd-core/references/planner-chunked.md +5 -1
- package/gsd-core/references/planner-coupling.md +42 -0
- package/gsd-core/references/planner-quick-batch.md +71 -0
- package/gsd-core/references/planner-reviews.md +47 -0
- package/gsd-core/references/planner-revision.md +75 -2
- package/gsd-core/references/planning-config.md +2 -1
- package/gsd-core/references/response-language-directive.md +9 -0
- package/gsd-core/references/revision-loop.md +118 -11
- package/gsd-core/references/tdd.md +14 -9
- package/gsd-core/references/verifier-evidence-gate.md +160 -0
- package/gsd-core/templates/phase-prompt.md +4 -0
- package/gsd-core/templates/verification-report.md +5 -0
- package/gsd-core/workflows/add-backlog.md +2 -0
- package/gsd-core/workflows/add-phase.md +2 -0
- package/gsd-core/workflows/add-tests.md +1 -1
- package/gsd-core/workflows/add-todo.md +1 -1
- package/gsd-core/workflows/ai-integration-phase.md +1 -1
- package/gsd-core/workflows/analyze-dependencies.md +2 -0
- package/gsd-core/workflows/audit-fix.md +2 -0
- package/gsd-core/workflows/audit-milestone.md +2 -0
- package/gsd-core/workflows/audit-uat.md +2 -0
- package/gsd-core/workflows/autonomous.md +2 -0
- package/gsd-core/workflows/check-todos.md +1 -1
- package/gsd-core/workflows/cleanup.md +1 -1
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +15 -13
- package/gsd-core/workflows/code-review-fix.md +2 -0
- package/gsd-core/workflows/code-review.md +73 -31
- package/gsd-core/workflows/complete-milestone.md +13 -4
- package/gsd-core/workflows/debug.md +1 -1
- package/gsd-core/workflows/diagnose-issues.md +5 -1
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/text.md +2 -0
- package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
- package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
- package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
- package/gsd-core/workflows/discuss-phase-power.md +2 -0
- package/gsd-core/workflows/discuss-phase.md +1 -1
- package/gsd-core/workflows/do.md +43 -13
- package/gsd-core/workflows/docs-update.md +1 -1
- package/gsd-core/workflows/edit-phase.md +2 -0
- package/gsd-core/workflows/eval-review.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +2 -0
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +17 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +8 -2
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -0
- package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
- package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
- package/gsd-core/workflows/execute-phase.md +32 -14
- package/gsd-core/workflows/execute-plan.md +8 -8
- package/gsd-core/workflows/explore.md +2 -0
- package/gsd-core/workflows/extract-learnings.md +2 -0
- package/gsd-core/workflows/fast.md +6 -0
- package/gsd-core/workflows/forensics.md +2 -0
- package/gsd-core/workflows/graduation.md +1 -1
- package/gsd-core/workflows/health.md +1 -1
- package/gsd-core/workflows/help/modes/brief.md +2 -0
- package/gsd-core/workflows/help/modes/default.md +2 -0
- package/gsd-core/workflows/help/modes/full.md +12 -0
- package/gsd-core/workflows/help/modes/topic.md +2 -0
- package/gsd-core/workflows/help.md +2 -0
- package/gsd-core/workflows/import.md +3 -3
- package/gsd-core/workflows/inbox.md +1 -1
- package/gsd-core/workflows/ingest-docs.md +1 -1
- package/gsd-core/workflows/insert-phase.md +2 -0
- package/gsd-core/workflows/list-phase-assumptions.md +2 -0
- package/gsd-core/workflows/list-seeds.md +2 -0
- package/gsd-core/workflows/list-workspaces.md +2 -0
- package/gsd-core/workflows/manager.md +3 -3
- package/gsd-core/workflows/map-codebase.md +2 -0
- package/gsd-core/workflows/milestone-summary.md +2 -0
- package/gsd-core/workflows/mvp-phase.md +1 -1
- package/gsd-core/workflows/new-milestone.md +1 -1
- package/gsd-core/workflows/new-project.md +5 -3
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +2 -0
- package/gsd-core/workflows/node-repair.md +2 -0
- package/gsd-core/workflows/note.md +2 -0
- package/gsd-core/workflows/onboard.md +1 -1
- package/gsd-core/workflows/pause-work.md +19 -4
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -0
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +9 -0
- package/gsd-core/workflows/plan-phase.md +130 -12
- package/gsd-core/workflows/plan-review-convergence.md +102 -10
- package/gsd-core/workflows/plant-seed.md +1 -1
- package/gsd-core/workflows/pr-branch.md +11 -3
- package/gsd-core/workflows/profile-user.md +1 -1
- package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
- package/gsd-core/workflows/progress.md +25 -3
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +37 -2
- package/gsd-core/workflows/quick/steps/research-phase.md +3 -3
- package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
- package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
- package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
- package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
- package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
- package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
- package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
- package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
- package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
- package/gsd-core/workflows/quick-batch.md +203 -0
- package/gsd-core/workflows/quick.md +13 -3
- package/gsd-core/workflows/reapply-patches.md +2 -0
- package/gsd-core/workflows/remove-phase.md +2 -0
- package/gsd-core/workflows/remove-workspace.md +1 -1
- package/gsd-core/workflows/resume-project.md +6 -2
- package/gsd-core/workflows/review.md +215 -10
- package/gsd-core/workflows/scan.md +2 -0
- package/gsd-core/workflows/section-manifest.json +12 -0
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/session-report.md +2 -0
- package/gsd-core/workflows/settings-advanced.md +2 -0
- package/gsd-core/workflows/settings-integrations.md +9 -8
- package/gsd-core/workflows/settings.md +1 -1
- package/gsd-core/workflows/ship.md +10 -10
- package/gsd-core/workflows/sketch-wrap-up.md +2 -0
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/smart-entry.md +1 -1
- package/gsd-core/workflows/spec-phase.md +24 -19
- package/gsd-core/workflows/spike-wrap-up.md +2 -0
- package/gsd-core/workflows/spike.md +1 -1
- package/gsd-core/workflows/stats.md +2 -0
- package/gsd-core/workflows/sync-skills.md +12 -4
- package/gsd-core/workflows/thread.md +2 -0
- package/gsd-core/workflows/transition.md +2 -0
- package/gsd-core/workflows/ui-phase.md +26 -5
- package/gsd-core/workflows/ui-review.md +1 -1
- package/gsd-core/workflows/ultraplan-phase.md +2 -0
- package/gsd-core/workflows/undo.md +1 -1
- package/gsd-core/workflows/update.md +41 -38
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-work.md +49 -3
- package/hooks/dist/gsd-check-update-worker.js +19 -2
- package/hooks/dist/gsd-context-monitor.js +283 -12
- package/hooks/dist/gsd-node-runner.sh +1 -0
- package/hooks/dist/gsd-prompt-guard.js +30 -5
- package/hooks/dist/gsd-read-guard.js +2 -0
- package/hooks/dist/gsd-read-injection-scanner.js +5 -5
- package/hooks/dist/gsd-secret-read-guard.js +1079 -0
- package/hooks/dist/gsd-statusline.js +7 -3
- package/hooks/dist/gsd-validate-commit.sh +444 -7
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/lib/git-cmd.js +210 -1
- package/hooks/dist/lib/injection-patterns.js +36 -6
- package/hooks/dist/managed-hooks-registry.cjs +1 -0
- package/hooks/gsd-check-update-worker.js +19 -2
- package/hooks/gsd-context-monitor.js +283 -12
- package/hooks/gsd-node-runner.sh +1 -0
- package/hooks/gsd-prompt-guard.js +30 -5
- package/hooks/gsd-read-guard.js +2 -0
- package/hooks/gsd-read-injection-scanner.js +5 -5
- package/hooks/gsd-secret-read-guard.js +1079 -0
- package/hooks/gsd-statusline.js +7 -3
- package/hooks/gsd-validate-commit.sh +444 -7
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/hooks.json +6 -0
- package/hooks/lib/git-cmd.js +210 -1
- package/hooks/lib/injection-patterns.js +36 -6
- package/hooks/managed-hooks-registry.cjs +1 -0
- package/package.json +5 -5
- package/scripts/build-hooks.js +11 -4
- package/scripts/ci-test-scope.cjs +7 -0
- package/scripts/docs-guard-registry.cjs +10 -0
- package/scripts/gen-loop-host-contract.cjs +67 -15
- package/scripts/lib/shellcheck-fetch.cjs +247 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +5 -0
- package/scripts/lint-phase-enumeration-drift.cjs +24 -6
- package/scripts/lint-phase-id-drift.cjs +133 -8
- package/scripts/lint-portable-grep.cjs +176 -0
- package/scripts/lint-response-language-coverage.cjs +524 -0
- package/scripts/lint-test-file-count.allowlist.json +3 -1
- package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
- package/scripts/lint-workflow-shellcheck.cjs +614 -0
- package/scripts/npm-audit-baseline.cjs +376 -0
- package/scripts/prompt-injection-scan.sh +8 -0
- package/scripts/require-issue-link-policy.cjs +16 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -1
- package/skills/gsd-ns-workflow/SKILL.md +1 -0
- package/skills/gsd-phase/SKILL.md +1 -1
- package/skills/gsd-quick-batch/SKILL.md +105 -0
- package/skills/gsd-surface/SKILL.md +18 -8
- package/vscode/package.json +1 -1
package/hooks/gsd-statusline.js
CHANGED
|
@@ -389,8 +389,12 @@ function formatGsdState(s) {
|
|
|
389
389
|
// Scene 2: idle + a recommended next command is visible to the user.
|
|
390
390
|
// Surfaces "what to run next" without the user opening STATE.md.
|
|
391
391
|
parts.push(`next ${s.nextAction} ${phasesStr}`);
|
|
392
|
-
} else if (Number(s.percent) === 100 || (s.
|
|
393
|
-
// Scene 3: milestone complete (every phase done).
|
|
392
|
+
} else if (Number(s.percent) === 100 || (Number(s.totalPhases) > 0 && Number(s.completedPhases) === Number(s.totalPhases))) {
|
|
393
|
+
// Scene 3: milestone complete (every phase done). #3945: the counters are
|
|
394
|
+
// regex-captured STRINGS, so the old `cp && tp && cp === tp` guard fired on
|
|
395
|
+
// the empty set ('0' is truthy, '0' === '0') — "0% · milestone complete".
|
|
396
|
+
// Numeric coercion + a non-empty denominator makes "nothing to measure"
|
|
397
|
+
// stop meaning "everything is done".
|
|
394
398
|
parts.push('milestone complete');
|
|
395
399
|
} else {
|
|
396
400
|
// Backward-compatible default — preserved EXACTLY for STATE.md files that
|
|
@@ -500,7 +504,7 @@ function formatGsdStateCompact(s) {
|
|
|
500
504
|
// still completes) wins over milestone-complete (Scene 3), even if a
|
|
501
505
|
// non-atomic STATE.md edit leaves percent=100 alongside a lifecycle phase.
|
|
502
506
|
const done = !s.activePhase && (Number(s.percent) === 100 ||
|
|
503
|
-
(s.
|
|
507
|
+
(Number(s.totalPhases) > 0 && Number(s.completedPhases) === Number(s.totalPhases)));
|
|
504
508
|
|
|
505
509
|
if (done) {
|
|
506
510
|
parts.push('complete');
|
|
@@ -24,13 +24,38 @@ cleanup_temp_files() {
|
|
|
24
24
|
}
|
|
25
25
|
trap cleanup_temp_files EXIT
|
|
26
26
|
|
|
27
|
+
# The 10 built-in Conventional Commits types — the SINGLE declaration (#3811
|
|
28
|
+
# review finding: this was previously hand-typed a second time inside the
|
|
29
|
+
# node -e script below, a generative-fix-divergence risk per CLAUDE.md's
|
|
30
|
+
# known-defect list). Threaded into node via an env var; reused directly by
|
|
31
|
+
# bash below when building COMMIT_TYPES.
|
|
32
|
+
BUILTIN_COMMIT_TYPES=(feat fix docs style refactor perf test build ci chore)
|
|
33
|
+
|
|
27
34
|
# Check opt-in config — exit silently if not enabled
|
|
28
35
|
if [ -f .planning/config.json ]; then
|
|
29
36
|
ENABLED_ERR=$(mktemp)
|
|
30
|
-
|
|
37
|
+
# Single node invocation reads BOTH hooks.community (line 1: '1'/'0') and
|
|
38
|
+
# hooks.commit_types (remaining lines: one sanitized extra type per line) —
|
|
39
|
+
# see #3811. Sanitizing here, not in bash, keeps the safe-token check in one
|
|
40
|
+
# place and guarantees only [a-z][a-z0-9-]* strings ever reach the regex
|
|
41
|
+
# built below, so a configured value can never alter the compiled pattern's
|
|
42
|
+
# structure.
|
|
43
|
+
BUILTIN_COMMIT_TYPES_CSV=$(IFS=,; echo "${BUILTIN_COMMIT_TYPES[*]}")
|
|
44
|
+
CONFIG_OUT=$(GSD_BUILTIN_COMMIT_TYPES="$BUILTIN_COMMIT_TYPES_CSV" node -e "
|
|
31
45
|
try{
|
|
32
46
|
const c=require('./.planning/config.json');
|
|
33
47
|
process.stdout.write(c.hooks?.community===true?'1':'0');
|
|
48
|
+
process.stdout.write('\n');
|
|
49
|
+
const raw=c.hooks?.commit_types;
|
|
50
|
+
const list=Array.isArray(raw)?raw:[];
|
|
51
|
+
const seen=new Set((process.env.GSD_BUILTIN_COMMIT_TYPES||'').split(',').filter(Boolean));
|
|
52
|
+
for (const t of list){
|
|
53
|
+
if (typeof t!=='string') continue;
|
|
54
|
+
if (!/^[a-z][a-z0-9-]*\$/.test(t)) continue;
|
|
55
|
+
if (seen.has(t)) continue;
|
|
56
|
+
seen.add(t);
|
|
57
|
+
process.stdout.write(t+'\n');
|
|
58
|
+
}
|
|
34
59
|
}catch(e){
|
|
35
60
|
process.stderr.write('CONFIG_READ_FAILED: '+(e&&e.message?e.message:String(e)));
|
|
36
61
|
process.exit(3);
|
|
@@ -44,7 +69,16 @@ if [ -f .planning/config.json ]; then
|
|
|
44
69
|
echo "gsd-validate-commit.sh: could not read .planning/config.json (opt-in check) — validator disabled for this call. $(cat "$ENABLED_ERR")" >&2
|
|
45
70
|
exit 0
|
|
46
71
|
fi
|
|
72
|
+
ENABLED=$(printf '%s\n' "$CONFIG_OUT" | head -1)
|
|
47
73
|
if [ "$ENABLED" != "1" ]; then exit 0; fi
|
|
74
|
+
# Remaining lines (if any) are the sanitized, deduped configured commit
|
|
75
|
+
# types beyond the 10 built-ins (#3811). Read into a bash-3.2-safe array —
|
|
76
|
+
# `mapfile`/`readarray` are bash 4+ only and this hook is tested against
|
|
77
|
+
# bash 3.2.57 (macOS default).
|
|
78
|
+
EXTRA_COMMIT_TYPES=()
|
|
79
|
+
while IFS= read -r _extra_type; do
|
|
80
|
+
[ -n "$_extra_type" ] && EXTRA_COMMIT_TYPES+=("$_extra_type")
|
|
81
|
+
done < <(printf '%s\n' "$CONFIG_OUT" | tail -n +2)
|
|
48
82
|
else
|
|
49
83
|
exit 0
|
|
50
84
|
fi
|
|
@@ -104,21 +138,424 @@ if [ "$CLASSIFY_STATUS" != "0" ] && [ "$CLASSIFY_STATUS" != "1" ]; then
|
|
|
104
138
|
exit 0
|
|
105
139
|
fi
|
|
106
140
|
if [ "$CLASSIFY_STATUS" = "0" ]; then
|
|
107
|
-
# Extract message from -m flag
|
|
141
|
+
# Extract message from -m flag.
|
|
142
|
+
#
|
|
143
|
+
# MSG_QUOTE records WHICH arm matched. bash treats the two arms differently
|
|
144
|
+
# and the subject step below depends on that difference — see the resolver
|
|
145
|
+
# gate (review of #3816, round 4).
|
|
108
146
|
MSG=""
|
|
147
|
+
MSG_QUOTE=""
|
|
148
|
+
MSG_MATCH=""
|
|
109
149
|
if [[ "$CMD" =~ -m[[:space:]]+\"([^\"]+)\" ]]; then
|
|
110
150
|
MSG="${BASH_REMATCH[1]}"
|
|
151
|
+
MSG_QUOTE=dq
|
|
152
|
+
MSG_MATCH="${BASH_REMATCH[0]}"
|
|
111
153
|
elif [[ "$CMD" =~ -m[[:space:]]+\'([^\']+)\' ]]; then
|
|
112
154
|
MSG="${BASH_REMATCH[1]}"
|
|
155
|
+
MSG_QUOTE=sq
|
|
156
|
+
MSG_MATCH="${BASH_REMATCH[0]}"
|
|
113
157
|
fi
|
|
114
158
|
|
|
115
159
|
if [ -n "$MSG" ]; then
|
|
116
|
-
|
|
160
|
+
# Subject = first line of the message, EXCEPT for the command-substituted
|
|
161
|
+
# heredoc form, where the first line is the opener rather than the message:
|
|
162
|
+
#
|
|
163
|
+
# git commit -m "$(cat <<'EOF'
|
|
164
|
+
# feat(auth): add login flow
|
|
165
|
+
# EOF
|
|
166
|
+
# )"
|
|
167
|
+
#
|
|
168
|
+
# The capture above spans it whole, because bash `[^"]` matches newlines, so
|
|
169
|
+
# `head -1` yielded the literal `$(cat <<'EOF'` and EVERY heredoc-form commit
|
|
170
|
+
# was blocked regardless of its message (#3802).
|
|
171
|
+
#
|
|
172
|
+
# Selection of WHICH argument is the message is unchanged above — only the
|
|
173
|
+
# subject-from-message step is delegated. Falls back to the previous `head -1`
|
|
174
|
+
# if node or the library is unavailable, so a broken extractor degrades to the
|
|
175
|
+
# old behavior instead of becoming a new silent-allow path.
|
|
176
|
+
#
|
|
177
|
+
# SINGLE-QUOTE GATE (review of #3816, round 4 — BLOCKER). The resolver may
|
|
178
|
+
# only run on the DOUBLE-quoted arm. Inside `-m '...'` bash performs NO
|
|
179
|
+
# command substitution, so `$(cat <<'EOF'` is literal text and git's real
|
|
180
|
+
# subject is that opener line — resolving the body there validates a
|
|
181
|
+
# message git never receives. Measured against the real hook, all four
|
|
182
|
+
# spellings (`<<'E'`, `<<"E"`, `<<\E`, `<<E`) went base=2 -> head=0: a
|
|
183
|
+
# net-new bypass reachable by the ordinary authoring slip of typing `'`
|
|
184
|
+
# for `"`. The sq arm therefore keeps the pre-fix `head -1`, which is exact
|
|
185
|
+
# base parity.
|
|
186
|
+
#
|
|
187
|
+
# ADJACENCY GUARD (review of #3816): text glued to the CLOSING quote —
|
|
188
|
+
# `-m "$(cat <<'EOF' ... )"suffix` — is concatenated by bash into the SAME
|
|
189
|
+
# argument, so the capture above holds only a PREFIX of the real message.
|
|
190
|
+
# Resolving a heredoc from a prefix hands the length gate a fraction of the
|
|
191
|
+
# real subject: a net-new bypass relative to base, which measured the
|
|
192
|
+
# opener line and blocked. When the quote is not followed by whitespace or
|
|
193
|
+
# the end of the command, skip the resolver and keep the pre-fix subject
|
|
194
|
+
# (first captured line): the heredoc form then fails the format gate
|
|
195
|
+
# exactly as it did on base, and the plain single-line form keeps base
|
|
196
|
+
# behavior unchanged. The guard is tested against the arm that MATCHED,
|
|
197
|
+
# not against both: testing both let a double-quoted heredoc whose BODY
|
|
198
|
+
# mentions a glued single-quoted token (`-m "... -m 'foo'bar ..."`) trip
|
|
199
|
+
# the sq arm and lose the fix for a message that never had a prefix
|
|
200
|
+
# problem (review of #3816, round 4, Minor 1).
|
|
201
|
+
# RESOLVER PRECONDITIONS. The resolver may run only where the captured text
|
|
202
|
+
# is provably the subject git receives. Each guard names an input where it
|
|
203
|
+
# is not; every refusal falls back to `head -1`, the pre-fix subject, which
|
|
204
|
+
# fails the format gate exactly as this whole form did before the fix.
|
|
205
|
+
RESOLVE=0
|
|
206
|
+
if [ "$MSG_QUOTE" = dq ]; then
|
|
207
|
+
RESOLVE=1
|
|
208
|
+
# Text before the message we matched. The heredoc BODY always sits after
|
|
209
|
+
# the match, so this window cannot be contaminated by message content —
|
|
210
|
+
# which is what lets the two guards below scan for tokens that would also
|
|
211
|
+
# be legal inside a commit message.
|
|
212
|
+
MSG_PREFIX="${CMD%%"$MSG_MATCH"*}"
|
|
213
|
+
# Text after it. Together, PREFIX and SUFFIX are the whole command MINUS
|
|
214
|
+
# the message — the window a guard must use when the token it scans for
|
|
215
|
+
# is also legal English inside a commit message, but may legally appear
|
|
216
|
+
# on EITHER side of the message on the command line.
|
|
217
|
+
MSG_SUFFIX="${CMD#*"$MSG_MATCH"}"
|
|
218
|
+
# LINE CONTINUATIONS ARE NOT SEPARATORS (review of #3816, rounds 8 and 9).
|
|
219
|
+
# `git commit \` newline ` -m "$(cat <<'EOF' …` is an ordinary way to
|
|
220
|
+
# spread an invocation over lines, and every guard below reads a newline in
|
|
221
|
+
# a window as a command separator, so the whole form was refused. That was
|
|
222
|
+
# disclosed as a fail-closed limit in round 8 because "is this newline a
|
|
223
|
+
# continuation" looked like the segmentation question this file has
|
|
224
|
+
# reverted twice. It is not: bash's rule is local and character-level. A
|
|
225
|
+
# newline preceded by an ODD run of backslashes is a continuation and bash
|
|
226
|
+
# removes both; an EVEN run (`\\` then newline) is a literal backslash
|
|
227
|
+
# followed by a real newline, which IS a separator. So the windows are
|
|
228
|
+
# joined the way bash joins them, in three bash-3.2-safe steps: every `\\`
|
|
229
|
+
# pair is parked on \x01, a byte no real command line carries, any
|
|
230
|
+
# backslash-newline that remains is a lone (odd) one and is removed, then
|
|
231
|
+
# the pairs are restored. Applied to BOTH windows, BEFORE the dequote
|
|
232
|
+
# copies are derived, so every scan sees the joined text.
|
|
233
|
+
#
|
|
234
|
+
# KNOWN OVER-BLOCK, fail-closed: a literal \x01 that IS present in the
|
|
235
|
+
# command is restored as `\\`, so an option-shaped token carrying one
|
|
236
|
+
# (`-\x01m`) reads as `-\\m`, dequotes to `-m`, and refuses where it did
|
|
237
|
+
# not before (independent review, round 9). Refusing is the recoverable
|
|
238
|
+
# direction; a control byte in an option name is not a spelling anyone
|
|
239
|
+
# types, and it is not a hole in the accept direction.
|
|
240
|
+
#
|
|
241
|
+
# Direction check: a continuation glued to the closing quote
|
|
242
|
+
# (`"$(…)"\` newline `suffix`) joins to `"$(…)"suffix`, which the glue
|
|
243
|
+
# guard refuses exactly as bash would have glued it; `\\` + newline keeps
|
|
244
|
+
# its newline and is still refused by the separator guard. Measured on
|
|
245
|
+
# bash 3.2.57 and 5.3.15 in tests/hooks-opt-in.test.cjs.
|
|
246
|
+
CONT_PARK=$'\x01'
|
|
247
|
+
MSG_PREFIX="${MSG_PREFIX//\\\\/$CONT_PARK}"
|
|
248
|
+
MSG_PREFIX="${MSG_PREFIX//\\$'\n'/}"
|
|
249
|
+
MSG_PREFIX="${MSG_PREFIX//$CONT_PARK/\\\\}"
|
|
250
|
+
MSG_SUFFIX="${MSG_SUFFIX//\\\\/$CONT_PARK}"
|
|
251
|
+
MSG_SUFFIX="${MSG_SUFFIX//\\$'\n'/}"
|
|
252
|
+
MSG_SUFFIX="${MSG_SUFFIX//$CONT_PARK/\\\\}"
|
|
253
|
+
|
|
254
|
+
# QUOTE-SPLICED SPELLINGS (independent review of #3816, round 6). Bash
|
|
255
|
+
# removes quotes before git ever sees an argument, so the same option has
|
|
256
|
+
# unboundedly many spellings on the command line: `--clean""up=verbatim`
|
|
257
|
+
# IS `--cleanup=verbatim` to git, and `-""m` IS `-m`. Both matched no
|
|
258
|
+
# literal and were measured ACCEPTING a 75-byte subject the length gate
|
|
259
|
+
# had recorded as 72. The guards below therefore scan a copy of their
|
|
260
|
+
# window with quote characters removed, which is what bash does to it.
|
|
261
|
+
# Only the two OPTION-NAME scans use it; the adjacency test deliberately
|
|
262
|
+
# does not, because it asks about a literal character position, and the
|
|
263
|
+
# message span itself is excluded from both windows either way.
|
|
264
|
+
MSG_PREFIX_DEQ="${MSG_PREFIX//[\"\']/}"
|
|
265
|
+
MSG_SUFFIX_DEQ="${MSG_SUFFIX//[\"\']/}"
|
|
266
|
+
# BACKSLASH-SPLICED SPELLINGS (independent review of #3816, round 7).
|
|
267
|
+
# Quote removal alone was not "the command as bash hands it to git": bash
|
|
268
|
+
# also removes syntactic backslashes, so `-\m WIP` IS `-m WIP` and
|
|
269
|
+
# `--clean\up=verbatim` IS `--cleanup=verbatim` to git, and both matched
|
|
270
|
+
# no literal. Measured: `-\m WIP -m <conforming heredoc>` accepted the
|
|
271
|
+
# heredoc while git recorded `WIP`, and a trailing `--clean\up=verbatim`
|
|
272
|
+
# accepted a 75-byte subject the length gate measured as 72. Stripped in a
|
|
273
|
+
# second pass so the class is unambiguous.
|
|
274
|
+
MSG_PREFIX_DEQ="${MSG_PREFIX_DEQ//\\/}"
|
|
275
|
+
MSG_SUFFIX_DEQ="${MSG_SUFFIX_DEQ//\\/}"
|
|
276
|
+
# DOLLAR-QUOTED SPELLINGS (independent review of #3816, round 8). The two
|
|
277
|
+
# passes above still were not "the command as bash hands it to git": bash
|
|
278
|
+
# has TWO more quoting forms whose introducer is a `$`, and removing the
|
|
279
|
+
# quote characters alone leaves that `$` stranded in the middle of the
|
|
280
|
+
# option name. `-$"m"` became `-$m` here while bash passes a real `-m` to
|
|
281
|
+
# git, and `--mes$'sage'=WIP` became `--mes$sage=WIP`; neither matched any
|
|
282
|
+
# literal, so the first-message guard below never fired. Measured on bash
|
|
283
|
+
# 3.2.57 and 5.3.15 against a real repository: the hook allowed
|
|
284
|
+
# `-$"m" WIP -m <conforming heredoc>` (exit 0) while `git cat-file -p`
|
|
285
|
+
# recorded the subject `WIP` — the same command spelled `-m WIP` is
|
|
286
|
+
# refused (exit 2). Stripping `$` closes both dollar-quote forms.
|
|
287
|
+
#
|
|
288
|
+
# RESIDUAL, and not fixable from a string: an option name assembled by an
|
|
289
|
+
# EXPANSION — `-${x}m`, `-$(printf m)` — is not knowable without running
|
|
290
|
+
# the command, the same limit this file already documents for expanded
|
|
291
|
+
# heredoc bodies. Stripping `$` makes those spellings collapse toward the
|
|
292
|
+
# literal too, which over-matches, and over-matching only refuses more.
|
|
293
|
+
MSG_PREFIX_DEQ="${MSG_PREFIX_DEQ//\$/}"
|
|
294
|
+
MSG_SUFFIX_DEQ="${MSG_SUFFIX_DEQ//\$/}"
|
|
295
|
+
|
|
296
|
+
# ADJACENCY GUARD (review of #3816): text glued to the CLOSING quote —
|
|
297
|
+
# `-m "$(cat <<'EOF' ... )"suffix` — is concatenated by bash into the SAME
|
|
298
|
+
# argument, so the capture holds only a PREFIX of the real message, and
|
|
299
|
+
# the length gate would measure a fraction of the real subject.
|
|
300
|
+
# SCOPE (review of #3816, round 6 — MAJOR). Glue is a property of the ONE
|
|
301
|
+
# character following the MATCHED span, so that character is the whole
|
|
302
|
+
# window. Scanning $CMD for the shape anywhere refused any conforming
|
|
303
|
+
# commit whose command merely CONTAINED a glued `-m` elsewhere —
|
|
304
|
+
# `git commit -m "<heredoc>" && echo -m "test"z` stayed blocked with
|
|
305
|
+
# CONVENTIONAL_COMMITS_VIOLATION. Base blocks it too, because base blocks
|
|
306
|
+
# EVERY heredoc form (that is #3802): this was the fix not reaching the
|
|
307
|
+
# shape, measured base=2 -> pre=2 -> post=0, not a regression.
|
|
308
|
+
# The separators and redirections are excluded because bash does NOT
|
|
309
|
+
# concatenate across them: in `-m "msg"&& echo hi` the argument ends at
|
|
310
|
+
# the quote, so there is no truncated capture to defend against.
|
|
311
|
+
# The class is held in a VARIABLE, not written inline. Inline, every
|
|
312
|
+
# member needs a backslash to get past the `[[ ]]` parser (`;`, `&` and
|
|
313
|
+
# `|` are metacharacters there) — and on bash 3.2, the system /bin/bash on
|
|
314
|
+
# macOS, those backslashes are passed THROUGH to the regex engine instead
|
|
315
|
+
# of being consumed by the shell, silently adding a literal `\` to the
|
|
316
|
+
# class. Unquoted expansion of a variable on the right of `=~` is the one
|
|
317
|
+
# spelling that is a plain regex on 3.2 and 5.x alike (review of #3816,
|
|
318
|
+
# round 8). Writing `[^[:space:];&|()<>]` inline is NOT the fix: it is a
|
|
319
|
+
# bash syntax error on both versions.
|
|
320
|
+
GLUE_CLASS='^[^[:space:];&|()<>]'
|
|
321
|
+
if [[ "$MSG_SUFFIX" =~ $GLUE_CLASS ]]; then RESOLVE=0; fi
|
|
322
|
+
|
|
323
|
+
# FIRST-MESSAGE GUARD (Codex review of #3816, round 4 — BLOCKER). The
|
|
324
|
+
# capture is a SEARCH over the whole command and the double-quoted arm is
|
|
325
|
+
# tried first, so it can select a `-m` that is not git's subject at all:
|
|
326
|
+
#
|
|
327
|
+
# git commit -m 'WIP first' -m "$(cat <<'EOF' -> git concatenates; the
|
|
328
|
+
# git commit -m WIP -m "$(cat <<'EOF' subject is `WIP first`
|
|
329
|
+
# git commit -m WIP -- -m "$(cat <<'EOF' -> after --, not a message
|
|
330
|
+
# git commit -m WIP && echo -m "$(cat <<'EOF' -> belongs to `echo`
|
|
331
|
+
#
|
|
332
|
+
# All four measured base=2 -> head=0, with git recording the FIRST message
|
|
333
|
+
# as the subject (verified against real commits, not the man page). The
|
|
334
|
+
# mis-selection is pre-existing; resolving it is what turned it into an
|
|
335
|
+
# enforcement bypass. Resolve only when nothing before the match could
|
|
336
|
+
# have been an earlier message, an end-of-options marker, or another
|
|
337
|
+
# command.
|
|
338
|
+
# BUNDLED SHORT OPTIONS (independent review of #3816, round 6). git splits
|
|
339
|
+
# `-am 'WIP first'` into `-a -m`, so the real subject is `WIP first` and
|
|
340
|
+
# the heredoc is git's SECOND message — measured accepting the heredoc's
|
|
341
|
+
# subject while git recorded `WIP first`. A standalone `-m` is therefore
|
|
342
|
+
# not the only spelling that claims the message; any short-option cluster
|
|
343
|
+
# ending in `m` does.
|
|
344
|
+
# ATTACHED VALUES AND --message ABBREVIATIONS (independent review of
|
|
345
|
+
# #3816, round 7). The scan required a space or `=` after the option name,
|
|
346
|
+
# so two spellings git accepts matched nothing: an ATTACHED short-option
|
|
347
|
+
# value (`-mWIP`, which git reads as `-m WIP`) and a long-option
|
|
348
|
+
# abbreviation (`--mes=WIP`), the same abbreviation behaviour this file
|
|
349
|
+
# already models for `--cleanup`. Both were measured accepting a later
|
|
350
|
+
# conforming heredoc while git recorded `WIP` as the subject — confirmed
|
|
351
|
+
# against the raw commit object, not `git log --pretty=%s`. The short arm
|
|
352
|
+
# therefore drops its trailing requirement entirely: a `-` followed by
|
|
353
|
+
# letters ending in `m` claims the message however it is spelled. Wider
|
|
354
|
+
# than git's own abbreviation set on purpose — over-matching only refuses
|
|
355
|
+
# more, which is the recoverable direction.
|
|
356
|
+
# Variable-held for the same bash-3.2 reason as GLUE_CLASS above.
|
|
357
|
+
# AN OPTION NAME BUILT BY A COMMAND SUBSTITUTION IS UNRESOLVABLE
|
|
358
|
+
# (independent review of #3816, round 8). Stripping `$` above collapses the
|
|
359
|
+
# two dollar-QUOTE forms onto their literals, but `--clean$(printf up)=`
|
|
360
|
+
# is a different thing: bash RUNS a program to finish the option name, so
|
|
361
|
+
# the argv git receives is not derivable from this string at all. Measured
|
|
362
|
+
# accepting a 75-byte subject the length gate had recorded as 72.
|
|
363
|
+
#
|
|
364
|
+
# SCOPED TO THE NAME, NOT THE VALUE. The class is a `-`-leading token whose
|
|
365
|
+
# characters up to the substitution contain no `=` — an option NAME being
|
|
366
|
+
# assembled. `--author="$(git config user.name)"` and `--author "$(…)"`
|
|
367
|
+
# both put the substitution in the VALUE, which this file never models and
|
|
368
|
+
# which stays allowed; only `-…$(` before any `=` refuses. Scanned on the
|
|
369
|
+
# RAW windows on purpose: the dequoted copies have had their `$` removed,
|
|
370
|
+
# so the shape is no longer visible there.
|
|
371
|
+
#
|
|
372
|
+
# This is a SHAPE, not a segmentation: it never tries to decide where
|
|
373
|
+
# git's own command ends. Two attempts at that were reverted for opening
|
|
374
|
+
# accept-direction holes, and the reasoning above still stands.
|
|
375
|
+
# WIDENED, and the strategy changed with it (independent review, round 9).
|
|
376
|
+
# The `$(`-only spelling above was the fourth patch in a row that tried to
|
|
377
|
+
# EMULATE what bash does to an argument before git sees it -- round 6
|
|
378
|
+
# removed quotes, round 7 backslashes, round 8 the `$` of a dollar-quote,
|
|
379
|
+
# and each time review found another transform that had been missed. Round
|
|
380
|
+
# 9 found four more, all measured accepting `WIP` as the real subject on
|
|
381
|
+
# bash 3.2.57 and 5.3.15 while the plain spelling of the same command is
|
|
382
|
+
# refused:
|
|
383
|
+
#
|
|
384
|
+
# -$'\155' WIP ANSI-C octal escape decodes to `m`
|
|
385
|
+
# -$'\x6d' WIP ANSI-C hex escape decodes to `m`
|
|
386
|
+
# -`printf m` WIP command substitution, backtick spelling
|
|
387
|
+
# x= … -${x}m WIP parameter expansion
|
|
388
|
+
# -? WIP pathname expansion, with a file named `-m`
|
|
389
|
+
#
|
|
390
|
+
# The last two settle the strategy: an option name finished by a PARAMETER
|
|
391
|
+
# expansion depends on a variable's runtime value, and one finished by a
|
|
392
|
+
# PATHNAME expansion depends on the contents of the working directory.
|
|
393
|
+
# Neither is derivable from the command string at any level of effort, so
|
|
394
|
+
# emulation cannot be completed -- not "has not been completed yet".
|
|
395
|
+
#
|
|
396
|
+
# So the rule is no longer "normalise it and match the literal". It is: an
|
|
397
|
+
# option NAME containing a shell expansion or quoting construct is
|
|
398
|
+
# UNRESOLVABLE, and unresolvable refuses. One rule covers every spelling
|
|
399
|
+
# above, and every spelling nobody has thought of yet, in the fail-closed
|
|
400
|
+
# direction. The dequoting passes above are kept: they still normalise the
|
|
401
|
+
# deterministic removals so the guards RECOGNISE `--clean""up=` and `-\m`
|
|
402
|
+
# rather than merely refusing them, which keeps the existing rows honest.
|
|
403
|
+
#
|
|
404
|
+
# SCOPED TO THE NAME, NOT THE VALUE, exactly as before: the class is a
|
|
405
|
+
# `-`-leading token whose characters up to the substitution contain no `=`.
|
|
406
|
+
# `--author="$(git config user.name)"` and `--author "$(…)"` put the
|
|
407
|
+
# construct in the VALUE and still resolve, pinned in both directions.
|
|
408
|
+
# Scanned on the RAW windows, because the dequoted copies have had `$` and
|
|
409
|
+
# the quote characters removed and the shape is no longer visible there.
|
|
410
|
+
#
|
|
411
|
+
# The class is bracket-only and holds no backslash, per round 8: a POSIX
|
|
412
|
+
# bracket expression has no escape mechanism, and a backslash written
|
|
413
|
+
# inside one becomes a literal member on bash 3.2.
|
|
414
|
+
SUBST_NAME_CLASS='(^|[[:space:]])-[^[:space:]=]*[$`?*[]'
|
|
415
|
+
if [[ "$MSG_PREFIX" =~ $SUBST_NAME_CLASS ]] \
|
|
416
|
+
|| [[ "$MSG_SUFFIX" =~ $SUBST_NAME_CLASS ]]; then RESOLVE=0; fi
|
|
417
|
+
SEP_CLASS='[;&|]'
|
|
418
|
+
if [[ "$MSG_PREFIX_DEQ" =~ (^|[[:space:]])(-[a-zA-Z]*m|--m[a-z]*([=[:space:]]|$)) ]] \
|
|
419
|
+
|| [[ "$MSG_PREFIX" =~ (^|[[:space:]])--([[:space:]]|$) ]] \
|
|
420
|
+
|| [[ "$MSG_PREFIX" =~ $SEP_CLASS ]] \
|
|
421
|
+
|| [[ "$MSG_PREFIX" == *$'\n'* ]]; then RESOLVE=0; fi
|
|
422
|
+
# NEWLINE IS A COMMAND SEPARATOR TOO (independent review of #3816, round
|
|
423
|
+
# 7) — the test above. The separator scan covered `;`, `&` and `|` but not
|
|
424
|
+
# a literal newline, so a LATER command's heredoc-shaped `-m` was taken
|
|
425
|
+
# for this commit's message:
|
|
426
|
+
#
|
|
427
|
+
# git commit --amend --no-edit
|
|
428
|
+
# echo -m "$(cat <<'EOF'
|
|
429
|
+
# fix: conforming text unrelated to the commit
|
|
430
|
+
# EOF
|
|
431
|
+
# )"
|
|
432
|
+
#
|
|
433
|
+
# The classifier recognises the leading commit, the capture reaches across
|
|
434
|
+
# the newline into `echo`'s argument, and a conforming string with no
|
|
435
|
+
# relationship to the commit was validated and allowed. Tested as a glob
|
|
436
|
+
# rather than folded into the bracket class, because a literal newline
|
|
437
|
+
# inside a bash regex bracket expression is not portably expressible.
|
|
438
|
+
|
|
439
|
+
# CLEANUP-MODE GUARD (Codex review of #3816, round 4 — BLOCKER). The
|
|
440
|
+
# resolver skips leading blank lines and strips trailing whitespace
|
|
441
|
+
# because git's DEFAULT cleanup=whitespace does. Under
|
|
442
|
+
# `--cleanup=verbatim` git does neither, so a 72-char subject plus three
|
|
443
|
+
# trailing spaces is committed as a 75-byte subject while the hook
|
|
444
|
+
# measured 72 — COMMIT_SUBJECT_TOO_LONG dodged (measured base=2 -> head=0;
|
|
445
|
+
# confirmed by reading the raw commit object, since `git log --pretty=%s`
|
|
446
|
+
# strips trailing whitespace in its own output and hides it).
|
|
447
|
+
# Any named mode other than `whitespace` refuses. A mode set persistently
|
|
448
|
+
# in git config is invisible here and stays a documented residual limit.
|
|
449
|
+
# SCOPE (review of #3816, round 5 — BLOCKER). This scan must exclude the
|
|
450
|
+
# message. `--cleanup=` and `commit.cleanup=` are ordinary English inside
|
|
451
|
+
# a commit message — this repository's own hooks and docs discuss them
|
|
452
|
+
# constantly — and the heredoc BODY sits verbatim inside $CMD, so
|
|
453
|
+
# scanning $CMD refused to resolve any conforming message that merely
|
|
454
|
+
# MENTIONED the token, blocking it with CONVENTIONAL_COMMITS_VIOLATION.
|
|
455
|
+
# Scanning $MSG_PREFIX alone (the fix as first prescribed) would reopen
|
|
456
|
+
# the bypass this guard exists for: git accepts the flag on either side
|
|
457
|
+
# of -m, and `git commit -m "<heredoc>" --cleanup=verbatim` is caught
|
|
458
|
+
# today only because the scan is command-wide. PREFIX + SUFFIX keeps both
|
|
459
|
+
# positions covered while excluding the one span that is message text.
|
|
460
|
+
# The two are joined with a space so a token cannot be forged across the
|
|
461
|
+
# seam out of a prefix tail and a suffix head.
|
|
462
|
+
# KNOWN LIMIT, deliberately fail-closed (#3816, round 6). This window is
|
|
463
|
+
# the whole command minus the message, so a `--cleanup=` that belongs to a
|
|
464
|
+
# DIFFERENT command — `git commit -m "<heredoc>" && echo --cleanup=verbatim`
|
|
465
|
+
# — also refuses, and a conforming commit git would accept stays blocked.
|
|
466
|
+
# Narrowing it to git's own segment was tried and reverted: deciding where
|
|
467
|
+
# git's command ends needs a shell parse, and a substring scan is not one.
|
|
468
|
+
# Trimming at the first `;&|` cut the window short whenever a separator sat
|
|
469
|
+
# inside an ordinary argument — `--author "a&b"`, and equally `--author
|
|
470
|
+
# a\&b` — which hid a REAL trailing `--cleanup=verbatim` and ACCEPTED a
|
|
471
|
+
# 75-byte subject the length gate had measured as 72. Two successive
|
|
472
|
+
# narrowings each reopened that hole on a shape the previous one missed, so
|
|
473
|
+
# the scan stays wide: refusing a commit git would take is recoverable,
|
|
474
|
+
# accepting an over-long subject is not.
|
|
475
|
+
# ABBREVIATIONS (independent review of #3816, round 6). git accepts any
|
|
476
|
+
# unambiguous prefix of a long option, so `--cle=verbatim` sets the mode
|
|
477
|
+
# while matching no literal `--cleanup` — measured accepting a 75-byte
|
|
478
|
+
# subject recorded as 72. The class is deliberately wider than git's own
|
|
479
|
+
# abbreviation set: over-matching only refuses more, which is the safe
|
|
480
|
+
# direction, and no other `--cl` option exists for git commit.
|
|
481
|
+
# LAST DIRECTIVE WINS, AND ONE MATCH CANNOT SEE IT (independent review of
|
|
482
|
+
# #3816, round 7). A bash regex yields ONE BASH_REMATCH, so only the
|
|
483
|
+
# FIRST cleanup directive was inspected — and git applies the LAST one.
|
|
484
|
+
# `--cleanup=whitespace -m <heredoc> --cleanup=verbatim` therefore read as
|
|
485
|
+
# mode=whitespace, resolution stayed enabled, and a 72-character subject
|
|
486
|
+
# plus trailing spaces was accepted while git recorded 75 bytes with the
|
|
487
|
+
# whitespace preserved (confirmed against the raw commit object). Deciding
|
|
488
|
+
# WHICH directive is last needs an argv order this substring scan does not
|
|
489
|
+
# have, so multiplicity itself refuses: more than one directive is
|
|
490
|
+
# unresolvable, not "probably fine". Single-directive behaviour is
|
|
491
|
+
# unchanged.
|
|
492
|
+
CLEANUP_WINDOW="$MSG_PREFIX_DEQ $MSG_SUFFIX_DEQ"
|
|
493
|
+
# `|| true` is load-bearing: this script runs under `set -euo pipefail`,
|
|
494
|
+
# and grep exits 1 when it matches NOTHING — which is the common case, a
|
|
495
|
+
# command with no cleanup directive at all. Without it the pipeline's
|
|
496
|
+
# non-zero status killed the hook outright (exit 1, no verdict) for every
|
|
497
|
+
# ordinary commit. Caught by running the real hook rather than the scan.
|
|
498
|
+
CLEANUP_HITS=$( { printf '%s' "$CLEANUP_WINDOW" | grep -oE '(--cl[a-z]*|commit\.cleanup)[=[:space:]]+[^[:space:]]+' || true; } | wc -l | tr -d ' ')
|
|
499
|
+
if [ "${CLEANUP_HITS:-0}" -gt 1 ]; then
|
|
500
|
+
RESOLVE=0
|
|
501
|
+
elif [[ "$CLEANUP_WINDOW" =~ (--cl[a-z]*|commit\.cleanup)[=[:space:]]+([^[:space:]]+) ]]; then
|
|
502
|
+
if [ "${BASH_REMATCH[2]}" != "whitespace" ]; then RESOLVE=0; fi
|
|
503
|
+
fi
|
|
504
|
+
|
|
505
|
+
# GIT-GENERATED SUBJECTS (independent review of #3816, round 7). With
|
|
506
|
+
# `--squash=<commit>` or `--fixup=<commit>` git composes the subject
|
|
507
|
+
# itself — measured recording `squash! base: something` while a conforming
|
|
508
|
+
# heredoc supplied via -m sailed through. The supplied message is not the
|
|
509
|
+
# subject in these modes at all, so there is nothing here worth measuring
|
|
510
|
+
# and resolution is refused outright. Abbreviations included for the same
|
|
511
|
+
# reason as --cleanup's. Deliberately NOT extended to the other
|
|
512
|
+
# message-SOURCE options (-C/--reuse-message, -c/--reedit-message,
|
|
513
|
+
# -F/--file, -t/--template): `-c` is also a git GLOBAL option that legally
|
|
514
|
+
# precedes the subcommand, so a scan for it would refuse ordinary
|
|
515
|
+
# `git -c k=v commit` invocations. Those remain a disclosed gap rather
|
|
516
|
+
# than a guessed guard.
|
|
517
|
+
if [[ "$MSG_PREFIX_DEQ $MSG_SUFFIX_DEQ" =~ (^|[[:space:]])--(squash|fixup|sq[a-z]*|fix[a-z]*)[=[:space:]] ]]; then RESOLVE=0; fi
|
|
518
|
+
fi
|
|
519
|
+
|
|
520
|
+
if [ "$RESOLVE" = 1 ]; then
|
|
521
|
+
SUBJECT=$(GIT_CMD_LIB="$HOOK_DIR/lib/git-cmd.js" MSG="$MSG" node -e "
|
|
522
|
+
const {resolveCommitSubject}=require(process.env.GIT_CMD_LIB);
|
|
523
|
+
process.stdout.write(resolveCommitSubject(process.env.MSG));
|
|
524
|
+
" 2>/dev/null) || SUBJECT=$(echo "$MSG" | head -1)
|
|
525
|
+
else
|
|
526
|
+
SUBJECT=$(echo "$MSG" | head -1)
|
|
527
|
+
fi
|
|
528
|
+
# Single source of truth for the accepted commit-type list (#3811): the
|
|
529
|
+
# 10 built-ins plus whatever passed the safe-token filter above. Both the
|
|
530
|
+
# regex alternation and the human-readable error text below are derived
|
|
531
|
+
# from this ONE array — no hand-synced second copy.
|
|
532
|
+
#
|
|
533
|
+
# The `"${EXTRA_COMMIT_TYPES[@]+"${EXTRA_COMMIT_TYPES[@]}"}"` form (not
|
|
534
|
+
# plain `"${EXTRA_COMMIT_TYPES[@]}"`) is required: on bash 3.2.57 (this
|
|
535
|
+
# repo's macOS test target), expanding `[@]` on an array that is declared
|
|
536
|
+
# but has zero elements throws "unbound variable" under `set -u` (which
|
|
537
|
+
# this script has via `set -euo pipefail`). Verified directly against
|
|
538
|
+
# /bin/bash 3.2.57 on macOS. The `${arr[@]+word}` form is the
|
|
539
|
+
# nounset-safe idiom for "expand if set, empty otherwise" on empty arrays.
|
|
540
|
+
COMMIT_TYPES=("${BUILTIN_COMMIT_TYPES[@]}" "${EXTRA_COMMIT_TYPES[@]+"${EXTRA_COMMIT_TYPES[@]}"}")
|
|
541
|
+
COMMIT_TYPE_ALT=$(IFS='|'; echo "${COMMIT_TYPES[*]}")
|
|
542
|
+
COMMIT_TYPE_LIST=$(printf '%s, ' "${COMMIT_TYPES[@]}")
|
|
543
|
+
COMMIT_TYPE_LIST="${COMMIT_TYPE_LIST%, }"
|
|
544
|
+
# Typed `valid_types` array (#3811 review finding): CONTRIBUTING.md bans
|
|
545
|
+
# substring/prose matching on `reason` in tests — a test needing to
|
|
546
|
+
# verify the accepted-type set must have a typed field, not grep prose.
|
|
547
|
+
# Safe to build with a bare printf (no JSON-escaping needed): every
|
|
548
|
+
# element of COMMIT_TYPES has already passed the `^[a-z][a-z0-9-]*$`
|
|
549
|
+
# safe-token filter (or is a literal built-in), so none can contain `"`
|
|
550
|
+
# or `\`.
|
|
551
|
+
COMMIT_TYPES_JSON=$(printf '"%s",' "${COMMIT_TYPES[@]}")
|
|
552
|
+
COMMIT_TYPES_JSON="[${COMMIT_TYPES_JSON%,}]"
|
|
117
553
|
# Validate Conventional Commits format
|
|
118
|
-
if ! [[ "$SUBJECT" =~ ^(
|
|
119
|
-
# Emit
|
|
120
|
-
# on the stable code string
|
|
121
|
-
|
|
554
|
+
if ! [[ "$SUBJECT" =~ ^($COMMIT_TYPE_ALT)(\(.+\))?:[[:space:]].+ ]]; then
|
|
555
|
+
# Emit typed `code` and `valid_types` fields alongside `reason` (#2974,
|
|
556
|
+
# #3811). Tests assert on the stable code string and the typed array;
|
|
557
|
+
# the reason is the human-readable copy, never grepped by tests.
|
|
558
|
+
echo "{\"decision\": \"block\", \"code\": \"CONVENTIONAL_COMMITS_VIOLATION\", \"valid_types\": $COMMIT_TYPES_JSON, \"reason\": \"Commit message must follow Conventional Commits: <type>(<scope>): <subject>. Valid types: $COMMIT_TYPE_LIST. Subject must be <=72 chars, lowercase, imperative mood, no trailing period.\"}"
|
|
122
559
|
exit 2
|
|
123
560
|
fi
|
|
124
561
|
if [ ${#SUBJECT} -gt 72 ]; then
|
|
@@ -366,7 +366,8 @@ process.stdin.on('end', () => {
|
|
|
366
366
|
'This edit will not be tracked in STATE.md or produce a SUMMARY.md. ' +
|
|
367
367
|
'Consider using /gsd:fast for trivial fixes or /gsd:quick for larger changes ' +
|
|
368
368
|
'to maintain project state tracking. ' +
|
|
369
|
-
'If this is intentional (e.g., user explicitly asked for a direct edit), proceed normally.'
|
|
369
|
+
'If this is intentional (e.g., user explicitly asked for a direct edit), proceed normally.',
|
|
370
|
+
code: 'WORKFLOW_ADVISORY'
|
|
370
371
|
}
|
|
371
372
|
};
|
|
372
373
|
|
package/hooks/hooks.json
CHANGED
|
@@ -28,6 +28,12 @@
|
|
|
28
28
|
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-write-guard.js\"", "timeout": 5 }
|
|
29
29
|
]
|
|
30
30
|
},
|
|
31
|
+
{
|
|
32
|
+
"matcher": "Read|Grep|Bash",
|
|
33
|
+
"hooks": [
|
|
34
|
+
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-secret-read-guard.js\"", "timeout": 5 }
|
|
35
|
+
]
|
|
36
|
+
},
|
|
31
37
|
{
|
|
32
38
|
"matcher": "Agent|Task",
|
|
33
39
|
"hooks": [
|