@mmnto/cli 1.121.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/artifact-vocabulary.d.ts +23 -0
- package/dist/artifact-vocabulary.d.ts.map +1 -0
- package/dist/artifact-vocabulary.js +23 -0
- package/dist/artifact-vocabulary.js.map +1 -0
- package/dist/commands/config-drift.test.js +89 -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 +241 -17
- package/dist/commands/install-hooks.js.map +1 -1
- package/dist/commands/install-hooks.test.js +1019 -23
- 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/commands/spec-cli-wiring.test.d.ts +19 -0
- package/dist/commands/spec-cli-wiring.test.d.ts.map +1 -0
- package/dist/commands/spec-cli-wiring.test.js +90 -0
- package/dist/commands/spec-cli-wiring.test.js.map +1 -0
- package/dist/commands/spec-templates.d.ts +18 -0
- package/dist/commands/spec-templates.d.ts.map +1 -1
- package/dist/commands/spec-templates.js +21 -0
- package/dist/commands/spec-templates.js.map +1 -1
- package/dist/commands/spec.d.ts +168 -1
- package/dist/commands/spec.d.ts.map +1 -1
- package/dist/commands/spec.js +448 -7
- package/dist/commands/spec.js.map +1 -1
- package/dist/commands/spec.test.js +903 -20
- package/dist/commands/spec.test.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 +47 -2
- package/dist/index.js.map +1 -1
- package/dist/services/run-artifacts.d.ts +12 -1
- package/dist/services/run-artifacts.d.ts.map +1 -1
- package/dist/services/run-artifacts.js +48 -3
- package/dist/services/run-artifacts.js.map +1 -1
- package/dist/services/run-artifacts.test.js +97 -1
- package/dist/services/run-artifacts.test.js.map +1 -1
- package/dist/utils.d.ts +22 -3
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +4 -0
- package/dist/utils.js.map +1 -1
- package/dist/utils.test.js +65 -1
- package/dist/utils.test.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `totem legs deposit` / `totem legs gate` (mmnto-ai/totem#2698).
|
|
3
|
+
*
|
|
4
|
+
* The writer is driven against a REAL temp git repo, because its whole job is
|
|
5
|
+
* to bind a deposit to a sha this repo can resolve — a mocked `rev-parse`
|
|
6
|
+
* would fixture away the only thing under test. Config loading is mocked (the
|
|
7
|
+
* `shield-covariate.test.ts` precedent) so no jiti / real config is needed.
|
|
8
|
+
*
|
|
9
|
+
* The gate is driven through its derivation seam (`runLegsGate` + `LegsGateDeps`)
|
|
10
|
+
* with an injected git adapter and a temp store: the five states are then
|
|
11
|
+
* exercised without a fixture repo per case, and "not owed never consults the
|
|
12
|
+
* store" can be asserted MECHANICALLY — a corrupt file is planted in the store
|
|
13
|
+
* whose sensor row is impossible to produce without reading it.
|
|
14
|
+
*
|
|
15
|
+
* Control bytes in fixtures are built with `String.fromCharCode` on purpose:
|
|
16
|
+
* this repo has a banked incident where an editing tool decoded a `\u`/`\x`
|
|
17
|
+
* escape in source into a RAW control byte.
|
|
18
|
+
*/
|
|
19
|
+
import { execFileSync } from 'node:child_process';
|
|
20
|
+
import * as fs from 'node:fs';
|
|
21
|
+
import * as os from 'node:os';
|
|
22
|
+
import * as path from 'node:path';
|
|
23
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
24
|
+
import { legDepositPath, legsDir, sanitizeForTerminal, saveLegDeposit, } from '@mmnto/totem';
|
|
25
|
+
import { cleanTmpDir } from '../test-utils.js';
|
|
26
|
+
import { runLegsGate } from './legs.js';
|
|
27
|
+
const TEST_CONFIG = { totemDir: '.totem', ignorePatterns: [] };
|
|
28
|
+
/**
|
|
29
|
+
* The config every command under test loads. Re-pointed per suite (the fold-1
|
|
30
|
+
* suite needs one whose `ignorePatterns` names the very file it asserts on), so
|
|
31
|
+
* every `beforeEach` states the config it means to run under rather than
|
|
32
|
+
* inheriting whichever the previous suite left behind.
|
|
33
|
+
*/
|
|
34
|
+
const loadConfigMock = vi.fn(async () => TEST_CONFIG);
|
|
35
|
+
// Config load: an in-memory config rooted at the (spied) cwd, so the writer
|
|
36
|
+
// resolves its store under the temp repo without a real config file.
|
|
37
|
+
vi.mock('../utils.js', async (importOriginal) => {
|
|
38
|
+
const actual = await importOriginal();
|
|
39
|
+
return {
|
|
40
|
+
...actual,
|
|
41
|
+
loadEnv: vi.fn(),
|
|
42
|
+
resolveConfigPath: (cwd) => path.join(cwd, 'totem.config.ts'),
|
|
43
|
+
loadConfig: loadConfigMock,
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
/** ESC — built, never authored as an escape sequence (the banked decode trap). */
|
|
47
|
+
const ESC = String.fromCharCode(27);
|
|
48
|
+
/**
|
|
49
|
+
* The base every fake scope resolves against, and the reach every fake
|
|
50
|
+
* candidate has by default: coverage (mmnto-ai/totem#2698 fold 3) is measured
|
|
51
|
+
* as `base...<diffSha>` intersected with the owed paths, so a fixture that
|
|
52
|
+
* declares neither would read as "covers nothing" and turn every pre-coverage
|
|
53
|
+
* case stale. `DEFAULT_REACH` is deliberately broad — the cases that mean to
|
|
54
|
+
* exercise coverage override it.
|
|
55
|
+
*/
|
|
56
|
+
const BASE = 'main';
|
|
57
|
+
const DEFAULT_REACH = [
|
|
58
|
+
'docs/wiki/enforcement-model.md',
|
|
59
|
+
'docs/wiki/cli-reference.md',
|
|
60
|
+
'docs/wiki/page.md',
|
|
61
|
+
'README.md',
|
|
62
|
+
'a.ts',
|
|
63
|
+
];
|
|
64
|
+
/** A syntactically valid 40-hex sha that names no object in any real repo. */
|
|
65
|
+
const ABSENT_SHA = 'a'.repeat(40);
|
|
66
|
+
const OTHER_SHA = 'b'.repeat(40);
|
|
67
|
+
const HEAD_SHA = 'c'.repeat(40);
|
|
68
|
+
function depositFixture(overrides = {}) {
|
|
69
|
+
return {
|
|
70
|
+
schemaVersion: '1.0.0',
|
|
71
|
+
diffSha: HEAD_SHA,
|
|
72
|
+
readAt: '2026-09-01T00:00:00.000Z',
|
|
73
|
+
findings: [
|
|
74
|
+
{
|
|
75
|
+
id: 'f1',
|
|
76
|
+
severity: 'BLOCKING',
|
|
77
|
+
file: 'packages/core/src/artifacts/legs.ts',
|
|
78
|
+
line: 12,
|
|
79
|
+
claim: 'the loader can throw on a corrupt file',
|
|
80
|
+
counterexample: 'loadLegDeposits catches per file',
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
folded: ['f1'],
|
|
84
|
+
verdict: 'one blocking finding, folded',
|
|
85
|
+
...overrides,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// ─── The writer ─────────────────────────────────────────────────────────────
|
|
89
|
+
describe('totem legs deposit (mmnto-ai/totem#2698)', () => {
|
|
90
|
+
/**
|
|
91
|
+
* The REAL working directory, captured before any spy.
|
|
92
|
+
*
|
|
93
|
+
* `cross-spawn` (which `safeExec` goes through) resolves a command by
|
|
94
|
+
* chdir-ing into the child's `cwd` and then restoring with `process.cwd()`
|
|
95
|
+
* — so a test that mocks `process.cwd()` leaves the process parked in the
|
|
96
|
+
* temp repo, which on Windows holds the directory open and fails teardown
|
|
97
|
+
* with EPERM. Restoring it explicitly is the `verify-manifest.test.ts`
|
|
98
|
+
* precedent; in production `process.cwd()` is genuine and the restore is
|
|
99
|
+
* correct.
|
|
100
|
+
*/
|
|
101
|
+
const realCwd = process.cwd();
|
|
102
|
+
let tmpDir;
|
|
103
|
+
let headSha;
|
|
104
|
+
let errors;
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
tmpDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-legs-write-')));
|
|
107
|
+
const git = (...args) => execFileSync('git', args, { cwd: tmpDir, encoding: 'utf-8' });
|
|
108
|
+
git('init', '-q');
|
|
109
|
+
git('config', 'user.email', 'leg@example.test');
|
|
110
|
+
git('config', 'user.name', 'Leg');
|
|
111
|
+
fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'one\n');
|
|
112
|
+
git('add', 'a.txt');
|
|
113
|
+
git('commit', '-q', '-m', 'one');
|
|
114
|
+
headSha = git('rev-parse', 'HEAD').trim();
|
|
115
|
+
errors = [];
|
|
116
|
+
loadConfigMock.mockResolvedValue(TEST_CONFIG);
|
|
117
|
+
vi.spyOn(process, 'cwd').mockReturnValue(tmpDir);
|
|
118
|
+
vi.spyOn(console, 'error').mockImplementation((...args) => {
|
|
119
|
+
errors.push(args.map((a) => String(a)).join(' '));
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
vi.restoreAllMocks();
|
|
124
|
+
process.chdir(realCwd);
|
|
125
|
+
cleanTmpDir(tmpDir);
|
|
126
|
+
});
|
|
127
|
+
/** Write a findings file and return its path. */
|
|
128
|
+
function writeFindings(body, name = 'findings.json') {
|
|
129
|
+
const file = path.join(tmpDir, name);
|
|
130
|
+
fs.writeFileSync(file, JSON.stringify(body, null, 2));
|
|
131
|
+
return file;
|
|
132
|
+
}
|
|
133
|
+
const findingsBody = (overrides = {}) => ({
|
|
134
|
+
readAt: '2026-09-01T00:00:00.000Z',
|
|
135
|
+
findings: [
|
|
136
|
+
{
|
|
137
|
+
id: 'f1',
|
|
138
|
+
severity: 'BLOCKING',
|
|
139
|
+
file: 'a.txt',
|
|
140
|
+
line: 1,
|
|
141
|
+
claim: 'the writer never validates',
|
|
142
|
+
counterexample: 'saveLegDeposit parses on the way out',
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: 'f2',
|
|
146
|
+
severity: 'MINOR',
|
|
147
|
+
file: 'a.txt',
|
|
148
|
+
line: 2,
|
|
149
|
+
claim: 'the line is unsanitized',
|
|
150
|
+
counterexample: '',
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
folded: ['f1'],
|
|
154
|
+
verdict: 'one blocking (folded), one minor',
|
|
155
|
+
...overrides,
|
|
156
|
+
});
|
|
157
|
+
it('refuses a --sha that names no commit, NAMING the ref', async () => {
|
|
158
|
+
const { legsDepositCommand } = await import('./legs.js');
|
|
159
|
+
await expect(legsDepositCommand({ from: writeFindings(findingsBody()), sha: ABSENT_SHA })).rejects.toThrow(`--sha ${ABSENT_SHA} does not name a commit in this repository`);
|
|
160
|
+
// A refused write leaves no store behind.
|
|
161
|
+
expect(fs.existsSync(legsDir(path.join(tmpDir, '.totem')))).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
it('refuses a ref that would reach git as a flag', async () => {
|
|
164
|
+
const { legsDepositCommand } = await import('./legs.js');
|
|
165
|
+
await expect(legsDepositCommand({ from: writeFindings(findingsBody()), sha: '--upload-pack=evil' })).rejects.toThrow('git-flag injection guard');
|
|
166
|
+
});
|
|
167
|
+
it('refuses a findings file whose diffSha disagrees with --sha, naming BOTH', async () => {
|
|
168
|
+
const { legsDepositCommand } = await import('./legs.js');
|
|
169
|
+
await expect(legsDepositCommand({ from: writeFindings(findingsBody({ diffSha: OTHER_SHA })) })).rejects.toThrow(`The findings file names diffSha ${OTHER_SHA}, but --sha resolved to ${headSha}`);
|
|
170
|
+
});
|
|
171
|
+
it('surfaces a schema violation with its Zod PATH', async () => {
|
|
172
|
+
const { legsDepositCommand } = await import('./legs.js');
|
|
173
|
+
const body = findingsBody();
|
|
174
|
+
// `folded` naming a finding that does not exist — the superRefine arm.
|
|
175
|
+
body['folded'] = ['nope'];
|
|
176
|
+
await expect(legsDepositCommand({ from: writeFindings(body) })).rejects.toThrow('folded.0');
|
|
177
|
+
});
|
|
178
|
+
it('surfaces a malformed finding with its Zod path', async () => {
|
|
179
|
+
const { legsDepositCommand } = await import('./legs.js');
|
|
180
|
+
const body = findingsBody({ folded: [] });
|
|
181
|
+
body['findings'][0]['severity'] = 'CRITICAL';
|
|
182
|
+
await expect(legsDepositCommand({ from: writeFindings(body) })).rejects.toThrow('findings.0.severity');
|
|
183
|
+
});
|
|
184
|
+
it('refuses a findings file that is not readable JSON', async () => {
|
|
185
|
+
const { legsDepositCommand } = await import('./legs.js');
|
|
186
|
+
const file = path.join(tmpDir, 'broken.json');
|
|
187
|
+
fs.writeFileSync(file, '{ not json');
|
|
188
|
+
await expect(legsDepositCommand({ from: file })).rejects.toThrow("Could not read the leg's findings at");
|
|
189
|
+
});
|
|
190
|
+
it('writes the deposit at the resolved head and prints the path plus the counts', async () => {
|
|
191
|
+
const { legsDepositCommand } = await import('./legs.js');
|
|
192
|
+
await legsDepositCommand({ from: writeFindings(findingsBody()) });
|
|
193
|
+
const stored = legDepositPath(path.join(tmpDir, '.totem'), headSha);
|
|
194
|
+
expect(fs.existsSync(stored)).toBe(true);
|
|
195
|
+
const printed = errors.join('\n');
|
|
196
|
+
expect(printed).toContain(stored);
|
|
197
|
+
expect(printed).toContain('blocking=1 material=0 minor=1 folded=1');
|
|
198
|
+
// The deposit names the head the leg read, not the file's own guess.
|
|
199
|
+
const written = JSON.parse(fs.readFileSync(stored, 'utf-8'));
|
|
200
|
+
expect(written.diffSha).toBe(headSha);
|
|
201
|
+
expect(written.schemaVersion).toBe('1.0.0');
|
|
202
|
+
});
|
|
203
|
+
it('stamps readAt when neither the file nor --read-at carries one, and SAYS so', async () => {
|
|
204
|
+
const { legsDepositCommand } = await import('./legs.js');
|
|
205
|
+
const body = findingsBody();
|
|
206
|
+
delete body['readAt'];
|
|
207
|
+
await legsDepositCommand({ from: writeFindings(body) });
|
|
208
|
+
const printed = errors.join('\n');
|
|
209
|
+
expect(printed).toContain('readAt defaulted to');
|
|
210
|
+
expect(printed).toContain("pass --read-at for the leg's own instant");
|
|
211
|
+
const written = JSON.parse(fs.readFileSync(legDepositPath(path.join(tmpDir, '.totem'), headSha), 'utf-8'));
|
|
212
|
+
expect(Number.isNaN(Date.parse(written.readAt))).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
it('--read-at overrides the file, and prints no defaulted line', async () => {
|
|
215
|
+
const { legsDepositCommand } = await import('./legs.js');
|
|
216
|
+
await legsDepositCommand({
|
|
217
|
+
from: writeFindings(findingsBody()),
|
|
218
|
+
readAt: '2026-08-30T12:00:00.000Z',
|
|
219
|
+
});
|
|
220
|
+
const written = JSON.parse(fs.readFileSync(legDepositPath(path.join(tmpDir, '.totem'), headSha), 'utf-8'));
|
|
221
|
+
expect(written.readAt).toBe('2026-08-30T12:00:00.000Z');
|
|
222
|
+
expect(errors.join('\n')).not.toContain('readAt defaulted');
|
|
223
|
+
});
|
|
224
|
+
it('refuses an occupied address without --replace, carrying the incumbent readAt', async () => {
|
|
225
|
+
const { legsDepositCommand } = await import('./legs.js');
|
|
226
|
+
await legsDepositCommand({ from: writeFindings(findingsBody()) });
|
|
227
|
+
// The refusal is core's typed LegDepositExistsError, propagated untouched:
|
|
228
|
+
// it carries the INCUMBENT's instant and names the one-flag cure.
|
|
229
|
+
const refusal = await legsDepositCommand({
|
|
230
|
+
from: writeFindings(findingsBody({ readAt: '2026-09-02T00:00:00.000Z' }), 'second.json'),
|
|
231
|
+
}).then(() => undefined, (err) => err);
|
|
232
|
+
expect(refusal).toBeInstanceOf(Error);
|
|
233
|
+
const typed = refusal;
|
|
234
|
+
expect(typed.code).toBe('LEG_DEPOSIT_EXISTS');
|
|
235
|
+
expect(typed.message).toContain('2026-09-01T00:00:00.000Z');
|
|
236
|
+
expect(typed.recoveryHint).toContain('--replace');
|
|
237
|
+
// The incumbent is untouched by the refused write.
|
|
238
|
+
const kept = JSON.parse(fs.readFileSync(legDepositPath(path.join(tmpDir, '.totem'), headSha), 'utf-8'));
|
|
239
|
+
expect(kept.readAt).toBe('2026-09-01T00:00:00.000Z');
|
|
240
|
+
});
|
|
241
|
+
it('--replace overwrites and REPORTS the instant it replaced', async () => {
|
|
242
|
+
const { legsDepositCommand } = await import('./legs.js');
|
|
243
|
+
await legsDepositCommand({ from: writeFindings(findingsBody()) });
|
|
244
|
+
errors.length = 0;
|
|
245
|
+
await legsDepositCommand({
|
|
246
|
+
from: writeFindings(findingsBody({ readAt: '2026-09-02T00:00:00.000Z' }), 'second.json'),
|
|
247
|
+
replace: true,
|
|
248
|
+
});
|
|
249
|
+
expect(errors.join('\n')).toContain('replaced the deposit read at 2026-09-01T00:00:00.000Z');
|
|
250
|
+
const written = JSON.parse(fs.readFileSync(legDepositPath(path.join(tmpDir, '.totem'), headSha), 'utf-8'));
|
|
251
|
+
expect(written.readAt).toBe('2026-09-02T00:00:00.000Z');
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
// ─── The gate ───────────────────────────────────────────────────────────────
|
|
255
|
+
describe('totem legs gate (mmnto-ai/totem#2698)', () => {
|
|
256
|
+
let tmpDir;
|
|
257
|
+
let totemDirAbs;
|
|
258
|
+
let calls;
|
|
259
|
+
beforeEach(() => {
|
|
260
|
+
tmpDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-legs-gate-')));
|
|
261
|
+
totemDirAbs = path.join(tmpDir, '.totem');
|
|
262
|
+
calls = [];
|
|
263
|
+
});
|
|
264
|
+
afterEach(() => {
|
|
265
|
+
cleanTmpDir(tmpDir);
|
|
266
|
+
});
|
|
267
|
+
/** A git seam that RECORDS every question asked of it. */
|
|
268
|
+
function fakeGit(answers) {
|
|
269
|
+
return {
|
|
270
|
+
isCommit(sha) {
|
|
271
|
+
calls.push(`isCommit ${sha}`);
|
|
272
|
+
return answers.isCommit?.(sha) ?? true;
|
|
273
|
+
},
|
|
274
|
+
isAncestor(base, head) {
|
|
275
|
+
calls.push(`isAncestor ${base} ${head}`);
|
|
276
|
+
return answers.isAncestor?.(base, head) ?? true;
|
|
277
|
+
},
|
|
278
|
+
distance(base, head) {
|
|
279
|
+
calls.push(`distance ${base} ${head}`);
|
|
280
|
+
return answers.distance?.(base, head) ?? 0;
|
|
281
|
+
},
|
|
282
|
+
changedFiles(base, head) {
|
|
283
|
+
calls.push(`changedFiles ${base} ${head}`);
|
|
284
|
+
// Default: the candidate's diff contained everything the deps declare
|
|
285
|
+
// changed, so the existing cases keep their pre-coverage meaning.
|
|
286
|
+
return answers.changedFiles?.(base, head) ?? DEFAULT_REACH;
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function makeDeps(overrides = {}) {
|
|
291
|
+
return {
|
|
292
|
+
root: tmpDir,
|
|
293
|
+
totemDirAbs,
|
|
294
|
+
globs: ['docs/wiki/**', '.changeset/**'],
|
|
295
|
+
git: fakeGit({}),
|
|
296
|
+
resolveHead: () => HEAD_SHA,
|
|
297
|
+
changedFiles: async () => ({ files: ['docs/wiki/enforcement-model.md'], base: BASE }),
|
|
298
|
+
...overrides,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
function storeDeposit(overrides = {}) {
|
|
302
|
+
saveLegDeposit(totemDirAbs, depositFixture(overrides));
|
|
303
|
+
}
|
|
304
|
+
/** Plant a file that is NOT a deposit — its sensor row proves the store was read. */
|
|
305
|
+
function storeCorrupt(sha, body) {
|
|
306
|
+
fs.mkdirSync(legsDir(totemDirAbs), { recursive: true });
|
|
307
|
+
fs.writeFileSync(path.join(legsDir(totemDirAbs), `${sha}.json`), body);
|
|
308
|
+
}
|
|
309
|
+
it('NOT OWED: names the globs it judged, and never consults the store', async () => {
|
|
310
|
+
// A corrupt file sits in the store. Its sensor row is impossible to
|
|
311
|
+
// produce without reading the store — so an empty stderr is the proof.
|
|
312
|
+
storeCorrupt(ABSENT_SHA, 'not json at all');
|
|
313
|
+
const outcome = await runLegsGate({}, makeDeps({
|
|
314
|
+
changedFiles: async () => ({
|
|
315
|
+
files: ['packages/cli/src/index.ts', 'README.md'],
|
|
316
|
+
base: BASE,
|
|
317
|
+
}),
|
|
318
|
+
}));
|
|
319
|
+
expect(outcome.derived).toBe(0);
|
|
320
|
+
expect(outcome.status).toBe(0);
|
|
321
|
+
expect(outcome.stdout).toEqual([
|
|
322
|
+
`[Totem] legs: not owed — no changed path matched hooks.legsOwed.globs (2 globs; head ${HEAD_SHA.slice(0, 8)})`,
|
|
323
|
+
]);
|
|
324
|
+
expect(outcome.stderr).toEqual([]);
|
|
325
|
+
expect(calls).toEqual([]);
|
|
326
|
+
});
|
|
327
|
+
it('NOT OWED: an empty branch diff is not owed', async () => {
|
|
328
|
+
const outcome = await runLegsGate({}, makeDeps({ changedFiles: async () => ({ files: [], base: BASE }) }));
|
|
329
|
+
expect(outcome.derived).toBe(0);
|
|
330
|
+
expect(outcome.stdout[0]).toContain('not owed');
|
|
331
|
+
});
|
|
332
|
+
it('OWED, no deposit: exit 3 with the basis pairs and the cure', async () => {
|
|
333
|
+
const outcome = await runLegsGate({}, makeDeps());
|
|
334
|
+
expect(outcome.derived).toBe(3);
|
|
335
|
+
expect(outcome.status).toBe(3);
|
|
336
|
+
expect(outcome.stdout[0]).toContain('[Totem] BLOCKED: this push is legs-owed (docs/wiki/** → docs/wiki/enforcement-model.md) and carries no fresh falsification-leg deposit for head cccccccc');
|
|
337
|
+
expect(outcome.stdout).toContain('[Totem] legs: run the leg, then: totem legs deposit --sha HEAD --from <findings.json>');
|
|
338
|
+
});
|
|
339
|
+
it('OWED: the basis line shows the first 5 pairs and counts the rest', async () => {
|
|
340
|
+
const files = Array.from({ length: 8 }, (_, i) => `docs/wiki/page-${i}.md`);
|
|
341
|
+
const outcome = await runLegsGate({}, makeDeps({ changedFiles: async () => ({ files: files, base: BASE }) }));
|
|
342
|
+
expect(outcome.stdout[0]).toContain('docs/wiki/page-0.md');
|
|
343
|
+
expect(outcome.stdout[0]).toContain('docs/wiki/page-4.md');
|
|
344
|
+
expect(outcome.stdout[0]).not.toContain('docs/wiki/page-5.md');
|
|
345
|
+
expect(outcome.stdout[0]).toContain('+3 more');
|
|
346
|
+
});
|
|
347
|
+
it('OWED, stale only: exit 3 naming BOTH stale reasons distinctly', async () => {
|
|
348
|
+
storeDeposit({ diffSha: ABSENT_SHA });
|
|
349
|
+
storeDeposit({ diffSha: OTHER_SHA });
|
|
350
|
+
const outcome = await runLegsGate({}, makeDeps({
|
|
351
|
+
git: fakeGit({
|
|
352
|
+
isCommit: (sha) => sha !== ABSENT_SHA,
|
|
353
|
+
isAncestor: () => false,
|
|
354
|
+
}),
|
|
355
|
+
}));
|
|
356
|
+
expect(outcome.derived).toBe(3);
|
|
357
|
+
expect(outcome.stdout).toContain(`[Totem] legs: stale deposit ${ABSENT_SHA.slice(0, 8)}: unknown to this repo`);
|
|
358
|
+
expect(outcome.stdout).toContain(`[Totem] legs: stale deposit ${OTHER_SHA.slice(0, 8)}: not an ancestor of head`);
|
|
359
|
+
});
|
|
360
|
+
it('OWED, exact deposit: exit 0 with the evidence line', async () => {
|
|
361
|
+
storeDeposit();
|
|
362
|
+
const outcome = await runLegsGate({}, makeDeps());
|
|
363
|
+
expect(outcome.derived).toBe(0);
|
|
364
|
+
expect(outcome.stdout).toHaveLength(1);
|
|
365
|
+
const line = outcome.stdout[0];
|
|
366
|
+
expect(line).toContain('[Totem] legs evidence: .totem/artifacts/legs/');
|
|
367
|
+
expect(line).toContain('(read 2026-09-01T00:00:00.000Z,');
|
|
368
|
+
expect(line).toContain(`· head ${HEAD_SHA.slice(0, 8)} ·`);
|
|
369
|
+
expect(line).toContain('· exact ·');
|
|
370
|
+
expect(line).toContain('blocking=1 material=0 folded=1');
|
|
371
|
+
// The path is repo-root relative and forward-slashed on every platform.
|
|
372
|
+
expect(line).not.toContain(tmpDir);
|
|
373
|
+
});
|
|
374
|
+
it('OWED, ancestor deposit: exit 0 and the line DISCLOSES the commits since the read', async () => {
|
|
375
|
+
storeDeposit({ diffSha: OTHER_SHA });
|
|
376
|
+
const outcome = await runLegsGate({}, makeDeps({ git: fakeGit({ isAncestor: () => true, distance: () => 7 }) }));
|
|
377
|
+
expect(outcome.derived).toBe(0);
|
|
378
|
+
expect(outcome.stdout[0]).toContain('· nearest ancestor, +7 commits since the leg read ·');
|
|
379
|
+
});
|
|
380
|
+
it('OWED, several candidates: the winner is named and the others are DISCLOSED', async () => {
|
|
381
|
+
storeDeposit(); // exact
|
|
382
|
+
storeDeposit({ diffSha: OTHER_SHA, readAt: '2026-08-20T00:00:00.000Z' });
|
|
383
|
+
const outcome = await runLegsGate({}, makeDeps({ git: fakeGit({ isAncestor: () => true, distance: () => 4 }) }));
|
|
384
|
+
expect(outcome.derived).toBe(0);
|
|
385
|
+
expect(outcome.stdout[0]).toContain('· exact ·');
|
|
386
|
+
expect(outcome.stdout[1]).toBe(`[Totem] legs: 1 superseded candidate(s) for this head: ${OTHER_SHA.slice(0, 8)} (read 2026-08-20T00:00:00.000Z, ancestor +4)`);
|
|
387
|
+
});
|
|
388
|
+
it('a corrupt sibling is disclosed on stderr and never masks the valid deposit', async () => {
|
|
389
|
+
storeDeposit();
|
|
390
|
+
storeCorrupt(ABSENT_SHA, '{"schemaVersion":"1.0.0"}');
|
|
391
|
+
storeCorrupt(OTHER_SHA, 'not json');
|
|
392
|
+
const outcome = await runLegsGate({}, makeDeps());
|
|
393
|
+
expect(outcome.derived).toBe(0);
|
|
394
|
+
expect(outcome.stdout[0]).toContain('[Totem] legs evidence:');
|
|
395
|
+
expect(outcome.stderr).toHaveLength(2);
|
|
396
|
+
for (const row of outcome.stderr) {
|
|
397
|
+
expect(row).toContain('[Totem] legs: sensor — ignoring corrupt deposit ');
|
|
398
|
+
}
|
|
399
|
+
expect(outcome.stderr.join('\n')).toContain(`${ABSENT_SHA}.json`);
|
|
400
|
+
expect(outcome.stderr.join('\n')).toContain(`${OTHER_SHA}.json`);
|
|
401
|
+
});
|
|
402
|
+
it('a corrupt store with no valid deposit still BLOCKS (the sensor is not evidence)', async () => {
|
|
403
|
+
storeCorrupt(ABSENT_SHA, 'not json');
|
|
404
|
+
const outcome = await runLegsGate({}, makeDeps());
|
|
405
|
+
expect(outcome.derived).toBe(3);
|
|
406
|
+
expect(outcome.stderr).toHaveLength(1);
|
|
407
|
+
});
|
|
408
|
+
it('NOT DERIVED: a thrown diff resolution is exit 2 with the cause', async () => {
|
|
409
|
+
const outcome = await runLegsGate({}, makeDeps({
|
|
410
|
+
changedFiles: async () => {
|
|
411
|
+
throw new Error('fatal: not a git repository');
|
|
412
|
+
},
|
|
413
|
+
}));
|
|
414
|
+
expect(outcome.derived).toBe(2);
|
|
415
|
+
expect(outcome.status).toBe(2);
|
|
416
|
+
expect(outcome.stdout).toEqual(['[Totem] legs: NOT DERIVED — fatal: not a git repository']);
|
|
417
|
+
});
|
|
418
|
+
it('NOT DERIVED: an unresolvable head is exit 2 before any store read', async () => {
|
|
419
|
+
storeCorrupt(ABSENT_SHA, 'not json');
|
|
420
|
+
const outcome = await runLegsGate({}, makeDeps({
|
|
421
|
+
resolveHead: () => {
|
|
422
|
+
throw new Error('fatal: ambiguous argument HEAD');
|
|
423
|
+
},
|
|
424
|
+
}));
|
|
425
|
+
expect(outcome.derived).toBe(2);
|
|
426
|
+
expect(outcome.stderr).toEqual([]);
|
|
427
|
+
});
|
|
428
|
+
it('--advisory maps 3 and 2 to 0 with BYTE-IDENTICAL stdout', async () => {
|
|
429
|
+
const owedDeps = makeDeps();
|
|
430
|
+
const strictOwed = await runLegsGate({}, owedDeps);
|
|
431
|
+
const advisoryOwed = await runLegsGate({ advisory: true }, owedDeps);
|
|
432
|
+
expect(strictOwed.derived).toBe(3);
|
|
433
|
+
expect(advisoryOwed.derived).toBe(3);
|
|
434
|
+
expect(advisoryOwed.status).toBe(0);
|
|
435
|
+
expect(advisoryOwed.stdout).toEqual(strictOwed.stdout);
|
|
436
|
+
const brokenDeps = makeDeps({
|
|
437
|
+
changedFiles: async () => {
|
|
438
|
+
throw new Error('fatal: not a git repository');
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
const strictBroken = await runLegsGate({}, brokenDeps);
|
|
442
|
+
const advisoryBroken = await runLegsGate({ advisory: true }, brokenDeps);
|
|
443
|
+
expect(strictBroken.derived).toBe(2);
|
|
444
|
+
expect(advisoryBroken.status).toBe(0);
|
|
445
|
+
expect(advisoryBroken.stdout).toEqual(strictBroken.stdout);
|
|
446
|
+
});
|
|
447
|
+
it('every echoed value is control-character sanitized', async () => {
|
|
448
|
+
// A changed path carrying a CSI sequence — built, never authored.
|
|
449
|
+
const hostile = `docs/wiki/${ESC}[31mred.md`;
|
|
450
|
+
const outcome = await runLegsGate({}, makeDeps({ changedFiles: async () => ({ files: [hostile], base: BASE }) }));
|
|
451
|
+
expect(outcome.derived).toBe(3);
|
|
452
|
+
expect(outcome.stdout[0]).toContain('docs/wiki/');
|
|
453
|
+
expect(outcome.stdout[0]).not.toContain(ESC);
|
|
454
|
+
});
|
|
455
|
+
it('the gate judges the globs it was GIVEN, not the default floor', async () => {
|
|
456
|
+
const outcome = await runLegsGate({}, makeDeps({
|
|
457
|
+
globs: ['packages/core/src/artifacts/**'],
|
|
458
|
+
changedFiles: async () => ({ files: ['docs/wiki/enforcement-model.md'], base: BASE }),
|
|
459
|
+
}));
|
|
460
|
+
expect(outcome.derived).toBe(0);
|
|
461
|
+
expect(outcome.stdout[0]).toContain('(1 globs;');
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
// ─── Fold 1: the floor sees the UNFILTERED branch diff ──────────────────────
|
|
465
|
+
/**
|
|
466
|
+
* `ignorePatterns` carries INDEX-exclusion semantics that were merged into the
|
|
467
|
+
* review/lint diff filter for back-compat (mmnto-ai/totem#1746 /
|
|
468
|
+
* mmnto-ai/totem#1748). If it also narrowed this predicate, a repo that keeps
|
|
469
|
+
* `README.md` out of its index would silently stop owing a leg for its public
|
|
470
|
+
* copy — a floor that cannot see the surface it names. This drives the REAL
|
|
471
|
+
* seam (`buildLegsGateDeps`) against a real two-branch repo, so the assertion
|
|
472
|
+
* is about what the resolution actually returns, not about a fake.
|
|
473
|
+
*/
|
|
474
|
+
describe('the legs floor classifies the UNFILTERED branch diff (mmnto-ai/totem#2698 fold 1)', () => {
|
|
475
|
+
const realCwd = process.cwd();
|
|
476
|
+
let tmpDir;
|
|
477
|
+
beforeEach(() => {
|
|
478
|
+
tmpDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-legs-filter-')));
|
|
479
|
+
const git = (...args) => execFileSync('git', args, { cwd: tmpDir, encoding: 'utf-8' });
|
|
480
|
+
git('init', '-q', '-b', 'main');
|
|
481
|
+
git('config', 'user.email', 'leg@example.test');
|
|
482
|
+
git('config', 'user.name', 'Leg');
|
|
483
|
+
fs.writeFileSync(path.join(tmpDir, 'README.md'), 'the public copy\n');
|
|
484
|
+
fs.writeFileSync(path.join(tmpDir, 'src.ts'), 'export const a = 1;\n');
|
|
485
|
+
git('add', '.');
|
|
486
|
+
git('commit', '-q', '-m', 'base');
|
|
487
|
+
// The branch under test: it touches the very file `ignorePatterns` names.
|
|
488
|
+
git('checkout', '-q', '-b', 'feat/public-copy');
|
|
489
|
+
fs.writeFileSync(path.join(tmpDir, 'README.md'), 'the public copy, revised\n');
|
|
490
|
+
git('add', 'README.md');
|
|
491
|
+
git('commit', '-q', '-m', 'revise the public copy');
|
|
492
|
+
vi.spyOn(process, 'cwd').mockReturnValue(tmpDir);
|
|
493
|
+
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
494
|
+
// The config IGNORES README.md — the pre-fold behavior would drop it here.
|
|
495
|
+
loadConfigMock.mockResolvedValue({
|
|
496
|
+
totemDir: '.totem',
|
|
497
|
+
ignorePatterns: ['README.md'],
|
|
498
|
+
shieldIgnorePatterns: ['README.md'],
|
|
499
|
+
hooks: { legsOwed: { globs: ['README.md', 'docs/wiki/**'] } },
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
afterEach(() => {
|
|
503
|
+
vi.restoreAllMocks();
|
|
504
|
+
loadConfigMock.mockResolvedValue(TEST_CONFIG);
|
|
505
|
+
process.chdir(realCwd);
|
|
506
|
+
cleanTmpDir(tmpDir);
|
|
507
|
+
});
|
|
508
|
+
it('an ignorePatterns-named path still reaches the predicate, and still owes a leg', async () => {
|
|
509
|
+
const { buildLegsGateDeps, runLegsGate } = await import('./legs.js');
|
|
510
|
+
const deps = await buildLegsGateDeps();
|
|
511
|
+
// The seam itself: the resolution returns the ignored path.
|
|
512
|
+
const scope = await deps.changedFiles();
|
|
513
|
+
expect(scope.files).toContain('README.md');
|
|
514
|
+
// The base rides back with the files: coverage is measured against it
|
|
515
|
+
// (mmnto-ai/totem#2698 fold 3), and it must be the base HEAD resolved on.
|
|
516
|
+
expect(scope.base).toBe('main');
|
|
517
|
+
// And the verdict names it as the basis.
|
|
518
|
+
const outcome = await runLegsGate({}, deps);
|
|
519
|
+
expect(outcome.derived).toBe(3);
|
|
520
|
+
expect(outcome.stdout[0]).toContain('README.md → README.md');
|
|
521
|
+
});
|
|
522
|
+
it('discloses that the floor judged an unfiltered diff', async () => {
|
|
523
|
+
const lines = [];
|
|
524
|
+
vi.spyOn(console, 'error').mockImplementation((...args) => {
|
|
525
|
+
lines.push(args.map((a) => String(a)).join(' '));
|
|
526
|
+
});
|
|
527
|
+
const { buildLegsGateDeps } = await import('./legs.js');
|
|
528
|
+
const deps = await buildLegsGateDeps();
|
|
529
|
+
await deps.changedFiles();
|
|
530
|
+
const narration = lines.join('\n');
|
|
531
|
+
// The stable halves: the base it judged against, and the fact that the
|
|
532
|
+
// ignore config did not apply. The middle now carries the resolved base.
|
|
533
|
+
expect(narration).toContain('Diff source: branch-vs-base (');
|
|
534
|
+
expect(narration).toContain('unfiltered — ignorePatterns do not apply to the floor)');
|
|
535
|
+
expect(narration).toContain('main...HEAD');
|
|
536
|
+
// Exactly ONE `Changed files` line: the suppressed resolver no longer
|
|
537
|
+
// prints its own C-quoted one beside the gate's raw one (fold 5).
|
|
538
|
+
expect(narration.split('Changed files (')).toHaveLength(2);
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
// ─── Fold 2: no echoed value can forge a second line ────────────────────────
|
|
542
|
+
describe('every echoed value is line-safe (mmnto-ai/totem#2698 fold 2)', () => {
|
|
543
|
+
/** LF — built, never authored as an escape (the banked decode trap). */
|
|
544
|
+
const LF = String.fromCharCode(10);
|
|
545
|
+
let tmpDir;
|
|
546
|
+
beforeEach(() => {
|
|
547
|
+
tmpDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-legs-line-')));
|
|
548
|
+
});
|
|
549
|
+
afterEach(() => {
|
|
550
|
+
cleanTmpDir(tmpDir);
|
|
551
|
+
});
|
|
552
|
+
/**
|
|
553
|
+
* Every stdout entry is exactly ONE line.
|
|
554
|
+
*
|
|
555
|
+
* The array element IS the unit of the write (`fs.writeSync(1, line + LF)`),
|
|
556
|
+
* so "no element contains a line break" is the whole forge-prevention
|
|
557
|
+
* property — a hostile value that merely repeats the literal text `[Totem]`
|
|
558
|
+
* inside one line is ugly, not a forged line, and the count of markers is
|
|
559
|
+
* therefore not the assertion.
|
|
560
|
+
*/
|
|
561
|
+
function expectNoForgedLine(lines) {
|
|
562
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
563
|
+
for (const line of lines) {
|
|
564
|
+
expect(line.includes(LF)).toBe(false);
|
|
565
|
+
expect(line.includes(String.fromCharCode(13))).toBe(false);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
it('a glob AND a changed path carrying a newline cannot forge a second [Totem] line', async () => {
|
|
569
|
+
const { runLegsGate } = await import('./legs.js');
|
|
570
|
+
// A literal glob that MATCHES the hostile path, so BOTH halves of the
|
|
571
|
+
// basis pair carry the newline and both must be flattened.
|
|
572
|
+
const hostile = `docs/wiki/a${LF}b.md`;
|
|
573
|
+
const outcome = await runLegsGate({}, {
|
|
574
|
+
root: tmpDir,
|
|
575
|
+
totemDirAbs: path.join(tmpDir, '.totem'),
|
|
576
|
+
globs: [hostile],
|
|
577
|
+
git: {
|
|
578
|
+
isCommit: () => true,
|
|
579
|
+
isAncestor: () => false,
|
|
580
|
+
distance: () => 0,
|
|
581
|
+
changedFiles: () => [],
|
|
582
|
+
},
|
|
583
|
+
resolveHead: () => HEAD_SHA,
|
|
584
|
+
changedFiles: async () => ({ files: [hostile], base: BASE }),
|
|
585
|
+
});
|
|
586
|
+
expect(outcome.derived).toBe(3);
|
|
587
|
+
// The basis pair is present — the value was flattened, not dropped.
|
|
588
|
+
expect(outcome.stdout[0]).toContain('docs/wiki/a?b.md → docs/wiki/a?b.md');
|
|
589
|
+
expectNoForgedLine(outcome.stdout);
|
|
590
|
+
// Owed-and-unanswered is exactly two lines: the block and the cure. A forged
|
|
591
|
+
// third would have to come from the payload, and cannot.
|
|
592
|
+
expect(outcome.stdout).toHaveLength(2);
|
|
593
|
+
expect(outcome.stdout.join(LF).split('[Totem]')).toHaveLength(3);
|
|
594
|
+
// sanitizeForTerminal ALONE would have left the LF standing; this is the
|
|
595
|
+
// property the second stage adds.
|
|
596
|
+
expect(sanitizeForTerminal(hostile).includes(LF)).toBe(true);
|
|
597
|
+
});
|
|
598
|
+
it('a corrupt-deposit reason and a NOT DERIVED cause are line-safe too', async () => {
|
|
599
|
+
const { runLegsGate } = await import('./legs.js');
|
|
600
|
+
const notDerived = await runLegsGate({}, {
|
|
601
|
+
root: tmpDir,
|
|
602
|
+
totemDirAbs: path.join(tmpDir, '.totem'),
|
|
603
|
+
globs: ['**'],
|
|
604
|
+
git: {
|
|
605
|
+
isCommit: () => true,
|
|
606
|
+
isAncestor: () => true,
|
|
607
|
+
distance: () => 0,
|
|
608
|
+
changedFiles: () => [],
|
|
609
|
+
},
|
|
610
|
+
resolveHead: () => {
|
|
611
|
+
throw new Error(`fatal: bad revision${LF}[Totem] legs evidence: forged`);
|
|
612
|
+
},
|
|
613
|
+
changedFiles: async () => ({ files: ['a.ts'], base: BASE }),
|
|
614
|
+
});
|
|
615
|
+
expect(notDerived.derived).toBe(2);
|
|
616
|
+
expectNoForgedLine(notDerived.stdout);
|
|
617
|
+
// NOT DERIVED is ONE line, even though the cause embeds a newline AND the
|
|
618
|
+
// literal `[Totem]` marker — the flattened text stays inside that one line.
|
|
619
|
+
expect(notDerived.stdout).toHaveLength(1);
|
|
620
|
+
expect(notDerived.stdout[0]).toContain('NOT DERIVED — fatal: bad revision?');
|
|
621
|
+
expect(notDerived.stdout[0]).toContain('?[Totem] legs evidence: forged');
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
// ─── Fold 2 (MINOR): a distance is derived or it is not derived ─────────────
|
|
625
|
+
describe('the gate never GUESSES a distance (mmnto-ai/totem#2698 fold 2)', () => {
|
|
626
|
+
let tmpDir;
|
|
627
|
+
beforeEach(() => {
|
|
628
|
+
tmpDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-legs-dist-')));
|
|
629
|
+
saveLegDeposit(path.join(tmpDir, '.totem'), depositFixture({ diffSha: OTHER_SHA }));
|
|
630
|
+
});
|
|
631
|
+
afterEach(() => {
|
|
632
|
+
cleanTmpDir(tmpDir);
|
|
633
|
+
});
|
|
634
|
+
it('an adapter whose distance fails yields NOT DERIVED, never a fabricated +0', async () => {
|
|
635
|
+
const { runLegsGate } = await import('./legs.js');
|
|
636
|
+
const outcome = await runLegsGate({}, {
|
|
637
|
+
root: tmpDir,
|
|
638
|
+
totemDirAbs: path.join(tmpDir, '.totem'),
|
|
639
|
+
globs: ['docs/wiki/**'],
|
|
640
|
+
git: {
|
|
641
|
+
isCommit: () => true,
|
|
642
|
+
isAncestor: () => true,
|
|
643
|
+
distance: () => {
|
|
644
|
+
throw new Error('git rev-list --count returned "" , which is not a commit count.');
|
|
645
|
+
},
|
|
646
|
+
// It COVERS the owed path — so the run reaches the distance probe,
|
|
647
|
+
// which is the failure under test.
|
|
648
|
+
changedFiles: () => ['docs/wiki/page.md'],
|
|
649
|
+
},
|
|
650
|
+
resolveHead: () => HEAD_SHA,
|
|
651
|
+
changedFiles: async () => ({ files: ['docs/wiki/page.md'], base: BASE }),
|
|
652
|
+
});
|
|
653
|
+
// The number is PRINTED as fact on the evidence line, so an underivable
|
|
654
|
+
// one is a failure to derive — not a zero that would render a stale read
|
|
655
|
+
// as an exact one.
|
|
656
|
+
expect(outcome.derived).toBe(2);
|
|
657
|
+
expect(outcome.stdout[0]).toContain('[Totem] legs: NOT DERIVED — git rev-list --count');
|
|
658
|
+
expect(outcome.stdout.join(' ')).not.toContain('legs evidence');
|
|
659
|
+
expect(outcome.stdout.join(' ')).not.toContain('+0 commits');
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
// ─── Fold 3: coverage is a freshness predicate ──────────────────────────────
|
|
663
|
+
//
|
|
664
|
+
// Ancestry alone let a deposit written against the branch's MERGE BASE pass:
|
|
665
|
+
// it is an ancestor, it reports a small distance, and the leg that wrote it saw
|
|
666
|
+
// none of the diff the push proposes. The gate now measures what a candidate
|
|
667
|
+
// could have READ (mmnto-ai/totem#2698 fold 3, operator-ruled).
|
|
668
|
+
describe('the gate measures COVERAGE, not only ancestry (mmnto-ai/totem#2698 fold 3)', () => {
|
|
669
|
+
let tmpDir;
|
|
670
|
+
let totemDirAbs;
|
|
671
|
+
beforeEach(() => {
|
|
672
|
+
tmpDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-legs-cov-')));
|
|
673
|
+
totemDirAbs = path.join(tmpDir, '.totem');
|
|
674
|
+
});
|
|
675
|
+
afterEach(() => {
|
|
676
|
+
cleanTmpDir(tmpDir);
|
|
677
|
+
});
|
|
678
|
+
/** Deps whose candidate reached exactly `reach`, against one owed glob. */
|
|
679
|
+
function coverageDeps(reach, changed) {
|
|
680
|
+
return {
|
|
681
|
+
root: tmpDir,
|
|
682
|
+
totemDirAbs,
|
|
683
|
+
globs: ['docs/wiki/**'],
|
|
684
|
+
git: {
|
|
685
|
+
isCommit: () => true,
|
|
686
|
+
isAncestor: () => true,
|
|
687
|
+
distance: () => 1,
|
|
688
|
+
changedFiles: () => reach,
|
|
689
|
+
},
|
|
690
|
+
resolveHead: () => HEAD_SHA,
|
|
691
|
+
changedFiles: async () => ({ files: changed, base: BASE }),
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
it('a merge-base deposit that covers NO owed path is stale, with its own reason', async () => {
|
|
695
|
+
saveLegDeposit(totemDirAbs, depositFixture({ diffSha: OTHER_SHA }));
|
|
696
|
+
const outcome = await runLegsGate({}, coverageDeps(['src/unrelated.ts'], ['docs/wiki/enforcement-model.md']));
|
|
697
|
+
expect(outcome.derived).toBe(3);
|
|
698
|
+
expect(outcome.stdout).toContain(`[Totem] legs: stale deposit ${OTHER_SHA.slice(0, 8)}: covers none of the owed paths (the deposit predates every owed change)`);
|
|
699
|
+
// It is BLOCKED, not merely disclosed — the exhibit that produced the rule.
|
|
700
|
+
expect(outcome.stdout[0]).toContain('[Totem] BLOCKED: this push is legs-owed');
|
|
701
|
+
});
|
|
702
|
+
it('PARTIAL coverage passes, and the evidence line reports covers K/N', async () => {
|
|
703
|
+
saveLegDeposit(totemDirAbs, depositFixture({ diffSha: OTHER_SHA }));
|
|
704
|
+
const outcome = await runLegsGate({}, coverageDeps(['docs/wiki/a.md'], ['docs/wiki/a.md', 'docs/wiki/b.md', 'docs/wiki/c.md']));
|
|
705
|
+
expect(outcome.derived).toBe(0);
|
|
706
|
+
// K < N is DISCLOSURE, never a block: the read happened, and the number is
|
|
707
|
+
// what makes the re-arm question legible to the round.
|
|
708
|
+
expect(outcome.stdout[0]).toContain('· nearest ancestor, +1 commits since the leg read · covers 1/3 owed paths · blocking=1');
|
|
709
|
+
});
|
|
710
|
+
it('an EXACT deposit covers everything without a coverage git call', async () => {
|
|
711
|
+
saveLegDeposit(totemDirAbs, depositFixture({ diffSha: HEAD_SHA }));
|
|
712
|
+
const outcome = await runLegsGate({}, {
|
|
713
|
+
...coverageDeps([], ['docs/wiki/a.md', 'docs/wiki/b.md']),
|
|
714
|
+
git: {
|
|
715
|
+
isCommit: () => true,
|
|
716
|
+
isAncestor: () => true,
|
|
717
|
+
distance: () => 0,
|
|
718
|
+
changedFiles: () => {
|
|
719
|
+
throw new Error('changedFiles must not be called for an exact match');
|
|
720
|
+
},
|
|
721
|
+
},
|
|
722
|
+
});
|
|
723
|
+
expect(outcome.derived).toBe(0);
|
|
724
|
+
expect(outcome.stdout[0]).toContain('· exact · covers 2/2 owed paths ·');
|
|
725
|
+
});
|
|
726
|
+
it('a scope with no base is NOT DERIVED — the gate never falls back to ancestry-only', async () => {
|
|
727
|
+
// Falling back would restore exactly the merge-base pass the ruling closed.
|
|
728
|
+
saveLegDeposit(totemDirAbs, depositFixture({ diffSha: OTHER_SHA }));
|
|
729
|
+
const deps = coverageDeps(['docs/wiki/a.md'], ['docs/wiki/a.md']);
|
|
730
|
+
const outcome = await runLegsGate({}, { ...deps, changedFiles: async () => ({ files: ['docs/wiki/a.md'] }) });
|
|
731
|
+
expect(outcome.derived).toBe(2);
|
|
732
|
+
expect(outcome.stdout[0]).toContain('NOT DERIVED — the branch scope resolved without a base ref, so coverage is not derivable');
|
|
733
|
+
});
|
|
734
|
+
it('coverage is measured against the base the scope resolved, not a guess', async () => {
|
|
735
|
+
saveLegDeposit(totemDirAbs, depositFixture({ diffSha: OTHER_SHA }));
|
|
736
|
+
const seen = [];
|
|
737
|
+
const deps = coverageDeps(['docs/wiki/a.md'], ['docs/wiki/a.md']);
|
|
738
|
+
await runLegsGate({}, {
|
|
739
|
+
...deps,
|
|
740
|
+
changedFiles: async () => ({ files: ['docs/wiki/a.md'], base: 'origin/release' }),
|
|
741
|
+
git: {
|
|
742
|
+
isCommit: () => true,
|
|
743
|
+
isAncestor: () => true,
|
|
744
|
+
distance: () => 1,
|
|
745
|
+
changedFiles: (base, head) => {
|
|
746
|
+
seen.push(`${base}...${head}`);
|
|
747
|
+
return ['docs/wiki/a.md'];
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
});
|
|
751
|
+
expect(seen).toEqual([`origin/release...${OTHER_SHA}`]);
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
// ─── Fold 4: the reach probe must not speak git's quoted dialect ────────────
|
|
755
|
+
//
|
|
756
|
+
// `git diff --name-only` C-QUOTES any path with a non-ASCII byte, a quote or a
|
|
757
|
+
// backslash under the default `core.quotePath` — `"docs/caf\303\251.md"` —
|
|
758
|
+
// while the owed set comes from `extractChangedFiles`, unquoted. The two then
|
|
759
|
+
// never intersect for such a path, and a COVERING ancestor is rejected with the
|
|
760
|
+
// false reason "predates every owed change". `-z` never quotes.
|
|
761
|
+
//
|
|
762
|
+
// This drives the REAL adapter (`buildLegsGateDeps`) against a real repo,
|
|
763
|
+
// because the defect lives in the argv, which a fake seam cannot reproduce.
|
|
764
|
+
//
|
|
765
|
+
// COVERAGE DECLARATION: the two names here — non-ASCII, and ASCII with a space
|
|
766
|
+
// — are legal on every platform, so this suite runs everywhere. The two NTFS
|
|
767
|
+
// refuses (a double quote, a backslash) are the sibling suite below, POSIX-only.
|
|
768
|
+
describe('the coverage reach reads unquoted paths (mmnto-ai/totem#2698 fold 4)', () => {
|
|
769
|
+
const realCwd = process.cwd();
|
|
770
|
+
let tmpDir;
|
|
771
|
+
/** `docs/café.md` — built, never authored as an escape (the banked trap). */
|
|
772
|
+
const ACCENTED = `docs/caf${String.fromCharCode(0xe9)}.md`;
|
|
773
|
+
/** The ASCII control: a space is not quoted by `--name-only`, and must stay green. */
|
|
774
|
+
const SPACED = 'docs/a page.md';
|
|
775
|
+
let coveredSha;
|
|
776
|
+
beforeEach(() => {
|
|
777
|
+
tmpDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-legs-utf8-')));
|
|
778
|
+
const git = (...args) => execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', ...args], { cwd: tmpDir, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
779
|
+
git('init', '-b', 'main');
|
|
780
|
+
fs.writeFileSync(path.join(tmpDir, 'src.ts'), 'export const a = 1;');
|
|
781
|
+
git('add', '.');
|
|
782
|
+
git('commit', '-m', 'base');
|
|
783
|
+
// C1 touches the two owed paths a leg here could have read.
|
|
784
|
+
git('checkout', '-b', 'feat/owed');
|
|
785
|
+
fs.mkdirSync(path.join(tmpDir, 'docs'), { recursive: true });
|
|
786
|
+
fs.writeFileSync(path.join(tmpDir, ...ACCENTED.split('/')), '# accented');
|
|
787
|
+
fs.writeFileSync(path.join(tmpDir, ...SPACED.split('/')), '# spaced');
|
|
788
|
+
git('add', '-A', 'docs');
|
|
789
|
+
git('commit', '-m', 'the owed pages');
|
|
790
|
+
coveredSha = git('rev-parse', 'HEAD');
|
|
791
|
+
// C2 adds a third owed path the C1 leg could NOT have read.
|
|
792
|
+
fs.writeFileSync(path.join(tmpDir, 'docs', 'later.md'), '# later');
|
|
793
|
+
git('add', 'docs/later.md');
|
|
794
|
+
git('commit', '-m', 'a later page');
|
|
795
|
+
loadConfigMock.mockResolvedValue({
|
|
796
|
+
totemDir: '.totem',
|
|
797
|
+
ignorePatterns: [],
|
|
798
|
+
hooks: { legsOwed: { globs: ['docs/**'] } },
|
|
799
|
+
});
|
|
800
|
+
vi.spyOn(process, 'cwd').mockReturnValue(tmpDir);
|
|
801
|
+
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
802
|
+
});
|
|
803
|
+
afterEach(() => {
|
|
804
|
+
vi.restoreAllMocks();
|
|
805
|
+
loadConfigMock.mockResolvedValue(TEST_CONFIG);
|
|
806
|
+
process.chdir(realCwd);
|
|
807
|
+
cleanTmpDir(tmpDir);
|
|
808
|
+
});
|
|
809
|
+
it('a non-ASCII owed path is COVERED, not falsely reported as uncovered', async () => {
|
|
810
|
+
const { buildLegsGateDeps, runLegsGate } = await import('./legs.js');
|
|
811
|
+
saveLegDeposit(path.join(tmpDir, '.totem'), depositFixture({ diffSha: coveredSha }));
|
|
812
|
+
const deps = await buildLegsGateDeps();
|
|
813
|
+
// The owed set itself carries the raw name — this is the side the reach
|
|
814
|
+
// probe has to match.
|
|
815
|
+
const scope = await deps.changedFiles();
|
|
816
|
+
expect(scope.files).toContain(ACCENTED);
|
|
817
|
+
const outcome = await runLegsGate({}, deps);
|
|
818
|
+
// Two of the three owed paths were inside C1's diff; without `-z` the
|
|
819
|
+
// accented one would drop out and this would read `covers 1/3`.
|
|
820
|
+
expect(outcome.derived).toBe(0);
|
|
821
|
+
expect(outcome.stdout[0]).toContain('covers 2/3 owed paths');
|
|
822
|
+
});
|
|
823
|
+
it('the ASCII-with-space control is covered on the same run', async () => {
|
|
824
|
+
const { buildLegsGateDeps } = await import('./legs.js');
|
|
825
|
+
const deps = await buildLegsGateDeps();
|
|
826
|
+
const scope = await deps.changedFiles();
|
|
827
|
+
expect(scope.files).toContain(SPACED);
|
|
828
|
+
// The adapter's own answer, read directly: both names come back raw.
|
|
829
|
+
const reach = deps.git.changedFiles(scope.base ?? 'main', coveredSha);
|
|
830
|
+
expect(reach).toContain(SPACED);
|
|
831
|
+
expect(reach).toContain(ACCENTED);
|
|
832
|
+
// And nothing came back wearing git's quotes.
|
|
833
|
+
expect(reach.some((entry) => entry.startsWith('"'))).toBe(false);
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
// The two names NTFS refuses (mmnto-ai/totem#2698 fold 5). A double quote and a
|
|
837
|
+
// backslash are legal on Linux and macOS — where the strict-tier population
|
|
838
|
+
// actually runs — and git C-quotes BOTH in `diff --git` headers, exactly as it
|
|
839
|
+
// does a non-ASCII byte. The fold-4 decoder handled octal triples only, so these
|
|
840
|
+
// two still reached the intersection escaped while the reach read them raw.
|
|
841
|
+
// Reading the owed side through the same `-z` helper is what makes them agree,
|
|
842
|
+
// and these cases are the ones a decoder could never have covered.
|
|
843
|
+
//
|
|
844
|
+
// COVERAGE DECLARATION: this suite is POSIX-only by necessity, so it lands on
|
|
845
|
+
// the ubuntu and macos CI legs; the non-ASCII and the space cases live in the
|
|
846
|
+
// suite above and run everywhere, win32 included.
|
|
847
|
+
describe.skipIf(process.platform === 'win32')('the coverage reach reads quote- and backslash-bearing paths (mmnto-ai/totem#2698 fold 5)', () => {
|
|
848
|
+
const realCwd = process.cwd();
|
|
849
|
+
let tmpDir;
|
|
850
|
+
/** Built, never authored as escapes. */
|
|
851
|
+
const QUOTED = `docs/a${String.fromCharCode(34)}quoted${String.fromCharCode(34)}.md`;
|
|
852
|
+
const BACKSLASHED = `docs/back${String.fromCharCode(92)}slash.md`;
|
|
853
|
+
let coveredSha;
|
|
854
|
+
beforeEach(() => {
|
|
855
|
+
tmpDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-legs-hostile-')));
|
|
856
|
+
const git = (...args) => execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', ...args], { cwd: tmpDir, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
857
|
+
git('init', '-b', 'main');
|
|
858
|
+
fs.writeFileSync(path.join(tmpDir, 'src.ts'), 'export const a = 1;');
|
|
859
|
+
git('add', '.');
|
|
860
|
+
git('commit', '-m', 'base');
|
|
861
|
+
// C1 — the two hostile names a leg here could have read.
|
|
862
|
+
git('checkout', '-b', 'feat/hostile');
|
|
863
|
+
fs.mkdirSync(path.join(tmpDir, 'docs'), { recursive: true });
|
|
864
|
+
fs.writeFileSync(path.join(tmpDir, ...QUOTED.split('/')), '# quoted');
|
|
865
|
+
fs.writeFileSync(path.join(tmpDir, ...BACKSLASHED.split('/')), '# backslashed');
|
|
866
|
+
git('add', '-A', 'docs');
|
|
867
|
+
git('commit', '-m', 'the hostile pages');
|
|
868
|
+
coveredSha = git('rev-parse', 'HEAD');
|
|
869
|
+
// C2 — one more owed path, after the read.
|
|
870
|
+
fs.writeFileSync(path.join(tmpDir, 'docs', 'later.md'), '# later');
|
|
871
|
+
git('add', 'docs/later.md');
|
|
872
|
+
git('commit', '-m', 'a later page');
|
|
873
|
+
loadConfigMock.mockResolvedValue({
|
|
874
|
+
totemDir: '.totem',
|
|
875
|
+
ignorePatterns: [],
|
|
876
|
+
hooks: { legsOwed: { globs: ['docs/**'] } },
|
|
877
|
+
});
|
|
878
|
+
vi.spyOn(process, 'cwd').mockReturnValue(tmpDir);
|
|
879
|
+
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
880
|
+
});
|
|
881
|
+
afterEach(() => {
|
|
882
|
+
vi.restoreAllMocks();
|
|
883
|
+
loadConfigMock.mockResolvedValue(TEST_CONFIG);
|
|
884
|
+
process.chdir(realCwd);
|
|
885
|
+
cleanTmpDir(tmpDir);
|
|
886
|
+
});
|
|
887
|
+
it('both names reach the owed set RAW, and an ancestor deposit covers them', async () => {
|
|
888
|
+
const { buildLegsGateDeps, runLegsGate } = await import('./legs.js');
|
|
889
|
+
saveLegDeposit(path.join(tmpDir, '.totem'), depositFixture({ diffSha: coveredSha }));
|
|
890
|
+
const deps = await buildLegsGateDeps();
|
|
891
|
+
const scope = await deps.changedFiles();
|
|
892
|
+
// Raw on the OWED side — the half the decoder could not fix.
|
|
893
|
+
expect(scope.files).toContain(QUOTED);
|
|
894
|
+
expect(scope.files).toContain(BACKSLASHED);
|
|
895
|
+
expect(scope.files.some((file) => file.includes(String.fromCharCode(92) + '"'))).toBe(false);
|
|
896
|
+
const outcome = await runLegsGate({}, deps);
|
|
897
|
+
expect(outcome.derived).toBe(0);
|
|
898
|
+
// An ANCESTOR deposit, so the reach probe really ran (an exact match
|
|
899
|
+
// would have been credited without one).
|
|
900
|
+
expect(outcome.stdout[0]).toContain('nearest ancestor, +1 commits since the leg read');
|
|
901
|
+
expect(outcome.stdout[0]).toContain('covers 2/3 owed paths');
|
|
902
|
+
});
|
|
903
|
+
it('the BLOCKED basis spells both names as they are on disk', async () => {
|
|
904
|
+
const { buildLegsGateDeps, runLegsGate } = await import('./legs.js');
|
|
905
|
+
const deps = await buildLegsGateDeps();
|
|
906
|
+
const outcome = await runLegsGate({}, deps);
|
|
907
|
+
expect(outcome.derived).toBe(3);
|
|
908
|
+
expect(outcome.stdout[0]).toContain(`${QUOTED}`);
|
|
909
|
+
expect(outcome.stdout[0]).toContain(`${BACKSLASHED}`);
|
|
910
|
+
});
|
|
911
|
+
});
|
|
912
|
+
// ─── Bot round on PR mmnto-ai/totem#2745 ───────────────────────────────────
|
|
913
|
+
describe('the evidence line names a future-dated readAt (mmnto-ai/totem#2745, CodeRabbit)', () => {
|
|
914
|
+
let tmpDir;
|
|
915
|
+
beforeEach(() => {
|
|
916
|
+
tmpDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-legs-age-')));
|
|
917
|
+
});
|
|
918
|
+
afterEach(() => {
|
|
919
|
+
cleanTmpDir(tmpDir);
|
|
920
|
+
});
|
|
921
|
+
function depositAgeDeps() {
|
|
922
|
+
return {
|
|
923
|
+
root: tmpDir,
|
|
924
|
+
totemDirAbs: path.join(tmpDir, '.totem'),
|
|
925
|
+
globs: ['docs/**'],
|
|
926
|
+
git: {
|
|
927
|
+
isCommit: () => true,
|
|
928
|
+
isAncestor: () => true,
|
|
929
|
+
distance: () => 0,
|
|
930
|
+
changedFiles: () => ['docs/a.md'],
|
|
931
|
+
},
|
|
932
|
+
resolveHead: () => HEAD_SHA,
|
|
933
|
+
changedFiles: async () => ({ files: ['docs/a.md'], base: BASE }),
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
it('a PAST readAt still reports its age in days', async () => {
|
|
937
|
+
const threeDaysAgo = new Date(Date.now() - 3 * 86_400_000).toISOString();
|
|
938
|
+
saveLegDeposit(path.join(tmpDir, '.totem'), depositFixture({ diffSha: HEAD_SHA, readAt: threeDaysAgo }));
|
|
939
|
+
const outcome = await runLegsGate({}, depositAgeDeps());
|
|
940
|
+
expect(outcome.stdout[0]).toContain('3 days old');
|
|
941
|
+
});
|
|
942
|
+
it('a FUTURE readAt is named as such, not reported as `age unknown`', async () => {
|
|
943
|
+
// `age unknown` reads as "unparseable" and hides the one thing worth
|
|
944
|
+
// acting on: the instant is wrong, and ranking breaks ties on it.
|
|
945
|
+
const twoDaysAhead = new Date(Date.now() + 2 * 86_400_000 + 1000).toISOString();
|
|
946
|
+
saveLegDeposit(path.join(tmpDir, '.totem'), depositFixture({ diffSha: HEAD_SHA, readAt: twoDaysAhead }));
|
|
947
|
+
const outcome = await runLegsGate({}, depositAgeDeps());
|
|
948
|
+
expect(outcome.stdout[0]).toContain('read 2 days in the FUTURE — check the clock or the deposit');
|
|
949
|
+
expect(outcome.stdout[0]).not.toContain('age unknown');
|
|
950
|
+
// Still evidence: a skewed clock is a disclosure, not a block.
|
|
951
|
+
expect(outcome.derived).toBe(0);
|
|
952
|
+
});
|
|
953
|
+
});
|
|
954
|
+
describe('the gate caps its changed-files disclosure (mmnto-ai/totem#2745, CodeRabbit)', () => {
|
|
955
|
+
const realCwd = process.cwd();
|
|
956
|
+
let tmpDir;
|
|
957
|
+
beforeEach(() => {
|
|
958
|
+
tmpDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-legs-cap-')));
|
|
959
|
+
const git = (...args) => execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', ...args], { cwd: tmpDir, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
960
|
+
git('init', '-b', 'main');
|
|
961
|
+
fs.writeFileSync(path.join(tmpDir, 'src.ts'), 'export const a = 1;');
|
|
962
|
+
git('add', '.');
|
|
963
|
+
git('commit', '-m', 'base');
|
|
964
|
+
git('checkout', '-b', 'feat/many');
|
|
965
|
+
fs.mkdirSync(path.join(tmpDir, 'docs'), { recursive: true });
|
|
966
|
+
// 20 changed paths — more than the cap, so the line must collapse.
|
|
967
|
+
for (let i = 0; i < 20; i++) {
|
|
968
|
+
fs.writeFileSync(path.join(tmpDir, 'docs', `page-${i}.md`), `# page ${i}`);
|
|
969
|
+
}
|
|
970
|
+
git('add', '-A', 'docs');
|
|
971
|
+
git('commit', '-m', 'many pages');
|
|
972
|
+
loadConfigMock.mockResolvedValue({
|
|
973
|
+
totemDir: '.totem',
|
|
974
|
+
ignorePatterns: [],
|
|
975
|
+
hooks: { legsOwed: { globs: ['docs/**'] } },
|
|
976
|
+
});
|
|
977
|
+
vi.spyOn(process, 'cwd').mockReturnValue(tmpDir);
|
|
978
|
+
});
|
|
979
|
+
afterEach(() => {
|
|
980
|
+
vi.restoreAllMocks();
|
|
981
|
+
loadConfigMock.mockResolvedValue(TEST_CONFIG);
|
|
982
|
+
process.chdir(realCwd);
|
|
983
|
+
cleanTmpDir(tmpDir);
|
|
984
|
+
});
|
|
985
|
+
it('names 12 paths, counts the rest, and keeps the TRUE total in the parentheses', async () => {
|
|
986
|
+
const lines = [];
|
|
987
|
+
vi.spyOn(console, 'error').mockImplementation((...args) => {
|
|
988
|
+
lines.push(args.map((a) => String(a)).join(' '));
|
|
989
|
+
});
|
|
990
|
+
const { buildLegsGateDeps } = await import('./legs.js');
|
|
991
|
+
const deps = await buildLegsGateDeps();
|
|
992
|
+
await deps.changedFiles();
|
|
993
|
+
const line = lines.find((entry) => entry.includes('Changed files ('));
|
|
994
|
+
expect(line).toBeDefined();
|
|
995
|
+
// The count is the truth; the list is what got shortened.
|
|
996
|
+
expect(line).toContain('Changed files (20):');
|
|
997
|
+
expect(line).toContain('+8 more');
|
|
998
|
+
expect(line).toContain('docs/page-0.md');
|
|
999
|
+
// The 13th name onward is collapsed. Names sort lexically, so `page-9.md`
|
|
1000
|
+
// is last of the twenty and certainly past the cap.
|
|
1001
|
+
expect(line).not.toContain('docs/page-9.md');
|
|
1002
|
+
});
|
|
1003
|
+
});
|
|
1004
|
+
//# sourceMappingURL=legs.test.js.map
|