@mmnto/cli 1.120.0 → 1.122.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 +26 -2
- package/dist/commands/config-drift.test.js.map +1 -1
- package/dist/commands/doctor-parity.d.ts.map +1 -1
- package/dist/commands/doctor-parity.js +21 -6
- package/dist/commands/doctor-parity.js.map +1 -1
- package/dist/commands/doctor-parity.test.js +14 -7
- package/dist/commands/doctor-parity.test.js.map +1 -1
- package/dist/commands/doctor.d.ts +21 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +167 -9
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +234 -6
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/ecl-gc.d.ts.map +1 -1
- package/dist/commands/ecl-gc.js +38 -5
- package/dist/commands/ecl-gc.js.map +1 -1
- package/dist/commands/ecl-gc.test.js +71 -0
- package/dist/commands/ecl-gc.test.js.map +1 -1
- package/dist/commands/eject-totemdir.test.d.ts +8 -0
- package/dist/commands/eject-totemdir.test.d.ts.map +1 -0
- package/dist/commands/eject-totemdir.test.js +101 -0
- package/dist/commands/eject-totemdir.test.js.map +1 -0
- package/dist/commands/eject.d.ts +30 -5
- package/dist/commands/eject.d.ts.map +1 -1
- package/dist/commands/eject.js +145 -31
- package/dist/commands/eject.js.map +1 -1
- package/dist/commands/eject.test.js +3 -3
- package/dist/commands/eject.test.js.map +1 -1
- package/dist/commands/hook-totemdir-render.test.d.ts +23 -0
- package/dist/commands/hook-totemdir-render.test.d.ts.map +1 -0
- package/dist/commands/hook-totemdir-render.test.js +288 -0
- package/dist/commands/hook-totemdir-render.test.js.map +1 -0
- package/dist/commands/init-templates.d.ts +4 -4
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +3 -3
- package/dist/commands/install-hooks-exit-contract.test.js +6 -6
- package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
- package/dist/commands/install-hooks.d.ts +162 -12
- package/dist/commands/install-hooks.d.ts.map +1 -1
- package/dist/commands/install-hooks.js +521 -88
- package/dist/commands/install-hooks.js.map +1 -1
- package/dist/commands/install-hooks.test.js +922 -147
- package/dist/commands/install-hooks.test.js.map +1 -1
- package/dist/commands/link.d.ts.map +1 -1
- package/dist/commands/link.js +71 -16
- package/dist/commands/link.js.map +1 -1
- package/dist/commands/link.test.d.ts +9 -0
- package/dist/commands/link.test.d.ts.map +1 -0
- package/dist/commands/link.test.js +92 -0
- package/dist/commands/link.test.js.map +1 -0
- package/dist/commands/mail.d.ts +48 -2
- package/dist/commands/mail.d.ts.map +1 -1
- package/dist/commands/mail.js +76 -2
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/mail.test.js +177 -0
- package/dist/commands/mail.test.js.map +1 -1
- package/dist/commands/pre-push-gate-matrix.test.js +9 -2
- package/dist/commands/pre-push-gate-matrix.test.js.map +1 -1
- package/dist/commands/shield.js +1 -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/commands/tools-hook-parity.test.js +7 -3
- package/dist/commands/tools-hook-parity.test.js.map +1 -1
- package/dist/index.js +16 -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
|
@@ -1,10 +1,100 @@
|
|
|
1
1
|
import { execSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import * as crypto from 'node:crypto';
|
|
2
3
|
import * as fs from 'node:fs';
|
|
3
4
|
import * as os from 'node:os';
|
|
4
5
|
import * as path from 'node:path';
|
|
5
6
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
7
|
+
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';
|
|
6
8
|
import { cleanTmpDir } from '../test-utils.js';
|
|
7
9
|
import { buildHookContent, buildPostCheckoutHookContent, buildPreCommitHook, buildPrePushHook, buildResolveBlock, checkHooksInstalled, detectTotemPrefix, generateHookHelpers, getFallbackCommand, installGitHook, installHooksNonInteractive, TOTEM_HOOK_END, TOTEM_HOOK_MARKER, TOTEM_PRECOMMIT_END, TOTEM_PRECOMMIT_MARKER, TOTEM_PREPUSH_END, TOTEM_PREPUSH_MARKER, upgradePrePushHookIfNeeded, } from './install-hooks.js';
|
|
10
|
+
import { SPEC_REQUIRED_SECTIONS } from './spec-templates.js';
|
|
11
|
+
// The default render options every pre-#2692 positional call implied
|
|
12
|
+
// (mmnto-ai/totem#2692 C2 — the builders take a REQUIRED options object now, so
|
|
13
|
+
// each site states the tier/totemDir/fallbackCmd it always assumed). Spread and
|
|
14
|
+
// override the one field a case actually varies.
|
|
15
|
+
const RENDER = {
|
|
16
|
+
tier: 'standard',
|
|
17
|
+
totemDir: '.totem',
|
|
18
|
+
fallbackCmd: 'pnpm dlx @mmnto/cli',
|
|
19
|
+
};
|
|
20
|
+
// ─── Anchored-evidence fixtures (mmnto-ai/totem#2700) ───
|
|
21
|
+
//
|
|
22
|
+
// Since #2700 an artifact is evidence only when it is ANCHORED and its SUBJECT
|
|
23
|
+
// carries a shape. These build the smallest artifact that satisfies each arm,
|
|
24
|
+
// so a test that means to exercise something else (age, newest-wins, torn
|
|
25
|
+
// files) does not accidentally exercise the shape check.
|
|
26
|
+
/** A draft that satisfies the TEMPLATE shape: every required heading with a body. */
|
|
27
|
+
const TEMPLATE_DRAFT = SPEC_REQUIRED_SECTIONS.map((heading) => `${heading}\n\nA non-blank body under ${heading}.\n`).join('\n');
|
|
28
|
+
/** A draft that satisfies only the looser DOCUMENT shape — one heading with a body. */
|
|
29
|
+
const DOCUMENT_DRAFT = '## A hand-shaped section\n\nA body under it.\n';
|
|
30
|
+
/** The minimal ISSUE-anchored, template-shaped run artifact the gate accepts. */
|
|
31
|
+
function specEvidenceArtifact(overrides = {}) {
|
|
32
|
+
return {
|
|
33
|
+
admission: { runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_BUILTIN } },
|
|
34
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_ISSUE, ref: '#2700' } },
|
|
35
|
+
output: { content: TEMPLATE_DRAFT },
|
|
36
|
+
createdAt: new Date().toISOString(),
|
|
37
|
+
...overrides,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The fixture wrapped in the fields every run artifact must carry and the
|
|
42
|
+
* reader never touches — so the whole thing can be parsed by the REAL schema.
|
|
43
|
+
*/
|
|
44
|
+
function asRunArtifact(fixture) {
|
|
45
|
+
const grounding = fixture['grounding'];
|
|
46
|
+
const output = fixture['output'];
|
|
47
|
+
return {
|
|
48
|
+
...fixture,
|
|
49
|
+
schemaVersion: RUN_ARTIFACT_SCHEMA_VERSION,
|
|
50
|
+
inputBundle: { maskedPrompt: 'the masked prompt' },
|
|
51
|
+
inputHash: 'a'.repeat(64),
|
|
52
|
+
grounding: { hash: 'b'.repeat(64), provenanceSummary: 'similarity-only:1', ...grounding },
|
|
53
|
+
backend: {
|
|
54
|
+
provider: 'gemini',
|
|
55
|
+
model: 'gemini-3-flash-preview',
|
|
56
|
+
qualifiedModel: 'gemini:gemini-3-flash-preview',
|
|
57
|
+
admissionClass: 'completion_only',
|
|
58
|
+
taskProfile: 'Spec',
|
|
59
|
+
},
|
|
60
|
+
output: { metrics: { durationMs: 500 }, ...output },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
// The hook's reader is a rendered `node -e` string: it walks the artifact by
|
|
64
|
+
// HAND-SPELLED paths (`grounding.anchor`, `admission.runMetadata.promptSource`,
|
|
65
|
+
// `output.content`) that no type checker sees. These tests bind the PASS
|
|
66
|
+
// fixtures those paths are exercised against to the real core schema, so a
|
|
67
|
+
// rename or a shape change on the writer's side breaks a test here rather than
|
|
68
|
+
// silently leaving the hook reading a field that no longer exists.
|
|
69
|
+
describe('the anchored-evidence fixtures parse as real run artifacts (mmnto-ai/totem#2700)', () => {
|
|
70
|
+
it('the issue-anchored PASS fixture round-trips through RunArtifactSchema with its three read paths intact', () => {
|
|
71
|
+
const parsed = RunArtifactSchema.parse(asRunArtifact(specEvidenceArtifact()));
|
|
72
|
+
expect(parsed.grounding.anchor).toEqual({ kind: GROUNDING_ANCHOR_ISSUE, ref: '#2700' });
|
|
73
|
+
expect(parsed.admission?.runMetadata?.promptSource).toBe(PROMPT_SOURCE_BUILTIN);
|
|
74
|
+
expect(parsed.output.content).toBe(TEMPLATE_DRAFT);
|
|
75
|
+
});
|
|
76
|
+
it('the record-anchored PASS fixture round-trips, sha256 and all', () => {
|
|
77
|
+
const sha256 = 'a'.repeat(64);
|
|
78
|
+
const parsed = RunArtifactSchema.parse(asRunArtifact(specEvidenceArtifact({
|
|
79
|
+
grounding: {
|
|
80
|
+
anchor: { kind: GROUNDING_ANCHOR_RECORD, ref: '.totem/specs/2700.md', sha256 },
|
|
81
|
+
},
|
|
82
|
+
})));
|
|
83
|
+
expect(parsed.grounding.anchor).toEqual({
|
|
84
|
+
kind: GROUNDING_ANCHOR_RECORD,
|
|
85
|
+
ref: '.totem/specs/2700.md',
|
|
86
|
+
sha256,
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
it('the override-prompt PASS fixture round-trips with promptSource "override"', () => {
|
|
90
|
+
const parsed = RunArtifactSchema.parse(asRunArtifact(specEvidenceArtifact({
|
|
91
|
+
admission: { runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_OVERRIDE } },
|
|
92
|
+
output: { content: DOCUMENT_DRAFT },
|
|
93
|
+
})));
|
|
94
|
+
expect(parsed.admission?.runMetadata?.promptSource).toBe(PROMPT_SOURCE_OVERRIDE);
|
|
95
|
+
expect(parsed.output.content).toBe(DOCUMENT_DRAFT);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
8
98
|
describe('detectTotemPrefix', () => {
|
|
9
99
|
let tmpDir;
|
|
10
100
|
beforeEach(() => {
|
|
@@ -152,76 +242,75 @@ describe('buildResolveBlock', () => {
|
|
|
152
242
|
});
|
|
153
243
|
describe('buildPreCommitHook', () => {
|
|
154
244
|
it('contains the marker for idempotency', () => {
|
|
155
|
-
const hook = buildPreCommitHook();
|
|
245
|
+
const hook = buildPreCommitHook(RENDER);
|
|
156
246
|
expect(hook).toContain(TOTEM_PRECOMMIT_MARKER);
|
|
157
247
|
});
|
|
158
248
|
it('blocks main and master branches', () => {
|
|
159
|
-
const hook = buildPreCommitHook();
|
|
249
|
+
const hook = buildPreCommitHook(RENDER);
|
|
160
250
|
expect(hook).toContain('"main"');
|
|
161
251
|
expect(hook).toContain('"master"');
|
|
162
252
|
});
|
|
163
253
|
it('prints a helpful error message with override instructions', () => {
|
|
164
|
-
const hook = buildPreCommitHook();
|
|
254
|
+
const hook = buildPreCommitHook(RENDER);
|
|
165
255
|
expect(hook).toContain('git checkout -b feat/my-feature');
|
|
166
256
|
expect(hook).toContain('git commit --no-verify');
|
|
167
257
|
});
|
|
168
258
|
it('starts with a shebang', () => {
|
|
169
|
-
const hook = buildPreCommitHook();
|
|
259
|
+
const hook = buildPreCommitHook(RENDER);
|
|
170
260
|
expect(hook).toMatch(/^#!\/bin\/sh\n/);
|
|
171
261
|
});
|
|
172
262
|
it('exits with code 1 when on protected branch', () => {
|
|
173
|
-
const hook = buildPreCommitHook();
|
|
263
|
+
const hook = buildPreCommitHook(RENDER);
|
|
174
264
|
expect(hook).toContain('exit 1');
|
|
175
265
|
});
|
|
176
266
|
});
|
|
177
267
|
describe('buildPrePushHook', () => {
|
|
178
|
-
const FALLBACK = 'pnpm dlx @mmnto/cli';
|
|
179
268
|
it('contains the marker for idempotency', () => {
|
|
180
|
-
const hook = buildPrePushHook(
|
|
269
|
+
const hook = buildPrePushHook(RENDER);
|
|
181
270
|
expect(hook).toContain(TOTEM_PREPUSH_MARKER);
|
|
182
271
|
});
|
|
183
272
|
it('starts with a shebang', () => {
|
|
184
|
-
const hook = buildPrePushHook(
|
|
273
|
+
const hook = buildPrePushHook(RENDER);
|
|
185
274
|
expect(hook).toMatch(/^#!\/bin\/sh\n/);
|
|
186
275
|
});
|
|
187
276
|
it('does not advertise --no-verify escape hatch', () => {
|
|
188
|
-
const hook = buildPrePushHook(
|
|
277
|
+
const hook = buildPrePushHook(RENDER);
|
|
189
278
|
expect(hook).not.toContain('--no-verify');
|
|
190
279
|
});
|
|
191
280
|
it('contains verify-manifest check', () => {
|
|
192
|
-
const hook = buildPrePushHook(
|
|
281
|
+
const hook = buildPrePushHook(RENDER);
|
|
193
282
|
expect(hook).toContain('verify-manifest');
|
|
194
283
|
expect(hook).toContain('compile manifest is stale');
|
|
195
284
|
});
|
|
196
285
|
it('contains $TOTEM_CMD lint', () => {
|
|
197
|
-
const hook = buildPrePushHook(
|
|
286
|
+
const hook = buildPrePushHook(RENDER);
|
|
198
287
|
expect(hook).toContain('$TOTEM_CMD lint');
|
|
199
288
|
});
|
|
200
289
|
// totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
|
|
201
290
|
it('contains $TOTEM_CMD verify-badges (mmnto-ai/totem#1926)', () => {
|
|
202
|
-
const hook = buildPrePushHook(
|
|
291
|
+
const hook = buildPrePushHook(RENDER);
|
|
203
292
|
// totem-context: substring match on hook script content (not secret masking) — toContain is correct here
|
|
204
293
|
expect(hook).toContain('$TOTEM_CMD verify-badges');
|
|
205
294
|
});
|
|
206
295
|
// totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
|
|
207
296
|
it('gates verify-badges on README.md existing', () => {
|
|
208
|
-
const hook = buildPrePushHook(
|
|
297
|
+
const hook = buildPrePushHook(RENDER);
|
|
209
298
|
expect(hook).toMatch(/-f "README\.md".*verify-badges/s);
|
|
210
299
|
});
|
|
211
300
|
// totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
|
|
212
301
|
it('contains $TOTEM_CMD verify-lockfile-sync (mmnto-ai/totem#1961)', () => {
|
|
213
|
-
const hook = buildPrePushHook(
|
|
302
|
+
const hook = buildPrePushHook(RENDER);
|
|
214
303
|
// totem-context: substring match on hook script content (not secret masking) — toContain is correct here
|
|
215
304
|
expect(hook).toContain('$TOTEM_CMD verify-lockfile-sync');
|
|
216
305
|
});
|
|
217
306
|
// totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
|
|
218
307
|
it('gates verify-lockfile-sync on pnpm-lock.yaml existing', () => {
|
|
219
|
-
const hook = buildPrePushHook(
|
|
308
|
+
const hook = buildPrePushHook(RENDER);
|
|
220
309
|
expect(hook).toMatch(/-f "pnpm-lock\.yaml".*verify-lockfile-sync/s);
|
|
221
310
|
});
|
|
222
311
|
// totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
|
|
223
312
|
it('slots verify-lockfile-sync before claim-discipline in the pre-push sequence', () => {
|
|
224
|
-
const hook = buildPrePushHook(
|
|
313
|
+
const hook = buildPrePushHook(RENDER);
|
|
225
314
|
// Assert presence with toContain (precise matcher); index comparison is
|
|
226
315
|
// safe only because both substrings are guaranteed present by the
|
|
227
316
|
// toContain assertions above.
|
|
@@ -235,12 +324,12 @@ describe('buildPrePushHook', () => {
|
|
|
235
324
|
});
|
|
236
325
|
// totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
|
|
237
326
|
it('contains $TOTEM_CMD doctor --claim-discipline --strict (Proposal 279 Q3)', () => {
|
|
238
|
-
const hook = buildPrePushHook(
|
|
327
|
+
const hook = buildPrePushHook(RENDER);
|
|
239
328
|
expect(hook).toContain('$TOTEM_CMD doctor --claim-discipline --strict');
|
|
240
329
|
});
|
|
241
330
|
// totem-context: hook-content assertion (mmnto-ai/totem#2002 — diff-scope narrowing)
|
|
242
331
|
it('contains --scope-to-diff on the claim-discipline invocation (mmnto-ai/totem#2002)', () => {
|
|
243
|
-
const hook = buildPrePushHook(
|
|
332
|
+
const hook = buildPrePushHook(RENDER);
|
|
244
333
|
// The flag must appear on the same `doctor --claim-discipline` line so the
|
|
245
334
|
// hook narrows the WWND scan to diff-touched files. Standing-gate full scan
|
|
246
335
|
// produced N=8 false-positive bypasses in <24hr on a pre-existing surface
|
|
@@ -249,7 +338,7 @@ describe('buildPrePushHook', () => {
|
|
|
249
338
|
});
|
|
250
339
|
// totem-context: hook-content assertion (mmnto-ai/totem#2002 — bootstrap defensive degrade)
|
|
251
340
|
it('defensively degrades --scope-to-diff when CLI predates 1.47.0 (cohort bootstrap safety)', () => {
|
|
252
|
-
const hook = buildPrePushHook(
|
|
341
|
+
const hook = buildPrePushHook(RENDER);
|
|
253
342
|
// The hook MUST detect --scope-to-diff support via --help and fall back to
|
|
254
343
|
// standing-scan when the resolved CLI doesn't carry the flag. Required so a
|
|
255
344
|
// cohort agent whose global @mmnto/cli predates 1.47.0 doesn't fail commander
|
|
@@ -270,13 +359,13 @@ describe('buildPrePushHook', () => {
|
|
|
270
359
|
});
|
|
271
360
|
// totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
|
|
272
361
|
it('gates claim-discipline on at-least-one in-scope public surface existing', () => {
|
|
273
|
-
const hook = buildPrePushHook(
|
|
362
|
+
const hook = buildPrePushHook(RENDER);
|
|
274
363
|
// The gate fires when any of README.md, AGENTS.md, design-tenets.md, or docs/wiki/ exists
|
|
275
364
|
expect(hook).toMatch(/-f "README\.md".*-f "AGENTS\.md".*-f "design-tenets\.md".*-d "docs\/wiki"/s);
|
|
276
365
|
});
|
|
277
366
|
// totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
|
|
278
367
|
it('slots claim-discipline after verify-badges in the pre-push sequence', () => {
|
|
279
|
-
const hook = buildPrePushHook(
|
|
368
|
+
const hook = buildPrePushHook(RENDER);
|
|
280
369
|
const verifyBadgesIdx = hook.indexOf('$TOTEM_CMD verify-badges');
|
|
281
370
|
const claimDisciplineIdx = hook.indexOf('$TOTEM_CMD doctor --claim-discipline');
|
|
282
371
|
expect(verifyBadgesIdx).toBeGreaterThan(-1);
|
|
@@ -285,36 +374,36 @@ describe('buildPrePushHook', () => {
|
|
|
285
374
|
});
|
|
286
375
|
// totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
|
|
287
376
|
it('mentions TOTEM_GATE_BYPASS_JUSTIFICATION as the bypass mechanism (Proposal 279 Q3 standardized convention)', () => {
|
|
288
|
-
const hook = buildPrePushHook(
|
|
377
|
+
const hook = buildPrePushHook(RENDER);
|
|
289
378
|
expect(hook).toContain('TOTEM_GATE_BYPASS_JUSTIFICATION');
|
|
290
379
|
});
|
|
291
380
|
it('does NOT contain old flag-file references', () => {
|
|
292
|
-
const hook = buildPrePushHook(
|
|
381
|
+
const hook = buildPrePushHook(RENDER);
|
|
293
382
|
expect(hook).not.toContain('.lint-passed');
|
|
294
383
|
expect(hook).not.toContain('.shield-passed');
|
|
295
384
|
expect(hook).not.toContain('.target-globs');
|
|
296
385
|
});
|
|
297
386
|
it('does NOT contain merge-base (no ancestry checks)', () => {
|
|
298
|
-
const hook = buildPrePushHook(
|
|
387
|
+
const hook = buildPrePushHook(RENDER);
|
|
299
388
|
expect(hook).not.toContain('merge-base');
|
|
300
389
|
});
|
|
301
390
|
it('uses the resolve block', () => {
|
|
302
|
-
const hook = buildPrePushHook(
|
|
391
|
+
const hook = buildPrePushHook(RENDER);
|
|
303
392
|
expect(hook).toContain('TOTEM_CMD=');
|
|
304
393
|
expect(hook).toContain('command -v totem');
|
|
305
394
|
});
|
|
306
395
|
it('uses POSIX-compatible syntax only', () => {
|
|
307
|
-
const hook = buildPrePushHook(
|
|
396
|
+
const hook = buildPrePushHook(RENDER);
|
|
308
397
|
// Must use [ ] not [[ ]]
|
|
309
398
|
expect(hook).not.toContain('[[');
|
|
310
399
|
expect(hook).not.toContain(']]');
|
|
311
400
|
});
|
|
312
401
|
it('embeds the provided fallback command', () => {
|
|
313
|
-
const hook = buildPrePushHook('yarn dlx @mmnto/cli');
|
|
402
|
+
const hook = buildPrePushHook({ ...RENDER, fallbackCmd: 'yarn dlx @mmnto/cli' });
|
|
314
403
|
expect(hook).toContain('yarn dlx @mmnto/cli');
|
|
315
404
|
});
|
|
316
405
|
it('includes format:check before push', () => {
|
|
317
|
-
const hook = buildPrePushHook(
|
|
406
|
+
const hook = buildPrePushHook(RENDER);
|
|
318
407
|
expect(hook).toContain('format:check');
|
|
319
408
|
expect(hook).toContain('Formatting check failed');
|
|
320
409
|
});
|
|
@@ -330,7 +419,7 @@ describe('installGitHook', () => {
|
|
|
330
419
|
cleanTmpDir(tmpDir);
|
|
331
420
|
});
|
|
332
421
|
it('creates a new hook file when none exists', () => {
|
|
333
|
-
const content = buildPreCommitHook();
|
|
422
|
+
const content = buildPreCommitHook(RENDER);
|
|
334
423
|
const result = installGitHook(hooksDir, 'pre-commit', content, TOTEM_PRECOMMIT_MARKER);
|
|
335
424
|
expect(result).toBe('installed');
|
|
336
425
|
expect(fs.existsSync(path.join(hooksDir, 'pre-commit'))).toBe(true);
|
|
@@ -339,11 +428,11 @@ describe('installGitHook', () => {
|
|
|
339
428
|
});
|
|
340
429
|
it('creates parent directories as needed', () => {
|
|
341
430
|
expect(fs.existsSync(hooksDir)).toBe(false);
|
|
342
|
-
installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
|
|
431
|
+
installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
|
|
343
432
|
expect(fs.existsSync(hooksDir)).toBe(true);
|
|
344
433
|
});
|
|
345
434
|
it('returns exists when marker is already present (idempotent)', () => {
|
|
346
|
-
const content = buildPreCommitHook();
|
|
435
|
+
const content = buildPreCommitHook(RENDER);
|
|
347
436
|
installGitHook(hooksDir, 'pre-commit', content, TOTEM_PRECOMMIT_MARKER);
|
|
348
437
|
const result = installGitHook(hooksDir, 'pre-commit', content, TOTEM_PRECOMMIT_MARKER);
|
|
349
438
|
expect(result).toBe('exists');
|
|
@@ -353,7 +442,7 @@ describe('installGitHook', () => {
|
|
|
353
442
|
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
354
443
|
const userHook = '#!/bin/sh\necho "user hook"\n';
|
|
355
444
|
fs.writeFileSync(hookPath, userHook);
|
|
356
|
-
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
|
|
445
|
+
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
|
|
357
446
|
expect(result).toBe('appended');
|
|
358
447
|
const written = fs.readFileSync(hookPath, 'utf-8');
|
|
359
448
|
expect(written).toContain('echo "user hook"');
|
|
@@ -366,13 +455,13 @@ describe('installGitHook', () => {
|
|
|
366
455
|
const hookPath = path.join(hooksDir, 'pre-push');
|
|
367
456
|
const userHook = '#!/bin/sh\nrun_my_tests\n';
|
|
368
457
|
fs.writeFileSync(hookPath, userHook);
|
|
369
|
-
installGitHook(hooksDir, 'pre-push', buildPrePushHook(
|
|
458
|
+
installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER);
|
|
370
459
|
const written = fs.readFileSync(hookPath, 'utf-8');
|
|
371
460
|
expect(written).toContain('run_my_tests');
|
|
372
461
|
expect(written).toContain(TOTEM_PREPUSH_MARKER);
|
|
373
462
|
});
|
|
374
463
|
it('is idempotent — double install does not duplicate', () => {
|
|
375
|
-
const content = buildPrePushHook(
|
|
464
|
+
const content = buildPrePushHook(RENDER);
|
|
376
465
|
installGitHook(hooksDir, 'pre-push', content, TOTEM_PREPUSH_MARKER);
|
|
377
466
|
installGitHook(hooksDir, 'pre-push', content, TOTEM_PREPUSH_MARKER);
|
|
378
467
|
const written = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
@@ -384,7 +473,7 @@ describe('installGitHook', () => {
|
|
|
384
473
|
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
385
474
|
const nodeHook = '#!/usr/bin/env node\nconsole.log("lint");\n'; // totem-ignore — test fixture, not real logging
|
|
386
475
|
fs.writeFileSync(hookPath, nodeHook);
|
|
387
|
-
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
|
|
476
|
+
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
|
|
388
477
|
expect(result).toBe('skipped-non-shell');
|
|
389
478
|
const written = fs.readFileSync(hookPath, 'utf-8');
|
|
390
479
|
expect(written).toBe(nodeHook); // File untouched
|
|
@@ -394,7 +483,7 @@ describe('installGitHook', () => {
|
|
|
394
483
|
const hookPath = path.join(hooksDir, 'pre-push');
|
|
395
484
|
const pythonHook = '#!/usr/bin/env python3\nimport subprocess\n';
|
|
396
485
|
fs.writeFileSync(hookPath, pythonHook);
|
|
397
|
-
const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(
|
|
486
|
+
const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER);
|
|
398
487
|
expect(result).toBe('skipped-non-shell');
|
|
399
488
|
const written = fs.readFileSync(hookPath, 'utf-8');
|
|
400
489
|
expect(written).toBe(pythonHook); // File untouched
|
|
@@ -403,33 +492,33 @@ describe('installGitHook', () => {
|
|
|
403
492
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
404
493
|
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
405
494
|
fs.writeFileSync(hookPath, '#!/bin/sh\necho "existing"\n');
|
|
406
|
-
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
|
|
495
|
+
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
|
|
407
496
|
expect(result).toBe('appended');
|
|
408
497
|
});
|
|
409
498
|
it('appends normally to bash hooks', () => {
|
|
410
499
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
411
500
|
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
412
501
|
fs.writeFileSync(hookPath, '#!/bin/bash\necho "existing"\n');
|
|
413
|
-
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
|
|
502
|
+
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
|
|
414
503
|
expect(result).toBe('appended');
|
|
415
504
|
});
|
|
416
505
|
it('appends normally to env bash hooks', () => {
|
|
417
506
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
418
507
|
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
419
508
|
fs.writeFileSync(hookPath, '#!/usr/bin/env bash\necho "existing"\n');
|
|
420
|
-
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
|
|
509
|
+
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
|
|
421
510
|
expect(result).toBe('appended');
|
|
422
511
|
});
|
|
423
512
|
it('appends to hooks without a shebang (plain text)', () => {
|
|
424
513
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
425
514
|
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
426
515
|
fs.writeFileSync(hookPath, 'echo "no shebang"\n');
|
|
427
|
-
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
|
|
516
|
+
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
|
|
428
517
|
expect(result).toBe('appended');
|
|
429
518
|
});
|
|
430
519
|
it('handles pre-commit and pre-push independently', () => {
|
|
431
|
-
installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
|
|
432
|
-
installGitHook(hooksDir, 'pre-push', buildPrePushHook(
|
|
520
|
+
installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
|
|
521
|
+
installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER);
|
|
433
522
|
expect(fs.existsSync(path.join(hooksDir, 'pre-commit'))).toBe(true);
|
|
434
523
|
expect(fs.existsSync(path.join(hooksDir, 'pre-push'))).toBe(true);
|
|
435
524
|
const preCommit = fs.readFileSync(path.join(hooksDir, 'pre-commit'), 'utf-8');
|
|
@@ -444,7 +533,7 @@ describe('installGitHook', () => {
|
|
|
444
533
|
const hookPath = path.join(hooksDir, 'pre-push');
|
|
445
534
|
// Write an old-format hook with the marker
|
|
446
535
|
fs.writeFileSync(hookPath, `#!/bin/sh\n# ${TOTEM_PREPUSH_MARKER}\n$TOTEM_CMD lint\n`);
|
|
447
|
-
const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(
|
|
536
|
+
const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER, true);
|
|
448
537
|
expect(result).toBe('overwritten');
|
|
449
538
|
const content = fs.readFileSync(hookPath, 'utf-8');
|
|
450
539
|
expect(content).toContain('verify-manifest'); // new format
|
|
@@ -453,7 +542,7 @@ describe('installGitHook', () => {
|
|
|
453
542
|
it('returns exists when on-disk content already matches canonical (idempotent)', () => {
|
|
454
543
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
455
544
|
const hookPath = path.join(hooksDir, 'pre-push');
|
|
456
|
-
const canonical = buildPrePushHook(
|
|
545
|
+
const canonical = buildPrePushHook(RENDER);
|
|
457
546
|
fs.writeFileSync(hookPath, canonical);
|
|
458
547
|
const result = installGitHook(hooksDir, 'pre-push', canonical, TOTEM_PREPUSH_MARKER);
|
|
459
548
|
expect(result).toBe('exists');
|
|
@@ -464,7 +553,7 @@ describe('installGitHook', () => {
|
|
|
464
553
|
// A totem-owned whole file frozen at an older generator's output — but it carries
|
|
465
554
|
// the bounded end marker, so drift-repair may upgrade it in place without --force.
|
|
466
555
|
fs.writeFileSync(hookPath, `#!/bin/sh\n# ${TOTEM_PREPUSH_MARKER}\n$TOTEM_CMD lint\n# ${TOTEM_PREPUSH_END}\n`);
|
|
467
|
-
const canonical = buildPrePushHook(
|
|
556
|
+
const canonical = buildPrePushHook(RENDER);
|
|
468
557
|
const result = installGitHook(hooksDir, 'pre-push', canonical, TOTEM_PREPUSH_MARKER, false, TOTEM_PREPUSH_END);
|
|
469
558
|
expect(result).toBe('overwritten');
|
|
470
559
|
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(canonical);
|
|
@@ -473,7 +562,7 @@ describe('installGitHook', () => {
|
|
|
473
562
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
474
563
|
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
475
564
|
fs.writeFileSync(hookPath, `#!/bin/sh\n# ${TOTEM_PRECOMMIT_MARKER}\nstale body\n# ${TOTEM_PRECOMMIT_END}\n`);
|
|
476
|
-
const canonical = buildPreCommitHook();
|
|
565
|
+
const canonical = buildPreCommitHook(RENDER);
|
|
477
566
|
const result = installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
478
567
|
expect(result).toBe('overwritten');
|
|
479
568
|
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(canonical);
|
|
@@ -486,7 +575,7 @@ describe('installGitHook', () => {
|
|
|
486
575
|
// marker → the region cannot be bounded → drift-repair declines (legacy path).
|
|
487
576
|
const legacy = `#!/bin/sh\n# ${TOTEM_PRECOMMIT_MARKER}\nstale legacy body\n`;
|
|
488
577
|
fs.writeFileSync(hookPath, legacy);
|
|
489
|
-
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
578
|
+
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
490
579
|
expect(result).toBe('exists');
|
|
491
580
|
// Left untouched — the legacy hook takes one `totem hook install --force`.
|
|
492
581
|
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(legacy);
|
|
@@ -498,7 +587,7 @@ describe('installGitHook', () => {
|
|
|
498
587
|
// block is bounded by an end marker.
|
|
499
588
|
const userThenTotem = `#!/bin/sh\nrun_my_tests\n# ${TOTEM_PREPUSH_MARKER}\nold content\n# ${TOTEM_PREPUSH_END}\n`;
|
|
500
589
|
fs.writeFileSync(hookPath, userThenTotem);
|
|
501
|
-
const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(
|
|
590
|
+
const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER, false, TOTEM_PREPUSH_END);
|
|
502
591
|
expect(result).toBe('exists');
|
|
503
592
|
// Left untouched — the user's hook is preserved verbatim.
|
|
504
593
|
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(userThenTotem);
|
|
@@ -510,7 +599,7 @@ describe('installGitHook', () => {
|
|
|
510
599
|
// whole-file overwrite would clobber it, so the end-marker guard must decline.
|
|
511
600
|
const staleWithTrailingUser = `#!/bin/sh\n# ${TOTEM_PRECOMMIT_MARKER}\nstale body\n# ${TOTEM_PRECOMMIT_END}\necho "my pre-commit notice"\n`;
|
|
512
601
|
fs.writeFileSync(hookPath, staleWithTrailingUser);
|
|
513
|
-
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
602
|
+
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
514
603
|
expect(result).toBe('exists');
|
|
515
604
|
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(staleWithTrailingUser);
|
|
516
605
|
});
|
|
@@ -519,7 +608,7 @@ describe('installGitHook', () => {
|
|
|
519
608
|
const hookPath = path.join(hooksDir, 'pre-push');
|
|
520
609
|
const staleWithTrailingUser = `#!/bin/sh\n# ${TOTEM_PREPUSH_MARKER}\nstale body\n# ${TOTEM_PREPUSH_END}\necho "my pre-push notice"\n`;
|
|
521
610
|
fs.writeFileSync(hookPath, staleWithTrailingUser);
|
|
522
|
-
const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(
|
|
611
|
+
const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER, false, TOTEM_PREPUSH_END);
|
|
523
612
|
expect(result).toBe('exists');
|
|
524
613
|
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(staleWithTrailingUser);
|
|
525
614
|
});
|
|
@@ -530,7 +619,7 @@ describe('installGitHook', () => {
|
|
|
530
619
|
// whole-file overwrite would clobber it, so the end-marker guard must decline.
|
|
531
620
|
const staleWithTrailingUser = `#!/bin/sh\n# ${TOTEM_HOOK_MARKER}\nstale body\n# ${TOTEM_HOOK_END}\necho "my deploy notice"\n`;
|
|
532
621
|
fs.writeFileSync(hookPath, staleWithTrailingUser);
|
|
533
|
-
const result = installGitHook(hooksDir, 'post-merge', buildHookContent(
|
|
622
|
+
const result = installGitHook(hooksDir, 'post-merge', buildHookContent(RENDER), TOTEM_HOOK_MARKER, false, TOTEM_HOOK_END);
|
|
534
623
|
expect(result).toBe('exists');
|
|
535
624
|
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(staleWithTrailingUser);
|
|
536
625
|
});
|
|
@@ -545,7 +634,7 @@ describe('generateHookHelpers', () => {
|
|
|
545
634
|
cleanTmpDir(tmpDir);
|
|
546
635
|
});
|
|
547
636
|
it('creates .totem/hooks/ directory and writes all 4 .sh files', () => {
|
|
548
|
-
generateHookHelpers(tmpDir,
|
|
637
|
+
generateHookHelpers(tmpDir, RENDER);
|
|
549
638
|
const hooksDir = path.join(tmpDir, '.totem', 'hooks');
|
|
550
639
|
expect(fs.existsSync(path.join(hooksDir, 'post-merge.sh'))).toBe(true);
|
|
551
640
|
expect(fs.existsSync(path.join(hooksDir, 'post-checkout.sh'))).toBe(true);
|
|
@@ -553,7 +642,7 @@ describe('generateHookHelpers', () => {
|
|
|
553
642
|
expect(fs.existsSync(path.join(hooksDir, 'pre-push.sh'))).toBe(true);
|
|
554
643
|
});
|
|
555
644
|
it('generated scripts contain expected content', () => {
|
|
556
|
-
generateHookHelpers(tmpDir,
|
|
645
|
+
generateHookHelpers(tmpDir, RENDER);
|
|
557
646
|
const hooksDir = path.join(tmpDir, '.totem', 'hooks');
|
|
558
647
|
const postMerge = fs.readFileSync(path.join(hooksDir, 'post-merge.sh'), 'utf-8');
|
|
559
648
|
expect(postMerge).toContain('command -v totem');
|
|
@@ -563,8 +652,8 @@ describe('generateHookHelpers', () => {
|
|
|
563
652
|
expect(prePush).toContain('verify-manifest');
|
|
564
653
|
});
|
|
565
654
|
it('is idempotent — calling twice does not error', () => {
|
|
566
|
-
generateHookHelpers(tmpDir,
|
|
567
|
-
generateHookHelpers(tmpDir,
|
|
655
|
+
generateHookHelpers(tmpDir, RENDER);
|
|
656
|
+
generateHookHelpers(tmpDir, RENDER);
|
|
568
657
|
const hooksDir = path.join(tmpDir, '.totem', 'hooks');
|
|
569
658
|
expect(fs.existsSync(path.join(hooksDir, 'post-merge.sh'))).toBe(true);
|
|
570
659
|
});
|
|
@@ -578,14 +667,14 @@ describe('installHooksNonInteractive', () => {
|
|
|
578
667
|
afterEach(() => {
|
|
579
668
|
cleanTmpDir(tmpDir);
|
|
580
669
|
});
|
|
581
|
-
it('returns null when not a git repo', () => {
|
|
582
|
-
const result = installHooksNonInteractive(tmpDir);
|
|
670
|
+
it('returns null when not a git repo', async () => {
|
|
671
|
+
const result = await installHooksNonInteractive(tmpDir);
|
|
583
672
|
expect(result).toBeNull();
|
|
584
673
|
});
|
|
585
|
-
it('installs all four hooks in a git repo', () => {
|
|
674
|
+
it('installs all four hooks in a git repo', async () => {
|
|
586
675
|
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
587
676
|
fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
|
|
588
|
-
const result = installHooksNonInteractive(tmpDir);
|
|
677
|
+
const result = await installHooksNonInteractive(tmpDir);
|
|
589
678
|
expect(result).not.toBeNull();
|
|
590
679
|
expect(result.preCommit).toBe('installed');
|
|
591
680
|
expect(result.prePush).toBe('installed');
|
|
@@ -598,33 +687,33 @@ describe('installHooksNonInteractive', () => {
|
|
|
598
687
|
expect(fs.existsSync(path.join(hooksDir, 'post-merge'))).toBe(true);
|
|
599
688
|
expect(fs.existsSync(path.join(hooksDir, 'post-checkout'))).toBe(true);
|
|
600
689
|
});
|
|
601
|
-
it('is idempotent — second call returns exists for all hooks', () => {
|
|
690
|
+
it('is idempotent — second call returns exists for all hooks', async () => {
|
|
602
691
|
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
603
692
|
fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
|
|
604
|
-
installHooksNonInteractive(tmpDir);
|
|
605
|
-
const result = installHooksNonInteractive(tmpDir);
|
|
693
|
+
await installHooksNonInteractive(tmpDir);
|
|
694
|
+
const result = await installHooksNonInteractive(tmpDir);
|
|
606
695
|
expect(result).not.toBeNull();
|
|
607
696
|
expect(result.preCommit).toBe('exists');
|
|
608
697
|
expect(result.prePush).toBe('exists');
|
|
609
698
|
expect(result.postMerge).toBe('exists');
|
|
610
699
|
expect(result.postCheckout).toBe('exists');
|
|
611
700
|
});
|
|
612
|
-
it('returns null and generates helper scripts when hook manager is detected', () => {
|
|
701
|
+
it('returns null and generates helper scripts when hook manager is detected', async () => {
|
|
613
702
|
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
614
703
|
fs.mkdirSync(path.join(tmpDir, '.husky'), { recursive: true });
|
|
615
|
-
const result = installHooksNonInteractive(tmpDir);
|
|
704
|
+
const result = await installHooksNonInteractive(tmpDir);
|
|
616
705
|
expect(result).toBeNull();
|
|
617
706
|
// Verify helper scripts were generated
|
|
618
707
|
const hooksDir = path.join(tmpDir, '.totem', 'hooks');
|
|
619
708
|
expect(fs.existsSync(path.join(hooksDir, 'post-merge.sh'))).toBe(true);
|
|
620
709
|
expect(fs.existsSync(path.join(hooksDir, 'pre-push.sh'))).toBe(true);
|
|
621
710
|
});
|
|
622
|
-
it('installs hooks at git root when run from a subdirectory', () => {
|
|
711
|
+
it('installs hooks at git root when run from a subdirectory', async () => {
|
|
623
712
|
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
624
713
|
fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
|
|
625
714
|
const subDir = path.join(tmpDir, 'packages', 'cli');
|
|
626
715
|
fs.mkdirSync(subDir, { recursive: true });
|
|
627
|
-
const result = installHooksNonInteractive(subDir);
|
|
716
|
+
const result = await installHooksNonInteractive(subDir);
|
|
628
717
|
expect(result).not.toBeNull();
|
|
629
718
|
expect(result.preCommit).toBe('installed');
|
|
630
719
|
expect(result.prePush).toBe('installed');
|
|
@@ -638,31 +727,31 @@ describe('installHooksNonInteractive', () => {
|
|
|
638
727
|
expect(fs.existsSync(path.join(hooksDir, 'post-checkout'))).toBe(true);
|
|
639
728
|
expect(fs.existsSync(path.join(subDir, '.git'))).toBe(false);
|
|
640
729
|
});
|
|
641
|
-
it('check passes from subdirectory after install', () => {
|
|
730
|
+
it('check passes from subdirectory after install', async () => {
|
|
642
731
|
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
643
732
|
fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
|
|
644
733
|
const subDir = path.join(tmpDir, 'packages', 'cli');
|
|
645
734
|
fs.mkdirSync(subDir, { recursive: true });
|
|
646
|
-
installHooksNonInteractive(subDir);
|
|
735
|
+
await installHooksNonInteractive(subDir);
|
|
647
736
|
expect(checkHooksInstalled(subDir)).toBe(true);
|
|
648
737
|
});
|
|
649
|
-
it('appends to existing hooks without clobbering', () => {
|
|
738
|
+
it('appends to existing hooks without clobbering', async () => {
|
|
650
739
|
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
651
740
|
fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
|
|
652
741
|
fs.writeFileSync(path.join(tmpDir, '.git', 'hooks', 'pre-push'), '#!/bin/sh\nrun_my_tests\n');
|
|
653
|
-
const result = installHooksNonInteractive(tmpDir);
|
|
742
|
+
const result = await installHooksNonInteractive(tmpDir);
|
|
654
743
|
expect(result.prePush).toBe('appended');
|
|
655
744
|
const content = fs.readFileSync(path.join(tmpDir, '.git', 'hooks', 'pre-push'), 'utf-8');
|
|
656
745
|
expect(content).toContain('run_my_tests');
|
|
657
746
|
expect(content).toContain(TOTEM_PREPUSH_MARKER);
|
|
658
747
|
});
|
|
659
|
-
it('force-overwrites all hooks when force is true', () => {
|
|
748
|
+
it('force-overwrites all hooks when force is true', async () => {
|
|
660
749
|
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
661
750
|
fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
|
|
662
751
|
// First install — creates hooks
|
|
663
|
-
installHooksNonInteractive(tmpDir);
|
|
752
|
+
await installHooksNonInteractive(tmpDir);
|
|
664
753
|
// Second install with force — overwrites all hooks
|
|
665
|
-
const result = installHooksNonInteractive(tmpDir, true);
|
|
754
|
+
const result = await installHooksNonInteractive(tmpDir, true);
|
|
666
755
|
expect(result).not.toBeNull();
|
|
667
756
|
expect(result.preCommit).toBe('overwritten');
|
|
668
757
|
expect(result.prePush).toBe('overwritten');
|
|
@@ -684,14 +773,14 @@ describe('checkHooksInstalled', () => {
|
|
|
684
773
|
it('returns false when no hooks are installed', () => {
|
|
685
774
|
expect(checkHooksInstalled(tmpDir)).toBe(false);
|
|
686
775
|
});
|
|
687
|
-
it('returns true when all hooks are installed', () => {
|
|
688
|
-
installHooksNonInteractive(tmpDir);
|
|
776
|
+
it('returns true when all hooks are installed', async () => {
|
|
777
|
+
await installHooksNonInteractive(tmpDir);
|
|
689
778
|
expect(checkHooksInstalled(tmpDir)).toBe(true);
|
|
690
779
|
});
|
|
691
780
|
it('returns false when only some hooks are installed', () => {
|
|
692
781
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
693
782
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
694
|
-
installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
|
|
783
|
+
installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
|
|
695
784
|
// Missing pre-push and post-merge
|
|
696
785
|
expect(checkHooksInstalled(tmpDir)).toBe(false);
|
|
697
786
|
});
|
|
@@ -716,8 +805,8 @@ describe('post-merge hook content', () => {
|
|
|
716
805
|
afterEach(() => {
|
|
717
806
|
cleanTmpDir(tmpDir);
|
|
718
807
|
});
|
|
719
|
-
it('generates post-merge hook with git diff-tree lesson check', () => {
|
|
720
|
-
installHooksNonInteractive(tmpDir);
|
|
808
|
+
it('generates post-merge hook with git diff-tree lesson check', async () => {
|
|
809
|
+
await installHooksNonInteractive(tmpDir);
|
|
721
810
|
const hookPath = path.join(tmpDir, '.git', 'hooks', 'post-merge');
|
|
722
811
|
const content = fs.readFileSync(hookPath, 'utf-8');
|
|
723
812
|
expect(content).toContain('ORIG_HEAD');
|
|
@@ -728,17 +817,17 @@ describe('post-merge hook content', () => {
|
|
|
728
817
|
expect(content).toContain('[totem] post-merge hook');
|
|
729
818
|
expect(content).toContain('[totem] end post-merge');
|
|
730
819
|
});
|
|
731
|
-
it('passes quiet flag to sync command in post-merge hook', () => {
|
|
732
|
-
installHooksNonInteractive(tmpDir);
|
|
820
|
+
it('passes quiet flag to sync command in post-merge hook', async () => {
|
|
821
|
+
await installHooksNonInteractive(tmpDir);
|
|
733
822
|
const hookPath = path.join(tmpDir, '.git', 'hooks', 'post-merge');
|
|
734
823
|
const content = fs.readFileSync(hookPath, 'utf-8');
|
|
735
824
|
expect(content).toContain('--quiet');
|
|
736
825
|
});
|
|
737
|
-
it('preserves existing hooks when appending post-merge block', () => {
|
|
826
|
+
it('preserves existing hooks when appending post-merge block', async () => {
|
|
738
827
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
739
828
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
740
829
|
fs.writeFileSync(path.join(hooksDir, 'post-merge'), '#!/bin/sh\necho "my custom hook"\n');
|
|
741
|
-
installHooksNonInteractive(tmpDir);
|
|
830
|
+
await installHooksNonInteractive(tmpDir);
|
|
742
831
|
const content = fs.readFileSync(path.join(hooksDir, 'post-merge'), 'utf-8');
|
|
743
832
|
expect(content).toContain('echo "my custom hook"');
|
|
744
833
|
expect(content).toContain('[totem] post-merge hook');
|
|
@@ -757,8 +846,8 @@ describe('post-checkout hook content', () => {
|
|
|
757
846
|
afterEach(() => {
|
|
758
847
|
cleanTmpDir(tmpDir);
|
|
759
848
|
});
|
|
760
|
-
it('generates post-checkout hook with branch switch guard', () => {
|
|
761
|
-
installHooksNonInteractive(tmpDir);
|
|
849
|
+
it('generates post-checkout hook with branch switch guard', async () => {
|
|
850
|
+
await installHooksNonInteractive(tmpDir);
|
|
762
851
|
const hookPath = path.join(tmpDir, '.git', 'hooks', 'post-checkout');
|
|
763
852
|
const content = fs.readFileSync(hookPath, 'utf-8');
|
|
764
853
|
expect(content).toContain('$3');
|
|
@@ -767,18 +856,18 @@ describe('post-checkout hook content', () => {
|
|
|
767
856
|
expect(content).toContain('[totem] end post-checkout');
|
|
768
857
|
});
|
|
769
858
|
it('handles null SHA for initial checkout', () => {
|
|
770
|
-
const hook = buildPostCheckoutHookContent(
|
|
859
|
+
const hook = buildPostCheckoutHookContent(RENDER);
|
|
771
860
|
expect(hook).toContain('0000000000000000000000000000000000000000');
|
|
772
861
|
expect(hook).toContain('.totem');
|
|
773
862
|
});
|
|
774
|
-
it('uses quiet sync command', () => {
|
|
775
|
-
installHooksNonInteractive(tmpDir);
|
|
863
|
+
it('uses quiet sync command', async () => {
|
|
864
|
+
await installHooksNonInteractive(tmpDir);
|
|
776
865
|
const hookPath = path.join(tmpDir, '.git', 'hooks', 'post-checkout');
|
|
777
866
|
const content = fs.readFileSync(hookPath, 'utf-8');
|
|
778
867
|
expect(content).toContain('--quiet');
|
|
779
868
|
});
|
|
780
|
-
it('includes post-checkout in non-interactive install', () => {
|
|
781
|
-
const result = installHooksNonInteractive(tmpDir);
|
|
869
|
+
it('includes post-checkout in non-interactive install', async () => {
|
|
870
|
+
const result = await installHooksNonInteractive(tmpDir);
|
|
782
871
|
expect(result).not.toBeNull();
|
|
783
872
|
expect(result.postCheckout).toBe('installed');
|
|
784
873
|
});
|
|
@@ -787,7 +876,7 @@ describe('post-checkout hook content', () => {
|
|
|
787
876
|
// ─── totem-status refresh-gh wiring (mmnto-ai/totem#2556) ─
|
|
788
877
|
describe('post-merge hook fires totem-status refresh-gh', () => {
|
|
789
878
|
it('invokes refresh-gh presence-gated and backgrounded (mmnto-ai/totem-status#127 C3)', () => {
|
|
790
|
-
const hook = buildHookContent(
|
|
879
|
+
const hook = buildHookContent(RENDER);
|
|
791
880
|
// Presence gate (absent binary = zero noise) AND primary-checkout gate: in a
|
|
792
881
|
// linked worktree .git is a pointer FILE and a backgrounded child inheriting
|
|
793
882
|
// the worktree cwd holds a Windows directory lock that breaks worktree removal.
|
|
@@ -799,7 +888,7 @@ describe('post-merge hook fires totem-status refresh-gh', () => {
|
|
|
799
888
|
expect(hook.trimEnd().endsWith(`# ${TOTEM_HOOK_END}`)).toBe(true);
|
|
800
889
|
});
|
|
801
890
|
it('#2570: stamps each firing and hands the child the same log (observability leg)', () => {
|
|
802
|
-
const hook = buildHookContent(
|
|
891
|
+
const hook = buildHookContent(RENDER);
|
|
803
892
|
// Repo-local log inside .git; the stamp carries the firing site, cwd, and
|
|
804
893
|
// WHICH binary resolved (shell search order can be shadowed by a
|
|
805
894
|
// checkout-local exe on Windows).
|
|
@@ -822,7 +911,7 @@ describe('post-merge hook fires totem-status refresh-gh', () => {
|
|
|
822
911
|
// (mmnto-ai/totem-status#127; sibling of mmnto-ai/totem#2556.) The durable
|
|
823
912
|
// obligation store gets the same post-merge moment as the GH snapshot.
|
|
824
913
|
it('also fires refresh-obligation-store under the SAME gate and log (mmnto-ai/totem-status#127)', () => {
|
|
825
|
-
const hook = buildHookContent(
|
|
914
|
+
const hook = buildHookContent(RENDER);
|
|
826
915
|
// One gate, not two: both verbs live inside the single presence +
|
|
827
916
|
// primary-checkout `if`, so a worktree/non-adopter skips BOTH.
|
|
828
917
|
const gateIdx = hook.indexOf('if [ -d .git ] && command -v totem-status >/dev/null 2>&1; then');
|
|
@@ -905,7 +994,7 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
|
|
|
905
994
|
const repo = path.join(tmpDir, 'repo');
|
|
906
995
|
fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
|
|
907
996
|
const hookPath = path.join(repo, 'post-merge');
|
|
908
|
-
fs.writeFileSync(hookPath, buildHookContent(
|
|
997
|
+
fs.writeFileSync(hookPath, buildHookContent(RENDER), { mode: 0o755 });
|
|
909
998
|
execSync('sh ./post-merge', {
|
|
910
999
|
cwd: repo,
|
|
911
1000
|
env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` },
|
|
@@ -935,7 +1024,7 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
|
|
|
935
1024
|
// append; falsification round, MAJOR 1).
|
|
936
1025
|
fs.mkdirSync(path.join(repo, '.git', 'totem-status-refresh-hook.log'), { recursive: true });
|
|
937
1026
|
const hookPath = path.join(repo, 'post-merge');
|
|
938
|
-
fs.writeFileSync(hookPath, buildHookContent(
|
|
1027
|
+
fs.writeFileSync(hookPath, buildHookContent(RENDER), { mode: 0o755 });
|
|
939
1028
|
const result = spawnSync('sh', ['./post-merge'], {
|
|
940
1029
|
cwd: repo,
|
|
941
1030
|
env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` },
|
|
@@ -953,7 +1042,7 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
|
|
|
953
1042
|
// Linked-worktree shape: .git is a pointer FILE, not a directory.
|
|
954
1043
|
fs.writeFileSync(path.join(repo, '.git'), 'gitdir: /elsewhere/.git/worktrees/x\n');
|
|
955
1044
|
const hookPath = path.join(repo, 'post-merge');
|
|
956
|
-
fs.writeFileSync(hookPath, buildHookContent(
|
|
1045
|
+
fs.writeFileSync(hookPath, buildHookContent(RENDER), { mode: 0o755 });
|
|
957
1046
|
execSync('sh ./post-merge', {
|
|
958
1047
|
cwd: repo,
|
|
959
1048
|
env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` },
|
|
@@ -967,14 +1056,14 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
|
|
|
967
1056
|
});
|
|
968
1057
|
describe('sync-log redirect resolves the git dir (worktree-safe)', () => {
|
|
969
1058
|
it('post-merge hook derives the log path from git rev-parse --git-dir', () => {
|
|
970
|
-
const hook = buildHookContent(
|
|
1059
|
+
const hook = buildHookContent(RENDER);
|
|
971
1060
|
expect(hook).toContain('GIT_DIR_RESOLVED=$(git rev-parse --git-dir 2>/dev/null || echo .git)');
|
|
972
1061
|
expect(hook).toContain('> "$GIT_DIR_RESOLVED/totem-sync.log"');
|
|
973
1062
|
// The hardcoded `.git/`-as-directory redirect must be gone (ENOTDIR in a worktree).
|
|
974
1063
|
expect(hook).not.toContain('> .git/totem-sync.log');
|
|
975
1064
|
});
|
|
976
1065
|
it('post-checkout hook derives the log path from git rev-parse --git-dir on both branches', () => {
|
|
977
|
-
const hook = buildPostCheckoutHookContent(
|
|
1066
|
+
const hook = buildPostCheckoutHookContent(RENDER);
|
|
978
1067
|
expect(hook).toContain('GIT_DIR_RESOLVED=$(git rev-parse --git-dir 2>/dev/null || echo .git)');
|
|
979
1068
|
// Both the null-SHA and the branch-diff redirect use the resolved dir.
|
|
980
1069
|
expect(hook.match(/> "\$GIT_DIR_RESOLVED\/totem-sync\.log"/g)).toHaveLength(2);
|
|
@@ -988,13 +1077,19 @@ describe('upgradePrePushHookIfNeeded', () => {
|
|
|
988
1077
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hooks-upgrade-'));
|
|
989
1078
|
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
990
1079
|
fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
|
|
1080
|
+
// Pin the repo-local render options: since mmnto-ai/totem#2692 the upgrade
|
|
1081
|
+
// path resolves `hooks.tier` from config, and a config-less temp repo would
|
|
1082
|
+
// fall through to the developer's global `~/.totem/` profile — a global
|
|
1083
|
+
// `hooks.tier: strict` would then break the standard-tier expectation below
|
|
1084
|
+
// (CodeRabbit on mmnto-ai/totem#2701).
|
|
1085
|
+
fs.writeFileSync(path.join(tmpDir, 'totem.yaml'), 'targets:\n - glob: "docs/*.md"\n type: lesson\n strategy: markdown-heading\nhooks:\n tier: standard\n', 'utf-8');
|
|
991
1086
|
});
|
|
992
1087
|
afterEach(() => {
|
|
993
1088
|
cleanTmpDir(tmpDir);
|
|
994
1089
|
});
|
|
995
1090
|
/**
|
|
996
1091
|
* Helper: extract the totem block from a hook file and compare it against
|
|
997
|
-
* the canonical output of buildPrePushHook(
|
|
1092
|
+
* the canonical output of buildPrePushHook(RENDER) (shebang stripped, trimmed).
|
|
998
1093
|
* Catches stale shell fragments or splice boundary bugs that toContain would miss.
|
|
999
1094
|
* The stateless format is now bounded by the pre-push end marker, so the block is the
|
|
1000
1095
|
* span from the start-marker comment through the end-marker comment line inclusive —
|
|
@@ -1012,11 +1107,11 @@ describe('upgradePrePushHookIfNeeded', () => {
|
|
|
1012
1107
|
}
|
|
1013
1108
|
/** Canonical totem block: shebang stripped, trimmed — the expected upgrade output. */
|
|
1014
1109
|
function expectedTotemBlock() {
|
|
1015
|
-
return buildPrePushHook(getFallbackCommand(tmpDir))
|
|
1110
|
+
return buildPrePushHook({ ...RENDER, fallbackCmd: getFallbackCommand(tmpDir) })
|
|
1016
1111
|
.replace(/^#!\/bin\/sh\n/, '')
|
|
1017
1112
|
.trim();
|
|
1018
1113
|
}
|
|
1019
|
-
it('upgrades old command-executing hook to stateless format', () => {
|
|
1114
|
+
it('upgrades old command-executing hook to stateless format', async () => {
|
|
1020
1115
|
// Install an old-style hook that executes $TOTEM_CMD lint
|
|
1021
1116
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1022
1117
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
@@ -1032,7 +1127,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
|
|
|
1032
1127
|
fi
|
|
1033
1128
|
`;
|
|
1034
1129
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
|
|
1035
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1130
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1036
1131
|
expect(upgraded).toBe(true);
|
|
1037
1132
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1038
1133
|
expect(content).toContain('verify-manifest');
|
|
@@ -1044,41 +1139,41 @@ fi
|
|
|
1044
1139
|
const actual = extractTotemBlock(content);
|
|
1045
1140
|
expect(actual).toBe(expectedTotemBlock());
|
|
1046
1141
|
});
|
|
1047
|
-
it('skips hook without totem marker', () => {
|
|
1142
|
+
it('skips hook without totem marker', async () => {
|
|
1048
1143
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1049
1144
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1050
1145
|
const userHook = '#!/bin/sh\necho "user hook"\n';
|
|
1051
1146
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), userHook);
|
|
1052
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1147
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1053
1148
|
expect(upgraded).toBe(false);
|
|
1054
1149
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1055
1150
|
expect(content).toBe(userHook); // File untouched
|
|
1056
1151
|
});
|
|
1057
|
-
it('skips hook that already uses stateless format', () => {
|
|
1152
|
+
it('skips hook that already uses stateless format', async () => {
|
|
1058
1153
|
// Install the current-version hook via non-interactive installer
|
|
1059
|
-
installHooksNonInteractive(tmpDir);
|
|
1154
|
+
await installHooksNonInteractive(tmpDir);
|
|
1060
1155
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1061
1156
|
const beforeContent = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1062
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1157
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1063
1158
|
expect(upgraded).toBe(false);
|
|
1064
1159
|
const afterContent = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1065
1160
|
expect(afterContent).toBe(beforeContent); // File untouched
|
|
1066
1161
|
});
|
|
1067
|
-
it('returns false when no pre-push hook exists', () => {
|
|
1068
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1162
|
+
it('returns false when no pre-push hook exists', async () => {
|
|
1163
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1069
1164
|
expect(upgraded).toBe(false);
|
|
1070
1165
|
});
|
|
1071
|
-
it('returns false when not a git repo', () => {
|
|
1166
|
+
it('returns false when not a git repo', async () => {
|
|
1072
1167
|
const nonGitDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-no-git-'));
|
|
1073
1168
|
try {
|
|
1074
|
-
const upgraded = upgradePrePushHookIfNeeded(nonGitDir);
|
|
1169
|
+
const upgraded = await upgradePrePushHookIfNeeded(nonGitDir);
|
|
1075
1170
|
expect(upgraded).toBe(false);
|
|
1076
1171
|
}
|
|
1077
1172
|
finally {
|
|
1078
1173
|
cleanTmpDir(nonGitDir);
|
|
1079
1174
|
}
|
|
1080
1175
|
});
|
|
1081
|
-
it('preserves user-appended content when upgrading', () => {
|
|
1176
|
+
it('preserves user-appended content when upgrading', async () => {
|
|
1082
1177
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1083
1178
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1084
1179
|
// Simulate an old totem hook with user content appended after it
|
|
@@ -1093,7 +1188,7 @@ fi
|
|
|
1093
1188
|
`;
|
|
1094
1189
|
const userAppended = '\n# My custom deploy notification\ncurl -X POST https://hooks.example.com/deploy\n';
|
|
1095
1190
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldTotemBlock + userAppended);
|
|
1096
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1191
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1097
1192
|
expect(upgraded).toBe(true);
|
|
1098
1193
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1099
1194
|
// New totem block should use stateless format
|
|
@@ -1107,7 +1202,7 @@ fi
|
|
|
1107
1202
|
const actual = extractTotemBlock(content);
|
|
1108
1203
|
expect(actual).toBe(expectedTotemBlock());
|
|
1109
1204
|
});
|
|
1110
|
-
it('preserves user-appended if/fi blocks without corrupting them', () => {
|
|
1205
|
+
it('preserves user-appended if/fi blocks without corrupting them', async () => {
|
|
1111
1206
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1112
1207
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1113
1208
|
// Old totem block (needs upgrade) PLUS user content that contains its own if/fi structures
|
|
@@ -1138,7 +1233,7 @@ if [ -n "$SLACK_WEBHOOK" ]; then
|
|
|
1138
1233
|
fi
|
|
1139
1234
|
`;
|
|
1140
1235
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldTotemBlock + userIfFiBlock);
|
|
1141
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1236
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1142
1237
|
expect(upgraded).toBe(true);
|
|
1143
1238
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1144
1239
|
// Totem block must match canonical output exactly
|
|
@@ -1156,7 +1251,7 @@ fi
|
|
|
1156
1251
|
const userBlockStart = content.indexOf('# Custom deploy guard');
|
|
1157
1252
|
expect(userBlockStart).toBeGreaterThan(totemBlockEnd);
|
|
1158
1253
|
});
|
|
1159
|
-
it('leaves no stale fi or orphaned shell fragments after upgrade', () => {
|
|
1254
|
+
it('leaves no stale fi or orphaned shell fragments after upgrade', async () => {
|
|
1160
1255
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1161
1256
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1162
1257
|
const oldHook = `#!/bin/sh
|
|
@@ -1171,7 +1266,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
|
|
|
1171
1266
|
fi
|
|
1172
1267
|
`;
|
|
1173
1268
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
|
|
1174
|
-
upgradePrePushHookIfNeeded(tmpDir);
|
|
1269
|
+
await upgradePrePushHookIfNeeded(tmpDir);
|
|
1175
1270
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1176
1271
|
// Count if/fi balance: every `if` must have a matching `fi`.
|
|
1177
1272
|
// Inline `if ... fi` on a single line are self-balanced and excluded from both counts.
|
|
@@ -1187,7 +1282,7 @@ fi
|
|
|
1187
1282
|
const actual = extractTotemBlock(content);
|
|
1188
1283
|
expect(actual).toBe(expectedTotemBlock());
|
|
1189
1284
|
});
|
|
1190
|
-
it('upgrades old flag-checking hook to stateless format', () => {
|
|
1285
|
+
it('upgrades old flag-checking hook to stateless format', async () => {
|
|
1191
1286
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1192
1287
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1193
1288
|
// Simulate the previous flag-checking format that used .lint-passed
|
|
@@ -1208,7 +1303,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
|
|
|
1208
1303
|
fi
|
|
1209
1304
|
`;
|
|
1210
1305
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
|
|
1211
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1306
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1212
1307
|
expect(upgraded).toBe(true);
|
|
1213
1308
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1214
1309
|
expect(content).toContain('verify-manifest');
|
|
@@ -1218,7 +1313,7 @@ fi
|
|
|
1218
1313
|
const actual = extractTotemBlock(content);
|
|
1219
1314
|
expect(actual).toBe(expectedTotemBlock());
|
|
1220
1315
|
});
|
|
1221
|
-
it('upgrades hook with auto-refresh and $TOTEM_CMD to stateless format', () => {
|
|
1316
|
+
it('upgrades hook with auto-refresh and $TOTEM_CMD to stateless format', async () => {
|
|
1222
1317
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1223
1318
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1224
1319
|
// Simulate a hook with the auto-refresh logic still using $TOTEM_CMD
|
|
@@ -1248,7 +1343,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
|
|
|
1248
1343
|
fi
|
|
1249
1344
|
`;
|
|
1250
1345
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
|
|
1251
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1346
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1252
1347
|
expect(upgraded).toBe(true);
|
|
1253
1348
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1254
1349
|
expect(content).toContain('verify-manifest');
|
|
@@ -1263,7 +1358,7 @@ fi
|
|
|
1263
1358
|
// ─── Enforcement tier: agent detection & strict mode ──
|
|
1264
1359
|
describe('buildPreCommitHook agent detection', () => {
|
|
1265
1360
|
it('includes agent detection snippet', () => {
|
|
1266
|
-
const hook = buildPreCommitHook();
|
|
1361
|
+
const hook = buildPreCommitHook(RENDER);
|
|
1267
1362
|
expect(hook).toContain('is_agent=0');
|
|
1268
1363
|
expect(hook).toContain('is_agent=1');
|
|
1269
1364
|
expect(hook).toContain('CLAUDE_CODE_AGENT');
|
|
@@ -1272,52 +1367,730 @@ describe('buildPreCommitHook agent detection', () => {
|
|
|
1272
1367
|
// GEMINI_API_KEY intentionally excluded — human devs export it for Totem's embedding provider
|
|
1273
1368
|
});
|
|
1274
1369
|
it('includes TOTEM_HOOK_TIER variable', () => {
|
|
1275
|
-
const hook = buildPreCommitHook();
|
|
1370
|
+
const hook = buildPreCommitHook(RENDER);
|
|
1276
1371
|
expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
|
|
1277
1372
|
});
|
|
1278
1373
|
it('sets TOTEM_HOOK_TIER to strict when tier is strict', () => {
|
|
1279
|
-
const hook = buildPreCommitHook('strict');
|
|
1374
|
+
const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
|
|
1280
1375
|
expect(hook).toContain('TOTEM_HOOK_TIER="strict"');
|
|
1281
1376
|
});
|
|
1282
1377
|
});
|
|
1283
1378
|
describe('buildPreCommitHook with strict tier', () => {
|
|
1284
|
-
it('
|
|
1285
|
-
const hook = buildPreCommitHook('strict');
|
|
1286
|
-
|
|
1379
|
+
it('gates on spec evidence and never on the retired hand-set marker', () => {
|
|
1380
|
+
const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
|
|
1381
|
+
// The former `.totem/cache/.spec-completed` sentinel (mmnto-ai/totem#2690)
|
|
1382
|
+
// may be NAMED in a comment that says it is not honored, but no test-able
|
|
1383
|
+
// path may read it.
|
|
1384
|
+
expect(hook).not.toMatch(/-f\s+"?\.totem\/cache\/\.spec-completed/);
|
|
1287
1385
|
expect(hook).toContain("Run 'totem spec <issue>' before committing (strict mode)");
|
|
1288
1386
|
});
|
|
1289
1387
|
it('gates spec check on agent detection or strict tier', () => {
|
|
1290
|
-
const hook = buildPreCommitHook('strict');
|
|
1388
|
+
const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
|
|
1291
1389
|
expect(hook).toContain('$is_agent');
|
|
1292
1390
|
expect(hook).toContain('$TOTEM_HOOK_TIER');
|
|
1293
1391
|
});
|
|
1392
|
+
it('gates on the totem spec run artifact, JSON-aware — the only evidence', () => {
|
|
1393
|
+
const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
|
|
1394
|
+
expect(hook).toContain('.totem/artifacts/runs');
|
|
1395
|
+
expect(hook).toContain('admission.runMetadata.caller');
|
|
1396
|
+
expect(hook).toContain('spec evidence:');
|
|
1397
|
+
expect(hook).not.toContain('(legacy marker)');
|
|
1398
|
+
});
|
|
1399
|
+
// mmnto-ai/totem#2700 — the reader's vocabulary is RENDERED from the one
|
|
1400
|
+
// canonical constant, never re-spelled in the hook text, so a change to the
|
|
1401
|
+
// writer's spelling can never leave the gate reading a stale word.
|
|
1402
|
+
it('renders SPEC_REQUIRED_SECTIONS and the anchor vocabulary as JSON literals', () => {
|
|
1403
|
+
const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
|
|
1404
|
+
expect(hook).toContain(`const REQUIRED = ${JSON.stringify(SPEC_REQUIRED_SECTIONS)};`);
|
|
1405
|
+
expect(hook).toContain(`const KIND_ISSUE = ${JSON.stringify(GROUNDING_ANCHOR_ISSUE)};`);
|
|
1406
|
+
expect(hook).toContain(`const KIND_RECORD = ${JSON.stringify(GROUNDING_ANCHOR_RECORD)};`);
|
|
1407
|
+
expect(hook).toContain(`const PROMPT_OVERRIDE = ${JSON.stringify(PROMPT_SOURCE_OVERRIDE)};`);
|
|
1408
|
+
});
|
|
1409
|
+
it('carries the exit-3 arm distinctly from the exit-2 and reader-failure arms', () => {
|
|
1410
|
+
const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
|
|
1411
|
+
expect(hook).toContain('elif [ "$reader_status" = "3" ]; then');
|
|
1412
|
+
expect(hook).toContain(`echo "[Totem] BLOCKED: $spec_evidence — run 'totem spec <issue>' or 'totem spec --from <record>' (add --fresh if the response is cached) (strict mode)"`);
|
|
1413
|
+
// The two pre-existing arms keep their exact text.
|
|
1414
|
+
expect(hook).toContain('no totem spec run artifact under .totem/artifacts/runs/');
|
|
1415
|
+
expect(hook).toContain('the spec-evidence reader could not run (node exit status');
|
|
1416
|
+
});
|
|
1417
|
+
it('the reader body carries no single quote (it lives inside a single-quoted node -e word)', () => {
|
|
1418
|
+
const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
|
|
1419
|
+
const reader = hook.split("node -e '")[1]?.split("\n' 2>/dev/null)")[0] ?? '';
|
|
1420
|
+
expect(reader.length).toBeGreaterThan(0);
|
|
1421
|
+
expect(reader).not.toContain("'");
|
|
1422
|
+
});
|
|
1423
|
+
});
|
|
1424
|
+
// The strict block EXECUTED under sh (mmnto-ai/totem#2690): the string checks
|
|
1425
|
+
// above pin the text; these pin the behavior the gate exists for. `git init` +
|
|
1426
|
+
// a feature branch so the protected-branch block does not fire; the agent
|
|
1427
|
+
// env var arms the strict tier the way an agent commit does.
|
|
1428
|
+
describe('buildPreCommitHook strict evidence — executed under sh (mmnto-ai/totem#2690)', () => {
|
|
1429
|
+
const shellOk = spawnSync('sh', ['-c', 'command -v node >/dev/null 2>&1'], { encoding: 'utf-8' }).status === 0;
|
|
1430
|
+
let tmpDir;
|
|
1431
|
+
beforeEach(() => {
|
|
1432
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-evidence-'));
|
|
1433
|
+
execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
|
|
1434
|
+
execSync('git checkout -q -b feat/evidence', { cwd: tmpDir, stdio: 'ignore' });
|
|
1435
|
+
fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook(RENDER));
|
|
1436
|
+
});
|
|
1437
|
+
afterEach(() => {
|
|
1438
|
+
cleanTmpDir(tmpDir);
|
|
1439
|
+
});
|
|
1440
|
+
function runHook() {
|
|
1441
|
+
const r = spawnSync('sh', ['./pre-commit'], {
|
|
1442
|
+
cwd: tmpDir,
|
|
1443
|
+
encoding: 'utf-8',
|
|
1444
|
+
env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
|
|
1445
|
+
});
|
|
1446
|
+
return { status: r.status, stdout: r.stdout };
|
|
1447
|
+
}
|
|
1448
|
+
function writeRun(name, artifact) {
|
|
1449
|
+
const dir = path.join(tmpDir, '.totem', 'artifacts', 'runs');
|
|
1450
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1451
|
+
fs.writeFileSync(path.join(dir, name), typeof artifact === 'string' ? artifact : JSON.stringify(artifact, null, 2));
|
|
1452
|
+
}
|
|
1453
|
+
// A `node` shim that fails: first on PATH, so the hook's reader cannot run.
|
|
1454
|
+
// Exercises the reader-failure arm without touching the real runtime.
|
|
1455
|
+
function shimNodeExiting(status) {
|
|
1456
|
+
const shimDir = path.join(tmpDir, 'node-shim');
|
|
1457
|
+
fs.mkdirSync(shimDir, { recursive: true });
|
|
1458
|
+
fs.writeFileSync(path.join(shimDir, 'node'), `#!/bin/sh\nexit ${status}\n`, { mode: 0o755 });
|
|
1459
|
+
return {
|
|
1460
|
+
...process.env,
|
|
1461
|
+
CLAUDE_CODE_AGENT: '1',
|
|
1462
|
+
PATH: `${shimDir}${path.delimiter}${process.env.PATH ?? ''}`,
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
function runHookWith(env) {
|
|
1466
|
+
const r = spawnSync('sh', ['./pre-commit'], { cwd: tmpDir, encoding: 'utf-8', env });
|
|
1467
|
+
return { status: r.status, stdout: r.stdout };
|
|
1468
|
+
}
|
|
1469
|
+
it.skipIf(!shellOk)('reports a reader that cannot run DISTINCTLY from missing evidence, and still fails closed', () => {
|
|
1470
|
+
const r = runHookWith(shimNodeExiting(5));
|
|
1471
|
+
expect(r.status).toBe(1);
|
|
1472
|
+
expect(r.stdout).toContain('[Totem] BLOCKED: the spec-evidence reader could not run (node exit status 5');
|
|
1473
|
+
expect(r.stdout).not.toContain('no totem spec run artifact');
|
|
1474
|
+
});
|
|
1475
|
+
it.skipIf(!shellOk)('a hand-set marker does not rescue a reader that cannot run — still the distinct failure, still closed', () => {
|
|
1476
|
+
fs.mkdirSync(path.join(tmpDir, '.totem', 'cache'), { recursive: true });
|
|
1477
|
+
fs.writeFileSync(path.join(tmpDir, '.totem', 'cache', '.spec-completed'), '');
|
|
1478
|
+
const r = runHookWith(shimNodeExiting(127));
|
|
1479
|
+
expect(r.status).toBe(1);
|
|
1480
|
+
expect(r.stdout).toContain('the spec-evidence reader could not run (node exit status 127');
|
|
1481
|
+
expect(r.stdout).not.toContain('legacy');
|
|
1482
|
+
});
|
|
1483
|
+
it.skipIf(!shellOk)('blocks when this checkout carries no evidence at all', () => {
|
|
1484
|
+
const r = runHook();
|
|
1485
|
+
expect(r.status).toBe(1);
|
|
1486
|
+
expect(r.stdout).toContain("[Totem] BLOCKED: Run 'totem spec <issue>' before committing (strict mode)");
|
|
1487
|
+
expect(r.stdout).toContain('no totem spec run artifact under .totem/artifacts/runs/');
|
|
1488
|
+
});
|
|
1489
|
+
it.skipIf(!shellOk)('passes on a spec run artifact and prints the evidence line with its age', () => {
|
|
1490
|
+
writeRun('a.json', specEvidenceArtifact());
|
|
1491
|
+
const r = runHook();
|
|
1492
|
+
expect(r.status).toBe(0);
|
|
1493
|
+
expect(r.stdout).toContain('[Totem] spec evidence: .totem/artifacts/runs/a.json (');
|
|
1494
|
+
expect(r.stdout).toContain('0 days old');
|
|
1495
|
+
});
|
|
1496
|
+
it.skipIf(!shellOk)('does NOT pass on a review artifact that merely CARRIES a caller:spec object below the top level (the substring hazard)', () => {
|
|
1497
|
+
// A substring read of the run store would match this file: the key
|
|
1498
|
+
// spelling appears verbatim, as a carried OBJECT under inputBundle (the
|
|
1499
|
+
// shape a review over run metadata produces). Only the TOP-LEVEL
|
|
1500
|
+
// admission.runMetadata.caller is evidence. (Carried TEXT — a prompt that
|
|
1501
|
+
// quotes the key — is JSON-escaped on disk and never matches either read;
|
|
1502
|
+
// the carried-object form is the real hazard, so it is the control.)
|
|
1503
|
+
writeRun('r.json', {
|
|
1504
|
+
admission: { runMetadata: { caller: 'review' } },
|
|
1505
|
+
inputBundle: { runMetadata: { caller: 'spec' } },
|
|
1506
|
+
createdAt: new Date().toISOString(),
|
|
1507
|
+
});
|
|
1508
|
+
const onDisk = fs.readFileSync(path.join(tmpDir, '.totem/artifacts/runs/r.json'), 'utf-8');
|
|
1509
|
+
// Mutation check on the control itself: the naive pattern WOULD match.
|
|
1510
|
+
expect(/"caller": *"spec"/.test(onDisk)).toBe(true);
|
|
1511
|
+
const r = runHook();
|
|
1512
|
+
expect(r.status).toBe(1);
|
|
1513
|
+
expect(r.stdout).toContain('BLOCKED');
|
|
1514
|
+
});
|
|
1515
|
+
it.skipIf(!shellOk)('ignores the retired hand-set marker — a marker alone is BLOCKED', () => {
|
|
1516
|
+
// No CLI path ever wrote `.totem/cache/.spec-completed`; honoring it would be
|
|
1517
|
+
// compatibility with a hand hack, not with a user (operator ruling
|
|
1518
|
+
// 2026-08-29, mmnto-ai/totem#2690).
|
|
1519
|
+
fs.mkdirSync(path.join(tmpDir, '.totem', 'cache'), { recursive: true });
|
|
1520
|
+
fs.writeFileSync(path.join(tmpDir, '.totem', 'cache', '.spec-completed'), '');
|
|
1521
|
+
const r = runHook();
|
|
1522
|
+
expect(r.status).toBe(1);
|
|
1523
|
+
expect(r.stdout).toContain('no totem spec run artifact under .totem/artifacts/runs/');
|
|
1524
|
+
expect(r.stdout).not.toContain('spec evidence:');
|
|
1525
|
+
});
|
|
1526
|
+
it.skipIf(!shellOk)('skips a torn (unparseable) artifact and still passes on a valid one beside it', () => {
|
|
1527
|
+
writeRun('torn.json', '{"admission": {"runMetadata": {"caller": "spec"');
|
|
1528
|
+
writeRun('ok.json', specEvidenceArtifact());
|
|
1529
|
+
const r = runHook();
|
|
1530
|
+
expect(r.status).toBe(0);
|
|
1531
|
+
expect(r.stdout).toContain('.totem/artifacts/runs/ok.json');
|
|
1532
|
+
});
|
|
1533
|
+
it.skipIf(!shellOk)('blocks when the only artifact is torn', () => {
|
|
1534
|
+
writeRun('torn.json', '{"admission": {"runMetadata": {"caller": "spec"');
|
|
1535
|
+
const r = runHook();
|
|
1536
|
+
expect(r.status).toBe(1);
|
|
1537
|
+
});
|
|
1538
|
+
it.skipIf(!shellOk)('names the NEWEST spec artifact by its own createdAt', () => {
|
|
1539
|
+
writeRun('old.json', specEvidenceArtifact({ createdAt: '2026-01-01T00:00:00.000Z' }));
|
|
1540
|
+
writeRun('new.json', specEvidenceArtifact());
|
|
1541
|
+
const r = runHook();
|
|
1542
|
+
expect(r.status).toBe(0);
|
|
1543
|
+
expect(r.stdout).toContain('.totem/artifacts/runs/new.json');
|
|
1544
|
+
expect(r.stdout).not.toContain('old.json');
|
|
1545
|
+
});
|
|
1546
|
+
});
|
|
1547
|
+
// The mmnto-ai/totem#2700 arm, EXECUTED: an artifact is evidence only when it
|
|
1548
|
+
// is ANCHORED (issue | record) and its SUBJECT carries a shape. Every negation
|
|
1549
|
+
// must BLOCK with its OWN named reason — never with the "no artifact" line and
|
|
1550
|
+
// never as a reader failure — because the reason is the whole cure.
|
|
1551
|
+
describe('buildPreCommitHook anchored evidence — executed under sh (mmnto-ai/totem#2700)', () => {
|
|
1552
|
+
const shellOk = spawnSync('sh', ['-c', 'command -v node >/dev/null 2>&1'], { encoding: 'utf-8' }).status === 0;
|
|
1553
|
+
/**
|
|
1554
|
+
* Whether this platform (and this account) can make a symlink — a Windows
|
|
1555
|
+
* account without SeCreateSymbolicLinkPrivilege cannot. Probed ONCE, outside
|
|
1556
|
+
* any test, so the skip is a real capability check rather than a swallowed
|
|
1557
|
+
* failure inside the assertion.
|
|
1558
|
+
*/
|
|
1559
|
+
const symlinkable = (() => {
|
|
1560
|
+
const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-link-probe-'));
|
|
1561
|
+
const target = path.join(probeDir, 'target');
|
|
1562
|
+
fs.writeFileSync(target, 'probe');
|
|
1563
|
+
let ok = false;
|
|
1564
|
+
try {
|
|
1565
|
+
fs.symlinkSync(target, path.join(probeDir, 'sym'));
|
|
1566
|
+
ok = true;
|
|
1567
|
+
}
|
|
1568
|
+
catch (err) {
|
|
1569
|
+
void err;
|
|
1570
|
+
ok = false;
|
|
1571
|
+
}
|
|
1572
|
+
cleanTmpDir(probeDir);
|
|
1573
|
+
return ok;
|
|
1574
|
+
})();
|
|
1575
|
+
let tmpDir;
|
|
1576
|
+
/** A directory OUTSIDE the worktree — the target of the escaping-symlink case. */
|
|
1577
|
+
let outsideDir;
|
|
1578
|
+
beforeEach(() => {
|
|
1579
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-anchor-'));
|
|
1580
|
+
outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-outside-'));
|
|
1581
|
+
execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
|
|
1582
|
+
execSync('git checkout -q -b feat/anchored', { cwd: tmpDir, stdio: 'ignore' });
|
|
1583
|
+
fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook(RENDER));
|
|
1584
|
+
});
|
|
1585
|
+
afterEach(() => {
|
|
1586
|
+
cleanTmpDir(tmpDir);
|
|
1587
|
+
cleanTmpDir(outsideDir);
|
|
1588
|
+
});
|
|
1589
|
+
function runHook() {
|
|
1590
|
+
const r = spawnSync('sh', ['./pre-commit'], {
|
|
1591
|
+
cwd: tmpDir,
|
|
1592
|
+
encoding: 'utf-8',
|
|
1593
|
+
env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
|
|
1594
|
+
});
|
|
1595
|
+
return { status: r.status, stdout: r.stdout };
|
|
1596
|
+
}
|
|
1597
|
+
function writeRun(name, artifact) {
|
|
1598
|
+
const dir = path.join(tmpDir, '.totem', 'artifacts', 'runs');
|
|
1599
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1600
|
+
fs.writeFileSync(path.join(dir, name), JSON.stringify(artifact, null, 2));
|
|
1601
|
+
}
|
|
1602
|
+
/** Write a bound record at a repo-relative path and return its sha256. */
|
|
1603
|
+
function writeRecord(relPath, body) {
|
|
1604
|
+
const abs = path.join(tmpDir, ...relPath.split('/'));
|
|
1605
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
1606
|
+
fs.writeFileSync(abs, body, 'utf-8');
|
|
1607
|
+
return crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex');
|
|
1608
|
+
}
|
|
1609
|
+
/** Every BLOCKED arm must be distinguishable from the other two arms. */
|
|
1610
|
+
function expectDistinctBlock(stdout) {
|
|
1611
|
+
expect(stdout).not.toContain('no totem spec run artifact');
|
|
1612
|
+
expect(stdout).not.toContain('the spec-evidence reader could not run');
|
|
1613
|
+
}
|
|
1614
|
+
// ── The migration invariant ──
|
|
1615
|
+
it.skipIf(!shellOk)('a PRE-EXISTING artifact (no grounding.anchor) BLOCKS with the `predates` reason, not the no-artifact line', () => {
|
|
1616
|
+
writeRun('legacy.json', {
|
|
1617
|
+
admission: { runMetadata: { caller: 'spec' } },
|
|
1618
|
+
output: { content: TEMPLATE_DRAFT },
|
|
1619
|
+
createdAt: new Date().toISOString(),
|
|
1620
|
+
});
|
|
1621
|
+
const r = runHook();
|
|
1622
|
+
expect(r.status).toBe(1);
|
|
1623
|
+
expect(r.stdout).toContain('predates the anchored-evidence rule (no grounding.anchor)');
|
|
1624
|
+
expect(r.stdout).toContain('.totem/artifacts/runs/legacy.json');
|
|
1625
|
+
expectDistinctBlock(r.stdout);
|
|
1626
|
+
});
|
|
1627
|
+
// ── The unanchored kinds ──
|
|
1628
|
+
it.skipIf(!shellOk)('a `free-text` anchor BLOCKS, naming the kind and the topic', () => {
|
|
1629
|
+
writeRun('ft.json', specEvidenceArtifact({
|
|
1630
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'a loose slug' } },
|
|
1631
|
+
}));
|
|
1632
|
+
const r = runHook();
|
|
1633
|
+
expect(r.status).toBe(1);
|
|
1634
|
+
expect(r.stdout).toContain(`is anchored ${GROUNDING_ANCHOR_FREE_TEXT} (a loose slug)`);
|
|
1635
|
+
expect(r.stdout).toContain('not gate evidence');
|
|
1636
|
+
expectDistinctBlock(r.stdout);
|
|
1637
|
+
});
|
|
1638
|
+
it.skipIf(!shellOk)('a `mixed` anchor BLOCKS, naming the kind and the ref', () => {
|
|
1639
|
+
writeRun('mx.json', specEvidenceArtifact({
|
|
1640
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_MIXED, ref: '#2700 | a loose slug' } },
|
|
1641
|
+
}));
|
|
1642
|
+
const r = runHook();
|
|
1643
|
+
expect(r.status).toBe(1);
|
|
1644
|
+
expect(r.stdout).toContain(`is anchored ${GROUNDING_ANCHOR_MIXED} (#2700 | a loose slug)`);
|
|
1645
|
+
expectDistinctBlock(r.stdout);
|
|
1646
|
+
});
|
|
1647
|
+
it.skipIf(!shellOk)('both cures are named on every BLOCKED arm', () => {
|
|
1648
|
+
writeRun('ft.json', specEvidenceArtifact({
|
|
1649
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'slug' } },
|
|
1650
|
+
}));
|
|
1651
|
+
const r = runHook();
|
|
1652
|
+
expect(r.stdout).toContain("run 'totem spec <issue>' or 'totem spec --from <record>'");
|
|
1653
|
+
});
|
|
1654
|
+
// ── The TEMPLATE shape (issue anchor, built-in prompt) ──
|
|
1655
|
+
it.skipIf(!shellOk)('the one-token draft (a lone newline) BLOCKS on the first missing heading', () => {
|
|
1656
|
+
writeRun('thin.json', specEvidenceArtifact({ output: { content: '\n' } }));
|
|
1657
|
+
const r = runHook();
|
|
1658
|
+
expect(r.status).toBe(1);
|
|
1659
|
+
expect(r.stdout).toContain('is missing heading ### Problem Statement');
|
|
1660
|
+
expectDistinctBlock(r.stdout);
|
|
1661
|
+
});
|
|
1662
|
+
it.skipIf(!shellOk)('both required headings present over EMPTY bodies BLOCK, naming the empty heading', () => {
|
|
1663
|
+
writeRun('empty.json', specEvidenceArtifact({
|
|
1664
|
+
output: { content: '### Problem Statement\n\n### Implementation Tasks\n' },
|
|
1665
|
+
}));
|
|
1666
|
+
const r = runHook();
|
|
1667
|
+
expect(r.status).toBe(1);
|
|
1668
|
+
expect(r.stdout).toContain('has an empty heading ### Problem Statement');
|
|
1669
|
+
expectDistinctBlock(r.stdout);
|
|
1670
|
+
});
|
|
1671
|
+
it.skipIf(!shellOk)('a second required heading left empty BLOCKS, naming THAT heading', () => {
|
|
1672
|
+
writeRun('half.json', specEvidenceArtifact({
|
|
1673
|
+
output: {
|
|
1674
|
+
content: '### Problem Statement\n\nA real body.\n\n### Implementation Tasks\n',
|
|
1675
|
+
},
|
|
1676
|
+
}));
|
|
1677
|
+
const r = runHook();
|
|
1678
|
+
expect(r.status).toBe(1);
|
|
1679
|
+
expect(r.stdout).toContain('has an empty heading ### Implementation Tasks');
|
|
1680
|
+
});
|
|
1681
|
+
it.skipIf(!shellOk)('both required headings with bodies PASS, and the evidence line carries the anchor and the shape', () => {
|
|
1682
|
+
writeRun('ok.json', specEvidenceArtifact());
|
|
1683
|
+
const r = runHook();
|
|
1684
|
+
expect(r.status).toBe(0);
|
|
1685
|
+
expect(r.stdout).toContain('[Totem] spec evidence: .totem/artifacts/runs/ok.json (');
|
|
1686
|
+
expect(r.stdout).toContain(`· anchor ${GROUNDING_ANCHOR_ISSUE} #2700`);
|
|
1687
|
+
expect(r.stdout).toContain('· shape TEMPLATE');
|
|
1688
|
+
});
|
|
1689
|
+
it.skipIf(!shellOk)('a non-string draft on an issue anchor BLOCKS as not-text', () => {
|
|
1690
|
+
writeRun('nt.json', specEvidenceArtifact({ output: { content: { nested: true } } }));
|
|
1691
|
+
const r = runHook();
|
|
1692
|
+
expect(r.status).toBe(1);
|
|
1693
|
+
expect(r.stdout).toContain('the draft is not text');
|
|
1694
|
+
expectDistinctBlock(r.stdout);
|
|
1695
|
+
});
|
|
1696
|
+
// ── The DOCUMENT shape (override prompt on an issue anchor) ──
|
|
1697
|
+
it.skipIf(!shellOk)('an OVERRIDE-prompt draft without the template headings PASSES on one heading with a body', () => {
|
|
1698
|
+
writeRun('ov.json', specEvidenceArtifact({
|
|
1699
|
+
admission: {
|
|
1700
|
+
runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_OVERRIDE },
|
|
1701
|
+
},
|
|
1702
|
+
output: { content: DOCUMENT_DRAFT },
|
|
1703
|
+
}));
|
|
1704
|
+
const r = runHook();
|
|
1705
|
+
expect(r.status).toBe(0);
|
|
1706
|
+
expect(r.stdout).toContain('· shape DOCUMENT');
|
|
1707
|
+
});
|
|
1708
|
+
it.skipIf(!shellOk)('an OVERRIDE-prompt draft with no heading at all BLOCKS', () => {
|
|
1709
|
+
writeRun('ovbad.json', specEvidenceArtifact({
|
|
1710
|
+
admission: { runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_OVERRIDE } },
|
|
1711
|
+
output: { content: 'just prose, no heading\n' },
|
|
1712
|
+
}));
|
|
1713
|
+
const r = runHook();
|
|
1714
|
+
expect(r.status).toBe(1);
|
|
1715
|
+
expect(r.stdout).toContain('has no heading with a body');
|
|
1716
|
+
expect(r.stdout).toContain('custom prompt');
|
|
1717
|
+
expectDistinctBlock(r.stdout);
|
|
1718
|
+
});
|
|
1719
|
+
// ── The record arm: the SUBJECT is the record's bytes ──
|
|
1720
|
+
function recordArtifact(ref, sha256, draft = TEMPLATE_DRAFT) {
|
|
1721
|
+
return specEvidenceArtifact({
|
|
1722
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_RECORD, ref, sha256 } },
|
|
1723
|
+
output: { content: draft },
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
it.skipIf(!shellOk)('a present record with a heading and a body PASSES, reporting `record sha256 matches`', () => {
|
|
1727
|
+
const sha = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
|
|
1728
|
+
writeRun('rec.json', recordArtifact('.totem/specs/2700.md', sha));
|
|
1729
|
+
const r = runHook();
|
|
1730
|
+
expect(r.status).toBe(0);
|
|
1731
|
+
expect(r.stdout).toContain(`· anchor ${GROUNDING_ANCHOR_RECORD} .totem/specs/2700.md`);
|
|
1732
|
+
expect(r.stdout).toContain('· shape DOCUMENT');
|
|
1733
|
+
expect(r.stdout).toContain('· record sha256 matches');
|
|
1734
|
+
});
|
|
1735
|
+
it.skipIf(!shellOk)('a REVISED record still PASSES — the digest is a SENSOR, never a gate', () => {
|
|
1736
|
+
const bound = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
|
|
1737
|
+
writeRun('rec.json', recordArtifact('.totem/specs/2700.md', bound));
|
|
1738
|
+
const now = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract, folded.\n');
|
|
1739
|
+
const r = runHook();
|
|
1740
|
+
expect(r.status).toBe(0);
|
|
1741
|
+
expect(r.stdout).toContain(`record revised since binding (bound ${bound.slice(0, 8)}, now ${now.slice(0, 8)})`);
|
|
1742
|
+
});
|
|
1743
|
+
it.skipIf(!shellOk)('a MISSING record BLOCKS, naming the path it was bound at', () => {
|
|
1744
|
+
writeRun('rec.json', recordArtifact('.totem/specs/gone.md', 'b'.repeat(64)));
|
|
1745
|
+
const r = runHook();
|
|
1746
|
+
expect(r.status).toBe(1);
|
|
1747
|
+
expect(r.stdout).toContain('the bound record is missing at .totem/specs/gone.md');
|
|
1748
|
+
expectDistinctBlock(r.stdout);
|
|
1749
|
+
});
|
|
1750
|
+
it.skipIf(!shellOk)('a record with NO heading-with-a-body BLOCKS', () => {
|
|
1751
|
+
const sha = writeRecord('.totem/specs/2700.md', 'prose with no heading\n');
|
|
1752
|
+
writeRun('rec.json', recordArtifact('.totem/specs/2700.md', sha));
|
|
1753
|
+
const r = runHook();
|
|
1754
|
+
expect(r.status).toBe(1);
|
|
1755
|
+
expect(r.stdout).toContain('the bound record at .totem/specs/2700.md has no heading with a body');
|
|
1756
|
+
expectDistinctBlock(r.stdout);
|
|
1757
|
+
});
|
|
1758
|
+
it.skipIf(!shellOk)('a record whose heading has no body BLOCKS (a heading alone is not a document)', () => {
|
|
1759
|
+
const sha = writeRecord('.totem/specs/2700.md', '# Heading only\n\n## Another heading\n');
|
|
1760
|
+
writeRun('rec.json', recordArtifact('.totem/specs/2700.md', sha));
|
|
1761
|
+
const r = runHook();
|
|
1762
|
+
expect(r.status).toBe(1);
|
|
1763
|
+
expect(r.stdout).toContain('has no heading with a body');
|
|
1764
|
+
});
|
|
1765
|
+
it.skipIf(!shellOk)('a THIN draft with a rich record PASSES — the reader never reads output.content on a record anchor', () => {
|
|
1766
|
+
const sha = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
|
|
1767
|
+
writeRun('rec.json', recordArtifact('.totem/specs/2700.md', sha, '\n'));
|
|
1768
|
+
const r = runHook();
|
|
1769
|
+
expect(r.status).toBe(0);
|
|
1770
|
+
expect(r.stdout).toContain('· record sha256 matches');
|
|
1771
|
+
});
|
|
1772
|
+
it.skipIf(!shellOk)('a RICH draft with a missing record BLOCKS — the record is the subject, not the draft', () => {
|
|
1773
|
+
writeRun('rec.json', recordArtifact('.totem/specs/gone.md', 'c'.repeat(64), TEMPLATE_DRAFT));
|
|
1774
|
+
const r = runHook();
|
|
1775
|
+
expect(r.status).toBe(1);
|
|
1776
|
+
expect(r.stdout).toContain('the bound record is missing at');
|
|
1777
|
+
});
|
|
1778
|
+
// ── The body-line boundary is a HEADING, not a leading "#" ──
|
|
1779
|
+
//
|
|
1780
|
+
// `#2700 is the issue.` is prose about an issue number, not a heading: no
|
|
1781
|
+
// space follows the hashes. Reading it as a boundary made a real body look
|
|
1782
|
+
// empty — the exact sentence a totem design record is most likely to open a
|
|
1783
|
+
// section with.
|
|
1784
|
+
it.skipIf(!shellOk)('a body line starting with `#2700` is a BODY on the TEMPLATE arm', () => {
|
|
1785
|
+
const draft = SPEC_REQUIRED_SECTIONS.map((heading) => `${heading}\n\n#2700 is the issue.\n`).join('\n');
|
|
1786
|
+
writeRun('hashbody.json', specEvidenceArtifact({ output: { content: draft } }));
|
|
1787
|
+
const r = runHook();
|
|
1788
|
+
expect(r.status).toBe(0);
|
|
1789
|
+
expect(r.stdout).toContain('· shape TEMPLATE');
|
|
1790
|
+
});
|
|
1791
|
+
it.skipIf(!shellOk)('a body line starting with `#2700` is a BODY on the DOCUMENT arm', () => {
|
|
1792
|
+
const sha = writeRecord('.totem/specs/2700.md', '# Record\n\n#2700 is the issue.\n');
|
|
1793
|
+
writeRun('hashrec.json', recordArtifact('.totem/specs/2700.md', sha));
|
|
1794
|
+
const r = runHook();
|
|
1795
|
+
expect(r.status).toBe(0);
|
|
1796
|
+
expect(r.stdout).toContain('· record sha256 matches');
|
|
1797
|
+
});
|
|
1798
|
+
// ── The DOCUMENT shape tolerates two ordinary authoring bytes ──
|
|
1799
|
+
it.skipIf(!shellOk)('a DRAFT opening with a UTF-8 BOM still reads as a template', () => {
|
|
1800
|
+
// The BOM strip runs on the SUBJECT, before the split — so it covers the
|
|
1801
|
+
// TEMPLATE arm too, where an unstripped BOM would make the first required
|
|
1802
|
+
// heading fail its exact-line match and BLOCK a well-formed draft.
|
|
1803
|
+
const bom = String.fromCharCode(0xfeff);
|
|
1804
|
+
writeRun('bomdraft.json', specEvidenceArtifact({ output: { content: bom + TEMPLATE_DRAFT } }));
|
|
1805
|
+
const r = runHook();
|
|
1806
|
+
expect(r.status).toBe(0);
|
|
1807
|
+
expect(r.stdout).toContain('· shape TEMPLATE');
|
|
1808
|
+
});
|
|
1809
|
+
it.skipIf(!shellOk)('a record opening with a UTF-8 BOM still reads as a document', () => {
|
|
1810
|
+
const bom = String.fromCharCode(0xfeff);
|
|
1811
|
+
const sha = writeRecord('.totem/specs/2700.md', `${bom}# Record\n\nThe ruled contract.\n`);
|
|
1812
|
+
writeRun('bom.json', recordArtifact('.totem/specs/2700.md', sha));
|
|
1813
|
+
const r = runHook();
|
|
1814
|
+
expect(r.status).toBe(0);
|
|
1815
|
+
expect(r.stdout).toContain('· shape DOCUMENT');
|
|
1816
|
+
});
|
|
1817
|
+
it.skipIf(!shellOk)('a heading separated from its text by a TAB is a heading', () => {
|
|
1818
|
+
const sha = writeRecord('.totem/specs/2700.md', '#\tTabbed heading\n\nThe body.\n');
|
|
1819
|
+
writeRun('tab.json', recordArtifact('.totem/specs/2700.md', sha));
|
|
1820
|
+
const r = runHook();
|
|
1821
|
+
expect(r.status).toBe(0);
|
|
1822
|
+
expect(r.stdout).toContain('· shape DOCUMENT');
|
|
1823
|
+
});
|
|
1824
|
+
// ── The artifact is hand-editable: nothing read out of it is trusted text ──
|
|
1825
|
+
it.skipIf(!shellOk)('a newline in anchor.ref cannot forge a second [Totem] line', () => {
|
|
1826
|
+
const forged = `slug${String.fromCharCode(0x0a)}[Totem] spec evidence: forged`;
|
|
1827
|
+
writeRun('inject.json', specEvidenceArtifact({
|
|
1828
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: forged } },
|
|
1829
|
+
}));
|
|
1830
|
+
const r = runHook();
|
|
1831
|
+
expect(r.status).toBe(1);
|
|
1832
|
+
// Exactly one line of the hook's own output claims to be from Totem.
|
|
1833
|
+
expect(r.stdout.split('\n').filter((line) => line.startsWith('[Totem]'))).toHaveLength(1);
|
|
1834
|
+
// The control character is collapsed, not dropped — the ref stays legible.
|
|
1835
|
+
expect(r.stdout).toContain('slug?[Totem] spec evidence: forged');
|
|
1836
|
+
});
|
|
1837
|
+
it.skipIf(!shellOk)('a C1 control (U+0085 NEL) in anchor.ref is collapsed too', () => {
|
|
1838
|
+
// A predicate stopping at 0x7f let the whole C1 band through; NEL is a
|
|
1839
|
+
// LINE BREAK to some terminals and pagers, so it is a forged-line vector
|
|
1840
|
+
// of exactly the same kind as 0x0a.
|
|
1841
|
+
const forged = `slug${String.fromCharCode(0x85)}[Totem] spec evidence: forged`;
|
|
1842
|
+
writeRun('nel.json', specEvidenceArtifact({
|
|
1843
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: forged } },
|
|
1844
|
+
}));
|
|
1845
|
+
const r = runHook();
|
|
1846
|
+
expect(r.status).toBe(1);
|
|
1847
|
+
expect(r.stdout).toContain('slug?[Totem] spec evidence: forged');
|
|
1848
|
+
expect(r.stdout).not.toContain(String.fromCharCode(0x85));
|
|
1849
|
+
});
|
|
1850
|
+
it.skipIf(!shellOk)('a newline in createdAt cannot forge a second [Totem] line on the PASS path', () => {
|
|
1851
|
+
// `createdAt` is read raw off the artifact and ECHOED in the evidence
|
|
1852
|
+
// line: unsanitized, a hand-edited stamp forges a second `[Totem] spec
|
|
1853
|
+
// evidence:` line inside a passing commit — the quietest place to hide
|
|
1854
|
+
// one.
|
|
1855
|
+
const forged = `2026-09-02T00:00:00.000Z${String.fromCharCode(0x0a)}[Totem] spec evidence: FORGED`;
|
|
1856
|
+
writeRun('stamp.json', specEvidenceArtifact({ createdAt: forged }));
|
|
1857
|
+
const r = runHook();
|
|
1858
|
+
expect(r.status).toBe(0);
|
|
1859
|
+
expect(r.stdout.split('\n').filter((line) => line.startsWith('[Totem]'))).toHaveLength(1);
|
|
1860
|
+
expect(r.stdout).toContain('.000Z?[Totem] spec evidence: FORGED');
|
|
1861
|
+
});
|
|
1862
|
+
it.skipIf(!shellOk)('a record ref that is ABSOLUTE is refused as outside the worktree', () => {
|
|
1863
|
+
writeRun('abs.json', recordArtifact('/etc/passwd', 'd'.repeat(64)));
|
|
1864
|
+
const r = runHook();
|
|
1865
|
+
expect(r.status).toBe(1);
|
|
1866
|
+
expect(r.stdout).toContain('the bound record ref is outside the worktree: /etc/passwd');
|
|
1867
|
+
expectDistinctBlock(r.stdout);
|
|
1868
|
+
});
|
|
1869
|
+
it.skipIf(!shellOk)('a record ref whose first segment is `..` is refused as outside the worktree', () => {
|
|
1870
|
+
writeRun('dotdot.json', recordArtifact('../outside.md', 'd'.repeat(64)));
|
|
1871
|
+
const r = runHook();
|
|
1872
|
+
expect(r.status).toBe(1);
|
|
1873
|
+
expect(r.stdout).toContain('the bound record ref is outside the worktree: ../outside.md');
|
|
1874
|
+
expectDistinctBlock(r.stdout);
|
|
1875
|
+
});
|
|
1876
|
+
// ── Containment is decided by RESOLUTION, not by segment 0 ──
|
|
1877
|
+
//
|
|
1878
|
+
// A first-segment test reads `sub/../../OUTSIDE.md` and `./../OUTSIDE.md` as
|
|
1879
|
+
// contained, so the reader would have READ and PASSED a file outside the
|
|
1880
|
+
// worktree. The reader now resolves the ref against `process.cwd()` — the
|
|
1881
|
+
// worktree top git runs hooks from — and refuses anything that lands outside
|
|
1882
|
+
// it, which is also the honest rule for the legitimate mid-path `..`.
|
|
1883
|
+
/** A backslash, built rather than escaped — the mmnto-ai/totem#2692 authoring trap. */
|
|
1884
|
+
const BACKSLASH = String.fromCharCode(0x5c);
|
|
1885
|
+
it.skipIf(!shellOk).each([
|
|
1886
|
+
['sub/../../OUTSIDE.md', 'a `..` past segment 0'],
|
|
1887
|
+
['./../OUTSIDE.md', 'a `.` first segment hiding a `..`'],
|
|
1888
|
+
[
|
|
1889
|
+
`a${BACKSLASH}..${BACKSLASH}..${BACKSLASH}OUTSIDE.md`,
|
|
1890
|
+
'the same escape spelled with backslashes',
|
|
1891
|
+
],
|
|
1892
|
+
])('refuses %s as outside the worktree (%s)', (ref) => {
|
|
1893
|
+
writeRun('escape.json', recordArtifact(ref, 'd'.repeat(64)));
|
|
1894
|
+
const r = runHook();
|
|
1895
|
+
expect(r.status).toBe(1);
|
|
1896
|
+
expect(r.stdout).toContain(`the bound record ref is outside the worktree: ${ref}`);
|
|
1897
|
+
expectDistinctBlock(r.stdout);
|
|
1898
|
+
});
|
|
1899
|
+
it.skipIf(!shellOk)('a mid-path `..` that stays INSIDE the worktree still PASSES — containment, not a segment ban', () => {
|
|
1900
|
+
// `a/` must exist for POSIX path resolution to reach `a/../b.md`.
|
|
1901
|
+
fs.mkdirSync(path.join(tmpDir, 'a'), { recursive: true });
|
|
1902
|
+
const sha = writeRecord('b.md', '# Record\n\nThe ruled contract.\n');
|
|
1903
|
+
writeRun('inside.json', recordArtifact('a/../b.md', sha));
|
|
1904
|
+
const r = runHook();
|
|
1905
|
+
expect(r.status).toBe(0);
|
|
1906
|
+
expect(r.stdout).toContain('· record sha256 matches');
|
|
1907
|
+
});
|
|
1908
|
+
it.skipIf(!shellOk)('a record anchor with NO sha256 BLOCKS — a binding without bytes is not a binding', () => {
|
|
1909
|
+
writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
|
|
1910
|
+
writeRun('nosha.json', specEvidenceArtifact({
|
|
1911
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_RECORD, ref: '.totem/specs/2700.md' } },
|
|
1912
|
+
}));
|
|
1913
|
+
const r = runHook();
|
|
1914
|
+
expect(r.status).toBe(1);
|
|
1915
|
+
expect(r.stdout).toContain('the record anchor carries no sha256 — not evidence');
|
|
1916
|
+
expectDistinctBlock(r.stdout);
|
|
1917
|
+
});
|
|
1918
|
+
it.skipIf(!shellOk)('a record anchor whose sha256 is MALFORMED BLOCKS with its OWN reason, not the absent one', () => {
|
|
1919
|
+
// "No digest" and "a digest that is not a digest" are different repairs:
|
|
1920
|
+
// the first says re-bind, the second says the artifact was edited. One
|
|
1921
|
+
// shared message would send both to the wrong cure.
|
|
1922
|
+
writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
|
|
1923
|
+
writeRun('badsha.json', recordArtifact('.totem/specs/2700.md', 'not-a-digest'));
|
|
1924
|
+
const r = runHook();
|
|
1925
|
+
expect(r.status).toBe(1);
|
|
1926
|
+
expect(r.stdout).toContain('the record anchor sha256 is not a 64-hex digest (not-a-digest) — not evidence');
|
|
1927
|
+
expect(r.stdout).not.toContain('carries no sha256');
|
|
1928
|
+
expectDistinctBlock(r.stdout);
|
|
1929
|
+
});
|
|
1930
|
+
// ── Containment sees THROUGH a link (mmnto-ai/totem#2700) ──
|
|
1931
|
+
//
|
|
1932
|
+
// A symlink inside the worktree is LEXICALLY contained, so the resolve-based
|
|
1933
|
+
// test above reads it as legal — and the reader would open, hash and judge a
|
|
1934
|
+
// file outside the tree. The realpath pair is compared once the ref is known
|
|
1935
|
+
// to exist and BEFORE its bytes are read.
|
|
1936
|
+
it.skipIf(!shellOk || !symlinkable)('a record ref that is a SYMLINK to OUTSIDE the worktree BLOCKS, naming both spellings', () => {
|
|
1937
|
+
const target = path.join(outsideDir, 'record.md');
|
|
1938
|
+
fs.writeFileSync(target, '# Outside\n\nThe ruled contract.\n', 'utf-8');
|
|
1939
|
+
const sha = crypto.createHash('sha256').update(fs.readFileSync(target)).digest('hex');
|
|
1940
|
+
fs.symlinkSync(target, path.join(tmpDir, 'linked.md'));
|
|
1941
|
+
// A CORRECT digest, so the block cannot be the sha256 arm firing early.
|
|
1942
|
+
writeRun('symlink-out.json', recordArtifact('linked.md', sha));
|
|
1943
|
+
const r = runHook();
|
|
1944
|
+
expect(r.status).toBe(1);
|
|
1945
|
+
expect(r.stdout).toContain('the bound record resolves outside the worktree: linked.md -> ');
|
|
1946
|
+
expect(r.stdout).toContain(fs.realpathSync.native(target));
|
|
1947
|
+
expectDistinctBlock(r.stdout);
|
|
1948
|
+
});
|
|
1949
|
+
it.skipIf(!shellOk || !symlinkable)('a record ref that is a SYMLINK to an IN-worktree file PASSES — containment, not a link ban', () => {
|
|
1950
|
+
const sha = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
|
|
1951
|
+
fs.symlinkSync(path.join(tmpDir, '.totem', 'specs', '2700.md'), path.join(tmpDir, 'linked.md'));
|
|
1952
|
+
writeRun('symlink-in.json', recordArtifact('linked.md', sha));
|
|
1953
|
+
const r = runHook();
|
|
1954
|
+
expect(r.status).toBe(0);
|
|
1955
|
+
expect(r.stdout).toContain('· record sha256 matches');
|
|
1956
|
+
});
|
|
1957
|
+
// ── The cure names the cache (mmnto-ai/totem#2700 m11) ──
|
|
1958
|
+
it.skipIf(!shellOk)('the BLOCKED cure names --fresh — a cached response mints no artifact', () => {
|
|
1959
|
+
writeRun('ft.json', specEvidenceArtifact({
|
|
1960
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'slug' } },
|
|
1961
|
+
}));
|
|
1962
|
+
const r = runHook();
|
|
1963
|
+
expect(r.stdout).toContain('(add --fresh if the response is cached)');
|
|
1964
|
+
});
|
|
1965
|
+
// ── The other arms stay distinct ──
|
|
1966
|
+
it.skipIf(!shellOk)('exit 3 is NEVER reported as a reader failure', () => {
|
|
1967
|
+
writeRun('ft.json', specEvidenceArtifact({
|
|
1968
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'slug' } },
|
|
1969
|
+
}));
|
|
1970
|
+
const r = runHook();
|
|
1971
|
+
expect(r.status).toBe(1);
|
|
1972
|
+
expect(r.stdout).not.toContain('node exit status');
|
|
1973
|
+
});
|
|
1974
|
+
it.skipIf(!shellOk)('the carried-object substring control still BLOCKS with the no-artifact line', () => {
|
|
1975
|
+
// Only the TOP-LEVEL caller counts: a review artifact that carries a
|
|
1976
|
+
// `caller: spec` object below the top level is not a spec artifact at all,
|
|
1977
|
+
// so the gate reports "none found", not "not evidence".
|
|
1978
|
+
writeRun('r.json', {
|
|
1979
|
+
admission: { runMetadata: { caller: 'review' } },
|
|
1980
|
+
inputBundle: { runMetadata: { caller: 'spec' } },
|
|
1981
|
+
createdAt: new Date().toISOString(),
|
|
1982
|
+
});
|
|
1983
|
+
const r = runHook();
|
|
1984
|
+
expect(r.status).toBe(1);
|
|
1985
|
+
expect(r.stdout).toContain('no totem spec run artifact');
|
|
1986
|
+
});
|
|
1987
|
+
it.skipIf(!shellOk)('the retired hand-set marker still rescues nothing', () => {
|
|
1988
|
+
fs.mkdirSync(path.join(tmpDir, '.totem', 'cache'), { recursive: true });
|
|
1989
|
+
fs.writeFileSync(path.join(tmpDir, '.totem', 'cache', '.spec-completed'), '');
|
|
1990
|
+
writeRun('ft.json', specEvidenceArtifact({
|
|
1991
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'slug' } },
|
|
1992
|
+
}));
|
|
1993
|
+
const r = runHook();
|
|
1994
|
+
expect(r.status).toBe(1);
|
|
1995
|
+
expect(r.stdout).not.toContain('spec evidence:');
|
|
1996
|
+
});
|
|
1997
|
+
});
|
|
1998
|
+
// The mmnto-ai/totem#2692 keystone, EXECUTED: under a custom `totemDir` the
|
|
1999
|
+
// hook's reader and `totem spec`'s writer must name the SAME tree. The fixture
|
|
2000
|
+
// writes where the WRITER writes (`<totemDir>/artifacts/runs`, the
|
|
2001
|
+
// `path.join(configRoot, config.totemDir)/artifacts/runs` the run store uses) and
|
|
2002
|
+
// the hook is rendered at that same totemDir — so a pass here is the two halves
|
|
2003
|
+
// agreeing, and the control below is the pre-fix behavior (evidence under
|
|
2004
|
+
// `.totem/` while the hook reads `knowledge/`) still failing closed.
|
|
2005
|
+
describe('buildPreCommitHook strict evidence under a CUSTOM totemDir — executed under sh (mmnto-ai/totem#2692)', () => {
|
|
2006
|
+
const shellOk = spawnSync('sh', ['-c', 'command -v node >/dev/null 2>&1'], { encoding: 'utf-8' }).status === 0;
|
|
2007
|
+
const CUSTOM_TOTEM_DIR = 'knowledge';
|
|
2008
|
+
let tmpDir;
|
|
2009
|
+
beforeEach(() => {
|
|
2010
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-customdir-'));
|
|
2011
|
+
execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
|
|
2012
|
+
execSync('git checkout -q -b feat/evidence', { cwd: tmpDir, stdio: 'ignore' });
|
|
2013
|
+
fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook({ ...RENDER, totemDir: CUSTOM_TOTEM_DIR }));
|
|
2014
|
+
});
|
|
2015
|
+
afterEach(() => {
|
|
2016
|
+
cleanTmpDir(tmpDir);
|
|
2017
|
+
});
|
|
2018
|
+
function runHook() {
|
|
2019
|
+
const r = spawnSync('sh', ['./pre-commit'], {
|
|
2020
|
+
cwd: tmpDir,
|
|
2021
|
+
encoding: 'utf-8',
|
|
2022
|
+
env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
|
|
2023
|
+
});
|
|
2024
|
+
return { status: r.status, stdout: r.stdout };
|
|
2025
|
+
}
|
|
2026
|
+
/** Write a run artifact where the WRITER writes it, for an arbitrary totemDir. */
|
|
2027
|
+
function writeRunUnder(totemDir, name, artifact) {
|
|
2028
|
+
const dir = path.join(tmpDir, ...totemDir.split('/'), 'artifacts', 'runs');
|
|
2029
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2030
|
+
fs.writeFileSync(path.join(dir, name), JSON.stringify(artifact, null, 2));
|
|
2031
|
+
}
|
|
2032
|
+
const specArtifact = () => specEvidenceArtifact();
|
|
2033
|
+
it.skipIf(!shellOk)('PASSES on evidence written under the configured totemDir', () => {
|
|
2034
|
+
writeRunUnder(CUSTOM_TOTEM_DIR, 'a.json', specArtifact());
|
|
2035
|
+
const r = runHook();
|
|
2036
|
+
expect(r.status).toBe(0);
|
|
2037
|
+
expect(r.stdout).toContain(`[Totem] spec evidence: ${CUSTOM_TOTEM_DIR}/artifacts/runs/a.json (`);
|
|
2038
|
+
});
|
|
2039
|
+
it.skipIf(!shellOk)('the pre-fix control: evidence under .totem/ does NOT satisfy a hook rendered for knowledge/', () => {
|
|
2040
|
+
// This is the mmnto-ai/totem#2692 defect, inverted: before the fix the hook
|
|
2041
|
+
// ALWAYS read `.totem/artifacts/runs` while the writer used the configured
|
|
2042
|
+
// dir, so the gate failed closed forever. With the hook rendered at the
|
|
2043
|
+
// configured dir, evidence in the WRONG tree is correctly not evidence.
|
|
2044
|
+
writeRunUnder('.totem', 'a.json', specArtifact());
|
|
2045
|
+
const r = runHook();
|
|
2046
|
+
expect(r.status).toBe(1);
|
|
2047
|
+
expect(r.stdout).toContain(`no totem spec run artifact under ${CUSTOM_TOTEM_DIR}/artifacts/runs/`);
|
|
2048
|
+
});
|
|
2049
|
+
it.skipIf(!shellOk)('blocks with a message naming the configured directory', () => {
|
|
2050
|
+
const r = runHook();
|
|
2051
|
+
expect(r.status).toBe(1);
|
|
2052
|
+
expect(r.stdout).toContain("[Totem] BLOCKED: Run 'totem spec <issue>' before committing (strict mode)");
|
|
2053
|
+
expect(r.stdout).toContain(`under ${CUSTOM_TOTEM_DIR}/artifacts/runs/`);
|
|
2054
|
+
expect(r.stdout).not.toContain('.totem');
|
|
2055
|
+
});
|
|
2056
|
+
it.skipIf(!shellOk)('a nested totemDir survives the same round trip', () => {
|
|
2057
|
+
const nested = 'var/totem-state';
|
|
2058
|
+
fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook({ ...RENDER, totemDir: nested }));
|
|
2059
|
+
writeRunUnder(nested, 'a.json', specArtifact());
|
|
2060
|
+
const r = runHook();
|
|
2061
|
+
expect(r.status).toBe(0);
|
|
2062
|
+
expect(r.stdout).toContain(`[Totem] spec evidence: ${nested}/artifacts/runs/a.json (`);
|
|
2063
|
+
});
|
|
1294
2064
|
});
|
|
1295
2065
|
describe('buildPreCommitHook with standard tier', () => {
|
|
1296
|
-
it('includes spec-
|
|
1297
|
-
const hook = buildPreCommitHook(
|
|
1298
|
-
expect(hook).toContain('.
|
|
2066
|
+
it('includes the spec-evidence check guarded by agent/tier condition', () => {
|
|
2067
|
+
const hook = buildPreCommitHook(RENDER);
|
|
2068
|
+
expect(hook).toContain('.totem/artifacts/runs');
|
|
1299
2069
|
expect(hook).toContain('is_agent');
|
|
1300
2070
|
expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
|
|
1301
2071
|
});
|
|
1302
|
-
|
|
1303
|
-
|
|
2072
|
+
// Pre-mmnto-ai/totem#2692 this read "defaults to standard tier when no tier
|
|
2073
|
+
// specified". There is no default any more — the options object is required and
|
|
2074
|
+
// the RESOLVER supplies 'standard' — so the surviving intent is that the
|
|
2075
|
+
// standard-tier render still names the run store the resolver hands it.
|
|
2076
|
+
it('renders the run store at the options it was given', () => {
|
|
2077
|
+
const hook = buildPreCommitHook(RENDER);
|
|
1304
2078
|
expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
|
|
1305
|
-
expect(hook).toContain('.
|
|
2079
|
+
expect(hook).toContain('.totem/artifacts/runs');
|
|
1306
2080
|
});
|
|
1307
2081
|
});
|
|
1308
2082
|
describe('buildPrePushHook with strict tier', () => {
|
|
1309
|
-
const FALLBACK = 'pnpm dlx @mmnto/cli';
|
|
1310
2083
|
it('includes shield gate', () => {
|
|
1311
|
-
const hook = buildPrePushHook(
|
|
2084
|
+
const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
|
|
1312
2085
|
expect(hook).toContain('review');
|
|
1313
2086
|
expect(hook).toContain('shield gate (strict mode)');
|
|
1314
2087
|
});
|
|
1315
2088
|
it('includes TOTEM_HOOK_TIER set to strict', () => {
|
|
1316
|
-
const hook = buildPrePushHook(
|
|
2089
|
+
const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
|
|
1317
2090
|
expect(hook).toContain('TOTEM_HOOK_TIER="strict"');
|
|
1318
2091
|
});
|
|
1319
2092
|
it('includes agent detection snippet', () => {
|
|
1320
|
-
const hook = buildPrePushHook(
|
|
2093
|
+
const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
|
|
1321
2094
|
expect(hook).toContain('is_agent=0');
|
|
1322
2095
|
expect(hook).toContain('CLAUDE_CODE_AGENT');
|
|
1323
2096
|
});
|
|
@@ -1325,12 +2098,12 @@ describe('buildPrePushHook with strict tier', () => {
|
|
|
1325
2098
|
// shieldBlock alongside the existing `totem review` gate. Repo-state
|
|
1326
2099
|
// checks (like checkAgentsMdCanonical) now block push when they fail.
|
|
1327
2100
|
it('includes $TOTEM_CMD doctor --strict in the shield block', () => {
|
|
1328
|
-
const hook = buildPrePushHook(
|
|
2101
|
+
const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
|
|
1329
2102
|
expect(hook).toContain('$TOTEM_CMD doctor --strict');
|
|
1330
2103
|
expect(hook).toContain('doctor --strict (repo-state gate)');
|
|
1331
2104
|
});
|
|
1332
2105
|
it('gates doctor --strict on the agent/strict guard (does NOT fire unconditionally)', () => {
|
|
1333
|
-
const hook = buildPrePushHook(
|
|
2106
|
+
const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
|
|
1334
2107
|
// Find the position of the doctor --strict invocation and the surrounding
|
|
1335
2108
|
// guard; assert the invocation lives INSIDE the agent-or-tier block.
|
|
1336
2109
|
const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]');
|
|
@@ -1342,20 +2115,22 @@ describe('buildPrePushHook with strict tier', () => {
|
|
|
1342
2115
|
});
|
|
1343
2116
|
});
|
|
1344
2117
|
describe('buildPrePushHook with standard tier', () => {
|
|
1345
|
-
const FALLBACK = 'pnpm dlx @mmnto/cli';
|
|
1346
2118
|
it('includes shield gate guarded by agent/tier condition', () => {
|
|
1347
|
-
const hook = buildPrePushHook(
|
|
2119
|
+
const hook = buildPrePushHook(RENDER);
|
|
1348
2120
|
expect(hook).toContain('shield gate');
|
|
1349
2121
|
expect(hook).toContain('is_agent');
|
|
1350
2122
|
expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
|
|
1351
2123
|
});
|
|
1352
|
-
|
|
1353
|
-
|
|
2124
|
+
// Pre-mmnto-ai/totem#2692 this read "defaults to standard tier when no tier
|
|
2125
|
+
// specified" — the builders take a REQUIRED options object now, so the
|
|
2126
|
+
// surviving intent is that a standard-tier render stamps the standard tier.
|
|
2127
|
+
it('stamps the standard tier it was given', () => {
|
|
2128
|
+
const hook = buildPrePushHook(RENDER);
|
|
1354
2129
|
expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
|
|
1355
2130
|
expect(hook).toContain('shield gate');
|
|
1356
2131
|
});
|
|
1357
2132
|
it('still includes agent detection', () => {
|
|
1358
|
-
const hook = buildPrePushHook(
|
|
2133
|
+
const hook = buildPrePushHook(RENDER);
|
|
1359
2134
|
expect(hook).toContain('is_agent=0');
|
|
1360
2135
|
});
|
|
1361
2136
|
// mmnto-ai/totem#1908 — even in standard tier, the doctor --strict line
|
|
@@ -1364,7 +2139,7 @@ describe('buildPrePushHook with standard tier', () => {
|
|
|
1364
2139
|
// operators bypass it because their `is_agent=0` and `TOTEM_HOOK_TIER` is
|
|
1365
2140
|
// standard, so the guard branch never enters.
|
|
1366
2141
|
it('emits doctor --strict gated by agent/strict guard (no unconditional fire in standard tier)', () => {
|
|
1367
|
-
const hook = buildPrePushHook(
|
|
2142
|
+
const hook = buildPrePushHook(RENDER);
|
|
1368
2143
|
const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]');
|
|
1369
2144
|
const doctorIdx = hook.indexOf('$TOTEM_CMD doctor --strict');
|
|
1370
2145
|
const fiCloseIdx = hook.indexOf('fi', guardIdx);
|
|
@@ -1375,7 +2150,7 @@ describe('buildPrePushHook with standard tier', () => {
|
|
|
1375
2150
|
});
|
|
1376
2151
|
describe('agent detection uses POSIX syntax', () => {
|
|
1377
2152
|
it('pre-commit hook has no bashisms', () => {
|
|
1378
|
-
const hook = buildPreCommitHook('strict');
|
|
2153
|
+
const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
|
|
1379
2154
|
// Must use [ ] not [[ ]]
|
|
1380
2155
|
expect(hook).not.toContain('[[');
|
|
1381
2156
|
expect(hook).not.toContain(']]');
|
|
@@ -1385,7 +2160,7 @@ describe('agent detection uses POSIX syntax', () => {
|
|
|
1385
2160
|
expect(hook).toMatch(/^#!\/bin\/sh\n/);
|
|
1386
2161
|
});
|
|
1387
2162
|
it('pre-push hook has no bashisms', () => {
|
|
1388
|
-
const hook = buildPrePushHook(
|
|
2163
|
+
const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
|
|
1389
2164
|
// Must use [ ] not [[ ]]
|
|
1390
2165
|
expect(hook).not.toContain('[[');
|
|
1391
2166
|
expect(hook).not.toContain(']]');
|