@sabaiway/agent-workflow-memory 8.2.1 → 8.3.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,23 @@ 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.3.0 — the hook installer arms the project-rules check and the layout check (AD-193)
|
|
8
|
+
|
|
9
|
+
**`references/scripts/install-git-hooks.mjs` gains `--rules-check <path>` and `--layout-check <path>`.** Each writes
|
|
10
|
+
`node "<absolute path>" --check` followed by its role comment, `# rules-check` or `# layout-check`, after the docs and
|
|
11
|
+
scripts lines and before the commit-guard line.
|
|
12
|
+
|
|
13
|
+
- A flagless run keeps each armed line by its role; two lines of one role exit 1 naming the role. No flag removes
|
|
14
|
+
them.
|
|
15
|
+
- `--rules-check` takes only a path to `rules-cli.mjs` and `--layout-check` only one to `source-size-check.mjs`; a path
|
|
16
|
+
holding `"`, `$`, a backtick, a backslash, CR or LF exits 2 (on Windows the path's `\` become `/` first). A missing
|
|
17
|
+
file exits 1, a flag without a value exits 2; each writes nothing.
|
|
18
|
+
- Over a hook with neither line, a flagless or `--commit-guard` run writes exactly the hook 4.0.0 to 8.2.1 wrote.
|
|
19
|
+
- Its two test suites strip every inherited git variable before they run git, so they also pass inside the
|
|
20
|
+
pre-commit hook.
|
|
21
|
+
|
|
22
|
+
MINOR: two new flags. The paths arrive from the caller; the kit's `hook-checks` item passes its own tools.
|
|
23
|
+
|
|
7
24
|
## 8.2.1 — the architecture template points to the layout rules instead of a fixed set of layers (AD-191)
|
|
8
25
|
|
|
9
26
|
`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.
|
|
6
|
+
version: '8.3.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-memory
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-memory",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.3.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,13 @@
|
|
|
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
|
|
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, and the not-a-git-checkout skip. Every child
|
|
8
|
+
// runs with each inherited GIT_* variable stripped, so a hook's environment never leaks in.
|
|
7
9
|
|
|
8
10
|
import { describe, it } from 'node:test';
|
|
9
11
|
import assert from 'node:assert/strict';
|
|
10
|
-
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
|
12
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, realpathSync } from 'node:fs';
|
|
11
13
|
import { tmpdir } from 'node:os';
|
|
12
14
|
import { join, dirname } from 'node:path';
|
|
13
15
|
import { fileURLToPath } from 'node:url';
|
|
@@ -15,20 +17,38 @@ import { spawnSync } from 'node:child_process';
|
|
|
15
17
|
|
|
16
18
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
19
|
const INSTALLER = join(HERE, 'install-git-hooks.mjs');
|
|
20
|
+
const WIN32 = ['--import', 'data:text/javascript,Object.defineProperty(process,"platform",{value:"win32"})'];
|
|
21
|
+
const BACKSLASH = String.fromCharCode(92);
|
|
22
|
+
const shown = (path) => (process.platform === 'win32' ? path.replaceAll(BACKSLASH, '/') : path);
|
|
23
|
+
|
|
24
|
+
const BASE_ENV = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^[Gg][Ii][Tt]_/.test(key)));
|
|
25
|
+
const envAt = (home, extra = {}) => ({
|
|
26
|
+
...BASE_ENV, HOME: home, XDG_CONFIG_HOME: join(home, '.config'), LC_ALL: 'C', GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null',
|
|
27
|
+
GIT_CONFIG_NOSYSTEM: '1', GIT_ATTR_NOSYSTEM: '1', ...extra,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// A child that never started or was killed has no exit status to judge — it fails the cell outright.
|
|
31
|
+
const spawnChecked = (command, args, options) => {
|
|
32
|
+
const r = spawnSync(command, args, { encoding: 'utf8', ...options });
|
|
33
|
+
if (r.error) throw r.error;
|
|
34
|
+
if (r.signal !== null) throw new Error(`${command} was killed by ${r.signal}`);
|
|
35
|
+
return r;
|
|
36
|
+
};
|
|
18
37
|
|
|
19
38
|
const mkRepo = () => {
|
|
20
|
-
const root = mkdtempSync(join(tmpdir(), 'hooks-repo-exec-'));
|
|
21
|
-
|
|
39
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), 'hooks-repo-exec-')));
|
|
40
|
+
assert.equal(spawnChecked('git', ['init', '-q'], { cwd: root, env: envAt(root) }).status, 0);
|
|
22
41
|
writeFileSync(join(root, 'guard.mjs'), '// a stand-in commit-guard tool\n');
|
|
23
42
|
return { root, gitDir: join(root, '.git') };
|
|
24
43
|
};
|
|
25
44
|
|
|
26
45
|
// The installer anchors its git queries at its OWN parent dir — GIT_DIR overrides that anchor to
|
|
27
46
|
// the fixture repo, so running the in-place file NEVER touches this tree's hooks.
|
|
28
|
-
const runAt = (gitDir, args = []) =>
|
|
29
|
-
|
|
47
|
+
const runAt = (gitDir, args = [], pre = []) =>
|
|
48
|
+
spawnChecked(process.execPath, [...pre, INSTALLER, ...args], { env: envAt(dirname(gitDir), { GIT_DIR: gitDir }) });
|
|
30
49
|
|
|
31
50
|
const hookOf = ({ gitDir }) => readFileSync(join(gitDir, 'hooks', 'pre-commit'), 'utf8');
|
|
51
|
+
const stub = (root, rel) => (mkdirSync(dirname(join(root, rel)), { recursive: true }), writeFileSync(join(root, rel), '// a stand-in tool\n'), join(root, rel));
|
|
32
52
|
|
|
33
53
|
describe('install-git-hooks — the in-place installer under a GIT_DIR-pinned fixture', () => {
|
|
34
54
|
it('installs the managed hook, is idempotent, and refuses an UNMANAGED hook', () => {
|
|
@@ -71,6 +91,36 @@ describe('install-git-hooks — the in-place installer under a GIT_DIR-pinned fi
|
|
|
71
91
|
rmSync(fx.root, { recursive: true, force: true });
|
|
72
92
|
});
|
|
73
93
|
|
|
94
|
+
it('the role lanes: arm both beside the guard in order → carry → duplicate fails closed; each refusal writes nothing', () => {
|
|
95
|
+
const fx = mkRepo();
|
|
96
|
+
const [rules, layout, guard] = ['t/rules-cli.mjs', 't/source-size-check.mjs', 'guard.mjs'].map((rel) => stub(fx.root, rel));
|
|
97
|
+
const ok = (args, pre) => { const r = runAt(fx.gitDir, args, pre); assert.equal(r.status, 0, r.stderr); return r; };
|
|
98
|
+
ok(['--commit-guard', guard, '--layout-check', layout, '--rules-check', rules]);
|
|
99
|
+
const tail = `node "${shown(rules)}" --check # rules-check\nnode "${shown(layout)}" --check # layout-check\nnode "${guard}" --check\n`;
|
|
100
|
+
assert.ok(hookOf(fx).endsWith(`node --test scripts/*.test.mjs\n${tail}`), hookOf(fx));
|
|
101
|
+
assert.match(ok([]).stdout, /already up to date/, 'the flagless re-run carries all three');
|
|
102
|
+
const before = hookOf(fx);
|
|
103
|
+
const cases = [
|
|
104
|
+
[['--rules-check', layout], 2], [['--layout-check', rules], 2], [['--rules-check'], 2], [['--layout-check'], 2],
|
|
105
|
+
[['--layout-check', join(fx.root, 'a$b', 'source-size-check.mjs')], 2], [['--rules-check', join(fx.root, 'no', 'rules-cli.mjs')], 1],
|
|
106
|
+
...(process.platform === 'win32' ? [] : [[['--rules-check', `${fx.root}/w${BACKSLASH}x/rules-cli.mjs`], 2]]),
|
|
107
|
+
];
|
|
108
|
+
for (const [args, status] of cases) {
|
|
109
|
+
const r = runAt(fx.gitDir, args);
|
|
110
|
+
assert.equal(r.status, status, `${JSON.stringify(args)}: ${r.stderr}`);
|
|
111
|
+
assert.equal(hookOf(fx), before, `${JSON.stringify(args)} wrote nothing`);
|
|
112
|
+
}
|
|
113
|
+
const winArmed = stub(fx.root, 'w/x/rules-cli.mjs');
|
|
114
|
+
ok(['--rules-check', `${fx.root}/w${BACKSLASH}x/rules-cli.mjs`], WIN32);
|
|
115
|
+
assert.ok(hookOf(fx).includes(`node "${shown(winArmed)}" --check # rules-check\n`), 'the win32 path arms with / separators');
|
|
116
|
+
const winHook = hookOf(fx);
|
|
117
|
+
writeFileSync(join(fx.gitDir, 'hooks', 'pre-commit'), `${winHook}node "${layout}" --check # layout-check\n`);
|
|
118
|
+
const dup = runAt(fx.gitDir);
|
|
119
|
+
assert.equal(dup.status, 1, dup.stderr);
|
|
120
|
+
assert.match(dup.stderr, /carries 2 layout-check lines/);
|
|
121
|
+
rmSync(fx.root, { recursive: true, force: true });
|
|
122
|
+
});
|
|
123
|
+
|
|
74
124
|
it('outside a git checkout the installer SKIPS loudly and writes nothing', () => {
|
|
75
125
|
const nowhere = mkdtempSync(join(tmpdir(), 'hooks-repo-exec-nogit-'));
|
|
76
126
|
const r = runAt(join(nowhere, 'absent-git-dir'));
|
|
@@ -20,6 +20,13 @@
|
|
|
20
20
|
// ambiguous duplicate fails closed); `--no-commit-guard` is the one consented disable; passing
|
|
21
21
|
// both flags together is a usage error.
|
|
22
22
|
//
|
|
23
|
+
// RULES AND LAYOUT CHECKS (optional): `--rules-check <path>` and `--layout-check <path>` arm one line
|
|
24
|
+
// each, `node "<abs>" --check # rules-check` and `node "<abs>" --check # layout-check`, ahead of the
|
|
25
|
+
// guard. The role comment is what a line's role is read by, so the guard rule above never matches
|
|
26
|
+
// either line and a legacy guard line keeps its role whatever its file name. Each takes only its own
|
|
27
|
+
// tool's file name and a path in one safe shell representation (on win32 its separators made `/`),
|
|
28
|
+
// and is carried forward by flagless re-runs exactly as the guard is; no flag disarms either.
|
|
29
|
+
//
|
|
23
30
|
// Package-manager-agnostic: the hook calls the scripts via `node` directly (no pnpm/npm
|
|
24
31
|
// assumption). Re-running is safe — the script detects a previously installed hook via
|
|
25
32
|
// the MAGIC_MARKER comment and rewrites only that file.
|
|
@@ -59,6 +66,15 @@ const gitHooksDir = () => {
|
|
|
59
66
|
// (quoted path + a bare ` --check` tail; nothing else in the managed hook matches).
|
|
60
67
|
const GUARD_LINE_RE = /^node "([^"]+)" --check$/;
|
|
61
68
|
|
|
69
|
+
// The two role-tagged lines, in hook order, each bound to its own tool's file name.
|
|
70
|
+
const ROLES = [
|
|
71
|
+
{ flag: '--rules-check', role: 'rules-check', file: 'rules-cli.mjs' },
|
|
72
|
+
{ flag: '--layout-check', role: 'layout-check', file: 'source-size-check.mjs' },
|
|
73
|
+
].map((entry) => ({ ...entry, re: new RegExp(`^node "([^"]+)" --check # ${entry.role}$`) }));
|
|
74
|
+
|
|
75
|
+
// A path the shell would read as anything but itself inside double quotes is refused.
|
|
76
|
+
const SHELL_ACTIVE = /["$`\\\r\n]/;
|
|
77
|
+
|
|
62
78
|
const ARGV = process.argv.slice(2);
|
|
63
79
|
|
|
64
80
|
// The optional commit-guard line: an explicit --commit-guard <path> arms it (resolved at install
|
|
@@ -86,6 +102,44 @@ const resolveGuardFlags = (argv) => {
|
|
|
86
102
|
return { mode: 'arm', path: abs };
|
|
87
103
|
};
|
|
88
104
|
|
|
105
|
+
// A role flag arms its line with the resolved path in its one safe shell representation, or refuses.
|
|
106
|
+
const resolveRoleFlag = (argv, { flag, file }) => {
|
|
107
|
+
const at = argv.indexOf(flag);
|
|
108
|
+
if (at === -1) return null;
|
|
109
|
+
const given = argv[at + 1];
|
|
110
|
+
if (!given) {
|
|
111
|
+
console.error(`[install-git-hooks] ${flag} needs a path`);
|
|
112
|
+
process.exit(2);
|
|
113
|
+
}
|
|
114
|
+
const resolved = resolve(ROOT, given);
|
|
115
|
+
const abs = process.platform === 'win32' ? resolved.replaceAll('\\', '/') : resolved;
|
|
116
|
+
if (basename(abs) !== file) {
|
|
117
|
+
console.error(`[install-git-hooks] ${flag} takes a path to ${file}, got ${abs}`);
|
|
118
|
+
process.exit(2);
|
|
119
|
+
}
|
|
120
|
+
if (SHELL_ACTIVE.test(abs)) {
|
|
121
|
+
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`);
|
|
122
|
+
process.exit(2);
|
|
123
|
+
}
|
|
124
|
+
if (!existsSync(abs)) {
|
|
125
|
+
console.error(`[install-git-hooks] ${flag} ${abs} does not exist`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
return abs;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Flagless re-runs carry each armed role line forward by the guard's rule: one keeps its path, none
|
|
132
|
+
// leaves it out, more than one fails CLOSED naming the role.
|
|
133
|
+
const carriedRolePath = (existingHook, { role, re }) => {
|
|
134
|
+
if (existingHook === null) return null;
|
|
135
|
+
const matches = existingHook.split('\n').map((l) => re.exec(l)).filter(Boolean);
|
|
136
|
+
if (matches.length > 1) {
|
|
137
|
+
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)`);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
return matches.length === 1 ? matches[0][1] : null;
|
|
141
|
+
};
|
|
142
|
+
|
|
89
143
|
// Flagless re-runs carry the armed guard forward from the MANAGED hook — strictly parsed:
|
|
90
144
|
// exactly one guard-shaped line keeps its path; none keeps the hook plain; more than one is
|
|
91
145
|
// ambiguous and fails CLOSED (an installer must never guess which line was the real guard).
|
|
@@ -99,7 +153,7 @@ const carriedGuardPath = (existingHook) => {
|
|
|
99
153
|
return matches.length === 1 ? matches[0][1] : null;
|
|
100
154
|
};
|
|
101
155
|
|
|
102
|
-
const preCommitContent = (guardPath) => `#!/usr/bin/env bash
|
|
156
|
+
const preCommitContent = (guardPath, rolePaths = []) => `#!/usr/bin/env bash
|
|
103
157
|
${MAGIC_MARKER}
|
|
104
158
|
# Auto-installed by scripts/install-git-hooks.mjs (run it from "prepare" or by hand).
|
|
105
159
|
# Runs the docs cap-validator + index-freshness gate + rotation-freshness checks
|
|
@@ -113,10 +167,11 @@ node scripts/archive-changelog.mjs --check
|
|
|
113
167
|
node scripts/archive-issues.mjs --check
|
|
114
168
|
node scripts/archive-decisions.mjs --check
|
|
115
169
|
node --test scripts/*.test.mjs
|
|
116
|
-
${guardPath ? `node "${guardPath}" --check\n` : ''}`;
|
|
170
|
+
${rolePaths.map((path, at) => (path ? `node "${path}" --check # ${ROLES[at].role}\n` : '')).join('')}${guardPath ? `node "${guardPath}" --check\n` : ''}`;
|
|
117
171
|
|
|
118
172
|
const main = async () => {
|
|
119
173
|
const flags = resolveGuardFlags(ARGV);
|
|
174
|
+
const roleFlags = ROLES.map((role) => resolveRoleFlag(ARGV, role));
|
|
120
175
|
const hooksDir = gitHooksDir();
|
|
121
176
|
if (hooksDir === null) {
|
|
122
177
|
console.log('[install-git-hooks] git cannot resolve a hooks path here — skipping (not a git checkout).');
|
|
@@ -136,7 +191,8 @@ const main = async () => {
|
|
|
136
191
|
}
|
|
137
192
|
|
|
138
193
|
const guardPath = flags.mode === 'arm' ? flags.path : flags.mode === 'disable' ? null : carriedGuardPath(existing);
|
|
139
|
-
const
|
|
194
|
+
const rolePaths = ROLES.map((role, at) => roleFlags[at] ?? carriedRolePath(existing, role));
|
|
195
|
+
const content = preCommitContent(guardPath, rolePaths);
|
|
140
196
|
if (existing === content) {
|
|
141
197
|
console.log('[install-git-hooks] pre-commit already up to date.');
|
|
142
198
|
return;
|
|
@@ -146,7 +202,7 @@ const main = async () => {
|
|
|
146
202
|
await writeFile(preCommitPath, content, 'utf8');
|
|
147
203
|
await chmod(preCommitPath, 0o755);
|
|
148
204
|
console.log(
|
|
149
|
-
`[install-git-hooks] installed ${preCommitPath} (docs caps + index freshness + archive checks + scripts/ tests${guardPath ? ' + commit guard' : ''}).`,
|
|
205
|
+
`[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
206
|
);
|
|
151
207
|
};
|
|
152
208
|
|
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
// install-git-hooks.test.mjs —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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,217 @@ 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
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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));
|
|
47
61
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
62
|
+
// The hook the 4.0.0-8.2.1 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 line = (path, role) => `node "${role ? shown(path) : path}" --check${role ? ` # ${role}` : ''}\n`;
|
|
79
|
+
|
|
80
|
+
describe('install-git-hooks — beside no package.json, or a malformed one, the marker names the directory basename', () => {
|
|
81
|
+
it('no package.json, then a MALFORMED one — the marker names the directory basename', inProject(({ root }) => {
|
|
51
82
|
const expected = markerFor(basename(root));
|
|
52
|
-
|
|
53
|
-
assert.equal(absent.status, 0, absent.stderr);
|
|
83
|
+
installs(root);
|
|
54
84
|
assert.ok(readHook(root).split('\n').includes(expected));
|
|
55
85
|
writeFileSync(join(root, 'package.json'), MALFORMED_PACKAGE_JSON);
|
|
56
|
-
|
|
57
|
-
assert.equal(malformed.status, 0, malformed.stderr);
|
|
86
|
+
installs(root);
|
|
58
87
|
assert.ok(readHook(root).split('\n').includes(expected));
|
|
59
|
-
|
|
60
|
-
});
|
|
88
|
+
}));
|
|
61
89
|
|
|
62
|
-
it('a named package.json wins
|
|
63
|
-
const { root } = mkProject();
|
|
90
|
+
it('a named package.json wins — the marker carries its name', inProject(({ root }) => {
|
|
64
91
|
writeFileSync(join(root, 'package.json'), NAMED_PACKAGE_JSON);
|
|
65
|
-
|
|
66
|
-
assert.equal(result.status, 0, result.stderr);
|
|
92
|
+
installs(root);
|
|
67
93
|
assert.ok(readHook(root).split('\n').includes(markerFor(NAMED_FIXTURE)));
|
|
68
|
-
|
|
69
|
-
});
|
|
94
|
+
}));
|
|
70
95
|
});
|
|
71
96
|
|
|
72
97
|
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
|
-
|
|
75
|
-
const r = runInstaller(root);
|
|
76
|
-
assert.equal(r.status, 0, r.stderr);
|
|
98
|
+
it('a normal repo installs at the git-path hooks location', inProject(({ root }) => {
|
|
99
|
+
installs(root);
|
|
77
100
|
assert.ok(existsSync(join(hooksPathOf(root), 'pre-commit')), 'the hook lands where git says hooks live');
|
|
78
|
-
|
|
79
|
-
});
|
|
101
|
+
}));
|
|
80
102
|
|
|
81
|
-
it('a LINKED WORKTREE installs at ITS OWN git-path hooks (never a hardcoded <worktree>/.git/hooks)', () => {
|
|
82
|
-
const { root, g } = mkProject();
|
|
103
|
+
it('a LINKED WORKTREE installs at ITS OWN git-path hooks (never a hardcoded <worktree>/.git/hooks)', inProject(({ root, g }) => {
|
|
83
104
|
const wt = join(root, '..', `wt-${Date.now() % 1e6}`);
|
|
84
105
|
g('worktree', 'add', '-q', wt);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
106
|
+
try {
|
|
107
|
+
mkdirSync(join(wt, 'scripts'), { recursive: true });
|
|
108
|
+
cpSync(INSTALLER_SRC, join(wt, 'scripts', 'install-git-hooks.mjs'));
|
|
109
|
+
installs(wt);
|
|
110
|
+
const expected = join(hooksPathOf(wt), 'pre-commit');
|
|
111
|
+
assert.ok(existsSync(expected), `the worktree hook lands at git's own answer (${expected})`);
|
|
112
|
+
assert.ok(!existsSync(join(wt, '.git', 'hooks', 'pre-commit')), 'never a literal <worktree>/.git/hooks (a worktree .git is a FILE)');
|
|
113
|
+
} finally {
|
|
114
|
+
rmSync(wt, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
}));
|
|
95
117
|
});
|
|
96
118
|
|
|
97
119
|
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();
|
|
120
|
+
it('an armed guard SURVIVES a later flagless re-run (carried forward from the managed hook)', inProject(({ root }) => {
|
|
100
121
|
assert.equal(runInstaller(root, ['--commit-guard', 'guard.mjs']).status, 0);
|
|
101
|
-
|
|
102
|
-
assert.match(armed, /guard\.mjs" --check/, 'the guard line is written armed');
|
|
122
|
+
assert.match(readHook(root), /guard\.mjs" --check/, 'the guard line is written armed');
|
|
103
123
|
assert.equal(runInstaller(root).status, 0, 'the flagless canonical re-run succeeds');
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
rmSync(root, { recursive: true, force: true });
|
|
107
|
-
});
|
|
124
|
+
assert.match(readHook(root), /guard\.mjs" --check/, 'the armed guard line survives a flagless re-run');
|
|
125
|
+
}));
|
|
108
126
|
|
|
109
|
-
it('--no-commit-guard is the ONLY consented disable; combining both flags is a usage error', () => {
|
|
110
|
-
const { root } = mkProject();
|
|
127
|
+
it('--no-commit-guard is the ONLY consented disable; combining both flags is a usage error', inProject(({ root }) => {
|
|
111
128
|
assert.equal(runInstaller(root, ['--commit-guard', 'guard.mjs']).status, 0);
|
|
112
129
|
assert.equal(runInstaller(root, ['--no-commit-guard']).status, 0);
|
|
113
|
-
|
|
114
|
-
assert.doesNotMatch(after, /" --check$/m, 'the guard line is removed on the explicit disable');
|
|
130
|
+
assert.doesNotMatch(readHook(root), /" --check$/m, 'the guard line is removed on the explicit disable');
|
|
115
131
|
const both = runInstaller(root, ['--commit-guard', 'guard.mjs', '--no-commit-guard']);
|
|
116
132
|
assert.equal(both.status, 2, 'both flags together are a usage error, never an order-dependent pick');
|
|
117
|
-
|
|
118
|
-
});
|
|
133
|
+
}));
|
|
119
134
|
|
|
120
|
-
it('a DUPLICATED/malformed guard line in the managed hook fails CLOSED on a flagless re-run', () => {
|
|
121
|
-
const { root } = mkProject();
|
|
135
|
+
it('a DUPLICATED/malformed guard line in the managed hook fails CLOSED on a flagless re-run', inProject(({ root }) => {
|
|
122
136
|
assert.equal(runInstaller(root, ['--commit-guard', 'guard.mjs']).status, 0);
|
|
123
|
-
const
|
|
124
|
-
const armed = readFileSync(hookPath, 'utf8');
|
|
137
|
+
const armed = readHook(root);
|
|
125
138
|
const guardLine = armed.split('\n').find((l) => /" --check$/.test(l));
|
|
126
|
-
writeFileSync(
|
|
139
|
+
writeFileSync(join(hooksPathOf(root), 'pre-commit'), `${armed}${guardLine}\n`); // duplicate the guard line
|
|
127
140
|
const r = runInstaller(root);
|
|
128
141
|
assert.equal(r.status, 1, 'two guard lines are ambiguous — fail closed, never guess');
|
|
129
142
|
assert.match(r.stderr, /guard/i);
|
|
130
|
-
|
|
131
|
-
|
|
143
|
+
}));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('spec:init-project/S58 install-git-hooks — the rules-check and layout-check lines on a deployed copy', () => {
|
|
147
|
+
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 }) => {
|
|
148
|
+
const [rules, layout, guard, guard2] = ['tools/rules-cli.mjs', 'tools/source-size-check.mjs', 'guard.mjs', 'g2/guard.mjs'].map((rel) => stub(root, rel));
|
|
149
|
+
installs(root, ['--commit-guard', 'guard.mjs']);
|
|
150
|
+
installs(root, ['--layout-check', 'tools/source-size-check.mjs']);
|
|
151
|
+
installs(root, ['--rules-check', rules]);
|
|
152
|
+
const armed = plainHook(root) + line(rules, 'rules-check') + line(layout, 'layout-check');
|
|
153
|
+
assert.equal(readHook(root), armed + line(guard), 'each arming run carried the other armed lines');
|
|
154
|
+
assert.match(installs(root).stdout, /already up to date/, 'a flagless re-run keeps all three');
|
|
155
|
+
assert.equal(readHook(root), armed + line(guard));
|
|
156
|
+
installs(root, ['--commit-guard', guard2]);
|
|
157
|
+
assert.equal(readHook(root), armed + line(guard2), '--commit-guard replaces the guard line alone');
|
|
158
|
+
installs(root, ['--no-commit-guard']);
|
|
159
|
+
assert.equal(readHook(root), armed, '--no-commit-guard drops the guard line alone');
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
it('a legacy guard line whose file name is rules-cli.mjs keeps the guard role beside a rules line', inProject(({ root }) => {
|
|
163
|
+
const [legacy, rules] = ['legacy/rules-cli.mjs', 'tools/rules-cli.mjs'].map((rel) => stub(root, rel));
|
|
164
|
+
installs(root, ['--commit-guard', legacy]);
|
|
165
|
+
installs(root, ['--rules-check', rules]);
|
|
166
|
+
const expected = plainHook(root) + line(rules, 'rules-check') + line(legacy);
|
|
167
|
+
assert.equal(readHook(root), expected);
|
|
168
|
+
installs(root);
|
|
169
|
+
assert.equal(readHook(root), expected, 'the flagless re-run keeps the legacy line as the guard');
|
|
170
|
+
}));
|
|
171
|
+
|
|
172
|
+
it('two lines of one role exit 1 naming that role, the hook unchanged', inProject(({ root }) => {
|
|
173
|
+
installs(root, ['--rules-check', stub(root, 'tools/rules-cli.mjs'), '--layout-check', stub(root, 'tools/source-size-check.mjs')]);
|
|
174
|
+
const armed = readHook(root);
|
|
175
|
+
for (const role of ['rules-check', 'layout-check']) {
|
|
176
|
+
const roleLine = armed.split('\n').find((l) => l.endsWith(` --check # ${role}`));
|
|
177
|
+
assert.ok(roleLine, `the ${role} line is armed`);
|
|
178
|
+
writeFileSync(join(hooksPathOf(root), 'pre-commit'), `${armed}${roleLine}\n`);
|
|
179
|
+
const r = runInstaller(root);
|
|
180
|
+
assert.equal(r.status, 1, r.stderr);
|
|
181
|
+
assert.match(r.stderr, new RegExp(`carries 2 ${role} lines`));
|
|
182
|
+
assert.equal(readHook(root), `${armed}${roleLine}\n`);
|
|
183
|
+
}
|
|
184
|
+
}));
|
|
185
|
+
|
|
186
|
+
it('over a hook with no rules or layout line, a flagless and a --commit-guard run write the 4.0.0-8.2.1 bytes', inProject(({ root }) => {
|
|
187
|
+
installs(root);
|
|
188
|
+
assert.equal(readHook(root), plainHook(root));
|
|
189
|
+
installs(root, ['--commit-guard', 'guard.mjs']);
|
|
190
|
+
assert.equal(readHook(root), plainHook(root) + line(join(root, 'guard.mjs')));
|
|
191
|
+
}));
|
|
192
|
+
|
|
193
|
+
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 }) => {
|
|
194
|
+
const [rules, layout] = ['tools/rules-cli.mjs', 'tools/source-size-check.mjs'].map((rel) => stub(root, rel));
|
|
195
|
+
installs(root, ['--commit-guard', 'guard.mjs']);
|
|
196
|
+
const before = readHook(root);
|
|
197
|
+
const cases = [
|
|
198
|
+
[['--rules-check', layout], 2, /--rules-check takes a path to rules-cli\.mjs/],
|
|
199
|
+
[['--layout-check', rules], 2, /--layout-check takes a path to source-size-check\.mjs/],
|
|
200
|
+
...SHELL_ACTIVE.flatMap((ch, at) => [
|
|
201
|
+
[['--rules-check', join(root, `r${at}${ch}x`, 'rules-cli.mjs')], 2, /a backslash, CR or LF/],
|
|
202
|
+
[['--layout-check', join(root, `l${at}${ch}x`, 'source-size-check.mjs')], 2, /a backslash, CR or LF/],
|
|
203
|
+
]),
|
|
204
|
+
[['--rules-check', join(root, 'nowhere', 'rules-cli.mjs')], 1, /does not exist/],
|
|
205
|
+
[['--layout-check', join(root, 'nowhere', 'source-size-check.mjs')], 1, /does not exist/],
|
|
206
|
+
[['--rules-check'], 2, /--rules-check needs a path/],
|
|
207
|
+
[['--layout-check'], 2, /--layout-check needs a path/],
|
|
208
|
+
];
|
|
209
|
+
for (const [args, status, message] of cases) {
|
|
210
|
+
const r = runInstaller(root, args);
|
|
211
|
+
assert.equal(r.status, status, `${JSON.stringify(args)}: ${r.stderr}`);
|
|
212
|
+
assert.match(r.stderr, message);
|
|
213
|
+
assert.equal(readHook(root), before, `${JSON.stringify(args)} wrote nothing`);
|
|
214
|
+
}
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
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 }) => {
|
|
218
|
+
installs(root);
|
|
219
|
+
const given = `${root}/a${BACKSLASH}b/rules-cli.mjs`;
|
|
220
|
+
const armedAt = stub(root, 'a/b/rules-cli.mjs');
|
|
221
|
+
if (process.platform !== 'win32') { // there the backslash is refused, writing nothing
|
|
222
|
+
const posix = runInstaller(root, ['--rules-check', given]);
|
|
223
|
+
assert.deepEqual([posix.status, /a backslash, CR or LF/.test(posix.stderr), readHook(root)], [2, true, plainHook(root)], posix.stderr);
|
|
224
|
+
}
|
|
225
|
+
installs(root, ['--rules-check', given], WIN32);
|
|
226
|
+
assert.equal(readHook(root), plainHook(root) + line(armedAt, 'rules-check'));
|
|
227
|
+
assert.match(installs(root).stdout, /already up to date/, 'the flagless re-run recognises the win32-armed line');
|
|
228
|
+
assert.equal(readHook(root), plainHook(root) + line(armedAt, 'rules-check'));
|
|
229
|
+
}));
|
|
132
230
|
});
|