macca-method 2.1.2 → 3.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/macca-lock.json +2 -1
- package/.agents/macca-managed-skills.txt +1 -0
- package/.agents/skills/_shared/references/additional-skills.md +17 -1
- package/.agents/skills/_shared/references/brainstorm-session.md +61 -21
- package/.agents/skills/_shared/references/config-mutation.md +58 -18
- package/.agents/skills/_shared/references/fix-mode.md +21 -7
- package/.agents/skills/_shared/references/human-loop.md +2 -0
- package/.agents/skills/_shared/references/implementation-principles.md +4 -0
- package/.agents/skills/_shared/references/interaction-contract.md +33 -0
- package/.agents/skills/_shared/references/invocation-policy.md +14 -3
- package/.agents/skills/_shared/references/language-config.md +24 -4
- package/.agents/skills/_shared/references/output-ownership.md +12 -5
- package/.agents/skills/_shared/references/planning-principles.md +35 -0
- package/.agents/skills/_shared/references/scope-delta.md +21 -0
- package/.agents/skills/_shared/references/scope-rules.md +1 -1
- package/.agents/skills/_shared/references/skill-catalog.md +30 -13
- package/.agents/skills/_shared/references/workspace-safety.md +11 -0
- package/.agents/skills/_shared/scripts/config-file.js +132 -0
- package/.agents/skills/_shared/scripts/config-validator.js +164 -0
- package/.agents/skills/_shared/scripts/read-preferences.js +128 -0
- package/.agents/skills/add-feature/SKILL.md +24 -18
- package/.agents/skills/brainstorm-api/SKILL.md +24 -23
- package/.agents/skills/brainstorm-api/assets/api.template.md +11 -23
- package/.agents/skills/brainstorm-architecture/SKILL.md +41 -48
- package/.agents/skills/brainstorm-architecture/assets/architecture.template.md +24 -16
- package/.agents/skills/brainstorm-prd/SKILL.md +26 -28
- package/.agents/skills/brainstorm-prd/assets/PRD.template.md +10 -9
- package/.agents/skills/brainstorm-rules/SKILL.md +42 -46
- package/.agents/skills/brainstorm-rules/assets/rules.template.md +8 -8
- package/.agents/skills/brainstorm-schema/SKILL.md +17 -17
- package/.agents/skills/brainstorm-schema/assets/schema.template.md +9 -8
- package/.agents/skills/brainstorm-styleguide/SKILL.md +27 -26
- package/.agents/skills/brainstorm-styleguide/assets/StyleGuide.template.md +27 -51
- package/.agents/skills/brainstorm-task/SKILL.md +53 -37
- package/.agents/skills/brainstorm-task/assets/Task.template.md +11 -12
- package/.agents/skills/bug-fix/SKILL.md +78 -175
- package/.agents/skills/bug-fix/assets/bug-log.template.md +61 -0
- package/.agents/skills/code-review/SKILL.md +14 -6
- package/.agents/skills/code-review/references/review-checklist.md +24 -44
- package/.agents/skills/developer/SKILL.md +23 -25
- package/.agents/skills/developer/references/close-phase.md +11 -6
- package/.agents/skills/developer/references/execute-task.md +15 -8
- package/.agents/skills/developer/references/onboarding.md +16 -19
- package/.agents/skills/help/SKILL.md +49 -49
- package/.agents/skills/meet/SKILL.md +37 -85
- package/.agents/skills/quick-dev/SKILL.md +40 -75
- package/.agents/skills/release-readiness/SKILL.md +1 -1
- package/.agents/skills/setup-macca-method/SKILL.md +79 -0
- package/.agents/skills/spec-audit/SKILL.md +21 -17
- package/.agents/skills/spec-compliance/SKILL.md +36 -18
- package/.agents/skills/spec-init/SKILL.md +53 -22
- package/CHANGELOG.md +31 -0
- package/README.md +92 -1195
- package/bin/macca-method.js +833 -286
- package/docs/configuration.md +174 -0
- package/docs/troubleshooting.md +180 -0
- package/docs/workflows.md +175 -0
- package/package.json +48 -4
- package/scripts/lib/npm-command.js +76 -0
- package/scripts/test-cli-setup.js +869 -0
- package/scripts/test-config.js +320 -0
- package/scripts/test-install.js +63 -28
- package/scripts/test-installer-safety.js +545 -0
- package/scripts/test-npm-command.js +143 -0
- package/scripts/test-package-safety.js +252 -0
- package/scripts/test-preferences.js +475 -0
- package/scripts/test-upgrade-legacy.js +11 -21
- package/scripts/validate-docs.js +166 -0
- package/scripts/validate-package.js +285 -0
- package/scripts/validate-skill-behavior.js +914 -12
- package/.agents/skills/antislop-copywriting/SKILL.md +0 -372
- package/.agents/skills/skill-creator/LICENSE.txt +0 -202
- package/.agents/skills/skill-creator/SKILL.md +0 -485
- package/.agents/skills/skill-creator/agents/analyzer.md +0 -274
- package/.agents/skills/skill-creator/agents/comparator.md +0 -202
- package/.agents/skills/skill-creator/agents/grader.md +0 -223
- package/.agents/skills/skill-creator/assets/eval_review.html +0 -146
- package/.agents/skills/skill-creator/eval-viewer/generate_review.py +0 -471
- package/.agents/skills/skill-creator/eval-viewer/viewer.html +0 -1325
- package/.agents/skills/skill-creator/references/schemas.md +0 -441
- package/.agents/skills/skill-creator/scripts/__init__.py +0 -0
- package/.agents/skills/skill-creator/scripts/aggregate_benchmark.py +0 -401
- package/.agents/skills/skill-creator/scripts/generate_report.py +0 -326
- package/.agents/skills/skill-creator/scripts/improve_description.py +0 -247
- package/.agents/skills/skill-creator/scripts/package_skill.py +0 -136
- package/.agents/skills/skill-creator/scripts/quick_validate.py +0 -103
- package/.agents/skills/skill-creator/scripts/run_eval.py +0 -310
- package/.agents/skills/skill-creator/scripts/run_loop.py +0 -328
- package/.agents/skills/skill-creator/scripts/utils.py +0 -47
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const os = require("node:os");
|
|
7
|
+
const path = require("node:path");
|
|
8
|
+
const vm = require("node:vm");
|
|
9
|
+
const { EventEmitter } = require("node:events");
|
|
10
|
+
const { spawnSync } = require("node:child_process");
|
|
11
|
+
|
|
12
|
+
const repo = path.resolve(__dirname, "..");
|
|
13
|
+
const cli = path.join(repo, "bin", "macca-method.js");
|
|
14
|
+
const source = fs.readFileSync(cli, "utf8").replace(/main\(\);\s*$/, "");
|
|
15
|
+
const root = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "macca-setup-"));
|
|
16
|
+
let passed = 0;
|
|
17
|
+
|
|
18
|
+
function load(overrides = {}, nodeVersion = process.versions.node) {
|
|
19
|
+
let stdout = "";
|
|
20
|
+
let stderr = "";
|
|
21
|
+
const input = Object.assign(new EventEmitter(), { isTTY: true });
|
|
22
|
+
const processMock = {
|
|
23
|
+
...process, versions: { ...process.versions, node: nodeVersion },
|
|
24
|
+
stdin: input,
|
|
25
|
+
stdout: { isTTY: true, write: (value) => { stdout += value; } },
|
|
26
|
+
stderr: { write: (value) => { stderr += value; } },
|
|
27
|
+
exitCode: 0,
|
|
28
|
+
};
|
|
29
|
+
const sandbox = {
|
|
30
|
+
// Production resolves the validator absolutely using PACKAGE_ROOT. This
|
|
31
|
+
// require belongs to scripts/, deliberately exercising the VM caveat.
|
|
32
|
+
require: (name) => overrides[name] || require(
|
|
33
|
+
overrides["node:path"] === path.win32 && name.endsWith("\\config-validator.js")
|
|
34
|
+
? path.join(repo, ".agents", "skills", "_shared", "scripts", "config-validator.js")
|
|
35
|
+
: name,
|
|
36
|
+
),
|
|
37
|
+
process: processMock, __dirname: path.dirname(cli), Buffer, console,
|
|
38
|
+
};
|
|
39
|
+
vm.runInNewContext(source, sandbox, { filename: cli });
|
|
40
|
+
return Object.assign(sandbox, {
|
|
41
|
+
output: () => `${stdout}${stderr}`,
|
|
42
|
+
state: () => vm.runInContext("operation", sandbox),
|
|
43
|
+
dispose: (prompt) => vm.runInContext("promptStates", sandbox).get(prompt).dispose(),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function fakePrompt(action) {
|
|
48
|
+
const prompt = new EventEmitter();
|
|
49
|
+
prompt.input = new EventEmitter();
|
|
50
|
+
prompt.questions = [];
|
|
51
|
+
prompt.setPrompt = (text) => { prompt.current = text; };
|
|
52
|
+
prompt.prompt = () => {
|
|
53
|
+
prompt.questions.push(prompt.current);
|
|
54
|
+
queueMicrotask(() => action(prompt, prompt.questions.length));
|
|
55
|
+
};
|
|
56
|
+
prompt.close = () => { prompt.emit("close"); };
|
|
57
|
+
return prompt;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function write(file, content) {
|
|
61
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
62
|
+
fs.writeFileSync(file, content);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function json(file, value) { write(file, `${JSON.stringify(value, null, 2)}\n`); }
|
|
66
|
+
function configPath(project) { return path.join(project, ".agents", "developer-config.json"); }
|
|
67
|
+
function readConfig(project) { return JSON.parse(fs.readFileSync(configPath(project), "utf8")); }
|
|
68
|
+
|
|
69
|
+
// Include directories, symlinks and exact bytes: a doctor must not even repair
|
|
70
|
+
// a journal or create an empty target directory.
|
|
71
|
+
function snapshot(directory) {
|
|
72
|
+
if (!fs.existsSync(directory)) return null;
|
|
73
|
+
const entries = [];
|
|
74
|
+
function walk(current, relative = "") {
|
|
75
|
+
for (const name of fs.readdirSync(current).sort()) {
|
|
76
|
+
const candidate = path.join(current, name);
|
|
77
|
+
const key = path.join(relative, name);
|
|
78
|
+
const stat = fs.lstatSync(candidate);
|
|
79
|
+
if (stat.isSymbolicLink()) entries.push([key, "link", fs.readlinkSync(candidate)]);
|
|
80
|
+
else if (stat.isDirectory()) { entries.push([key, "dir"]); walk(candidate, key); }
|
|
81
|
+
else if (stat.isFile()) entries.push([key, "file", fs.readFileSync(candidate).toString("base64")]);
|
|
82
|
+
else entries.push([key, "special", stat.mode, stat.ino, stat.size]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
walk(directory);
|
|
86
|
+
return entries;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function run(project, args, status = 0, executable = cli) {
|
|
90
|
+
const result = spawnSync(process.execPath, [executable, ...args, "--directory", project], {
|
|
91
|
+
encoding: "utf8", timeout: 60000,
|
|
92
|
+
});
|
|
93
|
+
assert.ifError(result.error);
|
|
94
|
+
assert.equal(result.status, status, `${result.stdout}${result.stderr}`);
|
|
95
|
+
return `${result.stdout}${result.stderr}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readOnlyFs() {
|
|
99
|
+
const mocked = Object.create(fs);
|
|
100
|
+
for (const name of ["mkdirSync", "writeFileSync", "appendFileSync", "renameSync", "rmSync", "unlinkSync", "rmdirSync", "copyFileSync", "cpSync", "openSync", "chmodSync"]) {
|
|
101
|
+
mocked[name] = () => { assert.fail(`Unexpected mutation: ${name}`); };
|
|
102
|
+
}
|
|
103
|
+
return mocked;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function versionFixture(name) {
|
|
107
|
+
const fixture = path.join(root, `${name} package`);
|
|
108
|
+
const executable = path.join(fixture, "bin", "macca-method.js");
|
|
109
|
+
write(executable, fs.readFileSync(cli));
|
|
110
|
+
fs.cpSync(path.join(repo, ".agents"), path.join(fixture, ".agents"), { recursive: true });
|
|
111
|
+
const packagedPath = path.join(fixture, ".agents", "macca-lock.json");
|
|
112
|
+
const lock = JSON.parse(fs.readFileSync(packagedPath, "utf8"));
|
|
113
|
+
const setVersion = (version) => json(packagedPath, { ...lock, version });
|
|
114
|
+
setVersion("2.0.0");
|
|
115
|
+
const project = path.join(root, `${name} project`);
|
|
116
|
+
run(project, ["install", "--yes"], 0, executable);
|
|
117
|
+
setVersion("3.0.0-rc.1");
|
|
118
|
+
return { project, executable, setVersion };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function pendingLock(project, { current, backup, stage, version = 2, phase = "committing" }) {
|
|
122
|
+
const agents = path.join(project, ".agents");
|
|
123
|
+
const targetPath = path.join(agents, "macca-lock.json");
|
|
124
|
+
const transactionId = "1-1-abcdef";
|
|
125
|
+
const backupPath = path.join(agents, `.macca-lock.json.macca-backup-${transactionId}`);
|
|
126
|
+
const stagingPath = stage === undefined ? null : path.join(agents, `.macca-lock.json.macca-stage-${transactionId}`);
|
|
127
|
+
for (const [file, value] of [[targetPath, current], [backupPath, backup], [stagingPath, stage]]) {
|
|
128
|
+
if (!file) continue;
|
|
129
|
+
if (value === undefined) fs.rmSync(file, { force: true });
|
|
130
|
+
else json(file, { version: value });
|
|
131
|
+
}
|
|
132
|
+
const instance = load();
|
|
133
|
+
const stagedHash = current !== undefined ? instance.hashFile(targetPath)
|
|
134
|
+
: stagingPath ? instance.hashFile(stagingPath) : null;
|
|
135
|
+
const entry = {
|
|
136
|
+
targetPath, backupPath, stagingPath, kind: "file", hadTarget: backup !== undefined || current !== undefined,
|
|
137
|
+
stagedHash,
|
|
138
|
+
...(version === 2 ? {
|
|
139
|
+
originalHash: backup !== undefined ? instance.hashFile(backupPath)
|
|
140
|
+
: current !== undefined ? instance.hashFile(targetPath) : null,
|
|
141
|
+
stagedSnapshotHash: stagingPath ? instance.hashFile(stagingPath) : null,
|
|
142
|
+
} : {}),
|
|
143
|
+
};
|
|
144
|
+
const journalPath = path.join(agents, "macca-transaction.json");
|
|
145
|
+
json(journalPath, { version, transactionId, phase, entries: [entry] });
|
|
146
|
+
return { journalPath, entry };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function assertVersionRefusal(fixture, pattern) {
|
|
150
|
+
const before = snapshot(fixture.project);
|
|
151
|
+
for (const command of ["install", "upgrade"]) {
|
|
152
|
+
for (const flags of [[], ["--force"]]) {
|
|
153
|
+
const output = run(fixture.project, [command, "--yes", ...flags], 1, fixture.executable);
|
|
154
|
+
assert.match(output, pattern);
|
|
155
|
+
assert.match(output, /not started; no files changed/);
|
|
156
|
+
assert.deepEqual(snapshot(fixture.project), before);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function test(name, action) {
|
|
162
|
+
await action();
|
|
163
|
+
passed += 1;
|
|
164
|
+
process.stdout.write(`OK: ${name}\n`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function main() {
|
|
168
|
+
await test("SemVer follows the complete standard prerelease precedence chain", () => {
|
|
169
|
+
const { compareSemver } = load();
|
|
170
|
+
const ordered = [
|
|
171
|
+
"1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-alpha.beta", "1.0.0-beta",
|
|
172
|
+
"1.0.0-beta.2", "1.0.0-beta.11", "1.0.0-rc.1", "1.0.0",
|
|
173
|
+
];
|
|
174
|
+
for (let i = 0; i < ordered.length; i += 1) {
|
|
175
|
+
for (let j = 0; j < ordered.length; j += 1) {
|
|
176
|
+
assert.equal(compareSemver(ordered[i], ordered[j]), Math.sign(i - j), `${ordered[i]} vs ${ordered[j]}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await test("SemVer compares core, numeric and ASCII identifiers without numeric precision loss", () => {
|
|
182
|
+
const { compareSemver } = load();
|
|
183
|
+
const huge = "9".repeat(400);
|
|
184
|
+
for (const [older, newer] of [
|
|
185
|
+
["0.0.0", "0.0.1"], ["1.9.99", "1.10.0"], ["2.99.99", "3.0.0-0"],
|
|
186
|
+
["3.0.0-rc.2", "3.0.0-rc.10"], ["3.0.0-0", "3.0.0-1"],
|
|
187
|
+
["3.0.0-999", "3.0.0-a"], ["3.0.0-A", "3.0.0-a"],
|
|
188
|
+
["3.0.0--", "3.0.0-A"], ["3.0.0-01a", "3.0.0-1a"],
|
|
189
|
+
["3.0.0-alpha", "3.0.0-alpha.0"], ["3.0.0-rc.1", "3.0.0"],
|
|
190
|
+
["9007199254740992.0.0", "9007199254740993.0.0"],
|
|
191
|
+
["1.9007199254740992.0", "1.9007199254740993.0"],
|
|
192
|
+
["1.0.9007199254740992", "1.0.9007199254740993"],
|
|
193
|
+
["1.0.0-9007199254740992", "1.0.0-9007199254740993"],
|
|
194
|
+
[`${huge}.0.0`, `1${"0".repeat(400)}.0.0`],
|
|
195
|
+
[`1.0.0-${huge}`, `1.0.0-1${"0".repeat(400)}`],
|
|
196
|
+
[`1.0.0-${huge}`, "1.0.0-a"],
|
|
197
|
+
]) {
|
|
198
|
+
assert.equal(compareSemver(older, newer), -1, `${older} < ${newer}`);
|
|
199
|
+
assert.equal(compareSemver(newer, older), 1, `${newer} > ${older}`);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await test("SemVer ignores build metadata, including leading zeros and hyphens", () => {
|
|
204
|
+
const { compareSemver } = load();
|
|
205
|
+
for (const version of ["0.0.0", "3.0.0", "3.0.0-rc.1", "1.0.0-0", "1.0.0-01a.-"]) {
|
|
206
|
+
assert.equal(compareSemver(version, version), 0);
|
|
207
|
+
assert.equal(compareSemver(`${version}+001.build-5`, `${version}+other.000`), 0);
|
|
208
|
+
assert.equal(compareSemver(`${version}+-`, version), 0);
|
|
209
|
+
assert.equal(compareSemver(version, `${version}+0`), 0);
|
|
210
|
+
}
|
|
211
|
+
assert.equal(compareSemver("3.0.0-rc.2+zzz", "3.0.0-rc.10+aaa"), -1);
|
|
212
|
+
assert.equal(compareSemver("3.0.0+001", "3.0.0-rc.1+999"), 1);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await test("SemVer rejects malformed or missing versions on either side instead of coercing zero", () => {
|
|
216
|
+
const { compareSemver } = load();
|
|
217
|
+
for (const invalid of [
|
|
218
|
+
undefined, null, false, 0, 3, {}, [], "", " ", "v3.0.0", "=3.0.0",
|
|
219
|
+
"3", "3.0", "3.0.0.0", "03.0.0", "3.00.0", "3.0.00",
|
|
220
|
+
"-3.0.0", "3.-1.0", "3.0.-1", "3.0.x", "3.0.0junk",
|
|
221
|
+
"3.0.0-", "3.0.0-01", "3.0.0-rc.01", "3.0.0-rc..1", "3.0.0-.rc", "3.0.0-rc.",
|
|
222
|
+
"3.0.0+", "3.0.0+build..1", "3.0.0+.build", "3.0.0+build.", "3.0.0+a+b",
|
|
223
|
+
"3.0.0-rc_1", "3.0.0+build_1", "3.0.0-rç", " 3.0.0", "3.0.0 ", "3.0.0\n",
|
|
224
|
+
]) {
|
|
225
|
+
for (const [left, right, side] of [[invalid, "0.0.0", "left"], ["0.0.0", invalid, "right"]]) {
|
|
226
|
+
assert.throws(() => compareSemver(left, right), {
|
|
227
|
+
code: "MACCA_VERSION_INVALID", message: new RegExp(`Invalid semantic version for ${side} version: expected MAJOR.MINOR.PATCH`),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await test("downgrade guard accepts equal metadata and valid legacy versions, rejects invalid lock versions", () => {
|
|
234
|
+
const agents = path.join(root, "semver guard", ".agents");
|
|
235
|
+
const installedPath = path.join(agents, "macca-lock.json");
|
|
236
|
+
const packagedPath = path.join(repo, ".agents", "macca-lock.json");
|
|
237
|
+
let packagedVersion = "3.0.0-rc.1";
|
|
238
|
+
const mocked = readOnlyFs();
|
|
239
|
+
mocked.readFileSync = (candidate, ...args) => candidate === packagedPath
|
|
240
|
+
? JSON.stringify({ version: packagedVersion }) : fs.readFileSync(candidate, ...args);
|
|
241
|
+
const instance = load({ "node:fs": mocked });
|
|
242
|
+
instance.assertPackageNotOlderThanInstalled(agents);
|
|
243
|
+
for (const installedVersion of ["0.0.0", "2.0.0", "3.0.0-beta.11", "3.0.0-rc.1+001"]) {
|
|
244
|
+
json(installedPath, { version: installedVersion });
|
|
245
|
+
instance.assertPackageNotOlderThanInstalled(agents);
|
|
246
|
+
}
|
|
247
|
+
for (const side of ["packaged", "installed"]) {
|
|
248
|
+
for (const invalid of [undefined, null, 3, "", "3.0", "3.0.0-01"]) {
|
|
249
|
+
packagedVersion = side === "packaged" ? invalid : "3.0.0-rc.1";
|
|
250
|
+
json(installedPath, { version: side === "installed" ? invalid : "2.0.0" });
|
|
251
|
+
const before = snapshot(agents);
|
|
252
|
+
assert.throws(() => instance.assertPackageNotOlderThanInstalled(agents), (error) => {
|
|
253
|
+
assert.equal(error.code, "MACCA_VERSION_INVALID");
|
|
254
|
+
assert.match(error.message, /Cannot verify MACCA downgrade safety/);
|
|
255
|
+
assert.match(error.message, new RegExp(`${side === "packaged" ? "left" : "right"} version`));
|
|
256
|
+
assert.ok(error.message.includes(packagedPath));
|
|
257
|
+
assert.ok(error.message.includes(installedPath));
|
|
258
|
+
return true;
|
|
259
|
+
});
|
|
260
|
+
assert.deepEqual(snapshot(agents), before);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await test("CLI install and upgrade refuse stable-to-RC downgrade, even forced, without modifying files", () => {
|
|
266
|
+
const fixture = path.join(root, "semver package");
|
|
267
|
+
const fixtureCli = path.join(fixture, "bin", "macca-method.js");
|
|
268
|
+
write(fixtureCli, fs.readFileSync(cli));
|
|
269
|
+
fs.cpSync(path.join(repo, ".agents"), path.join(fixture, ".agents"), { recursive: true });
|
|
270
|
+
const packagedPath = path.join(fixture, ".agents", "macca-lock.json");
|
|
271
|
+
const lock = JSON.parse(fs.readFileSync(packagedPath, "utf8"));
|
|
272
|
+
json(packagedPath, { ...lock, version: "3.0.0" });
|
|
273
|
+
const project = path.join(root, "semver stable install");
|
|
274
|
+
run(project, ["install", "--yes"], 0, fixtureCli);
|
|
275
|
+
const installedPath = path.join(project, ".agents", "macca-lock.json");
|
|
276
|
+
assert.equal(JSON.parse(fs.readFileSync(installedPath, "utf8")).version, "3.0.0");
|
|
277
|
+
json(packagedPath, { ...lock, version: "3.0.0-rc.1" });
|
|
278
|
+
const before = snapshot(project);
|
|
279
|
+
const packageBefore = snapshot(fixture);
|
|
280
|
+
for (const command of ["install", "upgrade"]) {
|
|
281
|
+
for (const flags of [[], ["--force"]]) {
|
|
282
|
+
const output = run(project, [command, "--yes", ...flags], 1, fixtureCli);
|
|
283
|
+
assert.match(output, /Refusing to downgrade MACCA from 3\.0\.0 to 3\.0\.0-rc\.1/);
|
|
284
|
+
assert.match(output, /not started; no files changed/);
|
|
285
|
+
assert.deepEqual(snapshot(project), before);
|
|
286
|
+
assert.deepEqual(snapshot(fixture), packageBefore);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
for (const side of ["packaged", "installed"]) {
|
|
290
|
+
for (const invalid of [undefined, 3, "3.0.0-01"]) {
|
|
291
|
+
json(packagedPath, { ...lock, version: side === "packaged" ? invalid : "3.0.0-rc.1" });
|
|
292
|
+
json(installedPath, { ...lock, version: side === "installed" ? invalid : "3.0.0" });
|
|
293
|
+
const invalidBefore = snapshot(project);
|
|
294
|
+
for (const command of ["install", "upgrade"]) {
|
|
295
|
+
const output = run(project, [command, "--yes", "--force"], 1, fixtureCli);
|
|
296
|
+
assert.match(output, /MACCA_VERSION_INVALID/);
|
|
297
|
+
assert.match(output, /Cannot verify MACCA downgrade safety/);
|
|
298
|
+
assert.match(output, /not started; no files changed/);
|
|
299
|
+
assert.deepEqual(snapshot(project), invalidBefore);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await test("version preflight retains even an empty committed journal on stable-to-RC refusal", () => {
|
|
306
|
+
const fixture = versionFixture("committed version preflight");
|
|
307
|
+
json(path.join(fixture.project, ".agents", "macca-lock.json"), { version: "3.0.0" });
|
|
308
|
+
json(path.join(fixture.project, ".agents", "macca-transaction.json"), {
|
|
309
|
+
version: 2, transactionId: "1-1-abcdef", phase: "committed", entries: [],
|
|
310
|
+
});
|
|
311
|
+
assertVersionRefusal(fixture, /Refusing to downgrade MACCA from 3\.0\.0 to 3\.0\.0-rc\.1/);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
await test("version preflight checks rollback backups with absent or older visible locks in v1 and v2 journals", () => {
|
|
315
|
+
const fixture = versionFixture("rollback version preflight");
|
|
316
|
+
for (const version of [1, 2]) {
|
|
317
|
+
for (const current of [undefined, "2.0.0"]) {
|
|
318
|
+
pendingLock(fixture.project, { version, current, backup: "3.0.0" });
|
|
319
|
+
assertVersionRefusal(fixture, /Refusing to downgrade MACCA from 3\.0\.0/);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await test("version preflight preserves newer staged locks and committed backup evidence", () => {
|
|
325
|
+
const fixture = versionFixture("staged version preflight");
|
|
326
|
+
for (const version of [1, 2]) {
|
|
327
|
+
pendingLock(fixture.project, { version, stage: "3.0.0" });
|
|
328
|
+
assertVersionRefusal(fixture, /Refusing to downgrade MACCA from 3\.0\.0/);
|
|
329
|
+
}
|
|
330
|
+
pendingLock(fixture.project, { current: "2.0.0", backup: "3.0.0", phase: "committed" });
|
|
331
|
+
assertVersionRefusal(fixture, /Refusing to downgrade MACCA from 3\.0\.0/);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
await test("invalid package, visible, backup and staged versions fail before journal mutation", () => {
|
|
335
|
+
const fixture = versionFixture("invalid recovery version");
|
|
336
|
+
for (const invalid of [null, "3.0.0-01"]) {
|
|
337
|
+
for (const location of ["package", "current", "backup", "stage"]) {
|
|
338
|
+
fixture.setVersion(location === "package" ? invalid : "3.0.0-rc.1");
|
|
339
|
+
pendingLock(fixture.project, {
|
|
340
|
+
current: location === "current" ? invalid : undefined,
|
|
341
|
+
backup: location === "backup" ? invalid : undefined,
|
|
342
|
+
stage: location === "stage" ? invalid : undefined,
|
|
343
|
+
});
|
|
344
|
+
assertVersionRefusal(fixture, /MACCA_VERSION_INVALID/);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const missing = path.join(root, "invalid version missing target");
|
|
348
|
+
fixture.setVersion("3.0");
|
|
349
|
+
assert.match(run(missing, ["install", "--yes"], 1, fixture.executable), /MACCA_VERSION_INVALID/);
|
|
350
|
+
assert.equal(snapshot(missing), null);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
await test("a newer package recovers valid legacy and current lock journals before installing or upgrading", () => {
|
|
354
|
+
const fixture = versionFixture("allowed recovery version");
|
|
355
|
+
fixture.setVersion("4.0.0");
|
|
356
|
+
for (const command of ["install", "upgrade"]) {
|
|
357
|
+
for (const version of [1, 2]) {
|
|
358
|
+
for (const current of [undefined, "2.0.0"]) {
|
|
359
|
+
const { journalPath, entry } = pendingLock(fixture.project, { version, current, backup: "3.0.0" });
|
|
360
|
+
run(fixture.project, [command, "--yes", "--force"], 0, fixture.executable);
|
|
361
|
+
assert.ok(!fs.existsSync(journalPath));
|
|
362
|
+
assert.ok(!fs.existsSync(entry.backupPath));
|
|
363
|
+
assert.equal(JSON.parse(fs.readFileSync(entry.targetPath, "utf8")).version, "4.0.0");
|
|
364
|
+
}
|
|
365
|
+
const { journalPath, entry } = pendingLock(fixture.project, { version, stage: "3.0.0" });
|
|
366
|
+
run(fixture.project, [command, "--yes", "--force"], 0, fixture.executable);
|
|
367
|
+
assert.ok(!fs.existsSync(journalPath));
|
|
368
|
+
assert.ok(!fs.existsSync(entry.stagingPath));
|
|
369
|
+
assert.equal(JSON.parse(fs.readFileSync(entry.targetPath, "utf8")).version, "4.0.0");
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
await test("version preflight validates journal whitelist and hashes before reading recovery lock metadata", () => {
|
|
375
|
+
const project = path.join(root, "preflight read boundaries");
|
|
376
|
+
const agents = path.join(project, ".agents");
|
|
377
|
+
for (const field of ["backupPath", "stagingPath"]) {
|
|
378
|
+
const { journalPath, entry } = pendingLock(project, { backup: "0.0.0", stage: "0.0.0" });
|
|
379
|
+
const original = entry[field];
|
|
380
|
+
const outside = path.join(root, "external-lock.json");
|
|
381
|
+
json(outside, { version: "99.0.0" });
|
|
382
|
+
entry[field] = outside;
|
|
383
|
+
json(journalPath, { version: 2, transactionId: "1-1-abcdef", phase: "committing", entries: [entry] });
|
|
384
|
+
const mocked = readOnlyFs();
|
|
385
|
+
mocked.readFileSync = (candidate, ...args) => {
|
|
386
|
+
assert.notEqual(candidate, outside, "unvalidated recovery path must never be read");
|
|
387
|
+
return fs.readFileSync(candidate, ...args);
|
|
388
|
+
};
|
|
389
|
+
assert.throws(() => load({ "node:fs": mocked }).assertPackageNotOlderThanInstalled(agents, true), /Invalid MACCA transaction (backup|staging) path/);
|
|
390
|
+
entry[field] = original;
|
|
391
|
+
json(journalPath, { version: 2, transactionId: "1-1-abcdef", phase: "committing", entries: [entry] });
|
|
392
|
+
write(original, "not JSON; modified after journal snapshot");
|
|
393
|
+
const before = snapshot(project);
|
|
394
|
+
assert.throws(() => load({ "node:fs": readOnlyFs() }).assertPackageNotOlderThanInstalled(agents, true), /modified transaction backup|matching transaction hash/);
|
|
395
|
+
assert.deepEqual(snapshot(project), before);
|
|
396
|
+
}
|
|
397
|
+
const { journalPath, entry } = pendingLock(project, { backup: "0.0.0" });
|
|
398
|
+
entry.targetPath = path.join(project, "unmanaged.json");
|
|
399
|
+
json(journalPath, { version: 2, transactionId: "1-1-abcdef", phase: "committing", entries: [entry] });
|
|
400
|
+
assert.throws(() => load({ "node:fs": readOnlyFs() }).assertPackageNotOlderThanInstalled(agents, true), /not a managed MACCA path/);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
await test("Windows drive ancestors start at the parsed root; UNC/device paths are rejected", () => {
|
|
404
|
+
const seen = [];
|
|
405
|
+
const mock = { lstatSync: (candidate) => { seen.push(candidate); return { isSymbolicLink: () => false }; } };
|
|
406
|
+
const instance = load({ "node:path": path.win32, "node:fs": mock });
|
|
407
|
+
assert.equal(instance.assertSafeTargetDirectory("C:\\Users\\Name\\project"), "C:\\Users\\Name\\project");
|
|
408
|
+
assert.deepEqual(seen, ["C:\\", "C:\\Users", "C:\\Users\\Name", "C:\\Users\\Name\\project"]);
|
|
409
|
+
seen.length = 0;
|
|
410
|
+
for (const candidate of ["\\\\server\\share\\app", "\\\\?\\C:\\app", "\\\\.\\C:\\app", "//server/share/app"]) {
|
|
411
|
+
assert.throws(() => instance.assertSafeTargetDirectory(candidate), /UNC\/device.*local drive/);
|
|
412
|
+
}
|
|
413
|
+
assert.deepEqual(seen, []);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
await test("root containment accepts descendants and rejects sibling prefixes", () => {
|
|
417
|
+
const instance = load();
|
|
418
|
+
instance.assertSafeProjectPath(path.parse(root).root, root);
|
|
419
|
+
assert.throws(() => instance.assertSafeProjectPath(path.join(root, "app"), path.join(root, "app-other")), /outside target/);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
await test("buffered answers share one readline interface and documents default to communication", async () => {
|
|
423
|
+
let creations = 0;
|
|
424
|
+
const prompt = fakePrompt((interface_, number) => {
|
|
425
|
+
if (number === 1) for (const line of ["codex", "English", ""]) interface_.emit("line", line);
|
|
426
|
+
});
|
|
427
|
+
const instance = load({ "node:readline": { createInterface: () => { creations += 1; return prompt; } } });
|
|
428
|
+
const project = path.join(root, "buffered answers 日本語");
|
|
429
|
+
await instance.runInstall({ tools: [], directory: project });
|
|
430
|
+
assert.equal(creations, 1);
|
|
431
|
+
assert.equal(prompt.questions.length, 3);
|
|
432
|
+
assert.doesNotMatch(prompt.questions.join(" "), /dipanggil|Nama project/);
|
|
433
|
+
const config = readConfig(project);
|
|
434
|
+
assert.equal(config.languagePreferences.communication.normalized, "english");
|
|
435
|
+
assert.equal(config.languagePreferences.documents.normalized, "english");
|
|
436
|
+
assert.ok(!Object.hasOwn(config, "name"));
|
|
437
|
+
assert.ok(!Object.hasOwn(config, "project"));
|
|
438
|
+
assert.equal(prompt.eventNames().length, 0);
|
|
439
|
+
assert.equal(prompt.input.eventNames().length, 0);
|
|
440
|
+
assert.match(instance.output(), /Restart.*setup-macca-method skill/);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
for (const event of ["SIGINT", "close", "error", "input-error"]) {
|
|
444
|
+
for (const stage of [1, 2, 3]) {
|
|
445
|
+
await test(`${event} at prompt ${stage} settles, cleans listeners and retains pending journal`, async () => {
|
|
446
|
+
const project = path.join(root, `cancel-${event}-${stage}`);
|
|
447
|
+
const journal = path.join(project, ".agents", "macca-transaction.json");
|
|
448
|
+
json(journal, { intentionally: "invalid pending journal must not be recovered" });
|
|
449
|
+
const before = snapshot(project);
|
|
450
|
+
const prompt = fakePrompt((interface_, number) => {
|
|
451
|
+
if (number !== stage) interface_.emit("line", number === 1 ? "codex" : "English");
|
|
452
|
+
else if (event === "input-error") interface_.input.emit("error", new Error("private input details"));
|
|
453
|
+
else interface_.emit(event, new Error("private input details"));
|
|
454
|
+
});
|
|
455
|
+
const instance = load({ "node:readline": { createInterface: () => prompt }, "node:fs": readOnlyFs() });
|
|
456
|
+
await assert.rejects(instance.runInstall({ tools: [], directory: project }), (error) => {
|
|
457
|
+
instance.exitWithError(error);
|
|
458
|
+
assert.equal(instance.process.exitCode, ["SIGINT", "close"].includes(event) ? 130 : 1);
|
|
459
|
+
return true;
|
|
460
|
+
});
|
|
461
|
+
assert.match(instance.output(), /not started; no files changed by this attempt/);
|
|
462
|
+
assert.match(instance.output(), /Pending transaction journal retained/);
|
|
463
|
+
assert.doesNotMatch(instance.output(), /private input details/);
|
|
464
|
+
assert.deepEqual(snapshot(project), before);
|
|
465
|
+
assert.equal(prompt.eventNames().length, 0);
|
|
466
|
+
assert.equal(prompt.input.eventNames().length, 0);
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
await test("closed readline between questions rejects the next question", async () => {
|
|
472
|
+
const instance = load();
|
|
473
|
+
const prompt = fakePrompt((interface_) => interface_.emit("line", "codex"));
|
|
474
|
+
assert.equal(await instance.askQuestion(prompt, "tools"), "codex");
|
|
475
|
+
prompt.emit("close");
|
|
476
|
+
await assert.rejects(instance.askQuestion(prompt, "language"), { code: "MACCA_CANCELLED" });
|
|
477
|
+
instance.dispose(prompt);
|
|
478
|
+
assert.equal(prompt.eventNames().length, 0);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
await test("non-TTY install does not create its missing target", () => {
|
|
482
|
+
const project = path.join(root, "non tty missing");
|
|
483
|
+
const output = run(project, ["install"], 1);
|
|
484
|
+
assert.match(output, /requires a TTY/);
|
|
485
|
+
assert.match(output, /not started/);
|
|
486
|
+
assert.equal(snapshot(project), null);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
await test("--yes defaults documents to English, preserves all settings and unknown fields on rerun", () => {
|
|
490
|
+
const project = path.join(root, "flags path Ω");
|
|
491
|
+
run(project, ["install", "--yes", "--tool", "cursor", "--communication-language", "English", "--name", "Tester", "--project", "Demo"]);
|
|
492
|
+
const config = readConfig(project);
|
|
493
|
+
assert.equal(config.languagePreferences.documents.normalized, "english");
|
|
494
|
+
config.extension = { token: "secret-extension-value", nested: [1, 2] };
|
|
495
|
+
config.languagePreferences.documents.extra = { keep: true };
|
|
496
|
+
config.codeReviewPreferences = { fixMode: "report-first" };
|
|
497
|
+
Object.defineProperty(config, "__proto__", { value: { unknown: true }, enumerable: true });
|
|
498
|
+
json(configPath(project), config);
|
|
499
|
+
run(project, ["install", "--yes"]);
|
|
500
|
+
assert.deepEqual(readConfig(project), config);
|
|
501
|
+
assert.equal(fs.readFileSync(path.join(project, ".agents", "macca-tools.txt"), "utf8"), "cursor\n");
|
|
502
|
+
run(project, ["upgrade"]);
|
|
503
|
+
assert.deepEqual(readConfig(project), config);
|
|
504
|
+
const output = run(project, ["doctor"]);
|
|
505
|
+
assert.doesNotMatch(output, /secret-extension-value/);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
await test("legacy document flags override the same-language default", () => {
|
|
509
|
+
const project = path.join(root, "language override");
|
|
510
|
+
run(project, ["install", "-y", "-t", "codex", "--communication-language=en", "--documents-language=Indonesia"]);
|
|
511
|
+
assert.equal(readConfig(project).languagePreferences.documents.normalized, "indonesian");
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
for (const bad of [
|
|
515
|
+
{ languagePreferences: { communication: { raw: { token: "SECRET" } } } },
|
|
516
|
+
{ developerPreferences: { workMode: "SECRET" } },
|
|
517
|
+
{ additionalSkills: [{ name: "valid", paths: { SECRET: 7 } }] },
|
|
518
|
+
{ availableMCPs: ["ok", { token: "SECRET" }] },
|
|
519
|
+
{ brainstormPreferences: { recommendations: "SECRET" } },
|
|
520
|
+
{ codeReviewPreferences: { fixMode: "SECRET" } },
|
|
521
|
+
["SECRET"],
|
|
522
|
+
]) {
|
|
523
|
+
await test(`invalid config ${passed} rejects install/upgrade even --force before recovery`, async () => {
|
|
524
|
+
const project = path.join(root, `invalid-${passed}`);
|
|
525
|
+
json(configPath(project), bad);
|
|
526
|
+
json(path.join(project, ".agents", "macca-transaction.json"), { pending: true });
|
|
527
|
+
const before = snapshot(project);
|
|
528
|
+
for (const command of ["install", "upgrade"]) {
|
|
529
|
+
const output = run(project, [command, "--yes", "--force"], 1);
|
|
530
|
+
assert.match(output, /MACCA_CONFIG_INVALID/);
|
|
531
|
+
assert.match(output, /not started/);
|
|
532
|
+
assert.doesNotMatch(output, /SECRET/);
|
|
533
|
+
assert.deepEqual(snapshot(project), before);
|
|
534
|
+
}
|
|
535
|
+
const instance = load({ "node:fs": readOnlyFs() });
|
|
536
|
+
assert.throws(() => instance.applyInstall(project, ["codex"], {}, { force: true }), { code: "MACCA_CONFIG_INVALID" });
|
|
537
|
+
assert.throws(() => instance.applyUpgrade(project, { force: true }), { code: "MACCA_CONFIG_INVALID" });
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
await test("malformed JSON errors never expose source excerpts", () => {
|
|
542
|
+
const project = path.join(root, "malformed");
|
|
543
|
+
write(configPath(project), '{"token":"SUPER_SECRET" trailing junk}');
|
|
544
|
+
for (const command of ["install", "upgrade", "doctor"]) {
|
|
545
|
+
const output = run(project, [command, "--yes", "--force"], 1);
|
|
546
|
+
assert.match(output, /valid JSON/);
|
|
547
|
+
assert.doesNotMatch(output, /SUPER_SECRET/);
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
await test("new merged config is validated before mkdir or writes", () => {
|
|
552
|
+
const instance = load({ "node:fs": readOnlyFs() });
|
|
553
|
+
const project = path.join(root, "bad supplied metadata");
|
|
554
|
+
assert.throws(() => instance.applyInstall(project, ["codex"], { name: 42 }), { code: "MACCA_CONFIG_INVALID" });
|
|
555
|
+
assert.equal(snapshot(project), null);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
await test("Node <22 rejects mutation; doctor fails unsupported and warns for unverified newer runtime", async () => {
|
|
559
|
+
const project = path.join(root, "unsupported");
|
|
560
|
+
const instance = load({ "node:fs": readOnlyFs() }, "20.19.0");
|
|
561
|
+
await assert.rejects(instance.runInstall({ tools: [], yes: true, directory: project }), { code: "MACCA_NODE_UNSUPPORTED" });
|
|
562
|
+
assert.throws(() => instance.runUpgrade({ directory: project }), { code: "MACCA_NODE_UNSUPPORTED" });
|
|
563
|
+
instance.runDoctor({ directory: project });
|
|
564
|
+
assert.equal(instance.process.exitCode, 1);
|
|
565
|
+
assert.match(instance.output(), /Unsupported Node/);
|
|
566
|
+
const newer = load({ "node:fs": readOnlyFs() }, "26.0.0");
|
|
567
|
+
newer.runDoctor({ directory: path.join(root, "flags path Ω") });
|
|
568
|
+
assert.equal(newer.process.exitCode, 0);
|
|
569
|
+
assert.match(newer.output(), /runtime is unverified/);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
await test("doctor is read-only for healthy, missing, damaged, drifted and interrupted installations", () => {
|
|
573
|
+
const project = path.join(root, "doctor");
|
|
574
|
+
run(project, ["install", "--yes", "--tool", "copilot,codex"]);
|
|
575
|
+
const check = (status, pattern) => {
|
|
576
|
+
const before = snapshot(project);
|
|
577
|
+
const instance = load({ "node:fs": readOnlyFs() });
|
|
578
|
+
instance.runDoctor({ directory: project });
|
|
579
|
+
assert.equal(instance.process.exitCode, status, instance.output());
|
|
580
|
+
assert.match(instance.output(), pattern);
|
|
581
|
+
assert.deepEqual(snapshot(project), before);
|
|
582
|
+
};
|
|
583
|
+
check(0, /0 failure\(s\)/);
|
|
584
|
+
const asset = path.join(project, ".github", "skills", "bug-fix", "assets", "bug-log.template.md");
|
|
585
|
+
assert.ok(fs.existsSync(asset));
|
|
586
|
+
const content = fs.readFileSync(asset);
|
|
587
|
+
fs.unlinkSync(asset);
|
|
588
|
+
check(1, /Required installed payload file is missing/);
|
|
589
|
+
write(asset, content);
|
|
590
|
+
fs.appendFileSync(asset, "\nlocal change\n");
|
|
591
|
+
check(0, /Local skill drift/);
|
|
592
|
+
write(asset, content);
|
|
593
|
+
const state = path.join(project, ".agents", "macca-state.json");
|
|
594
|
+
const stateContent = fs.readFileSync(state);
|
|
595
|
+
fs.unlinkSync(state);
|
|
596
|
+
check(0, /state is absent/);
|
|
597
|
+
write(state, stateContent);
|
|
598
|
+
const manifest = path.join(project, ".agents", "macca-managed-skills.txt");
|
|
599
|
+
const manifestContent = fs.readFileSync(manifest);
|
|
600
|
+
fs.unlinkSync(manifest);
|
|
601
|
+
check(1, /ENOENT/);
|
|
602
|
+
write(manifest, manifestContent);
|
|
603
|
+
const lockPath = path.join(project, ".agents", "macca-lock.json");
|
|
604
|
+
const lockContent = fs.readFileSync(lockPath);
|
|
605
|
+
const lock = JSON.parse(lockContent);
|
|
606
|
+
lock.skills = ["_shared"];
|
|
607
|
+
json(lockPath, lock);
|
|
608
|
+
check(1, /manifest disagree/);
|
|
609
|
+
write(lockPath, lockContent);
|
|
610
|
+
json(path.join(project, ".agents", "macca-transaction.json"), { pending: true });
|
|
611
|
+
check(1, /Interrupted transaction retained/);
|
|
612
|
+
const missing = path.join(root, "doctor missing", "nested");
|
|
613
|
+
const before = snapshot(root);
|
|
614
|
+
run(missing, ["doctor"], 1);
|
|
615
|
+
assert.deepEqual(snapshot(root), before);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
await test("marker reader distinguishes absent, unsafe and unreadable files without reading special files", () => {
|
|
619
|
+
const skill = path.join(root, "mock marker");
|
|
620
|
+
const marker = path.join(skill, ".macca-owned.json");
|
|
621
|
+
let reads = 0;
|
|
622
|
+
const mocked = readOnlyFs();
|
|
623
|
+
mocked.readFileSync = () => { reads += 1; throw new Error("Marker must not be read"); };
|
|
624
|
+
for (const kind of ["symlink", "fifo", "directory", "device"]) {
|
|
625
|
+
mocked.lstatSync = (candidate) => {
|
|
626
|
+
assert.equal(candidate, marker);
|
|
627
|
+
return { isSymbolicLink: () => kind === "symlink", isFile: () => false };
|
|
628
|
+
};
|
|
629
|
+
assert.throws(() => load({ "node:fs": mocked }).readOwnershipMarker(skill), { code: "MACCA_UNSAFE_MARKER", path: marker });
|
|
630
|
+
}
|
|
631
|
+
mocked.lstatSync = () => { throw Object.assign(new Error("Absent"), { code: "ENOENT" }); };
|
|
632
|
+
assert.equal(load({ "node:fs": mocked }).readOwnershipMarker(skill), null);
|
|
633
|
+
mocked.lstatSync = () => { throw Object.assign(new Error("Denied"), { code: "EACCES" }); };
|
|
634
|
+
assert.throws(() => load({ "node:fs": mocked }).readOwnershipMarker(skill), { code: "EACCES" });
|
|
635
|
+
assert.equal(reads, 0);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
await test("doctor rejects mocked special markers without reading them or mutating files on every platform", () => {
|
|
639
|
+
const project = path.join(root, "doctor mock marker");
|
|
640
|
+
run(project, ["install", "--yes"]);
|
|
641
|
+
const marker = path.join(project, ".agents", "skills", "developer", ".macca-owned.json");
|
|
642
|
+
const before = snapshot(project);
|
|
643
|
+
for (const kind of ["symlink", "fifo", "directory", "device"]) {
|
|
644
|
+
let reads = 0;
|
|
645
|
+
const mocked = readOnlyFs();
|
|
646
|
+
mocked.lstatSync = (candidate, ...args) => candidate === marker
|
|
647
|
+
? { isSymbolicLink: () => kind === "symlink", isFile: () => false }
|
|
648
|
+
: fs.lstatSync(candidate, ...args);
|
|
649
|
+
mocked.readFileSync = (candidate, ...args) => {
|
|
650
|
+
if (candidate === marker) { reads += 1; throw new Error("PRIVATE_MARKER_DATA"); }
|
|
651
|
+
return fs.readFileSync(candidate, ...args);
|
|
652
|
+
};
|
|
653
|
+
const instance = load({ "node:fs": mocked });
|
|
654
|
+
instance.runDoctor({ directory: project });
|
|
655
|
+
assert.equal(instance.process.exitCode, 1, instance.output());
|
|
656
|
+
assert.match(instance.output(), /FAIL: Unsafe ownership marker/);
|
|
657
|
+
assert.doesNotMatch(instance.output(), /PRIVATE_MARKER_DATA/);
|
|
658
|
+
assert.equal(reads, 0);
|
|
659
|
+
assert.deepEqual(snapshot(project), before);
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
await test("doctor does not follow an ownership marker symlink to external JSON", () => {
|
|
664
|
+
const project = path.join(root, "doctor marker symlink");
|
|
665
|
+
run(project, ["install", "--yes"]);
|
|
666
|
+
const marker = path.join(project, ".agents", "skills", "developer", ".macca-owned.json");
|
|
667
|
+
const outside = path.join(root, "external marker.json");
|
|
668
|
+
json(outside, { owner: "macca-method", skill: "developer", private: "EXTERNAL_SECRET" });
|
|
669
|
+
fs.unlinkSync(marker);
|
|
670
|
+
try { fs.symlinkSync(outside, marker, "file"); } catch (error) {
|
|
671
|
+
if (process.platform === "win32" && ["EPERM", "EACCES"].includes(error.code)) {
|
|
672
|
+
process.stdout.write(" SKIP: file symlink privilege unavailable; mocked symlink coverage passed\n");
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
throw error;
|
|
676
|
+
}
|
|
677
|
+
const before = snapshot(root);
|
|
678
|
+
let forbiddenReads = 0;
|
|
679
|
+
const mocked = readOnlyFs();
|
|
680
|
+
mocked.readFileSync = (candidate, ...args) => {
|
|
681
|
+
if (candidate === marker || candidate === outside) {
|
|
682
|
+
forbiddenReads += 1;
|
|
683
|
+
throw new Error("EXTERNAL_SECRET");
|
|
684
|
+
}
|
|
685
|
+
return fs.readFileSync(candidate, ...args);
|
|
686
|
+
};
|
|
687
|
+
const instance = load({ "node:fs": mocked });
|
|
688
|
+
instance.runDoctor({ directory: project });
|
|
689
|
+
assert.equal(instance.process.exitCode, 1);
|
|
690
|
+
assert.match(instance.output(), /FAIL: Unsafe ownership marker/);
|
|
691
|
+
assert.doesNotMatch(instance.output(), /EXTERNAL_SECRET/);
|
|
692
|
+
assert.equal(forbiddenReads, 0);
|
|
693
|
+
assert.match(run(project, ["doctor"], 1), /Unsafe ownership marker/);
|
|
694
|
+
assert.deepEqual(snapshot(root), before);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
await test("doctor rejects a real Linux FIFO marker without blocking", () => {
|
|
698
|
+
if (process.platform !== "linux") {
|
|
699
|
+
process.stdout.write(" SKIP: real FIFO requires Linux; mocked special-file coverage passed\n");
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const project = path.join(root, "doctor fifo marker");
|
|
703
|
+
run(project, ["install", "--yes"]);
|
|
704
|
+
const marker = path.join(project, ".agents", "skills", "developer", ".macca-owned.json");
|
|
705
|
+
assert.ok(fs.statSync(path.dirname(marker)).isDirectory());
|
|
706
|
+
fs.unlinkSync(marker);
|
|
707
|
+
const made = spawnSync("mkfifo", [marker], { encoding: "utf8", timeout: 5000 });
|
|
708
|
+
if (["ENOENT", "EACCES", "EPERM"].includes(made.error?.code)) {
|
|
709
|
+
process.stdout.write(" SKIP: mkfifo unavailable or disallowed; mocked special-file coverage passed\n");
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
assert.ifError(made.error);
|
|
713
|
+
assert.equal(made.status, 0, made.stderr);
|
|
714
|
+
assert.ok(fs.lstatSync(marker).isFIFO());
|
|
715
|
+
const before = snapshot(project);
|
|
716
|
+
const result = spawnSync(process.execPath, [cli, "doctor", "--directory", project], {
|
|
717
|
+
encoding: "utf8", timeout: 5000,
|
|
718
|
+
});
|
|
719
|
+
assert.ifError(result.error);
|
|
720
|
+
assert.equal(result.status, 1, `${result.stdout}${result.stderr}`);
|
|
721
|
+
assert.match(result.stdout, /FAIL: Unsafe ownership marker/);
|
|
722
|
+
assert.deepEqual(snapshot(project), before);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
for (const [name, payloadHash] of [
|
|
726
|
+
["missing", undefined], ["mismatched", "0".repeat(64)],
|
|
727
|
+
["malformed", "PRIVATE_INVALID_HASH"], ["non-string", { secret: "PRIVATE_INVALID_HASH" }],
|
|
728
|
+
]) {
|
|
729
|
+
await test(`doctor warns for ${name} marker hash and predicts ordinary upgrade refusal without repair`, () => {
|
|
730
|
+
const project = path.join(root, `doctor marker hash ${name}`);
|
|
731
|
+
run(project, ["install", "--yes"]);
|
|
732
|
+
const skill = path.join(project, ".agents", "skills", "developer");
|
|
733
|
+
const markerPath = path.join(skill, ".macca-owned.json");
|
|
734
|
+
const marker = JSON.parse(fs.readFileSync(markerPath, "utf8"));
|
|
735
|
+
marker.payloadHash = payloadHash;
|
|
736
|
+
marker.private = "PRIVATE_MARKER_VALUE";
|
|
737
|
+
json(markerPath, marker);
|
|
738
|
+
const before = snapshot(project);
|
|
739
|
+
const instance = load({ "node:fs": readOnlyFs() }, "22.0.0");
|
|
740
|
+
instance.runDoctor({ directory: project });
|
|
741
|
+
assert.equal(instance.process.exitCode, 0, instance.output());
|
|
742
|
+
assert.match(instance.output(), /WARN: Ownership marker payload fingerprint/);
|
|
743
|
+
assert.match(instance.output(), /ordinary upgrade will refuse.*Back up and review.*does not repair/);
|
|
744
|
+
assert.match(instance.output(), /0 failure\(s\), 1 warning\(s\)/);
|
|
745
|
+
assert.doesNotMatch(instance.output(), /PRIVATE_|0{64}/);
|
|
746
|
+
assert.ok(instance.output().includes(JSON.stringify(markerPath)));
|
|
747
|
+
assert.throws(() => instance.assertManagedDirectoryUnchanged(skill), /locally modified managed skill/);
|
|
748
|
+
assert.deepEqual(snapshot(project), before);
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
await test("symlinked ancestors and installed paths stay rejected with actionable realpath advice", () => {
|
|
753
|
+
const actual = path.join(root, "real");
|
|
754
|
+
fs.mkdirSync(actual);
|
|
755
|
+
const alias = path.join(root, "alias");
|
|
756
|
+
try { fs.symlinkSync(actual, alias, "junction"); } catch (error) {
|
|
757
|
+
if (process.platform === "win32" && ["EPERM", "EACCES"].includes(error.code)) {
|
|
758
|
+
process.stdout.write(" SKIP: symlink privilege unavailable\n");
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
throw error;
|
|
762
|
+
}
|
|
763
|
+
const output = run(alias, ["install", "--yes"], 1);
|
|
764
|
+
assert.match(output, /symlinked target project ancestor/);
|
|
765
|
+
assert.match(output, /real path.*macOS aliases/);
|
|
766
|
+
run(alias, ["doctor"], 1);
|
|
767
|
+
assert.deepEqual(snapshot(actual), []);
|
|
768
|
+
const broken = path.join(root, "broken-link");
|
|
769
|
+
fs.symlinkSync(path.join(root, "absent-link-target"), broken, "junction");
|
|
770
|
+
run(broken, ["install", "--yes"], 1);
|
|
771
|
+
assert.equal(snapshot(path.join(root, "absent-link-target")), null);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
await test("extra positional arguments are rejected and help lists doctor", () => {
|
|
775
|
+
const project = path.join(root, "stray");
|
|
776
|
+
assert.match(run(project, ["install", "extra", "--yes"], 1), /Unexpected positional arguments/);
|
|
777
|
+
assert.equal(snapshot(project), null);
|
|
778
|
+
assert.match(run(project, ["--help"]), /doctor.*Read-only/);
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
await test("filesystem errors retain codes, paths, next steps and truthful stages", () => {
|
|
782
|
+
for (const code of ["EACCES", "EPERM", "EBUSY", "ENOSPC", "ENOENT"]) {
|
|
783
|
+
for (const stage of ["not started", "writing", "recovery"]) {
|
|
784
|
+
const instance = load();
|
|
785
|
+
const target = path.join(root, "error project");
|
|
786
|
+
Object.assign(instance.state(), { targetDir: target, stage, recoveryChanged: stage === "recovery" });
|
|
787
|
+
instance.exitWithError(Object.assign(new Error("injected filesystem failure"), { code, path: target }));
|
|
788
|
+
const output = instance.output();
|
|
789
|
+
assert.match(output, new RegExp(`Error \\[${code}\\]`));
|
|
790
|
+
assert.ok(output.includes(JSON.stringify(target)));
|
|
791
|
+
assert.match(output, /Next:/);
|
|
792
|
+
assert.doesNotMatch(output, /sudo|reset/);
|
|
793
|
+
if (stage === "not started") assert.match(output, /no files changed by this attempt/);
|
|
794
|
+
else assert.match(output, /files may have changed/);
|
|
795
|
+
if (stage === "recovery") assert.match(output, /recovery may have changed files/);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
await test("a failure after actual recovery reports changes rather than not started", () => {
|
|
801
|
+
const project = path.join(root, "recovered then failed");
|
|
802
|
+
run(project, ["install", "--yes"]);
|
|
803
|
+
const journal = path.join(project, ".agents", "macca-transaction.json");
|
|
804
|
+
json(journal, { version: 2, transactionId: "1-1-abcdef", phase: "committed", entries: [] });
|
|
805
|
+
fs.appendFileSync(path.join(project, ".agents", "skills", "developer", "SKILL.md"), "\nlocal edit\n");
|
|
806
|
+
const output = run(project, ["upgrade"], 1);
|
|
807
|
+
assert.match(output, /recovery; files may have changed; recovery may have changed files/);
|
|
808
|
+
assert.doesNotMatch(output, /not started|no files changed/);
|
|
809
|
+
assert.ok(!fs.existsSync(journal));
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
await test("invalid rollback config is rejected before recovery mutates any path", () => {
|
|
813
|
+
const project = path.join(root, "invalid rollback config");
|
|
814
|
+
const instance = load();
|
|
815
|
+
const target = configPath(project);
|
|
816
|
+
json(target, { name: "valid visible config" });
|
|
817
|
+
const transactionId = "1-1-abcdef";
|
|
818
|
+
const backup = path.join(path.dirname(target), `.developer-config.json.macca-backup-${transactionId}`);
|
|
819
|
+
json(backup, { availableMCPs: 42 });
|
|
820
|
+
json(path.join(project, ".agents", "macca-transaction.json"), {
|
|
821
|
+
version: 2, transactionId, phase: "committing", entries: [{
|
|
822
|
+
targetPath: target, backupPath: backup, stagingPath: null, kind: "file", hadTarget: true,
|
|
823
|
+
originalHash: instance.hashFile(backup), stagedHash: instance.hashFile(target),
|
|
824
|
+
}],
|
|
825
|
+
});
|
|
826
|
+
const before = snapshot(project);
|
|
827
|
+
const output = run(project, ["upgrade", "--force"], 1);
|
|
828
|
+
assert.match(output, /MACCA_CONFIG_INVALID/);
|
|
829
|
+
assert.match(output, /not started/);
|
|
830
|
+
assert.deepEqual(snapshot(project), before);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
await test("--yes preserves language settings restored from a config-only backup", () => {
|
|
834
|
+
const project = path.join(root, "restore saved settings");
|
|
835
|
+
run(project, ["install", "--yes", "--communication-language", "English"]);
|
|
836
|
+
const saved = readConfig(project);
|
|
837
|
+
const target = configPath(project);
|
|
838
|
+
const transactionId = "1-1-abcdef";
|
|
839
|
+
const backup = path.join(path.dirname(target), `.developer-config.json.macca-backup-${transactionId}`);
|
|
840
|
+
fs.renameSync(target, backup);
|
|
841
|
+
json(path.join(project, ".agents", "macca-transaction.json"), {
|
|
842
|
+
version: 2, transactionId, phase: "committing", entries: [{
|
|
843
|
+
targetPath: target, backupPath: backup, stagingPath: null, kind: "file", hadTarget: true,
|
|
844
|
+
originalHash: load().hashFile(backup), stagedHash: null,
|
|
845
|
+
}],
|
|
846
|
+
});
|
|
847
|
+
run(project, ["install", "--yes"]);
|
|
848
|
+
assert.deepEqual(readConfig(project), saved);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
await test("local drift suggests backup/review before destructive force", () => {
|
|
852
|
+
const project = path.join(root, "local drift");
|
|
853
|
+
run(project, ["install", "--yes"]);
|
|
854
|
+
fs.appendFileSync(path.join(project, ".agents", "skills", "developer", "SKILL.md"), "\nlocal edit\n");
|
|
855
|
+
const before = snapshot(project);
|
|
856
|
+
const output = run(project, ["upgrade"], 1);
|
|
857
|
+
assert.match(output, /locally modified managed skill/);
|
|
858
|
+
assert.match(output, /Back up and review.*--force destructively/);
|
|
859
|
+
assert.match(output, /not started/);
|
|
860
|
+
assert.deepEqual(snapshot(project), before);
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
process.stdout.write(`CLI setup: ${passed} tests passed\n`);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
main().catch((error) => {
|
|
867
|
+
console.error(error);
|
|
868
|
+
process.exitCode = 1;
|
|
869
|
+
}).finally(() => fs.rmSync(root, { recursive: true, force: true }));
|