@mmnto/cli 1.122.0 → 1.123.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/dist/commands/config-drift.test.js +73 -1
- package/dist/commands/config-drift.test.js.map +1 -1
- package/dist/commands/init-templates.d.ts +6 -6
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +8 -7
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.test.js +18 -5
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/install-hooks.d.ts.map +1 -1
- package/dist/commands/install-hooks.js +58 -0
- package/dist/commands/install-hooks.js.map +1 -1
- package/dist/commands/install-hooks.test.js +460 -5
- package/dist/commands/install-hooks.test.js.map +1 -1
- package/dist/commands/legs.d.ts +259 -0
- package/dist/commands/legs.d.ts.map +1 -0
- package/dist/commands/legs.js +623 -0
- package/dist/commands/legs.js.map +1 -0
- package/dist/commands/legs.test.d.ts +20 -0
- package/dist/commands/legs.test.d.ts.map +1 -0
- package/dist/commands/legs.test.js +1004 -0
- package/dist/commands/legs.test.js.map +1 -0
- package/dist/commands/pre-push-gate-matrix.test.js +14 -0
- package/dist/commands/pre-push-gate-matrix.test.js.map +1 -1
- package/dist/commands/review-fan.d.ts +40 -1
- package/dist/commands/review-fan.d.ts.map +1 -1
- package/dist/commands/review-fan.js +108 -3
- package/dist/commands/review-fan.js.map +1 -1
- package/dist/commands/review-fan.test.js +444 -6
- package/dist/commands/review-fan.test.js.map +1 -1
- package/dist/commands/shield-covariate.test.js +19 -2
- package/dist/commands/shield-covariate.test.js.map +1 -1
- package/dist/commands/shield-nonreview.test.js +140 -1
- package/dist/commands/shield-nonreview.test.js.map +1 -1
- package/dist/commands/shield.d.ts.map +1 -1
- package/dist/commands/shield.js +53 -1
- package/dist/commands/shield.js.map +1 -1
- package/dist/git.d.ts +19 -0
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +13 -4
- package/dist/git.js.map +1 -1
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { execSync, spawnSync } from 'node:child_process';
|
|
1
|
+
import { execFileSync, execSync, spawnSync } from 'node:child_process';
|
|
2
2
|
import * as crypto from 'node:crypto';
|
|
3
3
|
import * as fs from 'node:fs';
|
|
4
4
|
import * as os from 'node:os';
|
|
5
5
|
import * as path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
7
8
|
import { GROUNDING_ANCHOR_FREE_TEXT, GROUNDING_ANCHOR_ISSUE, GROUNDING_ANCHOR_MIXED, GROUNDING_ANCHOR_RECORD, PROMPT_SOURCE_BUILTIN, PROMPT_SOURCE_OVERRIDE, RUN_ARTIFACT_SCHEMA_VERSION, RunArtifactSchema, } from '@mmnto/totem';
|
|
8
9
|
import { cleanTmpDir } from '../test-utils.js';
|
|
@@ -2106,9 +2107,16 @@ describe('buildPrePushHook with strict tier', () => {
|
|
|
2106
2107
|
const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
|
|
2107
2108
|
// Find the position of the doctor --strict invocation and the surrounding
|
|
2108
2109
|
// guard; assert the invocation lives INSIDE the agent-or-tier block.
|
|
2109
|
-
const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]');
|
|
2110
2110
|
const doctorIdx = hook.indexOf('$TOTEM_CMD doctor --strict');
|
|
2111
|
-
|
|
2111
|
+
// Anchored on the guard that ENCLOSES this invocation: since
|
|
2112
|
+
// mmnto-ai/totem#2698 the pre-push hook carries more than one agent/strict
|
|
2113
|
+
// guard (the review-leg floor arm has its own, and it is rendered first),
|
|
2114
|
+
// so a bare first-match lookup would measure the wrong block.
|
|
2115
|
+
const guardIdx = hook.lastIndexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]', doctorIdx);
|
|
2116
|
+
// Anchored to a LINE, not a substring: a bare `fi` also matches the one
|
|
2117
|
+
// inside "findings" in the shield block's comment, which sits between the
|
|
2118
|
+
// invocation and the real closing `fi` (CodeRabbit on PR mmnto-ai/totem#2745).
|
|
2119
|
+
const fiCloseIdx = hook.indexOf(`${String.fromCharCode(10)} fi`, doctorIdx);
|
|
2112
2120
|
expect(guardIdx).toBeGreaterThan(-1);
|
|
2113
2121
|
expect(doctorIdx).toBeGreaterThan(guardIdx);
|
|
2114
2122
|
expect(doctorIdx).toBeLessThan(fiCloseIdx);
|
|
@@ -2140,9 +2148,16 @@ describe('buildPrePushHook with standard tier', () => {
|
|
|
2140
2148
|
// standard, so the guard branch never enters.
|
|
2141
2149
|
it('emits doctor --strict gated by agent/strict guard (no unconditional fire in standard tier)', () => {
|
|
2142
2150
|
const hook = buildPrePushHook(RENDER);
|
|
2143
|
-
const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]');
|
|
2144
2151
|
const doctorIdx = hook.indexOf('$TOTEM_CMD doctor --strict');
|
|
2145
|
-
|
|
2152
|
+
// Anchored on the guard that ENCLOSES this invocation: since
|
|
2153
|
+
// mmnto-ai/totem#2698 the pre-push hook carries more than one agent/strict
|
|
2154
|
+
// guard (the review-leg floor arm has its own, and it is rendered first),
|
|
2155
|
+
// so a bare first-match lookup would measure the wrong block.
|
|
2156
|
+
const guardIdx = hook.lastIndexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]', doctorIdx);
|
|
2157
|
+
// Anchored to a LINE, not a substring: a bare `fi` also matches the one
|
|
2158
|
+
// inside "findings" in the shield block's comment, which sits between the
|
|
2159
|
+
// invocation and the real closing `fi` (CodeRabbit on PR mmnto-ai/totem#2745).
|
|
2160
|
+
const fiCloseIdx = hook.indexOf(`${String.fromCharCode(10)} fi`, doctorIdx);
|
|
2146
2161
|
expect(guardIdx).toBeGreaterThan(-1);
|
|
2147
2162
|
expect(doctorIdx).toBeGreaterThan(guardIdx);
|
|
2148
2163
|
expect(doctorIdx).toBeLessThan(fiCloseIdx);
|
|
@@ -2170,4 +2185,444 @@ describe('agent detection uses POSIX syntax', () => {
|
|
|
2170
2185
|
expect(hook).toMatch(/^#!\/bin\/sh\n/);
|
|
2171
2186
|
});
|
|
2172
2187
|
});
|
|
2188
|
+
// ─── The strict pre-push review-leg floor arm (mmnto-ai/totem#2698) ──────────
|
|
2189
|
+
describe('buildPrePushHook — the review-leg floor arm (mmnto-ai/totem#2698)', () => {
|
|
2190
|
+
const hook = buildPrePushHook(RENDER);
|
|
2191
|
+
it("probes the VERB's own help for an option only that verb has", () => {
|
|
2192
|
+
expect(hook).toContain(`if $TOTEM_CMD legs gate --help 2>/dev/null | grep -q -- '--advisory'; then`);
|
|
2193
|
+
// NOT the group's help for the word `gate` (mmnto-ai/totem#2698 fold 2): a
|
|
2194
|
+
// CLI predating `totem legs` answers any `legs …` with its curated
|
|
2195
|
+
// TOP-LEVEL help and exits 0, so that grep reads a moving surface and the
|
|
2196
|
+
// queued `merge-gate` verb (mmnto-ai/totem#2708) would satisfy it.
|
|
2197
|
+
expect(hook).not.toContain(`$TOTEM_CMD legs --help`);
|
|
2198
|
+
});
|
|
2199
|
+
it('invokes the bare gate on strict and the advisory form otherwise', () => {
|
|
2200
|
+
expect(hook).toContain('$TOTEM_CMD legs gate\n');
|
|
2201
|
+
expect(hook).toContain('$TOTEM_CMD legs gate --advisory');
|
|
2202
|
+
expect(hook).toContain('legs_status=$?');
|
|
2203
|
+
});
|
|
2204
|
+
it('blocks on exit 3 and on any other non-zero with DISTINCT lines', () => {
|
|
2205
|
+
expect(hook).toContain(`echo "[Totem] BLOCKED: this push is legs-owed and carries no fresh falsification-leg deposit — run the leg, then 'totem legs deposit --sha HEAD --from <findings.json>' (mmnto-ai/totem#2698, strict mode)"`);
|
|
2206
|
+
expect(hook).toContain('echo "[Totem] BLOCKED: the legs gate could not derive (totem legs gate exit status $legs_status) — fix the checkout and retry (strict mode)"');
|
|
2207
|
+
expect(hook).toContain('if [ "$legs_status" = "3" ]; then');
|
|
2208
|
+
expect(hook).toContain('elif [ "$legs_status" != "0" ]; then');
|
|
2209
|
+
});
|
|
2210
|
+
it('FAILS CLOSED on strict when the resolved CLI lacks the verb, compat-opens otherwise', () => {
|
|
2211
|
+
// The OPPOSITE of the `--gate` / `--scope-to-diff` precedent, by ruling
|
|
2212
|
+
// (mmnto-ai/totem#2698 OQ2): those flags degrade to a form that still runs;
|
|
2213
|
+
// an absent VERB has no degraded form, so strict blocks with the cure.
|
|
2214
|
+
expect(hook).toContain(`echo "[Totem] BLOCKED: this hook expects 'totem legs gate' (mmnto-ai/totem#2698) but the resolved CLI lacks it — 'npm i -g @mmnto/cli@latest' (strict mode)" >&2`);
|
|
2215
|
+
expect(hook).toContain(`echo "[totem] Hook running without the legs gate (CLI predates 'totem legs'); 'npm i -g @mmnto/cli@latest' enables it." >&2`);
|
|
2216
|
+
});
|
|
2217
|
+
it('runs BEFORE the shield block and inside the $TOTEM_CMD guard', () => {
|
|
2218
|
+
const cmdGuardIdx = hook.indexOf('if [ -n "$TOTEM_CMD" ]; then');
|
|
2219
|
+
const legsIdx = hook.indexOf('$TOTEM_CMD legs gate --help');
|
|
2220
|
+
const shieldIdx = hook.indexOf('Running shield gate (strict mode)');
|
|
2221
|
+
expect(cmdGuardIdx).toBeGreaterThan(-1);
|
|
2222
|
+
expect(legsIdx).toBeGreaterThan(cmdGuardIdx);
|
|
2223
|
+
// Slotted first so a legs-owed push is not paid for with the slow review gate.
|
|
2224
|
+
expect(legsIdx).toBeLessThan(shieldIdx);
|
|
2225
|
+
});
|
|
2226
|
+
it('gates the blocking arms on the agent/strict guard', () => {
|
|
2227
|
+
const legsIdx = hook.indexOf('$TOTEM_CMD legs gate --help');
|
|
2228
|
+
const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]', legsIdx);
|
|
2229
|
+
const gateIdx = hook.indexOf('$TOTEM_CMD legs gate\n');
|
|
2230
|
+
expect(guardIdx).toBeGreaterThan(legsIdx);
|
|
2231
|
+
expect(gateIdx).toBeGreaterThan(guardIdx);
|
|
2232
|
+
});
|
|
2233
|
+
});
|
|
2234
|
+
// The arm, EXECUTED. String assertions alone are satisfiable without the
|
|
2235
|
+
// behavior, and the exit-code mapping IS the contract here. The stub `totem` is
|
|
2236
|
+
// the FIRST entry on an isolated PATH and records its argv, so every case
|
|
2237
|
+
// proves the hook actually reached it — an un-isolated PATH or a stub on the
|
|
2238
|
+
// wrong stream is the false-green shape this suite exists to exclude.
|
|
2239
|
+
describe('the review-leg floor arm executed under sh (mmnto-ai/totem#2698)', () => {
|
|
2240
|
+
const shellOk = spawnSync('sh', ['-c', 'exit 0'], { encoding: 'utf-8' }).status === 0;
|
|
2241
|
+
let tmpDir;
|
|
2242
|
+
let repoDir;
|
|
2243
|
+
let binDir;
|
|
2244
|
+
let logPath;
|
|
2245
|
+
beforeEach(() => {
|
|
2246
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-legs-hook-'));
|
|
2247
|
+
repoDir = path.join(tmpDir, 'repo');
|
|
2248
|
+
binDir = path.join(tmpDir, 'stub-bin');
|
|
2249
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
2250
|
+
fs.mkdirSync(binDir);
|
|
2251
|
+
// Forward slashes: the stub is read by `sh`, and a Windows path's
|
|
2252
|
+
// backslashes would not survive the shell's quoting.
|
|
2253
|
+
logPath = path.join(tmpDir, 'totem-invocations.log').split(path.sep).join('/');
|
|
2254
|
+
});
|
|
2255
|
+
afterEach(() => {
|
|
2256
|
+
cleanTmpDir(tmpDir);
|
|
2257
|
+
});
|
|
2258
|
+
/**
|
|
2259
|
+
* A stub `totem` that records every invocation, answers the `legs --help`
|
|
2260
|
+
* probe with the given help text, and exits `gateExit` for `legs gate`. The
|
|
2261
|
+
* bare repo dir clears every earlier hook gate (no manifest, no compiled
|
|
2262
|
+
* rules, no lockfile, no package.json), so the legs arm and the shield block
|
|
2263
|
+
* are the only live ones.
|
|
2264
|
+
*/
|
|
2265
|
+
function writeStub(params) {
|
|
2266
|
+
const stub = [
|
|
2267
|
+
'#!/bin/sh',
|
|
2268
|
+
`printf '%s\\n' "$*" >> "${logPath}"`,
|
|
2269
|
+
// The help arm is tested FIRST and on THREE words: since
|
|
2270
|
+
// mmnto-ai/totem#2698 fold 2 the probe is `legs gate --help`, which also
|
|
2271
|
+
// matches the gate arm's first two.
|
|
2272
|
+
'if [ "$1" = "legs" ] && [ "$2" = "gate" ] && [ "$3" = "--help" ]; then',
|
|
2273
|
+
" cat <<'TOTEM_STUB_HELP'",
|
|
2274
|
+
params.helpText,
|
|
2275
|
+
'TOTEM_STUB_HELP',
|
|
2276
|
+
` exit ${params.helpExit ?? 0}`,
|
|
2277
|
+
'fi',
|
|
2278
|
+
'if [ "$1" = "legs" ] && [ "$2" = "gate" ]; then',
|
|
2279
|
+
' echo "[Totem] legs: stub gate line"',
|
|
2280
|
+
` exit ${params.gateExit ?? 0}`,
|
|
2281
|
+
'fi',
|
|
2282
|
+
'exit 0',
|
|
2283
|
+
'',
|
|
2284
|
+
].join('\n');
|
|
2285
|
+
fs.writeFileSync(path.join(binDir, 'totem'), stub, { mode: 0o755 });
|
|
2286
|
+
}
|
|
2287
|
+
/**
|
|
2288
|
+
* The verb's OWN help, and the top-level help an older CLI answers with.
|
|
2289
|
+
*
|
|
2290
|
+
* The unsupported fixture is a HOSTILE VARIANT of the 1.122.0 shape, not
|
|
2291
|
+
* that shape verbatim: the measured seven-command top-level help contains no
|
|
2292
|
+
* `gate` at all, so today's probe does not false-pass on it. This fixture
|
|
2293
|
+
* INJECTS a `merge-gate` entry (mmnto-ai/totem#2708 is queued for that list),
|
|
2294
|
+
* so the OLD group-level grep would false-pass and the verb-specific probe
|
|
2295
|
+
* must not. The defect it guards is latent, and this is what keeps it so.
|
|
2296
|
+
*/
|
|
2297
|
+
const HELP_WITH_GATE = 'Usage: totem legs gate [options]\n\nOptions:\n --advisory Print the same lines for every state\n -h, --help display help for command';
|
|
2298
|
+
const HELP_WITHOUT_GATE = 'Totem: local-first toolkit\n\nUsage: totem [command]\n\nCommands:\n init Initialize Totem\n lint Run compiled rules\n merge-gate Gate a merge';
|
|
2299
|
+
function runHook(options) {
|
|
2300
|
+
fs.writeFileSync(path.join(repoDir, 'pre-push'), buildPrePushHook({ ...RENDER, tier: options.tier }));
|
|
2301
|
+
// Every agent-detection variable is cleared explicitly: this suite runs
|
|
2302
|
+
// inside an agent session, and an inherited CLAUDE_CODE_AGENT would make
|
|
2303
|
+
// the standard-tier cases silently exercise the strict arm.
|
|
2304
|
+
const env = {
|
|
2305
|
+
...process.env,
|
|
2306
|
+
PATH: [binDir, process.env['PATH'] ?? ''].join(path.delimiter),
|
|
2307
|
+
};
|
|
2308
|
+
delete env['CLAUDE_CODE_AGENT'];
|
|
2309
|
+
delete env['CLAUDE_VERSION'];
|
|
2310
|
+
delete env['CURSOR_TRACE_ID'];
|
|
2311
|
+
if (options.agent)
|
|
2312
|
+
env['CLAUDE_CODE_AGENT'] = '1';
|
|
2313
|
+
const result = spawnSync('sh', ['./pre-push'], { cwd: repoDir, env, encoding: 'utf-8' });
|
|
2314
|
+
return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
|
2315
|
+
}
|
|
2316
|
+
/** Every recorded stub invocation, in order. Empty when it was never reached. */
|
|
2317
|
+
const invocations = () => {
|
|
2318
|
+
const native = logPath.split('/').join(path.sep);
|
|
2319
|
+
return fs.existsSync(native) ? fs.readFileSync(native, 'utf-8').trim().split('\n') : [];
|
|
2320
|
+
};
|
|
2321
|
+
it.skipIf(!shellOk)('probe UNSUPPORTED + strict: blocks with the upgrade line', () => {
|
|
2322
|
+
writeStub({ helpText: HELP_WITHOUT_GATE });
|
|
2323
|
+
const r = runHook({ tier: 'strict', agent: false });
|
|
2324
|
+
expect(invocations()).toContain('legs gate --help');
|
|
2325
|
+
expect(r.status).toBe(1);
|
|
2326
|
+
expect(r.stderr).toContain("this hook expects 'totem legs gate'");
|
|
2327
|
+
expect(r.stderr).toContain('npm i -g @mmnto/cli@latest');
|
|
2328
|
+
// Fail-closed means the gate was never invoked, not that it passed.
|
|
2329
|
+
expect(invocations()).not.toContain('legs gate');
|
|
2330
|
+
});
|
|
2331
|
+
it.skipIf(!shellOk)('probe UNSUPPORTED + standard: passes with the compat line on stderr', () => {
|
|
2332
|
+
writeStub({ helpText: HELP_WITHOUT_GATE });
|
|
2333
|
+
const r = runHook({ tier: 'standard', agent: false });
|
|
2334
|
+
expect(invocations()).toContain('legs gate --help');
|
|
2335
|
+
expect(r.status).toBe(0);
|
|
2336
|
+
expect(r.stderr).toContain("Hook running without the legs gate (CLI predates 'totem legs')");
|
|
2337
|
+
});
|
|
2338
|
+
it.skipIf(!shellOk)('gate exit 3 + strict: blocks with the legs-owed line and the cure', () => {
|
|
2339
|
+
writeStub({ helpText: HELP_WITH_GATE, gateExit: 3 });
|
|
2340
|
+
const r = runHook({ tier: 'strict', agent: false });
|
|
2341
|
+
expect(invocations()).toContain('legs gate');
|
|
2342
|
+
expect(r.status).toBe(1);
|
|
2343
|
+
expect(r.stdout).toContain('[Totem] BLOCKED: this push is legs-owed');
|
|
2344
|
+
expect(r.stdout).toContain('totem legs deposit --sha HEAD --from <findings.json>');
|
|
2345
|
+
// The gate's own line is not swallowed.
|
|
2346
|
+
expect(r.stdout).toContain('[Totem] legs: stub gate line');
|
|
2347
|
+
});
|
|
2348
|
+
it.skipIf(!shellOk)('gate exit 2 + strict: blocks with the DISTINCT not-derived line', () => {
|
|
2349
|
+
writeStub({ helpText: HELP_WITH_GATE, gateExit: 2 });
|
|
2350
|
+
const r = runHook({ tier: 'strict', agent: false });
|
|
2351
|
+
expect(invocations()).toContain('legs gate');
|
|
2352
|
+
expect(r.status).toBe(1);
|
|
2353
|
+
expect(r.stdout).toContain('the legs gate could not derive (totem legs gate exit status 2)');
|
|
2354
|
+
expect(r.stdout).not.toContain('this push is legs-owed');
|
|
2355
|
+
});
|
|
2356
|
+
it.skipIf(!shellOk)('gate exit 0 + strict: the hook proceeds PAST the arm', () => {
|
|
2357
|
+
writeStub({ helpText: HELP_WITH_GATE, gateExit: 0 });
|
|
2358
|
+
const r = runHook({ tier: 'strict', agent: false });
|
|
2359
|
+
expect(invocations()).toContain('legs gate');
|
|
2360
|
+
// The shield block sits after the arm — reaching it proves the arm passed.
|
|
2361
|
+
expect(invocations()).toContain('doctor --strict');
|
|
2362
|
+
expect(r.status).toBe(0);
|
|
2363
|
+
expect(r.stdout).not.toContain('BLOCKED');
|
|
2364
|
+
});
|
|
2365
|
+
it.skipIf(!shellOk)('an AGENT on the standard tier still takes the strict arm', () => {
|
|
2366
|
+
writeStub({ helpText: HELP_WITH_GATE, gateExit: 3 });
|
|
2367
|
+
const r = runHook({ tier: 'standard', agent: true });
|
|
2368
|
+
expect(invocations()).toContain('legs gate');
|
|
2369
|
+
expect(invocations()).not.toContain('legs gate --advisory');
|
|
2370
|
+
expect(r.status).toBe(1);
|
|
2371
|
+
});
|
|
2372
|
+
it.skipIf(!shellOk)('standard tier, no agent: --advisory is passed and exit 3 does NOT block', () => {
|
|
2373
|
+
writeStub({ helpText: HELP_WITH_GATE, gateExit: 3 });
|
|
2374
|
+
const r = runHook({ tier: 'standard', agent: false });
|
|
2375
|
+
// Read from the recorded argv, not from the hook text: the flag has to
|
|
2376
|
+
// have reached the CLI, not merely appear in the template.
|
|
2377
|
+
expect(invocations()).toContain('legs gate --advisory');
|
|
2378
|
+
expect(invocations()).not.toContain('legs gate');
|
|
2379
|
+
expect(r.status).toBe(0);
|
|
2380
|
+
expect(r.stdout).not.toContain('BLOCKED');
|
|
2381
|
+
});
|
|
2382
|
+
});
|
|
2383
|
+
// The arm COMPOSED with the real gate (mmnto-ai/totem#2698 fold 2, MATERIAL).
|
|
2384
|
+
//
|
|
2385
|
+
// The stub suite above proves the hook's exit MAPPING — it scripts the gate's
|
|
2386
|
+
// status, so the gate's own derivation is never exercised through the hook, and
|
|
2387
|
+
// a gate that stopped returning 3 for an owed-and-unanswered push would leave
|
|
2388
|
+
// every one of those tests green. This suite closes that: the shim on the
|
|
2389
|
+
// isolated PATH DELEGATES `legs` to the real built CLI, so each case runs the
|
|
2390
|
+
// whole composition — predicate, store, ancestry, exit code, hook arm, hook
|
|
2391
|
+
// line. Everything after the arm (the shield block's `doctor --strict` and
|
|
2392
|
+
// `review --gate`) is a different gate with its own coverage and stays stubbed,
|
|
2393
|
+
// so this suite's verdict never depends on state it does not control.
|
|
2394
|
+
//
|
|
2395
|
+
// COVERAGE DECLARATION (Q5). It needs `packages/cli/dist/index.js`, which
|
|
2396
|
+
// exists after this repo's CI builds before it tests (`pnpm -r build` precedes
|
|
2397
|
+
// the test run); when it does not, every case SKIPS rather than passing
|
|
2398
|
+
// vacuously, and the skip is visible in the reporter. It runs wherever `sh`
|
|
2399
|
+
// resolves: win32 through git-bash, plus the ubuntu and macos CI legs. The stub
|
|
2400
|
+
// suite above is retained and carries the mapping, including the states this
|
|
2401
|
+
// suite cannot reach through a real CLI (a build that predates the verb).
|
|
2402
|
+
//
|
|
2403
|
+
// One case inside it is POSIX-only, and says so at its own site: a quote- and
|
|
2404
|
+
// backslash-bearing owed path cannot exist on NTFS. Every other case, the
|
|
2405
|
+
// non-ASCII one included, runs on win32 too.
|
|
2406
|
+
describe('the review-leg floor arm COMPOSED with the real gate (mmnto-ai/totem#2698)', () => {
|
|
2407
|
+
/** Built, never authored as an escape (the banked decode trap). */
|
|
2408
|
+
const NL = String.fromCharCode(10);
|
|
2409
|
+
/**
|
|
2410
|
+
* A NON-ASCII owed path (mmnto-ai/totem#2698 fold 4). git C-quotes this name
|
|
2411
|
+
* on BOTH surfaces the gate reads, in two different ways, so an un-normalized
|
|
2412
|
+
* pair reports a covering leg as covering nothing. Driven here through the
|
|
2413
|
+
* REAL CLI, where the argv and the quoting actually happen.
|
|
2414
|
+
*/
|
|
2415
|
+
const ACCENTED = `docs/caf${String.fromCharCode(0xe9)}.md`;
|
|
2416
|
+
/** The two-character sequence `printf` needs in the shim, likewise built. */
|
|
2417
|
+
const BACKSLASH_N = String.fromCharCode(92) + 'n';
|
|
2418
|
+
const DIST = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../dist/index.js');
|
|
2419
|
+
const shellOk = spawnSync('sh', ['-c', 'exit 0'], { encoding: 'utf-8' }).status === 0;
|
|
2420
|
+
const composedOk = shellOk && fs.existsSync(DIST);
|
|
2421
|
+
/**
|
|
2422
|
+
* The REAL working directory, restored after each case: `cross-spawn` (which
|
|
2423
|
+
* the CLI's `safeExec` goes through) chdirs into a child's cwd and restores
|
|
2424
|
+
* with `process.cwd()`, which on Windows would otherwise leave this process
|
|
2425
|
+
* holding the temp repo open and fail teardown.
|
|
2426
|
+
*/
|
|
2427
|
+
const realCwd = process.cwd();
|
|
2428
|
+
let tmpDir;
|
|
2429
|
+
let repoDir;
|
|
2430
|
+
let binDir;
|
|
2431
|
+
let logPath;
|
|
2432
|
+
let baseSha;
|
|
2433
|
+
let ancestorSha;
|
|
2434
|
+
let headSha;
|
|
2435
|
+
function git(...args) {
|
|
2436
|
+
return execFileSync('git', args, { cwd: repoDir, encoding: 'utf-8' });
|
|
2437
|
+
}
|
|
2438
|
+
beforeEach(() => {
|
|
2439
|
+
if (!composedOk)
|
|
2440
|
+
return;
|
|
2441
|
+
tmpDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-legs-composed-')));
|
|
2442
|
+
repoDir = path.join(tmpDir, 'repo');
|
|
2443
|
+
binDir = path.join(tmpDir, 'stub-bin');
|
|
2444
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
2445
|
+
fs.mkdirSync(binDir);
|
|
2446
|
+
logPath = path.join(tmpDir, 'totem-invocations.log').split(path.sep).join('/');
|
|
2447
|
+
git('init', '-q', '-b', 'main');
|
|
2448
|
+
git('config', 'user.email', 'leg@example.test');
|
|
2449
|
+
git('config', 'user.name', 'Leg');
|
|
2450
|
+
// The minimal config the schema accepts, declaring ONE glob so the
|
|
2451
|
+
// owed / not-owed axis of this suite is unambiguous.
|
|
2452
|
+
fs.writeFileSync(path.join(repoDir, 'totem.config.ts'), [
|
|
2453
|
+
'export default {',
|
|
2454
|
+
" targets: [{ glob: 'docs/**/*.md', type: 'spec', strategy: 'markdown-heading' }],",
|
|
2455
|
+
" hooks: { legsOwed: { globs: ['docs/**'] } },",
|
|
2456
|
+
'};',
|
|
2457
|
+
'',
|
|
2458
|
+
].join(NL));
|
|
2459
|
+
fs.writeFileSync(path.join(repoDir, 'src.ts'), 'export const a = 1;' + NL);
|
|
2460
|
+
git('add', '.');
|
|
2461
|
+
git('commit', '-q', '-m', 'base');
|
|
2462
|
+
baseSha = git('rev-parse', 'HEAD').trim();
|
|
2463
|
+
// The branch under test: it touches the declared glob, so it is legs-owed.
|
|
2464
|
+
git('checkout', '-q', '-b', 'feat/owed');
|
|
2465
|
+
fs.mkdirSync(path.join(repoDir, 'docs'), { recursive: true });
|
|
2466
|
+
fs.writeFileSync(path.join(repoDir, 'docs', 'note.md'), '# a judgment-dense page' + NL);
|
|
2467
|
+
fs.writeFileSync(path.join(repoDir, ...ACCENTED.split('/')), '# an accented page' + NL);
|
|
2468
|
+
git('add', '-A', 'docs');
|
|
2469
|
+
git('commit', '-q', '-m', 'a docs change');
|
|
2470
|
+
// C1 — the head a leg could read, and an ANCESTOR of the head being pushed.
|
|
2471
|
+
ancestorSha = git('rev-parse', 'HEAD').trim();
|
|
2472
|
+
// C2 — one commit further, touching nothing owed, so the owed set is still
|
|
2473
|
+
// C1's two docs pages and a deposit at C1 covers all of them. Without it
|
|
2474
|
+
// that deposit would be EXACT, credited by construction, and the reach
|
|
2475
|
+
// probe this suite exists to exercise would never run.
|
|
2476
|
+
fs.writeFileSync(path.join(repoDir, 'src.ts'), 'export const a = 2;' + NL);
|
|
2477
|
+
git('add', 'src.ts');
|
|
2478
|
+
git('commit', '-q', '-m', 'a code-only change');
|
|
2479
|
+
headSha = git('rev-parse', 'HEAD').trim();
|
|
2480
|
+
// The shim IS the real CLI for `legs`, recorded either way. First on PATH,
|
|
2481
|
+
// so the hook's resolve block finds it as a plain `totem` (the temp repo has
|
|
2482
|
+
// no package.json, no node_modules and no workspace file, so no earlier
|
|
2483
|
+
// resolution tier can win).
|
|
2484
|
+
fs.writeFileSync(path.join(binDir, 'totem'), [
|
|
2485
|
+
'#!/bin/sh',
|
|
2486
|
+
`printf '%s${BACKSLASH_N}' "$*" >> "${logPath}"`,
|
|
2487
|
+
'if [ "$1" = "legs" ]; then',
|
|
2488
|
+
` exec node "${DIST.split(path.sep).join('/')}" "$@"`,
|
|
2489
|
+
'fi',
|
|
2490
|
+
'exit 0',
|
|
2491
|
+
'',
|
|
2492
|
+
].join(NL), { mode: 0o755 });
|
|
2493
|
+
});
|
|
2494
|
+
afterEach(() => {
|
|
2495
|
+
if (!composedOk)
|
|
2496
|
+
return;
|
|
2497
|
+
process.chdir(realCwd);
|
|
2498
|
+
cleanTmpDir(tmpDir);
|
|
2499
|
+
});
|
|
2500
|
+
function runHook() {
|
|
2501
|
+
fs.writeFileSync(path.join(repoDir, 'pre-push'), buildPrePushHook({ ...RENDER, tier: 'strict' }));
|
|
2502
|
+
const env = {
|
|
2503
|
+
...process.env,
|
|
2504
|
+
PATH: [binDir, process.env['PATH'] ?? ''].join(path.delimiter),
|
|
2505
|
+
};
|
|
2506
|
+
delete env['CLAUDE_CODE_AGENT'];
|
|
2507
|
+
delete env['CLAUDE_VERSION'];
|
|
2508
|
+
delete env['CURSOR_TRACE_ID'];
|
|
2509
|
+
const result = spawnSync('sh', ['./pre-push'], { cwd: repoDir, env, encoding: 'utf-8' });
|
|
2510
|
+
return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
|
2511
|
+
}
|
|
2512
|
+
const invocations = () => {
|
|
2513
|
+
const native = logPath.split('/').join(path.sep);
|
|
2514
|
+
return fs.existsSync(native) ? fs.readFileSync(native, 'utf-8').trim().split(NL) : [];
|
|
2515
|
+
};
|
|
2516
|
+
/** Deposit through the REAL writer, at `sha`. */
|
|
2517
|
+
function deposit(sha) {
|
|
2518
|
+
fs.writeFileSync(path.join(repoDir, 'findings.json'), JSON.stringify({
|
|
2519
|
+
readAt: new Date().toISOString(),
|
|
2520
|
+
findings: [
|
|
2521
|
+
{
|
|
2522
|
+
id: 'f1',
|
|
2523
|
+
severity: 'MATERIAL',
|
|
2524
|
+
file: 'docs/note.md',
|
|
2525
|
+
line: 1,
|
|
2526
|
+
claim: 'the page overstates the floor',
|
|
2527
|
+
counterexample: 'the gate never reads severities',
|
|
2528
|
+
},
|
|
2529
|
+
],
|
|
2530
|
+
folded: ['f1'],
|
|
2531
|
+
verdict: 'one material finding, folded',
|
|
2532
|
+
}));
|
|
2533
|
+
const result = spawnSync('node', [DIST, 'legs', 'deposit', '--sha', sha, '--from', 'findings.json'], { cwd: repoDir, encoding: 'utf-8' });
|
|
2534
|
+
// Anchored, with the writer's own stderr as the failure message: the
|
|
2535
|
+
// previous `toContain('0:')` also passed on a status of 10 or 130.
|
|
2536
|
+
expect(result.status, result.stderr ?? '').toBe(0);
|
|
2537
|
+
}
|
|
2538
|
+
it.skipIf(!composedOk)('OWED with no deposit: the hook blocks, and BOTH lines are present', () => {
|
|
2539
|
+
const r = runHook();
|
|
2540
|
+
expect(invocations()).toContain('legs gate');
|
|
2541
|
+
expect(r.status).toBe(1);
|
|
2542
|
+
// The GATE's own line, derived end to end from the real predicate + store.
|
|
2543
|
+
// Both owed paths are named, the accented one spelled as it is on disk.
|
|
2544
|
+
expect(r.stdout).toContain(`[Totem] BLOCKED: this push is legs-owed (docs/** → ${ACCENTED}, docs/** → docs/note.md)`);
|
|
2545
|
+
expect(r.stdout).toContain(`for head ${headSha.slice(0, 8)}`);
|
|
2546
|
+
// And the HOOK's own cure line, which only the strict arm emits.
|
|
2547
|
+
expect(r.stdout).toContain("run the leg, then 'totem legs deposit --sha HEAD --from <findings.json>'");
|
|
2548
|
+
});
|
|
2549
|
+
it.skipIf(!composedOk)('OWED with an ANCESTOR deposit that covers the owed set: the hook passes', () => {
|
|
2550
|
+
deposit(ancestorSha);
|
|
2551
|
+
const r = runHook();
|
|
2552
|
+
expect(invocations()).toContain('legs gate');
|
|
2553
|
+
expect(r.stdout).toContain('[Totem] legs evidence: .totem/artifacts/legs/');
|
|
2554
|
+
// ANCESTOR, so the reach probe actually RAN: this is the only composed
|
|
2555
|
+
// state that drives `git diff --name-only -z base...<deposit>` end to
|
|
2556
|
+
// end. Depositing at the head took core's exact branch, which credits
|
|
2557
|
+
// coverage by construction and never probes (mmnto-ai/totem#2698 fold 5).
|
|
2558
|
+
expect(r.stdout).toContain(`· head ${headSha.slice(0, 8)} · nearest ancestor, +1 commits since the leg read ·`);
|
|
2559
|
+
// 2/2 with an accented owed path among them: the measured result of
|
|
2560
|
+
// reading both sides raw.
|
|
2561
|
+
expect(r.stdout).toContain('· covers 2/2 owed paths ·');
|
|
2562
|
+
expect(r.stdout).toContain('blocking=0 material=1 folded=1');
|
|
2563
|
+
expect(r.stdout).not.toContain('BLOCKED');
|
|
2564
|
+
expect(r.status).toBe(0);
|
|
2565
|
+
});
|
|
2566
|
+
it.skipIf(!composedOk)('OWED with a deposit on a SIBLING branch: the hook blocks and the gate calls it stale', () => {
|
|
2567
|
+
// A commit that is not an ancestor of HEAD: a second branch off the base.
|
|
2568
|
+
git('checkout', '-q', '-b', 'feat/sibling', baseSha);
|
|
2569
|
+
fs.writeFileSync(path.join(repoDir, 'other.ts'), 'export const b = 2;' + NL);
|
|
2570
|
+
git('add', 'other.ts');
|
|
2571
|
+
git('commit', '-q', '-m', 'a sibling commit');
|
|
2572
|
+
const siblingSha = git('rev-parse', 'HEAD').trim();
|
|
2573
|
+
git('checkout', '-q', 'feat/owed');
|
|
2574
|
+
deposit(siblingSha);
|
|
2575
|
+
const r = runHook();
|
|
2576
|
+
expect(invocations()).toContain('legs gate');
|
|
2577
|
+
expect(r.status).toBe(1);
|
|
2578
|
+
expect(r.stdout).toContain(`[Totem] legs: stale deposit ${siblingSha.slice(0, 8)}: not an ancestor of head`);
|
|
2579
|
+
expect(r.stdout).toContain('[Totem] BLOCKED: this push is legs-owed');
|
|
2580
|
+
});
|
|
2581
|
+
it.skipIf(!composedOk)('OWED with a MERGE-BASE deposit: ancestor, but it covers none of the owed paths', () => {
|
|
2582
|
+
// The fold-3 exhibit, end to end: a deposit at the base is a real
|
|
2583
|
+
// ancestor one commit behind, so ancestry alone passed it — while the leg
|
|
2584
|
+
// that wrote it saw none of the diff this push proposes.
|
|
2585
|
+
deposit(baseSha);
|
|
2586
|
+
const r = runHook();
|
|
2587
|
+
expect(invocations()).toContain('legs gate');
|
|
2588
|
+
expect(r.status).toBe(1);
|
|
2589
|
+
expect(r.stdout).toContain(`[Totem] legs: stale deposit ${baseSha.slice(0, 8)}: covers none of the owed paths (the deposit predates every owed change)`);
|
|
2590
|
+
expect(r.stdout).toContain('[Totem] BLOCKED: this push is legs-owed');
|
|
2591
|
+
// And it is NOT reported as an ancestry failure — the reason is the cure.
|
|
2592
|
+
expect(r.stdout).not.toContain('not an ancestor of head');
|
|
2593
|
+
});
|
|
2594
|
+
// POSIX-only, and only for these two names: NTFS refuses a double quote and a
|
|
2595
|
+
// backslash in a filename, while Linux and macOS — where the strict-tier
|
|
2596
|
+
// population runs — allow both, and git C-quotes both in the `diff --git`
|
|
2597
|
+
// headers the owed set used to be parsed from. This drives them through the
|
|
2598
|
+
// REAL CLI and the generated hook (mmnto-ai/totem#2698 fold 5).
|
|
2599
|
+
it.skipIf(!composedOk || process.platform === 'win32')('OWED on quote- and backslash-bearing paths: the hook names them as they are on disk', () => {
|
|
2600
|
+
const quoted = `docs/a${String.fromCharCode(34)}quoted${String.fromCharCode(34)}.md`;
|
|
2601
|
+
const backslashed = `docs/back${String.fromCharCode(92)}slash.md`;
|
|
2602
|
+
fs.writeFileSync(path.join(repoDir, ...quoted.split('/')), '# quoted' + NL);
|
|
2603
|
+
fs.writeFileSync(path.join(repoDir, ...backslashed.split('/')), '# backslashed' + NL);
|
|
2604
|
+
git('add', '-A', 'docs');
|
|
2605
|
+
git('commit', '-q', '-m', 'the hostile pages');
|
|
2606
|
+
const r = runHook();
|
|
2607
|
+
expect(invocations()).toContain('legs gate');
|
|
2608
|
+
expect(r.status).toBe(1);
|
|
2609
|
+
// Raw in the basis. Escaped names here would be the fold-4 defect, and
|
|
2610
|
+
// the decoder that fold shipped could not have reached either of these.
|
|
2611
|
+
expect(r.stdout).toContain(quoted);
|
|
2612
|
+
expect(r.stdout).toContain(backslashed);
|
|
2613
|
+
expect(r.stdout).not.toContain(String.fromCharCode(92) + '"');
|
|
2614
|
+
});
|
|
2615
|
+
it.skipIf(!composedOk)('NOT OWED: the hook passes and the gate says what it judged', () => {
|
|
2616
|
+
// A branch whose only change misses the one declared glob.
|
|
2617
|
+
git('checkout', '-q', '-b', 'feat/not-owed', baseSha);
|
|
2618
|
+
fs.writeFileSync(path.join(repoDir, 'src.ts'), 'export const a = 2;' + NL);
|
|
2619
|
+
git('add', 'src.ts');
|
|
2620
|
+
git('commit', '-q', '-m', 'a code-only change');
|
|
2621
|
+
const r = runHook();
|
|
2622
|
+
expect(invocations()).toContain('legs gate');
|
|
2623
|
+
expect(r.status).toBe(0);
|
|
2624
|
+
expect(r.stdout).toContain('[Totem] legs: not owed — no changed path matched hooks.legsOwed.globs (1 globs; head');
|
|
2625
|
+
expect(r.stdout).not.toContain('BLOCKED');
|
|
2626
|
+
});
|
|
2627
|
+
});
|
|
2173
2628
|
//# sourceMappingURL=install-hooks.test.js.map
|