@sabaiway/agent-workflow-memory 8.2.1 → 8.4.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 CHANGED
@@ -4,6 +4,39 @@ All notable changes to the memory substrate. Versions are this **package's** npm
4
4
  they are distinct from the **deployment-lineage** stamp written into a project's
5
5
  `docs/ai/.memory-version` (which tracks the shared `agent-workflow` lineage, head `4.0.0`).
6
6
 
7
+ ## 8.4.0 — the commit hook runs your own scripts/pre-commit.local first (AD-195)
8
+
9
+ **Your own commit checks now have a place of their own: `scripts/pre-commit.local`.** Every hook that
10
+ `references/scripts/install-git-hooks.mjs` writes carries, right after `set -e`, a comment line naming that file and
11
+ `if [ -f scripts/pre-commit.local ]; then bash scripts/pre-commit.local; fi`, so the file runs with bash before the
12
+ docs and scripts lines.
13
+
14
+ - A non-zero exit of the file stops the commit; with no such file the hook runs as before.
15
+ - The installer never writes, reads or ships the file, and no flag turns the line off.
16
+ - A hook this installer wrote gains the line the next time the installer runs (`prepare`, or by hand); a hook it
17
+ did not write is refused as before.
18
+ - `install-git-hooks-repo-exec.test.mjs` proves it through real commits: the file runs before the docs check, an exit
19
+ 1 stops the commit, and an absent file lets it through.
20
+
21
+ MINOR: two new lines in every written hook.
22
+
23
+ ## 8.3.0 — the hook installer arms the project-rules check and the layout check (AD-193)
24
+
25
+ **`references/scripts/install-git-hooks.mjs` gains `--rules-check <path>` and `--layout-check <path>`.** Each writes
26
+ `node "<absolute path>" --check` followed by its role comment, `# rules-check` or `# layout-check`, after the docs and
27
+ scripts lines and before the commit-guard line.
28
+
29
+ - A flagless run keeps each armed line by its role; two lines of one role exit 1 naming the role. No flag removes
30
+ them.
31
+ - `--rules-check` takes only a path to `rules-cli.mjs` and `--layout-check` only one to `source-size-check.mjs`; a path
32
+ holding `"`, `$`, a backtick, a backslash, CR or LF exits 2 (on Windows the path's `\` become `/` first). A missing
33
+ file exits 1, a flag without a value exits 2; each writes nothing.
34
+ - Over a hook with neither line, a flagless or `--commit-guard` run writes exactly the hook 4.0.0 to 8.2.1 wrote.
35
+ - Its two test suites strip every inherited git variable before they run git, so they also pass inside the
36
+ pre-commit hook.
37
+
38
+ MINOR: two new flags. The paths arrive from the caller; the kit's `hook-checks` item passes its own tools.
39
+
7
40
  ## 8.2.1 — the architecture template points to the layout rules instead of a fixed set of layers (AD-191)
8
41
 
9
42
  `references/templates/architecture.md`: the lead line drops "Layers", and the Layers and Module Boundaries sections
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-memory
3
3
  description: Deploy or upgrade a portable AI-agent memory substrate in any project — an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) and a structured `docs/ai/` context store with cap/archive/index enforcement. Use when the user wants to bootstrap `docs/ai/`, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-memory` / `/agent-workflow-memory upgrade`. Triggers on "set up the memory system", "deploy the AI memory here", "bootstrap docs/ai", "upgrade the memory substrate". This is the substrate only — the workflow methodology (plan→execute→review, queue, Cleanup) is owned elsewhere and injected into AGENTS.md by the family composition root.
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '8.2.1'
6
+ version: '8.4.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-memory
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-memory",
5
5
  "kind": "memory-substrate",
6
- "version": "8.2.1",
6
+ "version": "8.4.0",
7
7
  "provides": ["context"],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-memory",
3
- "version": "8.2.1",
3
+ "version": "8.4.0",
4
4
  "description": "Portable, cross-agent memory substrate for AI coding agents — an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement, deployable standalone or as part of the agent-workflow family. The memory layer of the agent-workflow family.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -3,11 +3,14 @@
3
3
  // file — the D3(d) changed-line check reads real executions of the shipped bytes, while the
4
4
  // deploy-copy suite (install-git-hooks.test.mjs) keeps the consumer-shaped behavior pins. Every
5
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.
6
+ // lanes, the rules-check and layout-check arm/carry/duplicate/refusal lanes (win32 included), the
7
+ // unmanaged refusal, the up-to-date short-circuit, the not-a-git-checkout skip, and the S71
8
+ // real-commit lane (real `git commit` through the written hook over stand-in scripts). Every child
9
+ // runs with each inherited GIT_* variable stripped, so a hook's environment never leaks in.
7
10
 
8
11
  import { describe, it } from 'node:test';
9
12
  import assert from 'node:assert/strict';
10
- import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
13
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, realpathSync, chmodSync, statSync } from 'node:fs';
11
14
  import { tmpdir } from 'node:os';
12
15
  import { join, dirname } from 'node:path';
13
16
  import { fileURLToPath } from 'node:url';
@@ -15,20 +18,38 @@ import { spawnSync } from 'node:child_process';
15
18
 
16
19
  const HERE = dirname(fileURLToPath(import.meta.url));
17
20
  const INSTALLER = join(HERE, 'install-git-hooks.mjs');
21
+ const WIN32 = ['--import', 'data:text/javascript,Object.defineProperty(process,"platform",{value:"win32"})'];
22
+ const BACKSLASH = String.fromCharCode(92);
23
+ const shown = (path) => (process.platform === 'win32' ? path.replaceAll(BACKSLASH, '/') : path);
24
+
25
+ const BASE_ENV = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^[Gg][Ii][Tt]_/.test(key)));
26
+ const envAt = (home, extra = {}) => ({
27
+ ...BASE_ENV, HOME: home, XDG_CONFIG_HOME: join(home, '.config'), LC_ALL: 'C', GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null',
28
+ GIT_CONFIG_NOSYSTEM: '1', GIT_ATTR_NOSYSTEM: '1', ...extra,
29
+ });
30
+
31
+ // A child that never started or was killed has no exit status to judge — it fails the cell outright.
32
+ const spawnChecked = (command, args, options) => {
33
+ const r = spawnSync(command, args, { encoding: 'utf8', ...options });
34
+ if (r.error) throw r.error;
35
+ if (r.signal !== null) throw new Error(`${command} was killed by ${r.signal}`);
36
+ return r;
37
+ };
18
38
 
19
39
  const mkRepo = () => {
20
- const root = mkdtempSync(join(tmpdir(), 'hooks-repo-exec-'));
21
- spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf8' });
40
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'hooks-repo-exec-')));
41
+ assert.equal(spawnChecked('git', ['init', '-q'], { cwd: root, env: envAt(root) }).status, 0);
22
42
  writeFileSync(join(root, 'guard.mjs'), '// a stand-in commit-guard tool\n');
23
43
  return { root, gitDir: join(root, '.git') };
24
44
  };
25
45
 
26
46
  // The installer anchors its git queries at its OWN parent dir — GIT_DIR overrides that anchor to
27
47
  // 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 } });
48
+ const runAt = (gitDir, args = [], pre = []) =>
49
+ spawnChecked(process.execPath, [...pre, INSTALLER, ...args], { env: envAt(dirname(gitDir), { GIT_DIR: gitDir }) });
30
50
 
31
51
  const hookOf = ({ gitDir }) => readFileSync(join(gitDir, 'hooks', 'pre-commit'), 'utf8');
52
+ const stub = (root, rel) => (mkdirSync(dirname(join(root, rel)), { recursive: true }), writeFileSync(join(root, rel), '// a stand-in tool\n'), join(root, rel));
32
53
 
33
54
  describe('install-git-hooks — the in-place installer under a GIT_DIR-pinned fixture', () => {
34
55
  it('installs the managed hook, is idempotent, and refuses an UNMANAGED hook', () => {
@@ -71,6 +92,36 @@ describe('install-git-hooks — the in-place installer under a GIT_DIR-pinned fi
71
92
  rmSync(fx.root, { recursive: true, force: true });
72
93
  });
73
94
 
95
+ it('the role lanes: arm both beside the guard in order → carry → duplicate fails closed; each refusal writes nothing', () => {
96
+ const fx = mkRepo();
97
+ const [rules, layout, guard] = ['t/rules-cli.mjs', 't/source-size-check.mjs', 'guard.mjs'].map((rel) => stub(fx.root, rel));
98
+ const ok = (args, pre) => { const r = runAt(fx.gitDir, args, pre); assert.equal(r.status, 0, r.stderr); return r; };
99
+ ok(['--commit-guard', guard, '--layout-check', layout, '--rules-check', rules]);
100
+ const tail = `node "${shown(rules)}" --check # rules-check\nnode "${shown(layout)}" --check # layout-check\nnode "${guard}" --check\n`;
101
+ assert.ok(hookOf(fx).endsWith(`node --test scripts/*.test.mjs\n${tail}`), hookOf(fx));
102
+ assert.match(ok([]).stdout, /already up to date/, 'the flagless re-run carries all three');
103
+ const before = hookOf(fx);
104
+ const cases = [
105
+ [['--rules-check', layout], 2], [['--layout-check', rules], 2], [['--rules-check'], 2], [['--layout-check'], 2],
106
+ [['--layout-check', join(fx.root, 'a$b', 'source-size-check.mjs')], 2], [['--rules-check', join(fx.root, 'no', 'rules-cli.mjs')], 1],
107
+ ...(process.platform === 'win32' ? [] : [[['--rules-check', `${fx.root}/w${BACKSLASH}x/rules-cli.mjs`], 2]]),
108
+ ];
109
+ for (const [args, status] of cases) {
110
+ const r = runAt(fx.gitDir, args);
111
+ assert.equal(r.status, status, `${JSON.stringify(args)}: ${r.stderr}`);
112
+ assert.equal(hookOf(fx), before, `${JSON.stringify(args)} wrote nothing`);
113
+ }
114
+ const winArmed = stub(fx.root, 'w/x/rules-cli.mjs');
115
+ ok(['--rules-check', `${fx.root}/w${BACKSLASH}x/rules-cli.mjs`], WIN32);
116
+ assert.ok(hookOf(fx).includes(`node "${shown(winArmed)}" --check # rules-check\n`), 'the win32 path arms with / separators');
117
+ const winHook = hookOf(fx);
118
+ writeFileSync(join(fx.gitDir, 'hooks', 'pre-commit'), `${winHook}node "${layout}" --check # layout-check\n`);
119
+ const dup = runAt(fx.gitDir);
120
+ assert.equal(dup.status, 1, dup.stderr);
121
+ assert.match(dup.stderr, /carries 2 layout-check lines/);
122
+ rmSync(fx.root, { recursive: true, force: true });
123
+ });
124
+
74
125
  it('outside a git checkout the installer SKIPS loudly and writes nothing', () => {
75
126
  const nowhere = mkdtempSync(join(tmpdir(), 'hooks-repo-exec-nogit-'));
76
127
  const r = runAt(join(nowhere, 'absent-git-dir'));
@@ -80,3 +131,55 @@ describe('install-git-hooks — the in-place installer under a GIT_DIR-pinned fi
80
131
  rmSync(nowhere, { recursive: true, force: true });
81
132
  });
82
133
  });
134
+
135
+ // The commit lane also drops NODE_TEST_CONTEXT: an inherited one makes the hook's nested `node --test` skip every file.
136
+ const IDENTITY = { GIT_AUTHOR_NAME: 'p', GIT_AUTHOR_EMAIL: 'p@e', GIT_COMMITTER_NAME: 'p', GIT_COMMITTER_EMAIL: 'p@e' };
137
+ const bareAt = (home, extra = {}) => { const { NODE_TEST_CONTEXT: _, ...env } = envAt(home, { ...IDENTITY, ...extra }); return env; };
138
+ const passes = (r) => (assert.equal(r.status, 0, r.stderr), r);
139
+
140
+ // Passing stand-ins for every script the hook runs; check-docs-size.mjs, run right after the local line, records
141
+ // whether the marker existed. The marker and the record live in a sibling dir, outside the work tree.
142
+ const commitRepo = (root, side) => {
143
+ const [marker, record, local] = [join(side, 'marker'), join(side, 'record'), join(root, 'scripts', 'pre-commit.local')];
144
+ const git = (...args) => spawnChecked('git', args, { cwd: root, env: bareAt(root) });
145
+ mkdirSync(join(root, 'scripts'));
146
+ writeFileSync(join(root, 'scripts', 'check-docs-size.mjs'), `import { existsSync, writeFileSync } from 'node:fs';
147
+ if (!process.argv.includes('--check-index')) writeFileSync(${JSON.stringify(record)}, String(existsSync(${JSON.stringify(marker)})));\n`);
148
+ for (const name of ['archive-changelog', 'archive-issues', 'archive-decisions']) writeFileSync(join(root, 'scripts', `${name}.mjs`), 'process.exit(0);\n');
149
+ writeFileSync(join(root, 'scripts', 'stand-in.test.mjs'), "import { test } from 'node:test';\ntest('a passing stand-in', () => {});\n");
150
+ for (const args of [['init', '-q'], ['add', '-A'], ['commit', '-qm', 'base']]) passes(git(...args));
151
+ passes(spawnChecked(process.execPath, [INSTALLER], { env: bareAt(root, { GIT_DIR: join(root, '.git') }) }));
152
+ const head = () => git('rev-parse', 'HEAD').stdout.trim();
153
+ const commit = (body) => {
154
+ if (body !== undefined) { writeFileSync(local, body); chmodSync(local, 0o644); assert.equal(statSync(local).mode & 0o111, 0, 'the local file is not executable'); }
155
+ writeFileSync(join(root, 'change.txt'), 'change\n');
156
+ passes(git('add', 'change.txt'));
157
+ return git('commit', '-qm', 'change');
158
+ };
159
+ return { marker, record, head, commit };
160
+ };
161
+ const inCommitRepo = (cell) => () => {
162
+ const dirs = ['hooks-commit-', 'hooks-commit-side-'].map((prefix) => realpathSync(mkdtempSync(join(tmpdir(), prefix))));
163
+ try { cell(commitRepo(...dirs)); } finally { for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); }
164
+ };
165
+
166
+ describe('spec:init-project/S71 the local hook file through real commits over stand-ins', () => {
167
+ it('a local file that writes a marker runs before the docs check, and the commit exits 0', { timeout: 120_000 }, inCommitRepo((fx) => {
168
+ passes(fx.commit(`printf made > "${fx.marker}"\n`));
169
+ assert.ok(existsSync(fx.marker), 'the local file ran: its marker appeared');
170
+ assert.equal(readFileSync(fx.record, 'utf8'), 'true', 'the marker existed when check-docs-size.mjs ran');
171
+ }));
172
+
173
+ it('a local file that exits 1 stops the commit: non-zero, no new commit', { timeout: 120_000 }, inCommitRepo((fx) => {
174
+ const before = fx.head();
175
+ assert.notEqual(fx.commit('exit 1\n').status, 0, 'a local file exiting 1 stops the commit');
176
+ assert.equal(fx.head(), before, 'no new commit');
177
+ }));
178
+
179
+ it('with no local file the commit exits 0', { timeout: 120_000 }, inCommitRepo((fx) => {
180
+ const before = fx.head();
181
+ passes(fx.commit());
182
+ assert.notEqual(fx.head(), before, 'the commit landed');
183
+ assert.equal(readFileSync(fx.record, 'utf8'), 'false', 'the hook ran its stand-ins, with no marker');
184
+ }));
185
+ });
@@ -6,6 +6,7 @@
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
+ // First after `set -e` it runs the project's own `scripts/pre-commit.local` with bash when present.
9
10
  //
10
11
  // The hooks location comes from git's OWN answer (`git rev-parse --git-path hooks`) — a linked
11
12
  // worktree resolves ITS OWN hooks path (its `.git` is a FILE; a hardcoded `.git/hooks` would
@@ -20,6 +21,13 @@
20
21
  // ambiguous duplicate fails closed); `--no-commit-guard` is the one consented disable; passing
21
22
  // both flags together is a usage error.
22
23
  //
24
+ // RULES AND LAYOUT CHECKS (optional): `--rules-check <path>` and `--layout-check <path>` arm one line
25
+ // each, `node "<abs>" --check # rules-check` and `node "<abs>" --check # layout-check`, ahead of the
26
+ // guard. The role comment is what a line's role is read by, so the guard rule above never matches
27
+ // either line and a legacy guard line keeps its role whatever its file name. Each takes only its own
28
+ // tool's file name and a path in one safe shell representation (on win32 its separators made `/`),
29
+ // and is carried forward by flagless re-runs exactly as the guard is; no flag disarms either.
30
+ //
23
31
  // Package-manager-agnostic: the hook calls the scripts via `node` directly (no pnpm/npm
24
32
  // assumption). Re-running is safe — the script detects a previously installed hook via
25
33
  // the MAGIC_MARKER comment and rewrites only that file.
@@ -59,6 +67,15 @@ const gitHooksDir = () => {
59
67
  // (quoted path + a bare ` --check` tail; nothing else in the managed hook matches).
60
68
  const GUARD_LINE_RE = /^node "([^"]+)" --check$/;
61
69
 
70
+ // The two role-tagged lines, in hook order, each bound to its own tool's file name.
71
+ const ROLES = [
72
+ { flag: '--rules-check', role: 'rules-check', file: 'rules-cli.mjs' },
73
+ { flag: '--layout-check', role: 'layout-check', file: 'source-size-check.mjs' },
74
+ ].map((entry) => ({ ...entry, re: new RegExp(`^node "([^"]+)" --check # ${entry.role}$`) }));
75
+
76
+ // A path the shell would read as anything but itself inside double quotes is refused.
77
+ const SHELL_ACTIVE = /["$`\\\r\n]/;
78
+
62
79
  const ARGV = process.argv.slice(2);
63
80
 
64
81
  // The optional commit-guard line: an explicit --commit-guard <path> arms it (resolved at install
@@ -86,6 +103,44 @@ const resolveGuardFlags = (argv) => {
86
103
  return { mode: 'arm', path: abs };
87
104
  };
88
105
 
106
+ // A role flag arms its line with the resolved path in its one safe shell representation, or refuses.
107
+ const resolveRoleFlag = (argv, { flag, file }) => {
108
+ const at = argv.indexOf(flag);
109
+ if (at === -1) return null;
110
+ const given = argv[at + 1];
111
+ if (!given) {
112
+ console.error(`[install-git-hooks] ${flag} needs a path`);
113
+ process.exit(2);
114
+ }
115
+ const resolved = resolve(ROOT, given);
116
+ const abs = process.platform === 'win32' ? resolved.replaceAll('\\', '/') : resolved;
117
+ if (basename(abs) !== file) {
118
+ console.error(`[install-git-hooks] ${flag} takes a path to ${file}, got ${abs}`);
119
+ process.exit(2);
120
+ }
121
+ if (SHELL_ACTIVE.test(abs)) {
122
+ console.error(`[install-git-hooks] ${flag} ${JSON.stringify(abs)} carries ", $, a backtick, a backslash, CR or LF — inside the hook's quotes it would not name that file; refusing to arm it`);
123
+ process.exit(2);
124
+ }
125
+ if (!existsSync(abs)) {
126
+ console.error(`[install-git-hooks] ${flag} ${abs} does not exist`);
127
+ process.exit(1);
128
+ }
129
+ return abs;
130
+ };
131
+
132
+ // Flagless re-runs carry each armed role line forward by the guard's rule: one keeps its path, none
133
+ // leaves it out, more than one fails CLOSED naming the role.
134
+ const carriedRolePath = (existingHook, { role, re }) => {
135
+ if (existingHook === null) return null;
136
+ const matches = existingHook.split('\n').map((l) => re.exec(l)).filter(Boolean);
137
+ if (matches.length > 1) {
138
+ console.error(`[install-git-hooks] the managed pre-commit hook carries ${matches.length} ${role} lines — ambiguous; remove the duplicates by hand and re-run (refusing to guess)`);
139
+ process.exit(1);
140
+ }
141
+ return matches.length === 1 ? matches[0][1] : null;
142
+ };
143
+
89
144
  // Flagless re-runs carry the armed guard forward from the MANAGED hook — strictly parsed:
90
145
  // exactly one guard-shaped line keeps its path; none keeps the hook plain; more than one is
91
146
  // ambiguous and fails CLOSED (an installer must never guess which line was the real guard).
@@ -99,7 +154,7 @@ const carriedGuardPath = (existingHook) => {
99
154
  return matches.length === 1 ? matches[0][1] : null;
100
155
  };
101
156
 
102
- const preCommitContent = (guardPath) => `#!/usr/bin/env bash
157
+ const preCommitContent = (guardPath, rolePaths = []) => `#!/usr/bin/env bash
103
158
  ${MAGIC_MARKER}
104
159
  # Auto-installed by scripts/install-git-hooks.mjs (run it from "prepare" or by hand).
105
160
  # Runs the docs cap-validator + index-freshness gate + rotation-freshness checks
@@ -107,16 +162,19 @@ ${MAGIC_MARKER}
107
162
  # declared maxLines, the auto-generated index.md cannot silently go stale, stale
108
163
  # archive entries never reach a commit, and regressions to the scripts are caught.
109
164
  set -e
165
+ # Your own commit checks go in scripts/pre-commit.local; it runs first, with bash, when present.
166
+ if [ -f scripts/pre-commit.local ]; then bash scripts/pre-commit.local; fi
110
167
  node scripts/check-docs-size.mjs
111
168
  node scripts/check-docs-size.mjs --check-index
112
169
  node scripts/archive-changelog.mjs --check
113
170
  node scripts/archive-issues.mjs --check
114
171
  node scripts/archive-decisions.mjs --check
115
172
  node --test scripts/*.test.mjs
116
- ${guardPath ? `node "${guardPath}" --check\n` : ''}`;
173
+ ${rolePaths.map((path, at) => (path ? `node "${path}" --check # ${ROLES[at].role}\n` : '')).join('')}${guardPath ? `node "${guardPath}" --check\n` : ''}`;
117
174
 
118
175
  const main = async () => {
119
176
  const flags = resolveGuardFlags(ARGV);
177
+ const roleFlags = ROLES.map((role) => resolveRoleFlag(ARGV, role));
120
178
  const hooksDir = gitHooksDir();
121
179
  if (hooksDir === null) {
122
180
  console.log('[install-git-hooks] git cannot resolve a hooks path here — skipping (not a git checkout).');
@@ -136,7 +194,8 @@ const main = async () => {
136
194
  }
137
195
 
138
196
  const guardPath = flags.mode === 'arm' ? flags.path : flags.mode === 'disable' ? null : carriedGuardPath(existing);
139
- const content = preCommitContent(guardPath);
197
+ const rolePaths = ROLES.map((role, at) => roleFlags[at] ?? carriedRolePath(existing, role));
198
+ const content = preCommitContent(guardPath, rolePaths);
140
199
  if (existing === content) {
141
200
  console.log('[install-git-hooks] pre-commit already up to date.');
142
201
  return;
@@ -146,7 +205,7 @@ const main = async () => {
146
205
  await writeFile(preCommitPath, content, 'utf8');
147
206
  await chmod(preCommitPath, 0o755);
148
207
  console.log(
149
- `[install-git-hooks] installed ${preCommitPath} (docs caps + index freshness + archive checks + scripts/ tests${guardPath ? ' + commit guard' : ''}).`,
208
+ `[install-git-hooks] installed ${preCommitPath} (docs caps + index freshness + archive checks + scripts/ tests${rolePaths.map((path, at) => (path ? ` + ${ROLES[at].role}` : '')).join('')}${guardPath ? ' + commit guard' : ''}).`,
150
209
  );
151
210
  };
152
211
 
@@ -1,12 +1,11 @@
1
- // install-git-hooks.test.mjs — spec for the pre-commit installer (strip-the-kit round-1 folds):
2
- // the hooks path comes from git plumbing (a linked worktree resolves ITS OWN hooks path, never a
3
- // hardcoded `.git/hooks`), and an armed commit-guard line SURVIVES a flagless re-run (exactly one
4
- // strictly-parsed canonical guard line carries forward; --no-commit-guard is the only consented
5
- // disable; both flags together are a usage error; a duplicated guard line fails closed).
1
+ // install-git-hooks.test.mjs — the pre-commit installer on a deployed copy: the hooks path comes from git
2
+ // plumbing (a linked worktree resolves ITS OWN hooks path); the guard, rules-check and layout-check lines
3
+ // are carried by role, fail closed on a duplicate and refuse a bad flag value, writing nothing. Every child
4
+ // runs with each inherited GIT_* variable stripped, so a hook's environment never leaks in.
6
5
 
7
6
  import { describe, it } from 'node:test';
8
7
  import assert from 'node:assert/strict';
9
- import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, cpSync, existsSync } from 'node:fs';
8
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, cpSync, existsSync, realpathSync } from 'node:fs';
10
9
  import { tmpdir } from 'node:os';
11
10
  import { basename, join, dirname, resolve } from 'node:path';
12
11
  import { fileURLToPath } from 'node:url';
@@ -15,118 +14,228 @@ import { spawnSync } from 'node:child_process';
15
14
  const HERE = dirname(fileURLToPath(import.meta.url));
16
15
  const INSTALLER_SRC = join(HERE, 'install-git-hooks.mjs');
17
16
  const MALFORMED_PACKAGE_JSON = '{ not json';
18
- const NAMED_PACKAGE_JSON = '{"name":"named-fixture"}';
19
- const NAMED_FIXTURE = 'named-fixture';
17
+ const [NAMED_PACKAGE_JSON, NAMED_FIXTURE] = ['{"name":"named-fixture"}', 'named-fixture'];
18
+ const WIN32 = ['--import', 'data:text/javascript,Object.defineProperty(process,"platform",{value:"win32"})'];
19
+ const BACKSLASH = String.fromCharCode(92);
20
+ const SHELL_ACTIVE = [34, 36, 96, 92, 13, 10].map((code) => String.fromCharCode(code)).filter((ch) => process.platform !== 'win32' || ch !== BACKSLASH);
21
+ const shown = (path) => (process.platform === 'win32' ? path.replaceAll(BACKSLASH, '/') : path);
22
+
23
+ const BASE_ENV = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^[Gg][Ii][Tt]_/.test(key)));
24
+ const envAt = (home) => ({
25
+ ...BASE_ENV, HOME: home, XDG_CONFIG_HOME: join(home, '.config'), LC_ALL: 'C', GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null',
26
+ GIT_CONFIG_NOSYSTEM: '1', GIT_ATTR_NOSYSTEM: '1', GIT_AUTHOR_NAME: 'p', GIT_AUTHOR_EMAIL: 'p@e', GIT_COMMITTER_NAME: 'p', GIT_COMMITTER_EMAIL: 'p@e',
27
+ });
28
+
29
+ // A child that never started or was killed has no exit status to judge — it fails the cell outright.
30
+ const spawnChecked = (command, args, cwd) => {
31
+ const r = spawnSync(command, args, { cwd, env: envAt(cwd), encoding: 'utf8' });
32
+ if (r.error) throw r.error;
33
+ if (r.signal !== null) throw new Error(`${command} was killed by ${r.signal}`);
34
+ return r;
35
+ };
36
+ const succeeds = (r) => { assert.equal(r.status, 0, r.stderr); return r; };
37
+ const git = (cwd, ...args) => succeeds(spawnChecked('git', args, cwd));
20
38
 
21
39
  // The installer anchors ROOT at <its dir>/.. — deploy it into a fixture's scripts/ like a consumer.
22
40
  const mkProject = () => {
23
- const root = mkdtempSync(join(tmpdir(), 'install-hooks-'));
24
- const g = (...a) => spawnSync('git', a, { cwd: root, encoding: 'utf8' });
41
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'install-hooks-')));
42
+ const g = (...a) => git(root, ...a);
25
43
  g('init', '-q');
26
- g('config', 'user.email', 'p@e');
27
- g('config', 'user.name', 'p');
28
44
  mkdirSync(join(root, 'scripts'), { recursive: true });
29
45
  cpSync(INSTALLER_SRC, join(root, 'scripts', 'install-git-hooks.mjs'));
30
46
  writeFileSync(join(root, 'guard.mjs'), '// a stand-in commit-guard tool\n');
31
47
  writeFileSync(join(root, 'base.txt'), 'base\n');
32
- g('add', '-A');
33
- g('commit', '-qm', 'base');
48
+ for (const args of [['add', '-A'], ['commit', '-qm', 'base']]) g(...args);
34
49
  return { root, g };
35
50
  };
51
+ const inProject = (cell) => () => { const fx = mkProject(); try { cell(fx); } finally { rmSync(fx.root, { recursive: true, force: true }); } };
36
52
 
37
- const runInstaller = (root, args = []) =>
38
- spawnSync('node', [join(root, 'scripts', 'install-git-hooks.mjs'), ...args], { cwd: root, encoding: 'utf8' });
39
-
40
- const hooksPathOf = (root) => {
41
- const r = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], { cwd: root, encoding: 'utf8' });
42
- return resolve(root, r.stdout.trim());
43
- };
53
+ const runInstaller = (root, args = [], pre = []) =>
54
+ spawnChecked(process.execPath, [...pre, join(root, 'scripts', 'install-git-hooks.mjs'), ...args], root);
55
+ const installs = (...a) => succeeds(runInstaller(...a));
44
56
 
57
+ const hooksPathOf = (root) => resolve(root, git(root, 'rev-parse', '--git-path', 'hooks').stdout.trim());
45
58
  const markerFor = (name) => `# ${name}:install-git-hooks.mjs`;
46
59
  const readHook = (root) => readFileSync(join(hooksPathOf(root), 'pre-commit'), 'utf8');
60
+ const stub = (root, rel) => (mkdirSync(dirname(join(root, rel)), { recursive: true }), writeFileSync(join(root, rel), '// a stand-in tool\n'), join(root, rel));
61
+
62
+ // The hook the 8.3.0 installer body writes with no guard: the six docs and scripts lines.
63
+ const plainHook = (root) => `#!/usr/bin/env bash
64
+ ${markerFor(basename(root))}
65
+ # Auto-installed by scripts/install-git-hooks.mjs (run it from "prepare" or by hand).
66
+ # Runs the docs cap-validator + index-freshness gate + rotation-freshness checks
67
+ # + the scripts/ test suite before every commit, so files cannot drift over their
68
+ # declared maxLines, the auto-generated index.md cannot silently go stale, stale
69
+ # archive entries never reach a commit, and regressions to the scripts are caught.
70
+ set -e
71
+ node scripts/check-docs-size.mjs
72
+ node scripts/check-docs-size.mjs --check-index
73
+ node scripts/archive-changelog.mjs --check
74
+ node scripts/archive-issues.mjs --check
75
+ node scripts/archive-decisions.mjs --check
76
+ node --test scripts/*.test.mjs
77
+ `;
78
+ const LOCAL_LINE = 'if [ -f scripts/pre-commit.local ]; then bash scripts/pre-commit.local; fi';
79
+ // The plain hook every write carries: the 8.3.0 hook with, right after set -e, one `# ` comment naming
80
+ // scripts/pre-commit.local (its wording is free, so it is read from the written hook) and the exact local line.
81
+ const localHook = (root) => {
82
+ const lines = readHook(root).split('\n');
83
+ const comment = lines[lines.indexOf('set -e') + 1];
84
+ assert.ok(comment?.startsWith('# ') && comment.includes('scripts/pre-commit.local'), `right after set -e a # comment names scripts/pre-commit.local, got ${JSON.stringify(comment)}`);
85
+ assert.deepEqual(lines.filter((l) => l.includes('scripts/pre-commit.local')), [comment, LOCAL_LINE], 'the comment and the local line are the only lines naming scripts/pre-commit.local');
86
+ return plainHook(root).replace('set -e\n', `set -e\n${comment}\n${LOCAL_LINE}\n`);
87
+ };
88
+ const line = (path, role) => `node "${role ? shown(path) : path}" --check${role ? ` # ${role}` : ''}\n`;
47
89
 
48
- describe('install-git-hooks \u2014 beside no package.json, or a malformed one, the marker names the directory basename', () => {
49
- it('no package.json, then a MALFORMED one \u2014 the marker names the directory basename', () => {
50
- const { root } = mkProject();
90
+ describe('install-git-hooks — beside no package.json, or a malformed one, the marker names the directory basename', () => {
91
+ it('no package.json, then a MALFORMED one — the marker names the directory basename', inProject(({ root }) => {
51
92
  const expected = markerFor(basename(root));
52
- const absent = runInstaller(root);
53
- assert.equal(absent.status, 0, absent.stderr);
93
+ installs(root);
54
94
  assert.ok(readHook(root).split('\n').includes(expected));
55
95
  writeFileSync(join(root, 'package.json'), MALFORMED_PACKAGE_JSON);
56
- const malformed = runInstaller(root);
57
- assert.equal(malformed.status, 0, malformed.stderr);
96
+ installs(root);
58
97
  assert.ok(readHook(root).split('\n').includes(expected));
59
- rmSync(root, { recursive: true, force: true });
60
- });
98
+ }));
61
99
 
62
- it('a named package.json wins \u2014 the marker carries its name', () => {
63
- const { root } = mkProject();
100
+ it('a named package.json wins — the marker carries its name', inProject(({ root }) => {
64
101
  writeFileSync(join(root, 'package.json'), NAMED_PACKAGE_JSON);
65
- const result = runInstaller(root);
66
- assert.equal(result.status, 0, result.stderr);
102
+ installs(root);
67
103
  assert.ok(readHook(root).split('\n').includes(markerFor(NAMED_FIXTURE)));
68
- rmSync(root, { recursive: true, force: true });
69
- });
104
+ }));
70
105
  });
71
106
 
72
107
  describe('install-git-hooks — the hooks path comes from git plumbing (C7)', () => {
73
- it('a normal repo installs at the git-path hooks location', () => {
74
- const { root } = mkProject();
75
- const r = runInstaller(root);
76
- assert.equal(r.status, 0, r.stderr);
108
+ it('a normal repo installs at the git-path hooks location', inProject(({ root }) => {
109
+ installs(root);
77
110
  assert.ok(existsSync(join(hooksPathOf(root), 'pre-commit')), 'the hook lands where git says hooks live');
78
- rmSync(root, { recursive: true, force: true });
79
- });
111
+ }));
80
112
 
81
- it('a LINKED WORKTREE installs at ITS OWN git-path hooks (never a hardcoded <worktree>/.git/hooks)', () => {
82
- const { root, g } = mkProject();
113
+ it('a LINKED WORKTREE installs at ITS OWN git-path hooks (never a hardcoded <worktree>/.git/hooks)', inProject(({ root, g }) => {
83
114
  const wt = join(root, '..', `wt-${Date.now() % 1e6}`);
84
115
  g('worktree', 'add', '-q', wt);
85
- mkdirSync(join(wt, 'scripts'), { recursive: true });
86
- cpSync(INSTALLER_SRC, join(wt, 'scripts', 'install-git-hooks.mjs'));
87
- const r = runInstaller(wt);
88
- assert.equal(r.status, 0, r.stderr);
89
- const expected = join(hooksPathOf(wt), 'pre-commit');
90
- assert.ok(existsSync(expected), `the worktree hook lands at git's own answer (${expected})`);
91
- assert.ok(!existsSync(join(wt, '.git', 'hooks', 'pre-commit')), 'never a literal <worktree>/.git/hooks (a worktree .git is a FILE)');
92
- rmSync(wt, { recursive: true, force: true });
93
- rmSync(root, { recursive: true, force: true });
94
- });
116
+ try {
117
+ mkdirSync(join(wt, 'scripts'), { recursive: true });
118
+ cpSync(INSTALLER_SRC, join(wt, 'scripts', 'install-git-hooks.mjs'));
119
+ installs(wt);
120
+ const expected = join(hooksPathOf(wt), 'pre-commit');
121
+ assert.ok(existsSync(expected), `the worktree hook lands at git's own answer (${expected})`);
122
+ assert.ok(!existsSync(join(wt, '.git', 'hooks', 'pre-commit')), 'never a literal <worktree>/.git/hooks (a worktree .git is a FILE)');
123
+ } finally {
124
+ rmSync(wt, { recursive: true, force: true });
125
+ }
126
+ }));
95
127
  });
96
128
 
97
129
  describe('install-git-hooks — the commit-guard arm persists (C8)', () => {
98
- it('an armed guard SURVIVES a later flagless re-run (carried forward from the managed hook)', () => {
99
- const { root } = mkProject();
130
+ it('an armed guard SURVIVES a later flagless re-run (carried forward from the managed hook)', inProject(({ root }) => {
100
131
  assert.equal(runInstaller(root, ['--commit-guard', 'guard.mjs']).status, 0);
101
- const armed = readFileSync(join(hooksPathOf(root), 'pre-commit'), 'utf8');
102
- assert.match(armed, /guard\.mjs" --check/, 'the guard line is written armed');
132
+ assert.match(readHook(root), /guard\.mjs" --check/, 'the guard line is written armed');
103
133
  assert.equal(runInstaller(root).status, 0, 'the flagless canonical re-run succeeds');
104
- const after = readFileSync(join(hooksPathOf(root), 'pre-commit'), 'utf8');
105
- assert.match(after, /guard\.mjs" --check/, 'the armed guard line survives a flagless re-run');
106
- rmSync(root, { recursive: true, force: true });
107
- });
134
+ assert.match(readHook(root), /guard\.mjs" --check/, 'the armed guard line survives a flagless re-run');
135
+ }));
108
136
 
109
- it('--no-commit-guard is the ONLY consented disable; combining both flags is a usage error', () => {
110
- const { root } = mkProject();
137
+ it('--no-commit-guard is the ONLY consented disable; combining both flags is a usage error', inProject(({ root }) => {
111
138
  assert.equal(runInstaller(root, ['--commit-guard', 'guard.mjs']).status, 0);
112
139
  assert.equal(runInstaller(root, ['--no-commit-guard']).status, 0);
113
- const after = readFileSync(join(hooksPathOf(root), 'pre-commit'), 'utf8');
114
- assert.doesNotMatch(after, /" --check$/m, 'the guard line is removed on the explicit disable');
140
+ assert.doesNotMatch(readHook(root), /" --check$/m, 'the guard line is removed on the explicit disable');
115
141
  const both = runInstaller(root, ['--commit-guard', 'guard.mjs', '--no-commit-guard']);
116
142
  assert.equal(both.status, 2, 'both flags together are a usage error, never an order-dependent pick');
117
- rmSync(root, { recursive: true, force: true });
118
- });
143
+ }));
119
144
 
120
- it('a DUPLICATED/malformed guard line in the managed hook fails CLOSED on a flagless re-run', () => {
121
- const { root } = mkProject();
145
+ it('a DUPLICATED/malformed guard line in the managed hook fails CLOSED on a flagless re-run', inProject(({ root }) => {
122
146
  assert.equal(runInstaller(root, ['--commit-guard', 'guard.mjs']).status, 0);
123
- const hookPath = join(hooksPathOf(root), 'pre-commit');
124
- const armed = readFileSync(hookPath, 'utf8');
147
+ const armed = readHook(root);
125
148
  const guardLine = armed.split('\n').find((l) => /" --check$/.test(l));
126
- writeFileSync(hookPath, `${armed}${guardLine}\n`); // duplicate the guard line
149
+ writeFileSync(join(hooksPathOf(root), 'pre-commit'), `${armed}${guardLine}\n`); // duplicate the guard line
127
150
  const r = runInstaller(root);
128
151
  assert.equal(r.status, 1, 'two guard lines are ambiguous — fail closed, never guess');
129
152
  assert.match(r.stderr, /guard/i);
130
- rmSync(root, { recursive: true, force: true });
131
- });
153
+ }));
154
+ });
155
+
156
+ describe('spec:init-project/S58 install-git-hooks — the rules-check and layout-check lines on a deployed copy', () => {
157
+ it('each flag writes its resolved path and role comment, ordered docs, rules, layout, guard; a flagless re-run keeps all three; --commit-guard replaces and --no-commit-guard drops the guard alone', inProject(({ root }) => {
158
+ const [rules, layout, guard, guard2] = ['tools/rules-cli.mjs', 'tools/source-size-check.mjs', 'guard.mjs', 'g2/guard.mjs'].map((rel) => stub(root, rel));
159
+ installs(root, ['--commit-guard', 'guard.mjs']);
160
+ installs(root, ['--layout-check', 'tools/source-size-check.mjs']);
161
+ assert.equal(readHook(root), localHook(root) + line(layout, 'layout-check') + line(guard), 'the --layout-check run carried the guard line');
162
+ installs(root, ['--rules-check', rules]);
163
+ const armed = localHook(root) + line(rules, 'rules-check') + line(layout, 'layout-check');
164
+ assert.equal(readHook(root), armed + line(guard), 'each arming run carried the other armed lines');
165
+ assert.match(installs(root).stdout, /already up to date/, 'a flagless re-run keeps all three');
166
+ assert.equal(readHook(root), armed + line(guard));
167
+ installs(root, ['--commit-guard', guard2]);
168
+ assert.equal(readHook(root), armed + line(guard2), '--commit-guard replaces the guard line alone');
169
+ installs(root, ['--no-commit-guard']);
170
+ assert.equal(readHook(root), armed, '--no-commit-guard drops the guard line alone');
171
+ }));
172
+
173
+ it('a legacy guard line whose file name is rules-cli.mjs keeps the guard role beside a rules line', inProject(({ root }) => {
174
+ const [legacy, rules] = ['legacy/rules-cli.mjs', 'tools/rules-cli.mjs'].map((rel) => stub(root, rel));
175
+ installs(root, ['--commit-guard', legacy]);
176
+ installs(root, ['--rules-check', rules]);
177
+ const expected = localHook(root) + line(rules, 'rules-check') + line(legacy);
178
+ assert.equal(readHook(root), expected);
179
+ installs(root);
180
+ assert.equal(readHook(root), expected, 'the flagless re-run keeps the legacy line as the guard');
181
+ }));
182
+
183
+ it('two lines of one role exit 1 naming that role, the hook unchanged', inProject(({ root }) => {
184
+ installs(root, ['--rules-check', stub(root, 'tools/rules-cli.mjs'), '--layout-check', stub(root, 'tools/source-size-check.mjs')]);
185
+ const armed = readHook(root);
186
+ for (const role of ['rules-check', 'layout-check']) {
187
+ const roleLine = armed.split('\n').find((l) => l.endsWith(` --check # ${role}`));
188
+ assert.ok(roleLine, `the ${role} line is armed`);
189
+ writeFileSync(join(hooksPathOf(root), 'pre-commit'), `${armed}${roleLine}\n`);
190
+ const r = runInstaller(root);
191
+ assert.equal(r.status, 1, r.stderr);
192
+ assert.match(r.stderr, new RegExp(`carries 2 ${role} lines`));
193
+ assert.equal(readHook(root), `${armed}${roleLine}\n`);
194
+ }
195
+ }));
196
+
197
+ it('over a hook with no rules or layout line, a flagless and a --commit-guard run write the plain hook with the comment line and the local line right after set -e', inProject(({ root }) => {
198
+ installs(root);
199
+ assert.equal(readHook(root), localHook(root));
200
+ installs(root, ['--commit-guard', 'guard.mjs']);
201
+ assert.equal(readHook(root), localHook(root) + line(join(root, 'guard.mjs')));
202
+ }));
203
+
204
+ it('a wrong file name, a shell-active path or a missing value exits 2, a missing file exits 1, each writing nothing', inProject(({ root }) => {
205
+ const [rules, layout] = ['tools/rules-cli.mjs', 'tools/source-size-check.mjs'].map((rel) => stub(root, rel));
206
+ installs(root, ['--commit-guard', 'guard.mjs']);
207
+ const before = readHook(root);
208
+ const cases = [
209
+ [['--rules-check', layout], 2, /--rules-check takes a path to rules-cli\.mjs/],
210
+ [['--layout-check', rules], 2, /--layout-check takes a path to source-size-check\.mjs/],
211
+ ...SHELL_ACTIVE.flatMap((ch, at) => [
212
+ [['--rules-check', join(root, `r${at}${ch}x`, 'rules-cli.mjs')], 2, /a backslash, CR or LF/],
213
+ [['--layout-check', join(root, `l${at}${ch}x`, 'source-size-check.mjs')], 2, /a backslash, CR or LF/],
214
+ ]),
215
+ [['--rules-check', join(root, 'nowhere', 'rules-cli.mjs')], 1, /does not exist/],
216
+ [['--layout-check', join(root, 'nowhere', 'source-size-check.mjs')], 1, /does not exist/],
217
+ [['--rules-check'], 2, /--rules-check needs a path/],
218
+ [['--layout-check'], 2, /--layout-check needs a path/],
219
+ ];
220
+ for (const [args, status, message] of cases) {
221
+ const r = runInstaller(root, args);
222
+ assert.equal(r.status, status, `${JSON.stringify(args)}: ${r.stderr}`);
223
+ assert.match(r.stderr, message);
224
+ assert.equal(readHook(root), before, `${JSON.stringify(args)} wrote nothing`);
225
+ }
226
+ }));
227
+
228
+ it('an ordinary win32 path, its separators made /, arms and a flagless re-run carries it; on posix it is the backslash refusal', inProject(({ root }) => {
229
+ installs(root);
230
+ const given = `${root}/a${BACKSLASH}b/rules-cli.mjs`;
231
+ const armedAt = stub(root, 'a/b/rules-cli.mjs');
232
+ if (process.platform !== 'win32') { // there the backslash is refused, writing nothing
233
+ const posix = runInstaller(root, ['--rules-check', given]);
234
+ assert.deepEqual([posix.status, /a backslash, CR or LF/.test(posix.stderr), readHook(root)], [2, true, localHook(root)], posix.stderr);
235
+ }
236
+ installs(root, ['--rules-check', given], WIN32);
237
+ assert.equal(readHook(root), localHook(root) + line(armedAt, 'rules-check'));
238
+ assert.match(installs(root).stdout, /already up to date/, 'the flagless re-run recognises the win32-armed line');
239
+ assert.equal(readHook(root), localHook(root) + line(armedAt, 'rules-check'));
240
+ }));
132
241
  });