@sabaiway/agent-workflow-kit 2.1.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +94 -1
- package/README.md +7 -5
- package/SKILL.md +16 -8
- package/bin/install.mjs +11 -2
- package/bridges/antigravity-cli-bridge/SKILL.md +8 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +163 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review-model-screen.test.mjs +76 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +80 -15
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +171 -22
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +59 -41
- package/bridges/antigravity-cli-bridge/capability.json +3 -2
- package/bridges/codex-cli-bridge/SKILL.md +8 -1
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +67 -22
- package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +156 -0
- package/bridges/codex-cli-bridge/bin/codex-review.sh +71 -10
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +158 -27
- package/bridges/codex-cli-bridge/capability.json +3 -2
- package/capability.json +1 -1
- package/migrations/3.0.0-hardened-core-loop.md +44 -0
- package/migrations/README.md +1 -1
- package/package.json +2 -2
- package/references/hooks/gate-approve.mjs +1 -1
- package/references/modes/bootstrap.md +3 -3
- package/references/modes/commit-guard.md +19 -0
- package/references/modes/core-evidence.md +15 -0
- package/references/modes/coverage-check.md +12 -0
- package/references/modes/doc-parity.md +7 -6
- package/references/modes/gates.md +7 -6
- package/references/modes/grounding.md +1 -2
- package/references/modes/hook.md +1 -1
- package/references/modes/recommendations.md +1 -0
- package/references/modes/review-state.md +3 -3
- package/references/modes/upgrade.md +6 -6
- package/references/modes/velocity.md +2 -2
- package/references/modes/worktrees.md +120 -0
- package/references/scripts/archive-decisions.mjs +3 -3
- package/references/scripts/archive-decisions.test.mjs +5 -5
- package/references/scripts/check-docs-size-cli.test.mjs +41 -0
- package/references/scripts/check-docs-size.mjs +46 -29
- package/references/scripts/install-git-hooks-repo-exec.test.mjs +82 -0
- package/references/scripts/install-git-hooks.mjs +90 -18
- package/references/scripts/install-git-hooks.test.mjs +102 -0
- package/references/scripts/migrate-gates-branches.test.mjs +157 -0
- package/references/scripts/migrate-gates.mjs +395 -0
- package/references/scripts/migrate-gates.test.mjs +284 -0
- package/references/templates/agent_rules.md +2 -2
- package/tools/ack-write.mjs +1 -1
- package/tools/atomic-write.mjs +2 -2
- package/tools/autonomy-config.mjs +1 -1
- package/tools/autonomy-doctor.mjs +2 -2
- package/tools/autonomy-write.mjs +1 -1
- package/tools/bridge-settings-read.mjs +1 -1
- package/tools/bridge-settings.mjs +1 -1
- package/tools/changed-surface.mjs +16 -77
- package/tools/cheap-agents.mjs +2 -2
- package/tools/commands.mjs +26 -10
- package/tools/commit-guard.mjs +167 -0
- package/tools/core-evidence.mjs +914 -0
- package/tools/coverage-check.mjs +260 -0
- package/tools/delegation.mjs +1 -1
- package/tools/detect-backends.mjs +3 -3
- package/tools/doc-parity.mjs +11 -27
- package/tools/engine-source.mjs +1 -1
- package/tools/family-members.mjs +1 -1
- package/tools/family-registry.mjs +1 -1
- package/tools/fs-safe.mjs +1 -1
- package/tools/gate-hook.mjs +1 -1
- package/tools/{seed-gates.mjs → gates-init.mjs} +58 -52
- package/tools/grounding.mjs +19 -96
- package/tools/hide-footprint.mjs +1 -1
- package/tools/inject-methodology.mjs +72 -41
- package/tools/known-footprint.mjs +1 -1
- package/tools/labels.mjs +1 -1
- package/tools/lcov.mjs +6 -10
- package/tools/lens-region.mjs +1 -1
- package/tools/manifest/validate.mjs +19 -0
- package/tools/migrate-adr-store.mjs +5 -5
- package/tools/orchestration-config.mjs +1 -1
- package/tools/orchestration-write.mjs +1 -1
- package/tools/presentation.mjs +1 -1
- package/tools/procedures.mjs +3 -3
- package/tools/recipes.mjs +62 -11
- package/tools/recommendations.mjs +97 -14
- package/tools/release-scan.mjs +10 -3
- package/tools/renderers.mjs +1 -1
- package/tools/review-state.mjs +291 -343
- package/tools/run-gates.mjs +216 -92
- package/tools/sandbox-masks.mjs +14 -14
- package/tools/semver-lite.mjs +1 -1
- package/tools/set-autonomy.mjs +1 -1
- package/tools/set-recipe.mjs +2 -2
- package/tools/setup-backends.mjs +1 -1
- package/tools/surface.mjs +1 -1
- package/tools/uninstall.mjs +1 -1
- package/tools/velocity-profile.mjs +9 -9
- package/tools/view-model.mjs +1 -1
- package/tools/worktrees.mjs +2292 -0
- package/references/modes/fold-completeness.md +0 -30
- package/references/modes/review-ledger.md +0 -34
- package/references/templates/verification-profile.json +0 -10
- package/tools/fold-completeness-run.mjs +0 -1120
- package/tools/fold-completeness.mjs +0 -672
- package/tools/review-ledger-core.mjs +0 -428
- package/tools/review-ledger-write.mjs +0 -647
- package/tools/review-ledger.mjs +0 -630
- package/tools/sarif.mjs +0 -52
- package/tools/verification-profile.mjs +0 -219
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
### Mode: worktrees
|
|
2
|
+
|
|
3
|
+
Parallel feature worktrees (v1) — several features implemented simultaneously in DIFFERENT agent
|
|
4
|
+
sessions on one machine/repo, zero interference on working-tree files (the ONE exception: the
|
|
5
|
+
default `node_modules` symlink is a shared MUTABLE dependency cache — see below), unambiguous
|
|
6
|
+
ownership. One thin tool over git; every verification datum is recomputed live from git, never
|
|
7
|
+
read from stored metadata.
|
|
8
|
+
The ONE stored-metadata exception is the PREPARED OID recorded in the handoff: land and cleanup read it back only for recovery.
|
|
9
|
+
|
|
10
|
+
**Run** — `node ${CLAUDE_SKILL_DIR}/tools/worktrees.mjs <subcommand> …`:
|
|
11
|
+
|
|
12
|
+
Git ≥ 2.36 is required for NUL-terminated worktree porcelain; an older Git fails closed with its
|
|
13
|
+
own verbatim error through the existing Git-error surface.
|
|
14
|
+
|
|
15
|
+
- `provision <slug> --plan <path> [--as <name>.md] [--dir <path>] [--branch <name>] [--include <path>]... [--install] [--resume]`
|
|
16
|
+
— create a feature worktree (default: the visible sibling `<repoParent>/<repoName>--<slug>`,
|
|
17
|
+
branch `aw/<slug>`) and populate it: the registry-derived footprint copy-if-missing (a tracked
|
|
18
|
+
file is NEVER overwritten), EXACTLY ONE seeded feature plan, the `handoff-<slug>.md` stub
|
|
19
|
+
(written at provision — the tool's own record; `list` and `cleanup` read it), a
|
|
20
|
+
`node_modules` symlink when main has one and the link stays ignored — a SHARED MUTABLE cache:
|
|
21
|
+
writes through it hit MAIN's node_modules; for isolation RUN the printed isolated-install
|
|
22
|
+
command (`--install` only prints it), and on `--resume` an existing symlink is kept — run the
|
|
23
|
+
printed unlink-first recovery first. Absolute root-pinned gate commands are rebased on UNTRACKED copies only — and only
|
|
24
|
+
while their bytes still equal the MAIN source (or its rebased form); user-modified copies stay
|
|
25
|
+
byte-untouched. `--install` only PRINTS the install command — zero spawn, zero write.
|
|
26
|
+
`--resume <slug>` completes a half-done provision (identity fail-closed; handoff user sections,
|
|
27
|
+
the seeded plan, and edited copies are preserved byte-exact; the provision-record section is
|
|
28
|
+
refreshed atomically; copy-if-missing everywhere).
|
|
29
|
+
Resume identity also binds the EXISTING handoff: at most one `handoff-<slug>.md` may exist and
|
|
30
|
+
its recorded slug AND branch must match the live invocation — a second handoff, a name/record
|
|
31
|
+
mismatch, or a handoff that is not a regular file is a typed STOP before anything is written
|
|
32
|
+
(the writability probe itself runs only after these checks on a resume). The provision record
|
|
33
|
+
is read from exactly one REQUIRED `## Provision record` section (a decoy field elsewhere cannot
|
|
34
|
+
hijack identity); a missing or repeated section, or a duplicated single-valued field, is a typed
|
|
35
|
+
STOP, never last-wins.
|
|
36
|
+
- `list` — read-only: every worktree of this repo with slug (from the handoff file; none →
|
|
37
|
+
"unknown (foreign)"), path, branch, base OID (the worktree HEAD — under the v1 no-commit bar
|
|
38
|
+
that IS the provision base; a manual satellite commit moves it, and land derives its own base
|
|
39
|
+
live), dirty flag, handoff presence, opener suggestion. Honest read errors: only a genuinely
|
|
40
|
+
ABSENT docs/plans under a symlink-free, present worktree dir reads as `handoff: no`; a
|
|
41
|
+
symlinked docs/plans (or ancestor), a handoff-named entry that is not a regular file, a
|
|
42
|
+
vanished worktree dir, or any other read failure renders `handoff: (unreadable)` — never a
|
|
43
|
+
silent "no".
|
|
44
|
+
- `land <slug> --prepare` — stage the satellite's finished diff onto a CLEAN main; the commit is
|
|
45
|
+
NEVER run by the tool — it stays a dialogue ask at the primary session. Land takes the transient
|
|
46
|
+
common-git-dir lock, refuses a dirty main, graph divergence, visible `docs/ai` drift, excluded
|
|
47
|
+
staged paths, unstaged/untracked leftovers, an empty diff, or a red satellite review-state. The
|
|
48
|
+
complete satellite working-tree diff versus its base is inspected (staged and unstaged); every
|
|
49
|
+
unstaged/untracked path is listed and refused, so an accepted transfer cannot silently omit it.
|
|
50
|
+
The binary/no-ext-diff/no-textconv transfer excludes exactly `docs/ai` and `docs/plans`, applies
|
|
51
|
+
into the index, optionally runs the porcelain-visible sync adapter, then runs the main gate
|
|
52
|
+
matrix. It reports main HEAD, TRANSFER, PREPARED, and sync delta OIDs/data; the primary re-attests
|
|
53
|
+
that prepared tree and confirms main HEAD still equals the printed OID before the commit ask.
|
|
54
|
+
- `cleanup <slug> [--branch <name>] [--abandon]` — take the same transient lock and remove a LANDED
|
|
55
|
+
worktree fail-closed after live landed-verification against main HEAD. Verification uses exactly
|
|
56
|
+
the land exclusions (`docs/ai`, `docs/plans`), then checks untracked and ignored content before a
|
|
57
|
+
plain worktree remove, branch `-d`, and prune. Provision-derived ignored containers are ephemeral;
|
|
58
|
+
`node_modules` of any kind is provision-derivable because provision explicitly advises installs.
|
|
59
|
+
Foreign content stops cleanup. `--abandon` is the ONE destructive arm: it DESTROYS unlanded work,
|
|
60
|
+
requires the handoff identity, and is the only path where `--force` may appear.
|
|
61
|
+
|
|
62
|
+
**Honesty:** there is NO preview step on the writers — over-warned by design. The tool never
|
|
63
|
+
commits, never pushes, never runs a subscription CLI. Every content read and regular-file copy
|
|
64
|
+
goes through its one no-follow descriptor door (identity-bound source, exclusive destination,
|
|
65
|
+
descriptor mode update), and tripwire tests keep them the only paths.
|
|
66
|
+
|
|
67
|
+
**Settings:** the parent dir for new worktrees is the `docs/ai/worktrees.json` `{"parentDir": …}`
|
|
68
|
+
project setting (hand-editable strict JSON; absent file → the sibling default; malformed → a
|
|
69
|
+
typed STOP, never a guess). The file must be a REGULAR, non-symlink file reached through a
|
|
70
|
+
symlink-free path — anything else is a typed STOP (the advisor renders the same shape as a
|
|
71
|
+
stated skip), and the ancestor chain is verified even when the leaf is ABSENT (a symlinked
|
|
72
|
+
`docs/` or `docs/ai` never reads as plain absence). `--dir` overrides per invocation.
|
|
73
|
+
|
|
74
|
+
**Host-specific consent (zero-prompt only where the host honors it):** every sibling-dir mutation
|
|
75
|
+
runs a REAL create+delete writability probe first. An unwritable parent prints both the
|
|
76
|
+
settings-native line (`sandbox.filesystem.allowWrite` in `.claude/settings.json`) and the full
|
|
77
|
+
terminal command. On a settings-native host that honors the key, adding the parent can make later
|
|
78
|
+
provision/cleanup promptless. On a harness-managed host that ignores project settings, grant the
|
|
79
|
+
narrow parent through the host/session controls; if that is unavailable, use the printed terminal
|
|
80
|
+
command for each operation. The `recommendations` mode surfaces this lane but treats write access
|
|
81
|
+
as unverified without a trusted host-capability signal.
|
|
82
|
+
|
|
83
|
+
**Landing flow:** provision → work → handoff → land → re-attest → commit → cleanup. Satellite
|
|
84
|
+
commits are outside v1: graph divergence stops land and prints cherry-pick/rebase recovery. A gate
|
|
85
|
+
failure keeps the prepared main tree and names both recovery lanes. A second prepare is reset-only:
|
|
86
|
+
the STOP prints the current staged write-tree, compares it with the PREPARED OID recorded in the
|
|
87
|
+
handoff, distinguishes a converged re-run from foreign staged work, and lists removal commands for
|
|
88
|
+
untracked crash residue before the reset-and-re-run lane. A killed process may leave
|
|
89
|
+
`aw-prepare-lock`; after confirming no land/cleanup process owns it, remove that directory by hand.
|
|
90
|
+
|
|
91
|
+
The optional `scripts/sync-mirrors.mjs` adapter runs as a child. Its contract is porcelain-visible
|
|
92
|
+
output: tracked modifications/deletions or untracked-not-ignored creations. Ignored writes and
|
|
93
|
+
changes inside an already-untracked file are outside observation and therefore out of contract.
|
|
94
|
+
Cleanup reports EBUSY as likely lingering processes/open file descriptors (including a sandbox
|
|
95
|
+
mount); close them and retry, outside the sandbox when needed. Hidden satellite `docs/ai` state is
|
|
96
|
+
ephemeral by design; durable content belongs in the handoff before landing — the handoff carries a
|
|
97
|
+
free-form session-records digest slot (every section outside `## Provision record` is user-owned
|
|
98
|
+
and byte-preserved by the tool).
|
|
99
|
+
|
|
100
|
+
**Ownership:** MAIN owns MAIN-tree files, commits, pushes, releases, the gate matrix, every
|
|
101
|
+
docs/ai record, `docs/plans/queue.md`, and all shared git state — stash, hooks, repo config,
|
|
102
|
+
`.git/info/exclude`, and every ref except the satellite's configured branch. The SATELLITE owns
|
|
103
|
+
that one branch (`aw/<slug>` or the `--branch` override), its feature edits, its seeded plan, and
|
|
104
|
+
the user-owned handoff sections; `## Provision record` remains tool-owned. Satellite forbidden
|
|
105
|
+
verbs (the v1 docs-only bar): no `git commit`/`push`/`tag`/`git stash`/history rewrite — the ONE
|
|
106
|
+
legal rewrite is the tool-printed `git reset --hard` recovery of the satellite's OWN configured
|
|
107
|
+
branch (`aw/<slug>` or the `--branch` override) — no kit lifecycle writers
|
|
108
|
+
(`init`/`upgrade`/`setup`/`hide-footprint`/`install-git-hooks`/`sandbox-masks`/`ack-write`), no
|
|
109
|
+
queue.md writes, no version bumps or publishes, no edits to MAIN's files from the satellite
|
|
110
|
+
session — divergence and the landed verification enforce the observable half; the rest is the
|
|
111
|
+
stated contract. A symlinked `node_modules` under npm workspaces resolves
|
|
112
|
+
workspace self-links to MAIN-tree sources — use the printed isolated install when that matters.
|
|
113
|
+
|
|
114
|
+
**Other harnesses:** PROVEN — the host-installed codex/agy review wrappers run with a provisioned
|
|
115
|
+
worktree as their cwd; the footprint carries the root `AGENTS.md` required by the codex wrappers
|
|
116
|
+
and available to agy as its fallback project context. The host-level `bridge-settings.conf` under
|
|
117
|
+
`${XDG_CONFIG_HOME:-~/.config}/agent-workflow/` is per-host, shared by every worktree, and is not
|
|
118
|
+
copied into the worktree. ASSUMED until probed per harness — each target harness's project-local
|
|
119
|
+
settings and context pickup in a fresh worktree session; treat that as unverified until the target
|
|
120
|
+
harness demonstrates it.
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
// destructive --migrate --apply writes a durable snapshot to the git dir (uncommittable, the
|
|
47
47
|
// review-receipts precedent) BEFORE any delete, with a stated out-of-tree fallback off git.
|
|
48
48
|
//
|
|
49
|
-
// Dependency-free, Node >=
|
|
49
|
+
// Dependency-free, Node >= 22. Deployed into a consumer's scripts/ like its siblings.
|
|
50
50
|
|
|
51
51
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
52
52
|
import { dirname, resolve, join } from 'node:path';
|
|
@@ -649,7 +649,7 @@ const runMigrate = (root, flags, today, deps, log, logError) => {
|
|
|
649
649
|
verifyConservation(oldItems, newItems);
|
|
650
650
|
// Integrity over the FULL final store — existing records ∪ freshly-exploded — not just the new
|
|
651
651
|
// writes: a pre-existing adr/ record whose id also stays RETAINED in HOT would otherwise leave the
|
|
652
|
-
// ADR in two places
|
|
652
|
+
// ADR in two places. Same full-store pattern as runRotate.
|
|
653
653
|
const finalStoreById = new Map(existingStore.map((e) => [e.id, { id: e.id, idNum: e.idNum, fileName: e.fileName }]));
|
|
654
654
|
for (const r of records) finalStoreById.set(r.id, { id: r.id, idNum: r.idNum, fileName: r.fileName });
|
|
655
655
|
assertStoreIntegrity(retained, [...finalStoreById.values()]);
|
|
@@ -783,7 +783,7 @@ const runRotate = (root, flags, today, deps, log, logError) => {
|
|
|
783
783
|
}
|
|
784
784
|
|
|
785
785
|
const records = explode(toExplode, today);
|
|
786
|
-
// Crash-resume (fold-induced
|
|
786
|
+
// Crash-resume (fold-induced): a record from a prior crashed rotate may already be on
|
|
787
787
|
// disk. A byte-identical one is done (deduped, not a duplicate-id error); a divergent-body one is
|
|
788
788
|
// corrupt → FAIL. The FINAL store = existing ∪ freshly-exploded, deduped by id.
|
|
789
789
|
const existingById = new Map(existingStore.map((e) => [e.id, e]));
|
|
@@ -341,7 +341,7 @@ describe('1.3 --migrate --apply — records + snapshot + retire monoliths + HOT
|
|
|
341
341
|
assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0, 'the resumed tree passes --check');
|
|
342
342
|
});
|
|
343
343
|
|
|
344
|
-
it('refuses when a pre-existing adr/ record duplicates an ADR that stays in HOT (never two places —
|
|
344
|
+
it('refuses when a pre-existing adr/ record duplicates an ADR that stays in HOT (never two places — review-adr-archive-r04-major-01)', () => {
|
|
345
345
|
const root = makeRoot();
|
|
346
346
|
seedLegacy(root, { hot: ['005', '006'], warm: ['003'], cold: ['001'] });
|
|
347
347
|
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
@@ -452,7 +452,7 @@ describe('1.4 --check', () => {
|
|
|
452
452
|
assert.match(errText, /duplicate ADR id AD-002/);
|
|
453
453
|
});
|
|
454
454
|
|
|
455
|
-
it('a decisions.md with NO maxLines cap fails loud (never operates against an unknown budget —
|
|
455
|
+
it('a decisions.md with NO maxLines cap fails loud (never operates against an unknown budget — review-adr-archive-r04-major-02)', () => {
|
|
456
456
|
const root = makeRoot();
|
|
457
457
|
mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
|
|
458
458
|
const noCapFm = '---\ntype: reference\nlastUpdated: 2026-01-01\nscope: permanent\nstaleAfter: never\nowner: none\n---\n';
|
|
@@ -514,7 +514,7 @@ describe('1.4 --check', () => {
|
|
|
514
514
|
assert.match(errText, /two records for AD-001/);
|
|
515
515
|
});
|
|
516
516
|
|
|
517
|
-
it('a NESTED subdirectory in adr/ fails loud (the store is a flat directory —
|
|
517
|
+
it('a NESTED subdirectory in adr/ fails loud (the store is a flat directory — review-adr-archive-r02-major-01)', () => {
|
|
518
518
|
const root = makeRoot();
|
|
519
519
|
seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] });
|
|
520
520
|
mkdirSync(join(root, ADR_DIR_REL, 'nested'), { recursive: true });
|
|
@@ -561,7 +561,7 @@ describe('1.4 default rotate — explode the oldest beyond cap + regenerate the
|
|
|
561
561
|
assert.equal(regenCalls.length, 0, 'a no-op never regenerates the index');
|
|
562
562
|
});
|
|
563
563
|
|
|
564
|
-
it('is crash-resumable: a byte-identical record from a crashed prior rotate is deduped, not a fatal duplicate (
|
|
564
|
+
it('is crash-resumable: a byte-identical record from a crashed prior rotate is deduped, not a fatal duplicate (review-adr-archive-r02-major-02)', () => {
|
|
565
565
|
const root = makeRoot();
|
|
566
566
|
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
567
567
|
const blocks = ['005', '006', '007', '008'].map((id) => adrBlock(id));
|
|
@@ -579,7 +579,7 @@ describe('1.4 default rotate — explode the oldest beyond cap + regenerate the
|
|
|
579
579
|
assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0);
|
|
580
580
|
});
|
|
581
581
|
|
|
582
|
-
it('a no-op rotate still REFUSES a corrupt store (partition) — never a silent green no-op (
|
|
582
|
+
it('a no-op rotate still REFUSES a corrupt store (partition) — never a silent green no-op (review-adr-archive-r02-major-03)', () => {
|
|
583
583
|
const root = makeRoot();
|
|
584
584
|
seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] }); // under cap
|
|
585
585
|
const rec = explode(parseDecisionsText(tierText(999, '# T', [adrBlock('010')]), 'x').entries, '2026-07-09')[0];
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// check-docs-size-cli.test.mjs — runCli branch pins the subprocess smokes cannot reach
|
|
2
|
+
// in-process (Phase-5 coverage fill; the main spec file is parity-frozen, so these ride a
|
|
3
|
+
// colocated file): the unknown-argument refusal and the written-empty-index guard.
|
|
4
|
+
import { describe, it } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { runCli } from './check-docs-size.mjs';
|
|
10
|
+
|
|
11
|
+
const cli = async (argv) => {
|
|
12
|
+
const { code, stdout, stderr } = await runCli(argv);
|
|
13
|
+
return { code, stdout, stderr };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe('check-docs-size runCli — refusal branches', () => {
|
|
17
|
+
it('an unknown argument exits 2 naming it', async () => {
|
|
18
|
+
const { code, stderr } = await cli(['--bogus']);
|
|
19
|
+
assert.equal(code, 2);
|
|
20
|
+
assert.match(stderr, /Unknown argument: --bogus/);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('--write-index landing on a sink path (index stat size 0) is the loud written-empty refusal', async () => {
|
|
24
|
+
const root = mkdtempSync(join(tmpdir(), 'cds-cli-'));
|
|
25
|
+
try {
|
|
26
|
+
mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
|
|
27
|
+
writeFileSync(
|
|
28
|
+
join(root, 'docs', 'ai', 'a.md'),
|
|
29
|
+
'---\ntype: state\nlastUpdated: 2026-07-18\nscope: session\nstaleAfter: never\nowner: none\nmaxLines: 10\n---\n\n# a\n',
|
|
30
|
+
);
|
|
31
|
+
// The index path is a symlink into /dev/null: the write lands, the stat reads size 0 —
|
|
32
|
+
// the guard must refuse loudly instead of reporting a written index.
|
|
33
|
+
symlinkSync('/dev/null', join(root, 'docs', 'ai', 'index.md'));
|
|
34
|
+
const { code, stderr } = await cli(['--write-index', `--root=${root}`]);
|
|
35
|
+
assert.equal(code, 2);
|
|
36
|
+
assert.match(stderr, /index\.md was written empty/);
|
|
37
|
+
} finally {
|
|
38
|
+
rmSync(root, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -103,25 +103,19 @@ export const discoverMeta = async (root = ROOT) => {
|
|
|
103
103
|
return { projectName, hierarchicalLinks, onDemandLinks };
|
|
104
104
|
};
|
|
105
105
|
|
|
106
|
+
// Pure argv parser (no I/O, no exit): `help` / `error` ride out as data for runCli to render.
|
|
106
107
|
const parseArgs = (argv) => {
|
|
107
108
|
const flags = { report: false, writeIndex: false, checkIndex: false, quiet: false };
|
|
108
109
|
const opts = { today: null, root: null };
|
|
109
|
-
for (const arg of argv
|
|
110
|
+
for (const arg of argv) {
|
|
110
111
|
if (arg === '--report') flags.report = true;
|
|
111
112
|
else if (arg === '--write-index') flags.writeIndex = true;
|
|
112
113
|
else if (arg === '--check-index') flags.checkIndex = true;
|
|
113
114
|
else if (arg === '--quiet') flags.quiet = true;
|
|
114
115
|
else if (arg.startsWith('--today=')) opts.today = arg.slice('--today='.length);
|
|
115
116
|
else if (arg.startsWith('--root=')) opts.root = arg.slice('--root='.length);
|
|
116
|
-
else if (arg === '--help' || arg === '-h') {
|
|
117
|
-
|
|
118
|
-
'Usage: check-docs-size.mjs [--report|--write-index|--check-index] [--today=YYYY-MM-DD] [--root=<dir>] [--quiet]',
|
|
119
|
-
);
|
|
120
|
-
process.exit(0);
|
|
121
|
-
} else {
|
|
122
|
-
console.error(`Unknown argument: ${arg}`);
|
|
123
|
-
process.exit(2);
|
|
124
|
-
}
|
|
117
|
+
else if (arg === '--help' || arg === '-h') return { flags, opts, help: true };
|
|
118
|
+
else return { flags, opts, error: `Unknown argument: ${arg}` };
|
|
125
119
|
}
|
|
126
120
|
return { flags, opts };
|
|
127
121
|
};
|
|
@@ -218,7 +212,7 @@ const formatRow = (row) => {
|
|
|
218
212
|
return { status, sizeCell, ...row };
|
|
219
213
|
};
|
|
220
214
|
|
|
221
|
-
const printReport = (rows, quiet) => {
|
|
215
|
+
const printReport = (rows, quiet, log = console.log) => {
|
|
222
216
|
const widths = {
|
|
223
217
|
status: 2,
|
|
224
218
|
path: Math.max(4, ...rows.map((r) => r.path.length)),
|
|
@@ -228,15 +222,15 @@ const printReport = (rows, quiet) => {
|
|
|
228
222
|
};
|
|
229
223
|
const printable = quiet ? rows.filter((r) => r.errors.length || r.warnings.length) : rows;
|
|
230
224
|
if (printable.length > 0) {
|
|
231
|
-
|
|
225
|
+
log(
|
|
232
226
|
`${'S'.padEnd(widths.status)} ${'PATH'.padEnd(widths.path)} ${'SIZE/MAX'.padEnd(widths.size)} ${'TYPE'.padEnd(widths.type)} ${'UPDATED'.padEnd(widths.updated)}`,
|
|
233
227
|
);
|
|
234
228
|
for (const row of printable) {
|
|
235
|
-
|
|
229
|
+
log(
|
|
236
230
|
`${row.status.padEnd(widths.status)} ${row.path.padEnd(widths.path)} ${row.sizeCell.padEnd(widths.size)} ${(row.frontmatter?.type ?? '').padEnd(widths.type)} ${(row.frontmatter?.lastUpdated ?? '').padEnd(widths.updated)}`,
|
|
237
231
|
);
|
|
238
|
-
for (const err of row.errors)
|
|
239
|
-
for (const warn of row.warnings)
|
|
232
|
+
for (const err of row.errors) log(` - ERROR ${err}`);
|
|
233
|
+
for (const warn of row.warnings) log(` - WARN ${warn}`);
|
|
240
234
|
}
|
|
241
235
|
}
|
|
242
236
|
};
|
|
@@ -361,9 +355,29 @@ export const regenerateIndex = async (root, todayStr = null) => {
|
|
|
361
355
|
return { indexPath, files: rows.length };
|
|
362
356
|
};
|
|
363
357
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
358
|
+
// The return-code entry point (no process.argv / process.exit / console inside): argv[] →
|
|
359
|
+
// { code, stdout, stderr }. The thin shell at the bottom is the only process-coupled code.
|
|
360
|
+
export const runCli = async (argv, deps = {}) => {
|
|
361
|
+
const stdoutLines = [];
|
|
362
|
+
const stderrLines = [];
|
|
363
|
+
const log = (line) => stdoutLines.push(line);
|
|
364
|
+
const logError = (line) => stderrLines.push(line);
|
|
365
|
+
const result = (code) => ({
|
|
366
|
+
code,
|
|
367
|
+
stdout: stdoutLines.length > 0 ? `${stdoutLines.join('\n')}\n` : '',
|
|
368
|
+
stderr: stderrLines.length > 0 ? `${stderrLines.join('\n')}\n` : '',
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const { flags, opts, help, error } = parseArgs(argv);
|
|
372
|
+
if (help) {
|
|
373
|
+
log('Usage: check-docs-size.mjs [--report|--write-index|--check-index] [--today=YYYY-MM-DD] [--root=<dir>] [--quiet]');
|
|
374
|
+
return result(0);
|
|
375
|
+
}
|
|
376
|
+
if (error) {
|
|
377
|
+
logError(error);
|
|
378
|
+
return result(2);
|
|
379
|
+
}
|
|
380
|
+
const { root, docsDir, indexPath } = pathsFor(opts.root ? resolve(opts.root) : (deps.root ?? ROOT));
|
|
367
381
|
const today = computeToday(opts.today);
|
|
368
382
|
const files = (await walkMarkdownFiles(docsDir)).sort();
|
|
369
383
|
const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
|
|
@@ -373,11 +387,11 @@ const main = async () => {
|
|
|
373
387
|
|
|
374
388
|
if (flags.writeIndex) {
|
|
375
389
|
await writeIndex(rows, today, meta, indexPath);
|
|
376
|
-
|
|
390
|
+
log(`Wrote ${relative(root, indexPath)}`);
|
|
377
391
|
const after = await stat(indexPath);
|
|
378
392
|
if (after.size === 0) {
|
|
379
|
-
|
|
380
|
-
|
|
393
|
+
logError('index.md was written empty');
|
|
394
|
+
return result(2);
|
|
381
395
|
}
|
|
382
396
|
}
|
|
383
397
|
|
|
@@ -385,28 +399,31 @@ const main = async () => {
|
|
|
385
399
|
const onDisk = existsSync(indexPath) ? await readFile(indexPath, 'utf8') : null;
|
|
386
400
|
const { fresh } = checkIndexFreshness(rows, onDisk, meta);
|
|
387
401
|
if (!fresh) {
|
|
388
|
-
|
|
402
|
+
logError(
|
|
389
403
|
`[check-docs-size] FAIL: ${relative(root, indexPath)} is stale (out of sync with source frontmatter). Regenerate the index (--write-index) and commit the regenerated file.`,
|
|
390
404
|
);
|
|
391
|
-
|
|
405
|
+
return result(1);
|
|
392
406
|
}
|
|
393
|
-
|
|
407
|
+
log(
|
|
394
408
|
`[check-docs-size] OK — ${relative(root, indexPath)} is in sync with source frontmatter.`,
|
|
395
409
|
);
|
|
396
|
-
return;
|
|
410
|
+
return result(0);
|
|
397
411
|
}
|
|
398
412
|
|
|
399
|
-
printReport(rows, flags.quiet);
|
|
413
|
+
printReport(rows, flags.quiet, log);
|
|
400
414
|
const errorCount = rows.reduce((n, r) => n + r.errors.length, 0);
|
|
401
415
|
const warnCount = rows.reduce((n, r) => n + r.warnings.length, 0);
|
|
402
|
-
|
|
416
|
+
log(
|
|
403
417
|
`\n${rows.length} files inspected — ${errorCount} error(s), ${warnCount} warning(s)`,
|
|
404
418
|
);
|
|
405
419
|
|
|
406
|
-
|
|
420
|
+
return result(errorCount > 0 && !flags.report ? 1 : 0);
|
|
407
421
|
};
|
|
408
422
|
|
|
409
423
|
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
410
424
|
if (isDirectRun) {
|
|
411
|
-
await
|
|
425
|
+
const { code, stdout, stderr } = await runCli(process.argv.slice(2));
|
|
426
|
+
if (stdout) process.stdout.write(stdout);
|
|
427
|
+
if (stderr) process.stderr.write(stderr);
|
|
428
|
+
process.exitCode = code;
|
|
412
429
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// install-git-hooks-repo-exec.test.mjs — the SIBLING installer executed IN PLACE (no deploy
|
|
2
|
+
// copy): GIT_DIR pins every git answer to a fixture repo, so the run exercises this tree's own
|
|
3
|
+
// file — the D3(d) changed-line check reads real executions of the shipped bytes, while the
|
|
4
|
+
// deploy-copy suite (install-git-hooks.test.mjs) keeps the consumer-shaped behavior pins. Every
|
|
5
|
+
// branch of the installer runs from here: fresh install, the guard arm/carry/disable/conflict
|
|
6
|
+
// lanes, the unmanaged refusal, the up-to-date short-circuit, and the not-a-git-checkout skip.
|
|
7
|
+
|
|
8
|
+
import { describe, it } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join, dirname } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
|
|
16
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const INSTALLER = join(HERE, 'install-git-hooks.mjs');
|
|
18
|
+
|
|
19
|
+
const mkRepo = () => {
|
|
20
|
+
const root = mkdtempSync(join(tmpdir(), 'hooks-repo-exec-'));
|
|
21
|
+
spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf8' });
|
|
22
|
+
writeFileSync(join(root, 'guard.mjs'), '// a stand-in commit-guard tool\n');
|
|
23
|
+
return { root, gitDir: join(root, '.git') };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// The installer anchors its git queries at its OWN parent dir — GIT_DIR overrides that anchor to
|
|
27
|
+
// the fixture repo, so running the in-place file NEVER touches this tree's hooks.
|
|
28
|
+
const runAt = (gitDir, args = []) =>
|
|
29
|
+
spawnSync('node', [INSTALLER, ...args], { encoding: 'utf8', env: { ...process.env, GIT_DIR: gitDir } });
|
|
30
|
+
|
|
31
|
+
const hookOf = ({ gitDir }) => readFileSync(join(gitDir, 'hooks', 'pre-commit'), 'utf8');
|
|
32
|
+
|
|
33
|
+
describe('install-git-hooks — the in-place installer under a GIT_DIR-pinned fixture', () => {
|
|
34
|
+
it('installs the managed hook, is idempotent, and refuses an UNMANAGED hook', () => {
|
|
35
|
+
const fx = mkRepo();
|
|
36
|
+
assert.equal(runAt(fx.gitDir).status, 0);
|
|
37
|
+
assert.match(hookOf(fx), /:install-git-hooks\.mjs/, 'the managed marker is written');
|
|
38
|
+
const again = runAt(fx.gitDir);
|
|
39
|
+
assert.equal(again.status, 0);
|
|
40
|
+
assert.match(again.stdout, /already up to date/);
|
|
41
|
+
writeFileSync(join(fx.gitDir, 'hooks', 'pre-commit'), '#!/bin/sh\nexit 0\n');
|
|
42
|
+
const refused = runAt(fx.gitDir);
|
|
43
|
+
assert.equal(refused.status, 1, 'an unmanaged hook is never overwritten');
|
|
44
|
+
assert.match(refused.stderr, /Refusing to overwrite/);
|
|
45
|
+
rmSync(fx.root, { recursive: true, force: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('the guard lanes: arm (absolute path) → carry on flagless re-run → consented disable', () => {
|
|
49
|
+
const fx = mkRepo();
|
|
50
|
+
assert.equal(runAt(fx.gitDir, ['--commit-guard', join(fx.root, 'guard.mjs')]).status, 0);
|
|
51
|
+
assert.match(hookOf(fx), /guard\.mjs" --check/);
|
|
52
|
+
assert.equal(runAt(fx.gitDir).status, 0, 'the flagless re-run keeps the armed line');
|
|
53
|
+
assert.match(hookOf(fx), /guard\.mjs" --check/);
|
|
54
|
+
assert.equal(runAt(fx.gitDir, ['--no-commit-guard']).status, 0);
|
|
55
|
+
assert.doesNotMatch(hookOf(fx), /" --check$/m);
|
|
56
|
+
rmSync(fx.root, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('the guard refusals: both flags (usage), a bare flag (usage), a missing path, a duplicated line', () => {
|
|
60
|
+
const fx = mkRepo();
|
|
61
|
+
assert.equal(runAt(fx.gitDir, ['--commit-guard', join(fx.root, 'guard.mjs'), '--no-commit-guard']).status, 2);
|
|
62
|
+
assert.equal(runAt(fx.gitDir, ['--commit-guard']).status, 2, '--commit-guard without a path is a usage error');
|
|
63
|
+
assert.equal(runAt(fx.gitDir, ['--commit-guard', join(fx.root, 'no-such.mjs')]).status, 1, 'a nonexistent guard path refuses');
|
|
64
|
+
assert.equal(runAt(fx.gitDir, ['--commit-guard', join(fx.root, 'guard.mjs')]).status, 0);
|
|
65
|
+
const armed = hookOf(fx);
|
|
66
|
+
const guardLine = armed.split('\n').find((l) => /" --check$/.test(l));
|
|
67
|
+
writeFileSync(join(fx.gitDir, 'hooks', 'pre-commit'), `${armed}${guardLine}\n`);
|
|
68
|
+
const dup = runAt(fx.gitDir);
|
|
69
|
+
assert.equal(dup.status, 1, 'two guard lines are ambiguous — fail closed');
|
|
70
|
+
assert.match(dup.stderr, /commit-guard --check lines/);
|
|
71
|
+
rmSync(fx.root, { recursive: true, force: true });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('outside a git checkout the installer SKIPS loudly and writes nothing', () => {
|
|
75
|
+
const nowhere = mkdtempSync(join(tmpdir(), 'hooks-repo-exec-nogit-'));
|
|
76
|
+
const r = runAt(join(nowhere, 'absent-git-dir'));
|
|
77
|
+
assert.equal(r.status, 0);
|
|
78
|
+
assert.match(r.stdout, /skipping \(not a git checkout\)/);
|
|
79
|
+
assert.equal(existsSync(join(nowhere, 'absent-git-dir')), false, 'nothing was created');
|
|
80
|
+
rmSync(nowhere, { recursive: true, force: true });
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Idempotent installer for the project's git hooks.
|
|
3
3
|
//
|
|
4
|
-
// Installs
|
|
4
|
+
// Installs the `pre-commit` hook running the docs cap-validator + index-freshness
|
|
5
5
|
// gate + changelog/issues rotation-freshness checks + the `scripts/` test
|
|
6
6
|
// suite, so docs files cannot drift over their declared `maxLines`, the auto-generated
|
|
7
7
|
// `index.md` navigator cannot silently fall out of sync, stale archive entries never
|
|
8
8
|
// reach a commit, and regressions to the scripts themselves are caught at commit time.
|
|
9
9
|
//
|
|
10
|
+
// The hooks location comes from git's OWN answer (`git rev-parse --git-path hooks`) — a linked
|
|
11
|
+
// worktree resolves ITS OWN hooks path (its `.git` is a FILE; a hardcoded `.git/hooks` would
|
|
12
|
+
// miss or mis-write).
|
|
13
|
+
//
|
|
14
|
+
// COMMIT GUARD (D10, optional): `--commit-guard <path>` names the composition root's read-only
|
|
15
|
+
// commit-guard tool explicitly — the hook then gains its `--check` line with the RESOLVED,
|
|
16
|
+
// quoted path written at install time (a portable contract: no runtime guessing; this substrate
|
|
17
|
+
// knows no sibling by name, so the path always arrives from the caller). The guard binds the
|
|
18
|
+
// latest final-run receipt to the exact commit tree. An armed guard line SURVIVES flagless
|
|
19
|
+
// re-runs (carried forward from the managed hook — exactly ONE strictly-parsed line; an
|
|
20
|
+
// ambiguous duplicate fails closed); `--no-commit-guard` is the one consented disable; passing
|
|
21
|
+
// both flags together is a usage error.
|
|
22
|
+
//
|
|
10
23
|
// Package-manager-agnostic: the hook calls the scripts via `node` directly (no pnpm/npm
|
|
11
24
|
// assumption). Re-running is safe — the script detects a previously installed hook via
|
|
12
25
|
// the MAGIC_MARKER comment and rewrites only that file.
|
|
@@ -17,12 +30,11 @@ import { writeFile, readFile, mkdir, chmod } from 'node:fs/promises';
|
|
|
17
30
|
import { existsSync, readFileSync } from 'node:fs';
|
|
18
31
|
import { dirname, resolve, basename } from 'node:path';
|
|
19
32
|
import { fileURLToPath } from 'node:url';
|
|
33
|
+
import { spawnSync } from 'node:child_process';
|
|
20
34
|
|
|
21
35
|
const __filename = fileURLToPath(import.meta.url);
|
|
22
36
|
const __dirname = dirname(__filename);
|
|
23
37
|
const ROOT = resolve(__dirname, '..');
|
|
24
|
-
const HOOKS_DIR = resolve(ROOT, '.git/hooks');
|
|
25
|
-
const PRE_COMMIT_PATH = resolve(HOOKS_DIR, 'pre-commit');
|
|
26
38
|
|
|
27
39
|
const readProjectName = () => {
|
|
28
40
|
try {
|
|
@@ -36,7 +48,58 @@ const readProjectName = () => {
|
|
|
36
48
|
|
|
37
49
|
const MAGIC_MARKER = `# ${readProjectName()}:install-git-hooks.mjs`;
|
|
38
50
|
|
|
39
|
-
|
|
51
|
+
// git's own hooks location (worktree-correct), or null outside a git checkout.
|
|
52
|
+
const gitHooksDir = () => {
|
|
53
|
+
const r = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], { cwd: ROOT, encoding: 'utf8', windowsHide: true });
|
|
54
|
+
if (r.error || r.status !== 0) return null;
|
|
55
|
+
return resolve(ROOT, r.stdout.replace(/\r?\n$/, ''));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// The exact shape this installer writes — the strict carry-forward parse anchors on it
|
|
59
|
+
// (quoted path + a bare ` --check` tail; nothing else in the managed hook matches).
|
|
60
|
+
const GUARD_LINE_RE = /^node "([^"]+)" --check$/;
|
|
61
|
+
|
|
62
|
+
const ARGV = process.argv.slice(2);
|
|
63
|
+
|
|
64
|
+
// The optional commit-guard line: an explicit --commit-guard <path> arms it (resolved at install
|
|
65
|
+
// time); --no-commit-guard is the one consented disable; both together are a usage error —
|
|
66
|
+
// never an order-dependent pick.
|
|
67
|
+
const resolveGuardFlags = (argv) => {
|
|
68
|
+
const at = argv.indexOf('--commit-guard');
|
|
69
|
+
const noGuard = argv.includes('--no-commit-guard');
|
|
70
|
+
if (at !== -1 && noGuard) {
|
|
71
|
+
console.error('[install-git-hooks] --commit-guard and --no-commit-guard contradict each other — pass exactly one');
|
|
72
|
+
process.exit(2);
|
|
73
|
+
}
|
|
74
|
+
if (noGuard) return { mode: 'disable' };
|
|
75
|
+
if (at === -1) return { mode: 'carry' };
|
|
76
|
+
const given = argv[at + 1];
|
|
77
|
+
if (!given) {
|
|
78
|
+
console.error('[install-git-hooks] --commit-guard needs a path');
|
|
79
|
+
process.exit(2);
|
|
80
|
+
}
|
|
81
|
+
const abs = resolve(ROOT, given);
|
|
82
|
+
if (!existsSync(abs)) {
|
|
83
|
+
console.error(`[install-git-hooks] --commit-guard ${abs} does not exist`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
return { mode: 'arm', path: abs };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Flagless re-runs carry the armed guard forward from the MANAGED hook — strictly parsed:
|
|
90
|
+
// exactly one guard-shaped line keeps its path; none keeps the hook plain; more than one is
|
|
91
|
+
// ambiguous and fails CLOSED (an installer must never guess which line was the real guard).
|
|
92
|
+
const carriedGuardPath = (existingHook) => {
|
|
93
|
+
if (existingHook === null) return null;
|
|
94
|
+
const matches = existingHook.split('\n').map((l) => GUARD_LINE_RE.exec(l)).filter(Boolean);
|
|
95
|
+
if (matches.length > 1) {
|
|
96
|
+
console.error(`[install-git-hooks] the managed pre-commit hook carries ${matches.length} commit-guard --check lines — ambiguous; remove the duplicates by hand and re-run (refusing to guess)`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
return matches.length === 1 ? matches[0][1] : null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const preCommitContent = (guardPath) => `#!/usr/bin/env bash
|
|
40
103
|
${MAGIC_MARKER}
|
|
41
104
|
# Auto-installed by scripts/install-git-hooks.mjs (run it from "prepare" or by hand).
|
|
42
105
|
# Runs the docs cap-validator + index-freshness gate + rotation-freshness checks
|
|
@@ -50,32 +113,41 @@ node scripts/archive-changelog.mjs --check
|
|
|
50
113
|
node scripts/archive-issues.mjs --check
|
|
51
114
|
node scripts/archive-decisions.mjs --check
|
|
52
115
|
node --test scripts/*.test.mjs
|
|
53
|
-
`;
|
|
116
|
+
${guardPath ? `node "${guardPath}" --check\n` : ''}`;
|
|
54
117
|
|
|
55
118
|
const main = async () => {
|
|
56
|
-
|
|
57
|
-
|
|
119
|
+
const flags = resolveGuardFlags(ARGV);
|
|
120
|
+
const hooksDir = gitHooksDir();
|
|
121
|
+
if (hooksDir === null) {
|
|
122
|
+
console.log('[install-git-hooks] git cannot resolve a hooks path here — skipping (not a git checkout).');
|
|
58
123
|
return;
|
|
59
124
|
}
|
|
60
|
-
|
|
125
|
+
const preCommitPath = resolve(hooksDir, 'pre-commit');
|
|
61
126
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
console.log('[install-git-hooks] pre-commit already up to date.');
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
127
|
+
let existing = null;
|
|
128
|
+
if (existsSync(preCommitPath)) {
|
|
129
|
+
existing = await readFile(preCommitPath, 'utf8');
|
|
68
130
|
if (!existing.includes(MAGIC_MARKER)) {
|
|
69
131
|
console.warn(
|
|
70
|
-
|
|
132
|
+
`[install-git-hooks] WARNING: ${preCommitPath} exists and was not installed by this script. Refusing to overwrite — remove or merge it manually.`,
|
|
71
133
|
);
|
|
72
134
|
process.exit(1);
|
|
73
135
|
}
|
|
74
136
|
}
|
|
75
137
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
138
|
+
const guardPath = flags.mode === 'arm' ? flags.path : flags.mode === 'disable' ? null : carriedGuardPath(existing);
|
|
139
|
+
const content = preCommitContent(guardPath);
|
|
140
|
+
if (existing === content) {
|
|
141
|
+
console.log('[install-git-hooks] pre-commit already up to date.');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await mkdir(hooksDir, { recursive: true });
|
|
146
|
+
await writeFile(preCommitPath, content, 'utf8');
|
|
147
|
+
await chmod(preCommitPath, 0o755);
|
|
148
|
+
console.log(
|
|
149
|
+
`[install-git-hooks] installed ${preCommitPath} (docs caps + index freshness + archive checks + scripts/ tests${guardPath ? ' + commit guard' : ''}).`,
|
|
150
|
+
);
|
|
79
151
|
};
|
|
80
152
|
|
|
81
153
|
main().catch((err) => {
|