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.
- package/dist/cli/dispatch/ci.js +6 -1
- package/dist/cli/dispatch/tdd.d.ts +6 -3
- package/dist/cli/dispatch/tdd.js +62 -31
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.js +2 -2
- package/dist/commands/ci.d.ts +6 -0
- package/dist/commands/ci.js +233 -2
- package/dist/commands/hooks.d.ts +39 -3
- package/dist/commands/hooks.js +84 -6
- package/dist/commands/init/steps/git.d.ts +9 -7
- package/dist/commands/init/steps/git.js +30 -33
- package/dist/commands/tdd.d.ts +0 -14
- package/dist/commands/tdd.js +0 -88
- package/dist/lib/ci-config.d.ts +10 -0
- package/dist/lib/ci-config.js +33 -0
- package/dist/types/index.d.ts +0 -7
- package/package.json +1 -1
- package/dist/commands/tdd-pipeline.d.ts +0 -17
- package/dist/commands/tdd-pipeline.js +0 -144
package/dist/cli/dispatch/ci.js
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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>;
|
package/dist/cli/dispatch/tdd.js
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
process.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
41
|
-
console.error(" pipeline
|
|
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
|
}
|
package/dist/cli/help.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
21
|
-
tdd pipeline
|
|
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
|
package/dist/commands/ci.d.ts
CHANGED
|
@@ -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).
|
package/dist/commands/ci.js
CHANGED
|
@@ -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 = {
|
|
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
|
package/dist/commands/hooks.d.ts
CHANGED
|
@@ -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:
|
|
14
|
-
* sections have no factory yet (they land in
|
|
15
|
-
* feature is skipped by `composeSections` until its
|
|
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. */
|
package/dist/commands/hooks.js
CHANGED
|
@@ -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:
|
|
14
|
-
* sections have no factory yet (they land in
|
|
15
|
-
* feature is skipped by `composeSections` until its
|
|
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
|
-
|
|
118
|
-
|
|
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:
|
|
14
|
+
* Step 2: Install managed git hooks (D7 reconciliation).
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
*
|
|
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:
|
|
36
|
+
* Step 2: Install managed git hooks (D7 reconciliation).
|
|
38
37
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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,
|
|
75
|
+
report(onStep, stepId, label, "error", String(e));
|
|
79
76
|
}
|
|
80
77
|
};
|
|
81
78
|
//# sourceMappingURL=git.js.map
|
package/dist/commands/tdd.d.ts
CHANGED
|
@@ -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
|
package/dist/commands/tdd.js
CHANGED
|
@@ -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
|
package/dist/lib/ci-config.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/ci-config.js
CHANGED
|
@@ -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
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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,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
|