javi-forge 1.24.0 → 1.25.1

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.
@@ -70,9 +70,14 @@ export async function handleCi(cli, ctx) {
70
70
  // Sub-command: javi-forge ci init → install git hooks
71
71
  if (cli.input[1] === "init") {
72
72
  const { installCIHooks } = await import("../../commands/ci.js");
73
- const { installed, upgraded, backups, errors, states } = await installCIHooks(process.cwd(), {
73
+ const { installed, upgraded, backups, errors, states, notes } = await installCIHooks(process.cwd(), {
74
74
  force: cli.flags.force === true,
75
75
  });
76
+ // Migration notes (e.g. "legacy javi-forge hooksPath removed") come first:
77
+ // they explain a config change the operator did not explicitly ask for.
78
+ for (const note of notes) {
79
+ console.log(`ℹ ${note}`);
80
+ }
76
81
  for (const backup of backups) {
77
82
  console.log(`⚠ Backed up the previous hook → ${backup}`);
78
83
  }
@@ -2,9 +2,12 @@
2
2
  * `javi-forge tdd <init|pipeline>` handler.
3
3
  *
4
4
  * Console-only (no Ink, no React, no CIContextProvider).
5
- * Lazy-loads ./commands/tdd.js or ./commands/tdd-pipeline.js INSIDE the function
6
- * to preserve cold-start performance heavy command modules MUST NOT be
7
- * eager-imported at the top of this file.
5
+ *
6
+ * hook-consolidation S3: the handlers no longer WRITE `.git/hooks` themselves.
7
+ * They flip the `hooks.*.tdd` flag in `.javi-forge/ci.yaml` and delegate the
8
+ * hook install to the hardened `installCIHooks` — the single writer of
9
+ * `.git/hooks`. Heavy modules are lazy-imported INSIDE the function to preserve
10
+ * cold-start performance.
8
11
  */
9
12
  import type { CLI } from "./types.js";
10
13
  export declare function handleTdd(cli: CLI): Promise<void>;
@@ -2,43 +2,74 @@
2
2
  * `javi-forge tdd <init|pipeline>` handler.
3
3
  *
4
4
  * Console-only (no Ink, no React, no CIContextProvider).
5
- * Lazy-loads ./commands/tdd.js or ./commands/tdd-pipeline.js INSIDE the function
6
- * to preserve cold-start performance heavy command modules MUST NOT be
7
- * eager-imported at the top of this file.
5
+ *
6
+ * hook-consolidation S3: the handlers no longer WRITE `.git/hooks` themselves.
7
+ * They flip the `hooks.*.tdd` flag in `.javi-forge/ci.yaml` and delegate the
8
+ * hook install to the hardened `installCIHooks` — the single writer of
9
+ * `.git/hooks`. Heavy modules are lazy-imported INSIDE the function to preserve
10
+ * cold-start performance.
8
11
  */
12
+ /** Surface an installCIHooks result to the console (notes → backups → installed
13
+ * → upgraded → errors), matching the `ci init` output contract. */
14
+ function reportInstall(result) {
15
+ for (const note of result.notes) {
16
+ console.log(`ℹ ${note}`);
17
+ }
18
+ for (const backup of result.backups) {
19
+ console.log(`⚠ Backed up the previous hook → ${backup}`);
20
+ }
21
+ if (result.installed.length > 0) {
22
+ console.log(`✓ Installed git hooks: ${result.installed.join(", ")}`);
23
+ }
24
+ for (const hook of result.upgraded) {
25
+ const was = result.states.find((entry) => entry.name === hook)?.state;
26
+ console.log(`↑ Upgraded ${hook}${was === undefined ? "" : ` (was ${was})`}`);
27
+ }
28
+ for (const err of result.errors) {
29
+ console.error(`✗ ${err}`);
30
+ }
31
+ }
9
32
  export async function handleTdd(cli) {
10
- if (cli.input[1] === "init") {
11
- const { installTddHooks } = await import("../../commands/tdd.js");
12
- const { installed, errors } = await installTddHooks(process.cwd());
13
- if (installed.length > 0) {
14
- console.log(`\u2713 Installed TDD hooks: ${installed.join(", ")}`);
15
- console.log(" Pre-commit hook enforces tests must pass before commit");
16
- }
17
- for (const err of errors) {
18
- console.error(`\u2717 ${err}`);
19
- }
20
- process.exit(errors.length > 0 ? 1 : 0);
21
- }
22
- else if (cli.input[1] === "pipeline") {
23
- const { installTddPipelineHook } = await import("../../commands/tdd-pipeline.js");
33
+ const sub = cli.input[1];
34
+ const force = cli.flags.force === true;
35
+ if (sub === "init") {
36
+ const { setHookFeature } = await import("../../lib/ci-config.js");
37
+ const { installCIHooks } = await import("../../commands/ci.js");
38
+ // Install FIRST: installCIHooks writes generic static shims and does not
39
+ // depend on the tdd flag (the flag is read at hook-RUN time). Only once the
40
+ // managed hook is actually in place do we flip the config — otherwise a
41
+ // refused install (foreign hook / husky core.hooksPath) would leave ci.yaml
42
+ // claiming tdd:true while no managed hook exists.
43
+ const result = await installCIHooks(process.cwd(), { force });
44
+ reportInstall(result);
45
+ if (result.errors.length > 0)
46
+ process.exit(1);
47
+ const configPath = await setHookFeature(process.cwd(), "pre-commit", "tdd", true);
48
+ console.log(`✓ Enabled TDD in ${configPath} (hooks.pre-commit.tdd: true)`);
49
+ console.log(" The pre-commit hook now runs the stack test command via the dispatcher.");
50
+ process.exit(0);
51
+ }
52
+ else if (sub === "pipeline") {
24
53
  const mode = cli.flags.mode === "warn" ? "warn" : "strict";
25
- const result = await installTddPipelineHook(process.cwd(), mode);
26
- if (result.installed.length > 0) {
27
- console.log(`\u2713 Installed TDD pipeline hook: ${result.installed.join(", ")} [${result.mode}]`);
28
- console.log(` Pre-push hook enforces TDD pipeline (${result.mode} mode)`);
29
- }
30
- for (const skip of result.skipped) {
31
- console.log(`\u26A0 ${skip}`);
32
- }
33
- for (const err of result.errors) {
34
- console.error(`\u2717 ${err}`);
35
- }
36
- process.exit(result.errors.length > 0 ? 1 : 0);
54
+ const { setHookFeature } = await import("../../lib/ci-config.js");
55
+ const { installCIHooks } = await import("../../commands/ci.js");
56
+ // Install FIRST (see `init` above): never leave the config ahead of a
57
+ // refused install.
58
+ const result = await installCIHooks(process.cwd(), { force });
59
+ reportInstall(result);
60
+ if (result.errors.length > 0)
61
+ process.exit(1);
62
+ const configPath = await setHookFeature(process.cwd(), "pre-push", "tdd", mode);
63
+ console.log(`✓ Enabled TDD pipeline in ${configPath} (hooks.pre-push.tdd: ${mode})`);
64
+ console.log(mode === "warn"
65
+ ? " The pre-push hook runs tests as an ADVISORY section (never blocks)."
66
+ : " The pre-push hook BLOCKS the push when tests fail.");
67
+ process.exit(0);
37
68
  }
38
69
  else {
39
70
  console.error("Usage: javi-forge tdd <command>");
40
- console.error(" init Install TDD-enforcing pre-commit hook");
41
- console.error(" pipeline Install TDD pipeline pre-push hook (--mode strict|warn)");
71
+ console.error(" init Enable the TDD pre-commit section and install the managed hooks");
72
+ console.error(" pipeline Enable the TDD pre-push section (--mode strict|warn)");
42
73
  process.exit(1);
43
74
  }
44
75
  }
@@ -8,7 +8,7 @@
8
8
  * Help banner shown by meow when `--help` is passed or invalid args are supplied.
9
9
  * Multi-line template literal — preserve exact formatting (whitespace is significant).
10
10
  */
11
- export declare const HELP_TEXT = "\n Usage\n $ javi-forge [command] [options]\n\n Commands\n init Bootstrap a new project (default)\n ci Run CI simulation (lint + compile + test + security + ghagga)\n ci validate Validate .javi-forge/ci.yaml without running anything\n ci init Install git hooks that call javi-forge ci\n tdd init Install TDD-enforcing pre-commit hook (auto-detects stack)\n tdd pipeline Install TDD pipeline pre-push hook (--mode strict|warn)\n hooks run Run a git hook's composed sections (pre-commit | pre-push)\n analyze Run repoforge skills analysis\n doctor Show health report\n workflow show Render a workflow graph as ASCII (--template <name> or file path)\n workflow validate Validate project state against a workflow graph\n workflow list List available workflows and built-in templates\n plugin add Install a plugin from GitHub (org/repo)\n plugin remove Remove an installed plugin\n plugin list List installed plugins\n plugin search Search the plugin registry\n plugin validate Validate a local plugin directory\n plugin sync Auto-detect and wire installed plugins\n plugin export Export plugin to Agent Skills spec format (skills.json)\n plugin export --codex: Export plugin to Codex-compatible TOML subagent files\n plugin export-skills Generate aggregated skills.json from all installed plugins\n plugin export-skills global Generate global skills.json from all globally installed plugins\n plugin import Import an Agent Skills spec package as a javi-forge plugin\n skills doctor Show skills health report (add --deep for conflict detection)\n skills budget Show token cost of loaded skills (add -b N for custom budget)\n skills score Score a skill on quality dimensions (completeness, clarity, testability, token-efficiency)\n skills benchmark Benchmark a skill with structural quality checks\n skills auto Auto-detect project stack and suggest/install matching AI skills\n skills auto-install Alias for skills auto\n skill publish Package a skill directory for marketplace distribution (generates plugin.json)\n security baseline Create security baseline from current audit findings\n security check Check for regressions against baseline (exits non-zero if found)\n security update Re-snapshot baseline (acknowledge current vulns)\n security allowlist Add all current findings to the allowlist (suppress in future checks)\n llms-txt Generate AI-friendly llms.txt for current project\n\n Options\n --dry-run Preview changes without writing files\n --stack Project stack (node, python, go, rust, java-gradle, java-maven, elixir)\n --ci CI provider (github, gitlab, woodpecker)\n --memory Memory module (engram, obsidian-brain, memory-simple, none)\n --project-name Project name (skips name prompt)\n --ghagga Enable GHAGGA review system\n --mock Enable mock-first mode (no real API keys needed)\n --local-ai Include local AI dev stack (Ollama + Docker Compose)\n --batch Non-interactive mode (auto-proceed, no keyboard input)\n --deep Enable deep analysis (conflict + duplicate detection)\n --budget, -b Token budget limit for skills (default: 8000)\n --skills-dir Custom skills directory path\n --author Author name for skill publish\n --repo Repository URL for skill publish\n --version Show version\n --help Show this help\n\n CI options (javi-forge ci)\n --quick Lint + compile only (fast, for pre-commit)\n --shell Open interactive shell in CI container\n --detect Show detected stack and exit\n --config PATH Load ordered CI runners from a versioned config file\n (default discovery: .javi-forge/ci.yaml)\n --stack STACK Force a single explicit stack (single-stack repos only \u2014\n insufficient for hybrid repos; use --config instead)\n --no-docker Run commands natively (no Docker)\n --no-ci-ghagga Skip GHAGGA review\n --no-security Skip Semgrep security scan\n --timeout N Per-step timeout in seconds (default: 600)\n\n CI hooks (javi-forge ci init)\n Install git hooks that call javi-forge ci.\n No files copied \u2014 hooks reference the global CLI.\n Existing hooks javi-forge did not write are refused, never clobbered.\n --force Overwrite a foreign or locally modified hook. The previous\n content is copied to a .bak sibling first; if that backup\n cannot be written, the hook is left untouched. Symlinked\n hook paths are refused even with --force.\n\n Examples\n $ javi-forge\n $ javi-forge init --dry-run\n $ javi-forge init --stack node --ci github\n $ javi-forge ci\n $ javi-forge ci init\n $ javi-forge ci init --force\n $ javi-forge tdd init\n $ javi-forge ci --quick\n $ javi-forge ci --no-ci-ghagga --no-security\n $ javi-forge ci --no-docker\n $ javi-forge ci --shell\n $ javi-forge ci --config .javi-forge/ci.yaml\n $ javi-forge ci validate\n $ javi-forge ci --help\n $ javi-forge analyze\n $ javi-forge doctor\n $ javi-forge plugin add mapbox/agent-skills\n $ javi-forge plugin list\n";
11
+ export declare const HELP_TEXT = "\n Usage\n $ javi-forge [command] [options]\n\n Commands\n init Bootstrap a new project (default)\n ci Run CI simulation (lint + compile + test + security + ghagga)\n ci validate Validate .javi-forge/ci.yaml without running anything\n ci init Install git hooks that call javi-forge ci\n tdd init Enable the TDD pre-commit section + install managed hooks\n tdd pipeline Enable the TDD pre-push section (--mode strict|warn)\n hooks run Run a git hook's composed sections (pre-commit | pre-push)\n analyze Run repoforge skills analysis\n doctor Show health report\n workflow show Render a workflow graph as ASCII (--template <name> or file path)\n workflow validate Validate project state against a workflow graph\n workflow list List available workflows and built-in templates\n plugin add Install a plugin from GitHub (org/repo)\n plugin remove Remove an installed plugin\n plugin list List installed plugins\n plugin search Search the plugin registry\n plugin validate Validate a local plugin directory\n plugin sync Auto-detect and wire installed plugins\n plugin export Export plugin to Agent Skills spec format (skills.json)\n plugin export --codex: Export plugin to Codex-compatible TOML subagent files\n plugin export-skills Generate aggregated skills.json from all installed plugins\n plugin export-skills global Generate global skills.json from all globally installed plugins\n plugin import Import an Agent Skills spec package as a javi-forge plugin\n skills doctor Show skills health report (add --deep for conflict detection)\n skills budget Show token cost of loaded skills (add -b N for custom budget)\n skills score Score a skill on quality dimensions (completeness, clarity, testability, token-efficiency)\n skills benchmark Benchmark a skill with structural quality checks\n skills auto Auto-detect project stack and suggest/install matching AI skills\n skills auto-install Alias for skills auto\n skill publish Package a skill directory for marketplace distribution (generates plugin.json)\n security baseline Create security baseline from current audit findings\n security check Check for regressions against baseline (exits non-zero if found)\n security update Re-snapshot baseline (acknowledge current vulns)\n security allowlist Add all current findings to the allowlist (suppress in future checks)\n llms-txt Generate AI-friendly llms.txt for current project\n\n Options\n --dry-run Preview changes without writing files\n --stack Project stack (node, python, go, rust, java-gradle, java-maven, elixir)\n --ci CI provider (github, gitlab, woodpecker)\n --memory Memory module (engram, obsidian-brain, memory-simple, none)\n --project-name Project name (skips name prompt)\n --ghagga Enable GHAGGA review system\n --mock Enable mock-first mode (no real API keys needed)\n --local-ai Include local AI dev stack (Ollama + Docker Compose)\n --batch Non-interactive mode (auto-proceed, no keyboard input)\n --deep Enable deep analysis (conflict + duplicate detection)\n --budget, -b Token budget limit for skills (default: 8000)\n --skills-dir Custom skills directory path\n --author Author name for skill publish\n --repo Repository URL for skill publish\n --version Show version\n --help Show this help\n\n CI options (javi-forge ci)\n --quick Lint + compile only (fast, for pre-commit)\n --shell Open interactive shell in CI container\n --detect Show detected stack and exit\n --config PATH Load ordered CI runners from a versioned config file\n (default discovery: .javi-forge/ci.yaml)\n --stack STACK Force a single explicit stack (single-stack repos only \u2014\n insufficient for hybrid repos; use --config instead)\n --no-docker Run commands natively (no Docker)\n --no-ci-ghagga Skip GHAGGA review\n --no-security Skip Semgrep security scan\n --timeout N Per-step timeout in seconds (default: 600)\n\n CI hooks (javi-forge ci init)\n Install git hooks that call javi-forge ci.\n No files copied \u2014 hooks reference the global CLI.\n Existing hooks javi-forge did not write are refused, never clobbered.\n --force Overwrite a foreign or locally modified hook. The previous\n content is copied to a .bak sibling first; if that backup\n cannot be written, the hook is left untouched. Symlinked\n hook paths are refused even with --force.\n\n Examples\n $ javi-forge\n $ javi-forge init --dry-run\n $ javi-forge init --stack node --ci github\n $ javi-forge ci\n $ javi-forge ci init\n $ javi-forge ci init --force\n $ javi-forge tdd init\n $ javi-forge ci --quick\n $ javi-forge ci --no-ci-ghagga --no-security\n $ javi-forge ci --no-docker\n $ javi-forge ci --shell\n $ javi-forge ci --config .javi-forge/ci.yaml\n $ javi-forge ci validate\n $ javi-forge ci --help\n $ javi-forge analyze\n $ javi-forge doctor\n $ javi-forge plugin add mapbox/agent-skills\n $ javi-forge plugin list\n";
12
12
  /**
13
13
  * Per-command help for `ci`, shown by `javi-forge ci --help` (or when `ci` is
14
14
  * given an unknown subcommand). Kept consistent with the global HELP_TEXT
package/dist/cli/help.js CHANGED
@@ -17,8 +17,8 @@ export const HELP_TEXT = `
17
17
  ci Run CI simulation (lint + compile + test + security + ghagga)
18
18
  ci validate Validate .javi-forge/ci.yaml without running anything
19
19
  ci init Install git hooks that call javi-forge ci
20
- tdd init Install TDD-enforcing pre-commit hook (auto-detects stack)
21
- tdd pipeline Install TDD pipeline pre-push hook (--mode strict|warn)
20
+ tdd init Enable the TDD pre-commit section + install managed hooks
21
+ tdd pipeline Enable the TDD pre-push section (--mode strict|warn)
22
22
  hooks run Run a git hook's composed sections (pre-commit | pre-push)
23
23
  analyze Run repoforge skills analysis
24
24
  doctor Show health report
@@ -212,6 +212,12 @@ export interface InstallHooksResult {
212
212
  backups: string[];
213
213
  errors: string[];
214
214
  states: HookStateReport[];
215
+ /**
216
+ * Informational messages from the hooksPath guard (D6) — e.g. the "legacy
217
+ * javi-forge hooksPath removed" note emitted when the migration path unsets
218
+ * a `ci-local/hooks` local value. NOT errors; surfaced to the operator.
219
+ */
220
+ notes: string[];
215
221
  }
216
222
  /**
217
223
  * Pure classification of hook CONTENT (D6 steps 1-5).
@@ -1548,9 +1548,227 @@ async function repairHookMode(hookPath) {
1548
1548
  async function readHookBody(hookName) {
1549
1549
  return await fs.readFile(path.join(HOOK_ASSETS_DIR, hookName), "utf8");
1550
1550
  }
1551
+ /** The exact — and ONLY — value javi-forge itself ever wrote (git.ts:83). */
1552
+ const LEGACY_HOOKSPATH = "ci-local/hooks";
1553
+ /**
1554
+ * Read a higher-scope `core.hooksPath` with `git config <scope> --get`.
1555
+ *
1556
+ * `--global`/`--system` reads return each scope's OWN value regardless of the
1557
+ * local value, so a shadowing higher-scope hooksPath is visible with ZERO
1558
+ * mutation. git exits 1 (no section / no config file) or 5 (key unset) for "no
1559
+ * value" → `{ value: "" }`. ANY other failure is surfaced as `failed` so the
1560
+ * guard can fail CLOSED: a blind spot on a possible shadow must never let the
1561
+ * migration unset the local value and half-migrate the repo.
1562
+ */
1563
+ async function readScopedHooksPath(projectDir, scope) {
1564
+ try {
1565
+ const { stdout } = await execFileAsync("git", ["config", scope, "--get", "core.hooksPath"], { cwd: projectDir });
1566
+ return { value: stdout.trim() };
1567
+ }
1568
+ catch (e) {
1569
+ const code = e.code;
1570
+ if (code === 1 || code === 5) {
1571
+ return { value: "" };
1572
+ }
1573
+ return { value: "", failed: e instanceof Error ? e.message : String(e) };
1574
+ }
1575
+ }
1576
+ /**
1577
+ * Read the LOCAL `core.hooksPath` with the SAME fail-closed discipline as
1578
+ * {@link readScopedHooksPath} (JDA-001).
1579
+ *
1580
+ * git exits 1 (no section / no config file) or 5 (key unset) for a genuine "no
1581
+ * value" → `{ value: "" }`, the safe fresh-install path. ANY OTHER failure
1582
+ * (config lock, I/O error, a corrupt `.git/config`, a git fault) is surfaced as
1583
+ * `failed` so the guard can REFUSE. Swallowing such a failure to `""` would be a
1584
+ * FAIL-OPEN: a local value genuinely = `ci-local/hooks` that transiently fails
1585
+ * to read would look like a fresh repo, so step 5 would never unset it and the
1586
+ * shims would be installed into `.git/hooks` while the still-present local value
1587
+ * keeps SHADOWING them — `installCIHooks` would report installed/upgraded while
1588
+ * git runs the OLD ci-local bodies. An unknown-state local read must never be
1589
+ * treated as a fresh install.
1590
+ */
1591
+ async function readLocalHooksPath(projectDir) {
1592
+ try {
1593
+ const { stdout } = await execFileAsync("git", ["config", "--local", "--get", "core.hooksPath"], { cwd: projectDir });
1594
+ return { value: stdout.trim() };
1595
+ }
1596
+ catch (e) {
1597
+ const code = e.code;
1598
+ if (code === 1 || code === 5) {
1599
+ return { value: "" };
1600
+ }
1601
+ return { value: "", failed: e instanceof Error ? e.message : String(e) };
1602
+ }
1603
+ }
1604
+ /**
1605
+ * Read a WORKTREE-scoped `core.hooksPath` (JDA-002), which — with
1606
+ * `extensions.worktreeConfig` enabled — ALSO shadows `.git/hooks`.
1607
+ *
1608
+ * The extension is checked FIRST for a load-bearing reason: when it is OFF, `git
1609
+ * config --worktree` silently behaves like `--local` (verified), so reading it
1610
+ * here would double-count the LOCAL value and refuse a valid legacy migration.
1611
+ * It is a DISTINCT scope only when the extension is enabled, and only then does
1612
+ * `--worktree --get` read from `$GIT_DIR/config.worktree` WITHOUT falling back to
1613
+ * local. Gating on the extension also handles the multiple-worktree + extension-off
1614
+ * case where `--worktree` errors out ("not enabled"): we never issue that read, so
1615
+ * that specific error can never surface as a spurious refuse.
1616
+ *
1617
+ * Same fail-closed discipline as the scoped reads: exit 1/5 → genuine no value;
1618
+ * any OTHER failure → `failed` so the guard REFUSES rather than fail open.
1619
+ */
1620
+ async function readWorktreeHooksPath(projectDir) {
1621
+ // Determine whether the worktree config extension is enabled.
1622
+ let enabled = false;
1623
+ try {
1624
+ const { stdout } = await execFileAsync("git", ["config", "--bool", "--get", "extensions.worktreeConfig"], { cwd: projectDir });
1625
+ enabled = stdout.trim() === "true";
1626
+ }
1627
+ catch (e) {
1628
+ const code = e.code;
1629
+ if (code === 1 || code === 5) {
1630
+ // Extension unset/disabled → no DISTINCT worktree scope exists; the local
1631
+ // read (step 3) already covers the --worktree==--local fallback. Treat as
1632
+ // no worktree value, never a refuse.
1633
+ return { value: "" };
1634
+ }
1635
+ // Cannot determine the extension state → fail CLOSED.
1636
+ return { value: "", failed: e instanceof Error ? e.message : String(e) };
1637
+ }
1638
+ if (!enabled) {
1639
+ return { value: "" };
1640
+ }
1641
+ try {
1642
+ const { stdout } = await execFileAsync("git", ["config", "--worktree", "--get", "core.hooksPath"], { cwd: projectDir });
1643
+ return { value: stdout.trim() };
1644
+ }
1645
+ catch (e) {
1646
+ const code = e.code;
1647
+ if (code === 1 || code === 5) {
1648
+ return { value: "" };
1649
+ }
1650
+ return { value: "", failed: e instanceof Error ? e.message : String(e) };
1651
+ }
1652
+ }
1653
+ /**
1654
+ * The ATOMIC `core.hooksPath` guard (design D6). Runs BEFORE the install loop
1655
+ * mutates anything.
1656
+ *
1657
+ * DETECT-BEFORE-MUTATE — steps 1-4 are pure reads; only step 5 writes (unset
1658
+ * the legacy local value). EVERY refuse path returns `{ refuse }` having mutated
1659
+ * NOTHING, so the repo is left in its EXACT prior state and can never land
1660
+ * half-migrated (JDA-001 / JDA-008). The single write (`--local --unset`) and
1661
+ * the subsequent shim install happen ONLY on the fully-validated success/force
1662
+ * path.
1663
+ */
1664
+ async function guardHooksPath(projectDir, hooksDir, manifest, force) {
1665
+ // ── Step 1: classify the managed slots (pure read), exactly as the install
1666
+ // loop will. A manifest problem is NOT classified here — it surfaces
1667
+ // per-hook in the loop; an unclassifiable slot is treated as ABSENT (never
1668
+ // fabricate a foreign refusal from a manifest error).
1669
+ const foreignSlots = [];
1670
+ for (const name of HOOK_NAMES) {
1671
+ const entry = manifest[name];
1672
+ try {
1673
+ assertHookManifestEntry(entry, name);
1674
+ }
1675
+ catch {
1676
+ continue;
1677
+ }
1678
+ const state = await classifyHookPath(path.join(hooksDir, name), name, entry);
1679
+ if (state === HOOK_STATE.FOREIGN) {
1680
+ foreignSlots.push(name);
1681
+ }
1682
+ }
1683
+ // ── Step 2: DETECT a shadowing higher-scope hooksPath via SCOPED reads (ZERO
1684
+ // mutation). A global/system value shadows .git/hooks the moment git
1685
+ // consults hooksPath, so installing there would be INERT — refuse rather
1686
+ // than fail open. Detected WITHOUT unsetting local, so a
1687
+ // `local-unset + global-present` half-migration is impossible.
1688
+ for (const scope of ["--system", "--global"]) {
1689
+ const probe = await readScopedHooksPath(projectDir, scope);
1690
+ if (probe.failed !== undefined) {
1691
+ return {
1692
+ refuse: `could not read ${scope} git config core.hooksPath (${probe.failed}). Refusing to install: a higher-scope hooksPath could silently shadow .git/hooks and the hooks would not run. Resolve the git error and re-run.`,
1693
+ };
1694
+ }
1695
+ if (probe.value !== "") {
1696
+ const label = scope === "--global" ? "global" : "system";
1697
+ return {
1698
+ refuse: `a ${label} core.hooksPath='${probe.value}' would redirect git away from .git/hooks — the installed hooks would NOT run. Resolve it first (git config ${scope} --unset core.hooksPath) and re-run.`,
1699
+ };
1700
+ }
1701
+ }
1702
+ // ── Step 2b (JDA-002): a WORKTREE-scoped hooksPath (with
1703
+ // extensions.worktreeConfig enabled) ALSO shadows .git/hooks. Same
1704
+ // fail-closed handling: a non-1/5 read failure REFUSES rather than fail open.
1705
+ const worktree = await readWorktreeHooksPath(projectDir);
1706
+ if (worktree.failed !== undefined) {
1707
+ return {
1708
+ refuse: `could not read --worktree git config core.hooksPath (${worktree.failed}). Refusing to install: a worktree-scoped hooksPath could silently shadow .git/hooks and the hooks would not run. Resolve the git error and re-run.`,
1709
+ };
1710
+ }
1711
+ if (worktree.value !== "") {
1712
+ return {
1713
+ refuse: `a worktree core.hooksPath='${worktree.value}' would redirect git away from .git/hooks — the installed hooks would NOT run. Resolve it first (git config --worktree --unset core.hooksPath) and re-run.`,
1714
+ };
1715
+ }
1716
+ // Shadow reads now cover --global, --system and --worktree. The includeIf
1717
+ // residual edge remains: a hooksPath injected only through a
1718
+ // [includeIf "gitdir:…"] conditional include is NOT returned by
1719
+ // --global/--system/--worktree --get and would surface only in an effective
1720
+ // read — a documented residual edge, not covered by these scoped reads.
1721
+ // ── Step 3: the LOCAL value. A foreign local manager (husky/lefthook/custom)
1722
+ // owns this repo's hooks; never hijack it. --force does NOT override (force
1723
+ // is consent to lose a file, not to seize another manager's config). The read
1724
+ // fails CLOSED (JDA-001): a non-1/5 failure REFUSES rather than mistake an
1725
+ // unreadable local value for a fresh repo and install over a shadowing value.
1726
+ const localProbe = await readLocalHooksPath(projectDir);
1727
+ if (localProbe.failed !== undefined) {
1728
+ return {
1729
+ refuse: `could not read --local git config core.hooksPath (${localProbe.failed}). Refusing to install: a local hooksPath could silently shadow .git/hooks and the installed hooks would not run. Resolve the git error and re-run.`,
1730
+ };
1731
+ }
1732
+ const local = localProbe.value;
1733
+ if (local !== "" && local !== LEGACY_HOOKSPATH) {
1734
+ return {
1735
+ refuse: `core.hooksPath is set to '${local}' — another hook manager owns this repo's hooks. javi-forge refuses to install or change it. Unset it yourself (git config --local --unset core.hooksPath) if you want javi-forge hooks.`,
1736
+ };
1737
+ }
1738
+ const legacy = local === LEGACY_HOOKSPATH;
1739
+ // ── Step 4: installability, ONLY on the migration path. Unsetting the legacy
1740
+ // value ACTIVATES whatever sits in .git/hooks; if a slot is FOREIGN without
1741
+ // --force, migrating now would activate a dormant foreign hook → ATOMIC
1742
+ // REFUSE, leave core.hooksPath SET, install NOTHING. When there is no legacy
1743
+ // value to unset, foreign slots are handled per-hook by the install loop as
1744
+ // before — nothing is being activated.
1745
+ if (legacy && !force && foreignSlots.length > 0) {
1746
+ const slot = foreignSlots[0];
1747
+ return {
1748
+ refuse: `core.hooksPath=${LEGACY_HOOKSPATH} would be removed, but .git/hooks/${slot} holds a foreign hook (a prior 'tdd init' or hand-written hook). Migrating now would activate it. Resolve it (delete/back it up) or re-run with --force.`,
1749
+ };
1750
+ }
1751
+ // ── Step 5: ONLY NOW mutate. Every check passed (or --force). Unset the
1752
+ // legacy local value — the ONLY value javi-forge ever wrote, and only the
1753
+ // --local scope is ever touched — then let the caller install. The install
1754
+ // NEVER runs before steps 2 and 4.
1755
+ if (legacy) {
1756
+ await execFileAsync("git", ["config", "--local", "--unset", "core.hooksPath"], { cwd: projectDir });
1757
+ return {
1758
+ note: "legacy javi-forge hooksPath removed; hooks now live in .git/hooks",
1759
+ };
1760
+ }
1761
+ return {};
1762
+ }
1551
1763
  export async function installCIHooks(projectDir, options = {}) {
1552
1764
  const force = options.force === true;
1553
- const empty = { installed: [], upgraded: [], backups: [], states: [] };
1765
+ const empty = {
1766
+ installed: [],
1767
+ upgraded: [],
1768
+ backups: [],
1769
+ states: [],
1770
+ notes: [],
1771
+ };
1554
1772
  const gitDir = path.join(projectDir, ".git");
1555
1773
  if (!(await fs.pathExists(gitDir))) {
1556
1774
  return {
@@ -1587,6 +1805,19 @@ export async function installCIHooks(projectDir, options = {}) {
1587
1805
  const backups = [];
1588
1806
  const errors = [];
1589
1807
  const states = [];
1808
+ const notes = [];
1809
+ // The ATOMIC core.hooksPath guard (D6) is the SINGLE choke point shared by
1810
+ // `ci init` and `init`. It runs DETECT-BEFORE-MUTATE: on refuse it has
1811
+ // mutated nothing and we return without installing; on the success/force
1812
+ // path it has already unset any legacy `ci-local/hooks` value and we
1813
+ // proceed to install the shims.
1814
+ const guard = await guardHooksPath(projectDir, hooksDir, manifest, force);
1815
+ if ("refuse" in guard) {
1816
+ return { ...empty, errors: [guard.refuse] };
1817
+ }
1818
+ if (guard.note !== undefined) {
1819
+ notes.push(guard.note);
1820
+ }
1590
1821
  for (const name of HOOK_NAMES) {
1591
1822
  const hookPath = path.join(hooksDir, name);
1592
1823
  const entry = manifest[name];
@@ -1631,6 +1862,6 @@ export async function installCIHooks(projectDir, options = {}) {
1631
1862
  errors.push(`${name}: ${e instanceof Error ? e.message : String(e)}`);
1632
1863
  }
1633
1864
  }
1634
- return { installed, upgraded, backups, errors, states };
1865
+ return { installed, upgraded, backups, errors, states, notes };
1635
1866
  }
1636
1867
  //# sourceMappingURL=ci.js.map
@@ -10,12 +10,15 @@
10
10
  * behavior); a config that FAILS to validate exits 1 — a broken config never
11
11
  * silently skips a gate.
12
12
  *
13
- * S1a scope: only the `ci` section has a body. The tdd/secrets/permissions/deps
14
- * sections have no factory yet (they land in S3/S4); an enabled-but-unregistered
15
- * feature is skipped by `composeSections` until its slice wires a factory.
13
+ * S1a/S3 scope: the `ci` and `tdd` sections have bodies. The
14
+ * secrets/permissions/deps sections have no factory yet (they land in S4); an
15
+ * enabled-but-unregistered feature is skipped by `composeSections` until its
16
+ * slice wires a factory.
16
17
  */
17
18
  import { type CIHooksConfig } from "../lib/ci-config.js";
19
+ import type { Stack } from "../types/index.js";
18
20
  import { runCI } from "./ci.js";
21
+ import { getTddTestCommand } from "./tdd.js";
19
22
  /** A single composable unit of hook work. */
20
23
  export interface HookSection {
21
24
  /** stable id — "secrets" | "permissions" | "tdd" | "deps" | "ci" */
@@ -41,6 +44,39 @@ export type HookName = "pre-commit" | "pre-push";
41
44
  * in later slices.
42
45
  */
43
46
  export declare function composeSections(name: HookName, config: CIHooksConfig | null, registry: SectionRegistry): HookSection[];
47
+ /**
48
+ * Injectable seams for the `tdd` section. The section resolves the project's
49
+ * test command at HOOK-RUN time (never interpolated into a file), so the stack
50
+ * detector, the command resolver and the command runner are all overridable in
51
+ * tests. Every field defaults to the real implementation in `defaultRegistry`.
52
+ */
53
+ export interface TddSectionDeps {
54
+ /** Only the stack + build tool are consumed — the wider CIStackInfo is fine. */
55
+ detectStack: (projectDir: string) => Promise<{
56
+ stackType: Stack;
57
+ buildTool: string;
58
+ }>;
59
+ resolveTestCmd: typeof getTddTestCommand;
60
+ runCommand: (cmd: string, projectDir: string) => Promise<{
61
+ ok: boolean;
62
+ detail?: string;
63
+ }>;
64
+ log: (msg: string) => void;
65
+ }
66
+ /**
67
+ * The `tdd` section: at hook-run time it detects the stack, resolves the stack
68
+ * test command and runs it. A null command (no test runner configured) is NOT a
69
+ * failure — it prints an honest skip notice and returns ok:true (parity with the
70
+ * old warning-only generated hook). A missing test runner must never block a
71
+ * commit or push. When the command resolves it runs and its exit code decides.
72
+ */
73
+ export declare function tddSection(deps: TddSectionDeps): HookSection;
74
+ /**
75
+ * The default section registry: the real `ci` and `tdd` factories. `tddDeps`
76
+ * overrides the tdd section's seams in tests (stack/command/runner); every
77
+ * omitted seam falls back to the real implementation.
78
+ */
79
+ export declare function defaultRegistry(runCIImpl: typeof runCI, log: (msg: string) => void, tddDeps?: Partial<TddSectionDeps>): SectionRegistry;
44
80
  /** Resolve the parsed `hooks:` config for a project (null → default [ci]). */
45
81
  export declare function loadHooksConfig(projectDir: string): Promise<CIHooksConfig | null>;
46
82
  /** Injectable seams for tests; every field defaults to the real implementation. */
@@ -10,12 +10,15 @@
10
10
  * behavior); a config that FAILS to validate exits 1 — a broken config never
11
11
  * silently skips a gate.
12
12
  *
13
- * S1a scope: only the `ci` section has a body. The tdd/secrets/permissions/deps
14
- * sections have no factory yet (they land in S3/S4); an enabled-but-unregistered
15
- * feature is skipped by `composeSections` until its slice wires a factory.
13
+ * S1a/S3 scope: the `ci` and `tdd` sections have bodies. The
14
+ * secrets/permissions/deps sections have no factory yet (they land in S4); an
15
+ * enabled-but-unregistered feature is skipped by `composeSections` until its
16
+ * slice wires a factory.
16
17
  */
18
+ import { spawn } from "node:child_process";
17
19
  import { findCIConfig, loadCIConfig, } from "../lib/ci-config.js";
18
- import { runCI } from "./ci.js";
20
+ import { detectCIStack, runCI } from "./ci.js";
21
+ import { getTddTestCommand } from "./tdd.js";
19
22
  /** Fixed cheap→expensive order per hook (deterministic is a feature). */
20
23
  const PRE_COMMIT_ORDER = ["secrets", "permissions", "tdd", "ci"];
21
24
  const PRE_PUSH_ORDER = ["deps", "tdd", "ci"];
@@ -114,8 +117,83 @@ function reportStep(step, log) {
114
117
  else if (step.status === "warning")
115
118
  log(` ⚠ ${step.label}`);
116
119
  }
117
- function defaultRegistry(runCIImpl, log) {
118
- return { ci: () => ciSection(runCIImpl, log) };
120
+ /**
121
+ * Run a resolved test command through `bash -c`, matching the rest of the
122
+ * codebase's project-command idiom (`runStep` in ci.ts). `getTddTestCommand`
123
+ * only ever returns a fixed, controlled set of commands (`npm test`,
124
+ * `<buildTool> run test`, `pytest`, `go test ./...`) — never user input — so
125
+ * `bash -c` introduces no injection vector, and it fixes native Windows where
126
+ * `npm`/`pnpm`/`yarn` are `.cmd` shims that a shell-less `spawn` cannot exec
127
+ * (ENOENT). A non-zero exit or a spawn error is a blocking failure; a passing
128
+ * command is ok:true.
129
+ */
130
+ function runTestCommand(cmd, projectDir) {
131
+ return new Promise((resolve) => {
132
+ const proc = spawn("bash", ["-c", cmd], {
133
+ cwd: projectDir,
134
+ stdio: "inherit",
135
+ env: { ...process.env, CI: "true" },
136
+ });
137
+ proc.on("close", (code) => resolve(code === 0
138
+ ? { ok: true }
139
+ : { ok: false, detail: `tests failed (exit ${code ?? "unknown"})` }));
140
+ proc.on("error", (e) => resolve({
141
+ ok: false,
142
+ detail: e instanceof Error ? e.message : String(e),
143
+ }));
144
+ });
145
+ }
146
+ /**
147
+ * The `tdd` section: at hook-run time it detects the stack, resolves the stack
148
+ * test command and runs it. A null command (no test runner configured) is NOT a
149
+ * failure — it prints an honest skip notice and returns ok:true (parity with the
150
+ * old warning-only generated hook). A missing test runner must never block a
151
+ * commit or push. When the command resolves it runs and its exit code decides.
152
+ */
153
+ export function tddSection(deps) {
154
+ return {
155
+ id: "tdd",
156
+ blocking: true,
157
+ async run({ projectDir }) {
158
+ // A thrown detector / resolver / runner must NEVER propagate out of the
159
+ // section — an unhandled rejection would crash the hook (raw stack trace)
160
+ // and, for an advisory pre-push tdd:"warn", would BLOCK the push,
161
+ // violating the advisory-never-blocks invariant. Mirror ciSection: on a
162
+ // throw, report a blocking-mappable failure instead.
163
+ try {
164
+ const { stackType, buildTool } = await deps.detectStack(projectDir);
165
+ const testCmd = await deps.resolveTestCmd(stackType, buildTool, projectDir);
166
+ if (testCmd === null) {
167
+ deps.log(` ⓘ tdd: no test command detected for stack '${stackType}' — skipping (a missing test runner does not block).`);
168
+ return { ok: true };
169
+ }
170
+ deps.log(` ▶ tdd: ${testCmd}`);
171
+ return await deps.runCommand(testCmd, projectDir);
172
+ }
173
+ catch (e) {
174
+ return {
175
+ ok: false,
176
+ detail: e instanceof Error ? e.message : String(e),
177
+ };
178
+ }
179
+ },
180
+ };
181
+ }
182
+ /**
183
+ * The default section registry: the real `ci` and `tdd` factories. `tddDeps`
184
+ * overrides the tdd section's seams in tests (stack/command/runner); every
185
+ * omitted seam falls back to the real implementation.
186
+ */
187
+ export function defaultRegistry(runCIImpl, log, tddDeps = {}) {
188
+ return {
189
+ ci: () => ciSection(runCIImpl, log),
190
+ tdd: () => tddSection({
191
+ detectStack: tddDeps.detectStack ?? detectCIStack,
192
+ resolveTestCmd: tddDeps.resolveTestCmd ?? getTddTestCommand,
193
+ runCommand: tddDeps.runCommand ?? runTestCommand,
194
+ log: tddDeps.log ?? log,
195
+ }),
196
+ };
119
197
  }
120
198
  /** Resolve the parsed `hooks:` config for a project (null → default [ci]). */
121
199
  export async function loadHooksConfig(projectDir) {
@@ -11,15 +11,17 @@ import type { StepFn } from "../types.js";
11
11
  */
12
12
  export declare const stepGitInit: StepFn;
13
13
  /**
14
- * Step 2: Configure git hooks path.
14
+ * Step 2: Install managed git hooks (D7 reconciliation).
15
15
  *
16
- * - Copies templates/ci-local/ <project>/ci-local/ (no overwrite).
17
- * - chmod 0755 on hook files.
18
- * - Sets git config core.hooksPath to ci-local/hooks.
19
- * - Skips entirely when CI_LOCAL_DIR template is missing.
20
- * - Errors are swallowed and reported as status:"error" never thrown.
16
+ * Init no longer copies `ci-local/` hook bodies into the project nor flips
17
+ * `core.hooksPath` both were the pre-consolidation mechanism. It now delegates
18
+ * to `installCIHooks`, the SINGLE writer of `.git/hooks` and the choke point for
19
+ * the ATOMIC `core.hooksPath` guard (D6). A foreign/global hooksPath, or a
20
+ * dormant foreign slot, is REFUSED there with zero mutation and surfaced as an
21
+ * error here — init never overrides the user's hook manager.
21
22
  *
22
- * Extracted VERBATIM from src/commands/init.ts (PR 1 of 6).
23
+ * - dry-run: reports "would install managed hooks", calls nothing.
24
+ * - Errors are swallowed and reported as status:"error" — never thrown.
23
25
  */
24
26
  export declare const stepGitHooks: StepFn;
25
27
  //# sourceMappingURL=git.d.ts.map
@@ -1,6 +1,5 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
- import { CI_LOCAL_DIR } from "../../../constants.js";
4
3
  import { execFileAsync } from "../../../lib/exec.js";
5
4
  import { report } from "../report.js";
6
5
  /**
@@ -34,48 +33,46 @@ export const stepGitInit = async (ctx) => {
34
33
  }
35
34
  };
36
35
  /**
37
- * Step 2: Configure git hooks path.
36
+ * Step 2: Install managed git hooks (D7 reconciliation).
38
37
  *
39
- * - Copies templates/ci-local/ <project>/ci-local/ (no overwrite).
40
- * - chmod 0755 on hook files.
41
- * - Sets git config core.hooksPath to ci-local/hooks.
42
- * - Skips entirely when CI_LOCAL_DIR template is missing.
43
- * - Errors are swallowed and reported as status:"error" never thrown.
38
+ * Init no longer copies `ci-local/` hook bodies into the project nor flips
39
+ * `core.hooksPath` both were the pre-consolidation mechanism. It now delegates
40
+ * to `installCIHooks`, the SINGLE writer of `.git/hooks` and the choke point for
41
+ * the ATOMIC `core.hooksPath` guard (D6). A foreign/global hooksPath, or a
42
+ * dormant foreign slot, is REFUSED there with zero mutation and surfaced as an
43
+ * error here — init never overrides the user's hook manager.
44
44
  *
45
- * Extracted VERBATIM from src/commands/init.ts (PR 1 of 6).
45
+ * - dry-run: reports "would install managed hooks", calls nothing.
46
+ * - Errors are swallowed and reported as status:"error" — never thrown.
46
47
  */
47
48
  export const stepGitHooks = async (ctx) => {
48
49
  const { projectDir, dryRun, onStep } = ctx;
49
50
  const stepId = "git-hooks";
50
- report(onStep, stepId, "Configure git hooks path", "running");
51
+ const label = "Install git hooks";
52
+ report(onStep, stepId, label, "running");
53
+ if (dryRun) {
54
+ report(onStep, stepId, label, "done", "would install managed hooks");
55
+ return;
56
+ }
51
57
  try {
52
- const ciLocalSrc = CI_LOCAL_DIR;
53
- const ciLocalDest = path.join(projectDir, "ci-local");
54
- if (await fs.pathExists(ciLocalSrc)) {
55
- if (!dryRun) {
56
- await fs.copy(ciLocalSrc, ciLocalDest, {
57
- overwrite: false,
58
- errorOnExist: false,
59
- });
60
- // Set core.hooksPath to ci-local/hooks
61
- const hooksDir = path.join(ciLocalDest, "hooks");
62
- if (await fs.pathExists(hooksDir)) {
63
- // Ensure hooks are executable
64
- const hookFiles = await fs.readdir(hooksDir);
65
- for (const hook of hookFiles) {
66
- await fs.chmod(path.join(hooksDir, hook), 0o755);
67
- }
68
- await execFileAsync("git", ["config", "core.hooksPath", "ci-local/hooks"], { cwd: projectDir });
69
- }
70
- }
71
- report(onStep, stepId, "Configure git hooks path", "done", "ci-local/hooks");
72
- }
73
- else {
74
- report(onStep, stepId, "Configure git hooks path", "skipped", "no ci-local dir");
58
+ // Lazy import: installCIHooks pulls in the CI command surface; keep it off
59
+ // the init cold-start path, exactly like the `ci init` dispatch branch.
60
+ const { installCIHooks } = await import("../../ci.js");
61
+ const { installed, upgraded, backups, errors, notes } = await installCIHooks(projectDir);
62
+ if (errors.length > 0) {
63
+ report(onStep, stepId, label, "error", errors.join("; "));
64
+ return;
75
65
  }
66
+ const detail = [
67
+ ...installed.map((h) => `installed ${h}`),
68
+ ...upgraded.map((h) => `upgraded ${h}`),
69
+ ...notes,
70
+ ...backups.map((b) => `backup ${b}`),
71
+ ].join("; ") || "managed hooks installed";
72
+ report(onStep, stepId, label, "done", detail);
76
73
  }
77
74
  catch (e) {
78
- report(onStep, stepId, "Configure git hooks path", "error", String(e));
75
+ report(onStep, stepId, label, "error", String(e));
79
76
  }
80
77
  };
81
78
  //# sourceMappingURL=git.js.map
@@ -1,21 +1,7 @@
1
1
  import type { Stack } from "../types/index.js";
2
- export interface TddHookResult {
3
- installed: string[];
4
- errors: string[];
5
- }
6
2
  /**
7
3
  * Resolve the correct test command for TDD hook based on stack and build tool.
8
4
  * Returns null if no test command can be determined.
9
5
  */
10
6
  export declare function getTddTestCommand(stack: Stack, buildTool: string, projectDir: string): Promise<string | null>;
11
- /**
12
- * Generate a TDD-enforcing pre-commit hook script.
13
- * If testCmd is null, generates a warning-only hook.
14
- */
15
- export declare function generateTddHook(testCmd: string | null, stack: Stack): string;
16
- /**
17
- * Install TDD pre-commit hook into .git/hooks/.
18
- * Detects the project stack automatically and generates the appropriate hook.
19
- */
20
- export declare function installTddHooks(projectDir: string): Promise<TddHookResult>;
21
7
  //# sourceMappingURL=tdd.d.ts.map
@@ -1,6 +1,5 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
- import { detectCIStack } from "./ci.js";
4
3
  // =============================================================================
5
4
  // Test command resolution
6
5
  // =============================================================================
@@ -30,91 +29,4 @@ export async function getTddTestCommand(stack, buildTool, projectDir) {
30
29
  return null;
31
30
  }
32
31
  }
33
- // =============================================================================
34
- // Hook generation
35
- // =============================================================================
36
- /**
37
- * Generate a TDD-enforcing pre-commit hook script.
38
- * If testCmd is null, generates a warning-only hook.
39
- */
40
- export function generateTddHook(testCmd, stack) {
41
- if (!testCmd) {
42
- return `#!/bin/bash
43
- # =============================================================================
44
- # TDD PRE-COMMIT: No test command detected for stack "${stack}"
45
- # =============================================================================
46
- # Install a test runner and re-run: javi-forge tdd init
47
- # =============================================================================
48
-
49
- echo "TDD HOOK: No test command configured for stack '${stack}' — skipping."
50
- exit 0
51
- `;
52
- }
53
- return `#!/bin/bash
54
- # =============================================================================
55
- # TDD PRE-COMMIT: Enforced test-driven development
56
- # =============================================================================
57
- # Flow: Tests MUST pass before commit is allowed.
58
- # Stack: ${stack} | Command: ${testCmd}
59
- # To skip: git commit --no-verify
60
- # =============================================================================
61
-
62
- set -e
63
-
64
- echo "TDD PRE-COMMIT: Running tests..."
65
- echo " Stack: ${stack}"
66
- echo " Command: ${testCmd}"
67
- echo ""
68
-
69
- ${testCmd} || {
70
- echo ""
71
- echo "TDD FAILED — Tests did not pass."
72
- echo " Fix failing tests before committing."
73
- echo " To skip: git commit --no-verify"
74
- exit 1
75
- }
76
-
77
- echo ""
78
- echo "TDD PASSED — All tests green. Commit allowed."
79
- `;
80
- }
81
- // =============================================================================
82
- // Hook installation
83
- // =============================================================================
84
- /**
85
- * Install TDD pre-commit hook into .git/hooks/.
86
- * Detects the project stack automatically and generates the appropriate hook.
87
- */
88
- export async function installTddHooks(projectDir) {
89
- const gitDir = path.join(projectDir, ".git");
90
- if (!(await fs.pathExists(gitDir))) {
91
- return {
92
- installed: [],
93
- errors: ["Not a git repository. Run git init first."],
94
- };
95
- }
96
- const hooksDir = path.join(gitDir, "hooks");
97
- await fs.ensureDir(hooksDir);
98
- // Detect stack
99
- let stackInfo;
100
- try {
101
- stackInfo = await detectCIStack(projectDir);
102
- }
103
- catch {
104
- return { installed: [], errors: ["Failed to detect project stack."] };
105
- }
106
- const testCmd = await getTddTestCommand(stackInfo.stackType, stackInfo.buildTool, projectDir);
107
- const hookContent = generateTddHook(testCmd, stackInfo.stackType);
108
- const installed = [];
109
- const errors = [];
110
- const hookPath = path.join(hooksDir, "pre-commit");
111
- try {
112
- await fs.writeFile(hookPath, hookContent, { mode: 0o755 });
113
- installed.push("pre-commit");
114
- }
115
- catch (e) {
116
- errors.push(`pre-commit: ${e instanceof Error ? e.message : String(e)}`);
117
- }
118
- return { installed, errors };
119
- }
120
32
  //# sourceMappingURL=tdd.js.map
@@ -144,6 +144,16 @@ export declare function parseCIConfig(rawYaml: string, source?: string): CIConfi
144
144
  * validation error throws — no config is ever silently ignored.
145
145
  */
146
146
  export declare function loadCIConfig(configPath: string): Promise<CIConfig>;
147
+ /**
148
+ * Set a single `hooks.<hook>.<feature>` flag in a project's CI config, creating
149
+ * a minimal `version: 2` `.javi-forge/ci.yaml` when none exists. Existing
150
+ * content — runners, gates, other hooks, comments and formatting — is preserved
151
+ * via a YAML document round-trip (`parseDocument`), so this NEVER clobbers a
152
+ * hand-authored config. `hooks:` is a v2-only key, so the version is bumped to 2
153
+ * when a hook feature is written into an older document. Returns the path
154
+ * written. Fail-closed callers still validate on the next `loadCIConfig`.
155
+ */
156
+ export declare function setHookFeature(projectDir: string, hook: "pre-commit" | "pre-push", feature: string, value: boolean | string): Promise<string>;
147
157
  /**
148
158
  * Discover the default CI config for a project directory.
149
159
  * Returns the config path, or null when the project has no config
@@ -633,6 +633,39 @@ export async function loadCIConfig(configPath) {
633
633
  const raw = await fs.readFile(configPath, "utf-8");
634
634
  return parseCIConfig(raw, configPath);
635
635
  }
636
+ /**
637
+ * Set a single `hooks.<hook>.<feature>` flag in a project's CI config, creating
638
+ * a minimal `version: 2` `.javi-forge/ci.yaml` when none exists. Existing
639
+ * content — runners, gates, other hooks, comments and formatting — is preserved
640
+ * via a YAML document round-trip (`parseDocument`), so this NEVER clobbers a
641
+ * hand-authored config. `hooks:` is a v2-only key, so the version is bumped to 2
642
+ * when a hook feature is written into an older document. Returns the path
643
+ * written. Fail-closed callers still validate on the next `loadCIConfig`.
644
+ */
645
+ export async function setHookFeature(projectDir, hook, feature, value) {
646
+ const existing = await findCIConfig(projectDir);
647
+ const configPath = existing ?? path.join(projectDir, CI_CONFIG_CANDIDATES[0]);
648
+ const doc = existing
649
+ ? YAML.parseDocument(await fs.readFile(existing, "utf-8"))
650
+ : YAML.parseDocument("version: 2\n");
651
+ // Fail-closed: never write over a malformed config. A parse error means the
652
+ // document is only partially represented, so setIn + write would silently
653
+ // drop the unparseable remainder of a hand-authored file.
654
+ if (doc.errors.length > 0) {
655
+ throw new CIConfigError(doc.errors.map((e) => ({
656
+ path: "<document>",
657
+ message: `invalid YAML: ${e.message.split("\n")[0]}`,
658
+ })), configPath);
659
+ }
660
+ // `hooks:` requires version 2; bump an older/unset document in place.
661
+ if (doc.get("version") !== 2) {
662
+ doc.set("version", 2);
663
+ }
664
+ doc.setIn(["hooks", hook, feature], value);
665
+ await fs.ensureDir(path.dirname(configPath));
666
+ await fs.writeFile(configPath, doc.toString());
667
+ return configPath;
668
+ }
636
669
  /**
637
670
  * Discover the default CI config for a project directory.
638
671
  * Returns the config path, or null when the project has no config
@@ -67,13 +67,6 @@ export interface DoctorSection {
67
67
  export interface DoctorResult {
68
68
  sections: DoctorSection[];
69
69
  }
70
- export type TddPipelineMode = "strict" | "warn";
71
- export interface TddPipelineResult {
72
- installed: string[];
73
- skipped: string[];
74
- errors: string[];
75
- mode: TddPipelineMode;
76
- }
77
70
  export interface PluginManifest {
78
71
  name: string;
79
72
  version: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.24.0",
3
+ "version": "1.25.1",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,17 +0,0 @@
1
- import type { Stack, TddPipelineMode, TddPipelineResult } from "../types/index.js";
2
- /**
3
- * Generate a TDD pipeline enforcement pre-push hook script.
4
- *
5
- * - strict: tests MUST pass or push is blocked (exit 1).
6
- * - warn: tests are run and results shown, but push is never blocked.
7
- *
8
- * If testCmd is null, generates a skip-only hook regardless of mode.
9
- */
10
- export declare function generateTddPipelineHook(mode: TddPipelineMode, testCmd: string | null, stack: Stack): string;
11
- /**
12
- * Install TDD pipeline pre-push hook into .git/hooks/.
13
- * Detects the project stack automatically and generates the appropriate hook.
14
- * Backs up any existing pre-push hook to pre-push.bak.
15
- */
16
- export declare function installTddPipelineHook(projectDir: string, mode: TddPipelineMode): Promise<TddPipelineResult>;
17
- //# sourceMappingURL=tdd-pipeline.d.ts.map
@@ -1,144 +0,0 @@
1
- import path from "node:path";
2
- import fs from "fs-extra";
3
- import { detectCIStack } from "./ci.js";
4
- import { getTddTestCommand } from "./tdd.js";
5
- // =============================================================================
6
- // Hook generation
7
- // =============================================================================
8
- /**
9
- * Generate a TDD pipeline enforcement pre-push hook script.
10
- *
11
- * - strict: tests MUST pass or push is blocked (exit 1).
12
- * - warn: tests are run and results shown, but push is never blocked.
13
- *
14
- * If testCmd is null, generates a skip-only hook regardless of mode.
15
- */
16
- export function generateTddPipelineHook(mode, testCmd, stack) {
17
- if (!testCmd) {
18
- return `#!/bin/bash
19
- # =============================================================================
20
- # TDD PIPELINE (pre-push): No test command detected for stack "${stack}"
21
- # =============================================================================
22
- # Install a test runner and re-run: javi-forge tdd pipeline --mode ${mode}
23
- # =============================================================================
24
-
25
- echo "TDD PIPELINE: No test command configured for stack '${stack}' — skipping."
26
- exit 0
27
- `;
28
- }
29
- if (mode === "warn") {
30
- return `#!/bin/bash
31
- # =============================================================================
32
- # TDD PIPELINE (pre-push): WARN mode
33
- # =============================================================================
34
- # Flow: Spec → Tests → Fail → Implement → Pass
35
- # Stack: ${stack} | Command: ${testCmd}
36
- # Mode: warn — tests run but push is NEVER blocked
37
- # To skip: git push --no-verify
38
- # =============================================================================
39
-
40
- echo "TDD PIPELINE [WARN]: Running tests before push..."
41
- echo " Stack: ${stack}"
42
- echo " Command: ${testCmd}"
43
- echo " Mode: warn (push will proceed regardless)"
44
- echo ""
45
-
46
- ${testCmd} && {
47
- echo ""
48
- echo "TDD PIPELINE [WARN]: All tests passed."
49
- } || {
50
- echo ""
51
- echo "TDD PIPELINE [WARN]: Tests FAILED — but push will proceed (warn mode)."
52
- echo " Consider fixing tests before merging."
53
- }
54
-
55
- exit 0
56
- `;
57
- }
58
- // strict mode (default)
59
- return `#!/bin/bash
60
- # =============================================================================
61
- # TDD PIPELINE (pre-push): STRICT mode
62
- # =============================================================================
63
- # Flow: Spec → Tests → Fail → Implement → Pass
64
- # Stack: ${stack} | Command: ${testCmd}
65
- # Mode: strict — push is BLOCKED if tests fail
66
- # To skip: git push --no-verify
67
- # =============================================================================
68
-
69
- set -e
70
-
71
- echo "TDD PIPELINE [STRICT]: Running tests before push..."
72
- echo " Stack: ${stack}"
73
- echo " Command: ${testCmd}"
74
- echo " Mode: strict (push blocked on failure)"
75
- echo ""
76
-
77
- ${testCmd} || {
78
- echo ""
79
- echo "TDD PIPELINE [STRICT]: FAILED — Tests did not pass."
80
- echo " Fix failing tests before pushing."
81
- echo " To skip: git push --no-verify"
82
- exit 1
83
- }
84
-
85
- echo ""
86
- echo "TDD PIPELINE [STRICT]: All tests passed. Push allowed."
87
- `;
88
- }
89
- // =============================================================================
90
- // Hook installation
91
- // =============================================================================
92
- /**
93
- * Install TDD pipeline pre-push hook into .git/hooks/.
94
- * Detects the project stack automatically and generates the appropriate hook.
95
- * Backs up any existing pre-push hook to pre-push.bak.
96
- */
97
- export async function installTddPipelineHook(projectDir, mode) {
98
- const result = {
99
- installed: [],
100
- skipped: [],
101
- errors: [],
102
- mode,
103
- };
104
- const gitDir = path.join(projectDir, ".git");
105
- if (!(await fs.pathExists(gitDir))) {
106
- result.errors.push("Not a git repository. Run git init first.");
107
- return result;
108
- }
109
- const hooksDir = path.join(gitDir, "hooks");
110
- await fs.ensureDir(hooksDir);
111
- // Detect stack
112
- let stackInfo;
113
- try {
114
- stackInfo = await detectCIStack(projectDir);
115
- }
116
- catch {
117
- result.errors.push("Failed to detect project stack.");
118
- return result;
119
- }
120
- const testCmd = await getTddTestCommand(stackInfo.stackType, stackInfo.buildTool, projectDir);
121
- const hookContent = generateTddPipelineHook(mode, testCmd, stackInfo.stackType);
122
- const hookPath = path.join(hooksDir, "pre-push");
123
- // Backup existing hook
124
- if (await fs.pathExists(hookPath)) {
125
- const backupPath = path.join(hooksDir, "pre-push.bak");
126
- try {
127
- await fs.copy(hookPath, backupPath, { overwrite: true });
128
- result.skipped.push("pre-push (backed up to pre-push.bak)");
129
- }
130
- catch (e) {
131
- result.errors.push(`backup: ${e instanceof Error ? e.message : String(e)}`);
132
- return result;
133
- }
134
- }
135
- try {
136
- await fs.writeFile(hookPath, hookContent, { mode: 0o755 });
137
- result.installed.push("pre-push");
138
- }
139
- catch (e) {
140
- result.errors.push(`pre-push: ${e instanceof Error ? e.message : String(e)}`);
141
- }
142
- return result;
143
- }
144
- //# sourceMappingURL=tdd-pipeline.js.map