@mmnto/cli 1.121.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 +16 -0
- package/dist/commands/config-drift.test.js.map +1 -1
- package/dist/commands/init-templates.d.ts +2 -2
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +2 -2
- package/dist/commands/install-hooks.d.ts.map +1 -1
- package/dist/commands/install-hooks.js +183 -17
- package/dist/commands/install-hooks.js.map +1 -1
- package/dist/commands/install-hooks.test.js +559 -18
- package/dist/commands/install-hooks.test.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/index.js +7 -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,7 +1,80 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { GROUNDING_ANCHOR_FREE_TEXT, GROUNDING_ANCHOR_ISSUE, GROUNDING_ANCHOR_MIXED, GROUNDING_ANCHOR_RECORD, GroundingAnchorSchema, hasUnrenderableHookChar, PROMPT_SOURCE_BUILTIN, PROMPT_SOURCE_OVERRIDE, TotemConfigError, } from '@mmnto/totem';
|
|
7
|
+
import { cleanTmpDir } from '../test-utils.js';
|
|
3
8
|
import { log } from '../ui.js';
|
|
4
|
-
import { assemblePrompt, expandSpecQuery, MAX_LESSON_CHARS, MAX_LESSONS, resolveDefaultSpecPath, retrieveContext, sanitizeSpecFilename, SPEC_SYSTEM_PROMPT, validateOutputOptions, } from './spec.js';
|
|
9
|
+
import { assemblePrompt, assertOutDoesNotOverwriteRecord, buildRecordSearchQuery, evaluateGroundingFloor, expandSpecQuery, formatGroundingRefusal, isRecordPathOutsideRoot, loadSpecRecord, MAX_LESSON_CHARS, MAX_LESSONS, resolveDefaultSpecPath, resolveGroundingAnchor, retrieveContext, sanitizeSpecFilename, SPEC_SYSTEM_PROMPT, specCommand, validateOutputOptions, validateSpecInvocation, } from './spec.js';
|
|
10
|
+
import { SPEC_REQUIRED_SECTIONS } from './spec-templates.js';
|
|
11
|
+
// ─── Mocks for the executed `specCommand` suite ─────────
|
|
12
|
+
//
|
|
13
|
+
// Everything the command touches OUTSIDE its own logic is stubbed so the
|
|
14
|
+
// anchored-evidence invariants (mmnto-ai/totem#2700) are measured on control
|
|
15
|
+
// flow, not on a live index: `runOrchestrator` is the ONLY writer of a run
|
|
16
|
+
// artifact, so "a refusal mints nothing" is provable by it never being
|
|
17
|
+
// reached, and `connect` counts prove a validation refused before the store.
|
|
18
|
+
const harness = vi.hoisted(() => ({
|
|
19
|
+
/** Store hits keyed by the `typeFilter` `retrieveContext` asks for. */
|
|
20
|
+
searchResults: {},
|
|
21
|
+
/** Every `runOrchestrator` invocation, in order — empty means no artifact could exist. */
|
|
22
|
+
orchestratorArgs: [],
|
|
23
|
+
/** What the stubbed orchestrator returns as the draft. */
|
|
24
|
+
orchestratorContent: 'DRAFT',
|
|
25
|
+
/** Resolved config the command reads (floor, dirs, embedding). */
|
|
26
|
+
config: {},
|
|
27
|
+
/** How many times a store was connected — 0 proves a refusal preceded the store. */
|
|
28
|
+
connects: 0,
|
|
29
|
+
/** Every `writeOutput(content, outPath?)` call. */
|
|
30
|
+
writes: [],
|
|
31
|
+
}));
|
|
32
|
+
vi.mock('@mmnto/totem', async () => {
|
|
33
|
+
const actual = await vi.importActual('@mmnto/totem');
|
|
34
|
+
return {
|
|
35
|
+
...actual,
|
|
36
|
+
createEmbedder: vi.fn(() => ({})),
|
|
37
|
+
LanceStore: class {
|
|
38
|
+
async connect() {
|
|
39
|
+
harness.connects += 1;
|
|
40
|
+
}
|
|
41
|
+
async search({ typeFilter }) {
|
|
42
|
+
return harness.searchResults[typeFilter] ?? [];
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
vi.mock('../utils.js', async () => {
|
|
48
|
+
const actual = await vi.importActual('../utils.js');
|
|
49
|
+
return {
|
|
50
|
+
...actual,
|
|
51
|
+
resolveConfigPath: (cwd) => path.join(cwd, 'totem.config.ts'),
|
|
52
|
+
loadEnv: () => { },
|
|
53
|
+
loadConfig: async () => harness.config,
|
|
54
|
+
requireEmbedding: () => ({ provider: 'gemini', model: 'test' }),
|
|
55
|
+
runOrchestrator: async (args) => {
|
|
56
|
+
harness.orchestratorArgs.push(args);
|
|
57
|
+
return harness.orchestratorContent;
|
|
58
|
+
},
|
|
59
|
+
writeOutput: (content, outPath) => {
|
|
60
|
+
harness.writes.push(outPath === undefined ? { content } : { content, outPath });
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
vi.mock('./qbd-seam.js', () => ({
|
|
65
|
+
recordQbdDerive: async () => ({}),
|
|
66
|
+
}));
|
|
67
|
+
vi.mock('../adapters/create-issue-adapter.js', () => ({
|
|
68
|
+
createIssueAdapter: async () => ({
|
|
69
|
+
fetchIssue: (num) => ({
|
|
70
|
+
number: num,
|
|
71
|
+
title: `Issue ${num}`,
|
|
72
|
+
body: 'issue body',
|
|
73
|
+
state: 'open',
|
|
74
|
+
labels: [],
|
|
75
|
+
}),
|
|
76
|
+
}),
|
|
77
|
+
}));
|
|
5
78
|
// ─── Helpers ─────────────────────────────────────────────
|
|
6
79
|
function makeLesson(overrides = {}) {
|
|
7
80
|
return {
|
|
@@ -66,12 +139,12 @@ describe('assemblePrompt', () => {
|
|
|
66
139
|
...emptyContext(),
|
|
67
140
|
lessons: [makeLesson()],
|
|
68
141
|
};
|
|
69
|
-
const result = await assemblePrompt([{ issue: null, freeText: 'test topic' }], ctx, 'system prompt');
|
|
142
|
+
const result = await assemblePrompt([{ issue: null, freeText: 'test topic', record: null }], ctx, 'system prompt');
|
|
70
143
|
expect(result).toContain('RELEVANT LESSONS (HARD CONSTRAINTS)');
|
|
71
144
|
expect(result).toContain('Always validate input at boundaries.');
|
|
72
145
|
});
|
|
73
146
|
it('omits lessons section when no lessons found', async () => {
|
|
74
|
-
const result = await assemblePrompt([{ issue: null, freeText: 'test topic' }], emptyContext(), 'system prompt');
|
|
147
|
+
const result = await assemblePrompt([{ issue: null, freeText: 'test topic', record: null }], emptyContext(), 'system prompt');
|
|
75
148
|
expect(result).not.toContain('RELEVANT LESSONS');
|
|
76
149
|
});
|
|
77
150
|
it('includes full lesson body without truncation', async () => {
|
|
@@ -80,7 +153,7 @@ describe('assemblePrompt', () => {
|
|
|
80
153
|
...emptyContext(),
|
|
81
154
|
lessons: [makeLesson({ content: longBody })],
|
|
82
155
|
};
|
|
83
|
-
const result = await assemblePrompt([{ issue: null, freeText: 'test' }], ctx, 'system prompt');
|
|
156
|
+
const result = await assemblePrompt([{ issue: null, freeText: 'test', record: null }], ctx, 'system prompt');
|
|
84
157
|
expect(result).toContain(longBody);
|
|
85
158
|
});
|
|
86
159
|
it('includes lesson score in output', async () => {
|
|
@@ -88,7 +161,7 @@ describe('assemblePrompt', () => {
|
|
|
88
161
|
...emptyContext(),
|
|
89
162
|
lessons: [makeLesson({ score: 0.789 })],
|
|
90
163
|
};
|
|
91
|
-
const result = await assemblePrompt([{ issue: null, freeText: 'test' }], ctx, 'system prompt');
|
|
164
|
+
const result = await assemblePrompt([{ issue: null, freeText: 'test', record: null }], ctx, 'system prompt');
|
|
92
165
|
expect(result).toContain('0.789');
|
|
93
166
|
});
|
|
94
167
|
it('respects MAX_LESSON_CHARS budget', async () => {
|
|
@@ -96,7 +169,7 @@ describe('assemblePrompt', () => {
|
|
|
96
169
|
const bigLesson = makeLesson({ content: 'X'.repeat(2000) });
|
|
97
170
|
const lessons = Array.from({ length: 10 }, () => ({ ...bigLesson }));
|
|
98
171
|
const ctx = { ...emptyContext(), lessons };
|
|
99
|
-
const result = await assemblePrompt([{ issue: null, freeText: 'test' }], ctx, 'system prompt');
|
|
172
|
+
const result = await assemblePrompt([{ issue: null, freeText: 'test', record: null }], ctx, 'system prompt');
|
|
100
173
|
// Extract just the lessons section (stop at the next === section)
|
|
101
174
|
const afterLessons = result.split('RELEVANT LESSONS (HARD CONSTRAINTS)')[1] ?? '';
|
|
102
175
|
const lessonSection = afterLessons.split(/\n===\s/)[0] ?? '';
|
|
@@ -109,13 +182,13 @@ describe('assemblePrompt', () => {
|
|
|
109
182
|
...emptyContext(),
|
|
110
183
|
lessons: [hugeLesson, smallLesson],
|
|
111
184
|
};
|
|
112
|
-
const result = await assemblePrompt([{ issue: null, freeText: 'test' }], ctx, 'system prompt');
|
|
185
|
+
const result = await assemblePrompt([{ issue: null, freeText: 'test', record: null }], ctx, 'system prompt');
|
|
113
186
|
expect(result).toContain('RELEVANT LESSONS');
|
|
114
187
|
expect(result).toContain('Small lesson body');
|
|
115
188
|
expect(result).not.toContain('H'.repeat(100));
|
|
116
189
|
});
|
|
117
190
|
it('includes shared helpers section (#1015)', async () => {
|
|
118
|
-
const result = await assemblePrompt([{ issue: null, freeText: 'test topic' }], emptyContext(), 'system prompt');
|
|
191
|
+
const result = await assemblePrompt([{ issue: null, freeText: 'test topic', record: null }], emptyContext(), 'system prompt');
|
|
119
192
|
expect(result).toContain('SHARED HELPERS');
|
|
120
193
|
expect(result).toContain('safeExec');
|
|
121
194
|
expect(result).toContain('Instead of:');
|
|
@@ -126,7 +199,7 @@ describe('assemblePrompt', () => {
|
|
|
126
199
|
specs: [makeSpec()],
|
|
127
200
|
lessons: [makeLesson()],
|
|
128
201
|
};
|
|
129
|
-
const result = await assemblePrompt([{ issue: null, freeText: 'test' }], ctx, 'system prompt');
|
|
202
|
+
const result = await assemblePrompt([{ issue: null, freeText: 'test', record: null }], ctx, 'system prompt');
|
|
130
203
|
expect(result).toContain('RELATED SPECS & ADRs');
|
|
131
204
|
expect(result).toContain('RELEVANT LESSONS (HARD CONSTRAINTS)');
|
|
132
205
|
});
|
|
@@ -145,6 +218,7 @@ describe('assemblePrompt', () => {
|
|
|
145
218
|
labels: ['bug'],
|
|
146
219
|
},
|
|
147
220
|
freeText: null,
|
|
221
|
+
record: null,
|
|
148
222
|
},
|
|
149
223
|
], ctx, 'system prompt');
|
|
150
224
|
expect(result).toContain('ISSUE #42');
|
|
@@ -221,7 +295,7 @@ describe('retrieveContext partitioning', () => {
|
|
|
221
295
|
code: [],
|
|
222
296
|
lessons: [makeLesson({ filePath: '.totem/lessons.md' })],
|
|
223
297
|
};
|
|
224
|
-
const result = await assemblePrompt([{ issue: null, freeText: 'test' }], ctx, 'system prompt');
|
|
298
|
+
const result = await assemblePrompt([{ issue: null, freeText: 'test', record: null }], ctx, 'system prompt');
|
|
225
299
|
// Lessons appear in their own section, not mixed with specs
|
|
226
300
|
const specSection = result.split('RELATED SPECS & ADRs')[1]?.split('===')[0] ?? '';
|
|
227
301
|
expect(specSection).not.toContain('lessons.md');
|
|
@@ -322,11 +396,11 @@ describe('resolveDefaultSpecPath', () => {
|
|
|
322
396
|
pathJoin: (...parts) => parts.join('/'),
|
|
323
397
|
};
|
|
324
398
|
it('resolves single issue input to <gitRoot>/.totem/specs/<number>.md', () => {
|
|
325
|
-
const result = resolveDefaultSpecPath([{ issue: makeIssue(1555), freeText: null }], '/repo/packages/cli', deps);
|
|
399
|
+
const result = resolveDefaultSpecPath([{ issue: makeIssue(1555), freeText: null, record: null }], '/repo/packages/cli', deps);
|
|
326
400
|
expect(result).toBe('/repo/.totem/specs/1555.md');
|
|
327
401
|
});
|
|
328
402
|
it('resolves single free-text input to sanitized filename', () => {
|
|
329
|
-
const result = resolveDefaultSpecPath([{ issue: null, freeText: 'migration plan' }], '/repo', deps);
|
|
403
|
+
const result = resolveDefaultSpecPath([{ issue: null, freeText: 'migration plan', record: null }], '/repo', deps);
|
|
330
404
|
expect(result).toBe('/repo/.totem/specs/migration-plan.md');
|
|
331
405
|
});
|
|
332
406
|
it('falls back to cwd when git root is unavailable', () => {
|
|
@@ -334,27 +408,836 @@ describe('resolveDefaultSpecPath', () => {
|
|
|
334
408
|
resolveGitRoot: vi.fn(() => null),
|
|
335
409
|
pathJoin: (...parts) => parts.join('/'),
|
|
336
410
|
};
|
|
337
|
-
const result = resolveDefaultSpecPath([{ issue: makeIssue(42), freeText: null }], '/some/cwd', fallbackDeps);
|
|
411
|
+
const result = resolveDefaultSpecPath([{ issue: makeIssue(42), freeText: null, record: null }], '/some/cwd', fallbackDeps);
|
|
338
412
|
expect(result).toBe('/some/cwd/.totem/specs/42.md');
|
|
339
413
|
});
|
|
340
414
|
it('returns null for multi-input invocations', () => {
|
|
341
415
|
const result = resolveDefaultSpecPath([
|
|
342
|
-
{ issue: makeIssue(1), freeText: null },
|
|
343
|
-
{ issue: makeIssue(2), freeText: null },
|
|
416
|
+
{ issue: makeIssue(1), freeText: null, record: null },
|
|
417
|
+
{ issue: makeIssue(2), freeText: null, record: null },
|
|
344
418
|
], '/repo', deps);
|
|
345
419
|
expect(result).toBeNull();
|
|
346
420
|
});
|
|
347
421
|
it('returns null when free text sanitizes to empty', () => {
|
|
348
|
-
const result = resolveDefaultSpecPath([{ issue: null, freeText: '!!!' }], '/repo', deps);
|
|
422
|
+
const result = resolveDefaultSpecPath([{ issue: null, freeText: '!!!', record: null }], '/repo', deps);
|
|
349
423
|
expect(result).toBeNull();
|
|
350
424
|
});
|
|
351
425
|
it('returns null when single input has neither issue nor free text', () => {
|
|
352
|
-
const result = resolveDefaultSpecPath([{ issue: null, freeText: null }], '/repo', deps);
|
|
426
|
+
const result = resolveDefaultSpecPath([{ issue: null, freeText: null, record: null }], '/repo', deps);
|
|
353
427
|
expect(result).toBeNull();
|
|
354
428
|
});
|
|
355
429
|
it('uses git root over cwd for monorepo subpackages', () => {
|
|
356
|
-
const result = resolveDefaultSpecPath([{ issue: makeIssue(99), freeText: null }], '/repo/packages/cli/src', deps);
|
|
430
|
+
const result = resolveDefaultSpecPath([{ issue: makeIssue(99), freeText: null, record: null }], '/repo/packages/cli/src', deps);
|
|
357
431
|
expect(result).toBe('/repo/.totem/specs/99.md');
|
|
358
432
|
});
|
|
433
|
+
// mmnto-ai/totem#2700: a bound record derives NO path — the only path it
|
|
434
|
+
// could derive is the record's own, and the tool never drafts over it.
|
|
435
|
+
it('returns null for a bound record (the draft goes to stdout or --out)', () => {
|
|
436
|
+
const result = resolveDefaultSpecPath([{ issue: null, freeText: null, record: makeRecord() }], '/repo', deps);
|
|
437
|
+
expect(result).toBeNull();
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
// ─── SPEC_REQUIRED_SECTIONS (mmnto-ai/totem#2700) ────────
|
|
441
|
+
describe('SPEC_REQUIRED_SECTIONS', () => {
|
|
442
|
+
it('every entry is a VERBATIM line of the system prompt (the gate requires only what the command asks for)', () => {
|
|
443
|
+
const promptLines = SPEC_SYSTEM_PROMPT.split('\n');
|
|
444
|
+
for (const section of SPEC_REQUIRED_SECTIONS) {
|
|
445
|
+
expect(promptLines, `${section} is not a line of SPEC_SYSTEM_PROMPT`).toContain(section);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
it('every entry is renderable into the single-quoted node -e reader (the mmnto-ai/totem#2692 C4 predicate)', () => {
|
|
449
|
+
for (const section of SPEC_REQUIRED_SECTIONS) {
|
|
450
|
+
// A quote, backslash, dollar, backtick, control character or non-ASCII
|
|
451
|
+
// byte would break the `sh` single-quoted word, the JS string literal
|
|
452
|
+
// inside it, or both — and could forge hook lines.
|
|
453
|
+
expect(hasUnrenderableHookChar(section), `${section} cannot be rendered`).toBe(false);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
it('names both promised sections, in prompt order', () => {
|
|
457
|
+
expect([...SPEC_REQUIRED_SECTIONS]).toEqual([
|
|
458
|
+
'### Problem Statement',
|
|
459
|
+
'### Implementation Tasks',
|
|
460
|
+
]);
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
// ─── resolveGroundingAnchor (mmnto-ai/totem#2700) ────────
|
|
464
|
+
const RECORD_SHA = 'a'.repeat(64);
|
|
465
|
+
function makeRecord(overrides = {}) {
|
|
466
|
+
return {
|
|
467
|
+
path: '.totem/specs/2700.md',
|
|
468
|
+
sha256: RECORD_SHA,
|
|
469
|
+
body: '# Design record\n\nA body under the heading.\n',
|
|
470
|
+
...overrides,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
function issueInput(num, typed) {
|
|
474
|
+
return {
|
|
475
|
+
issue: makeIssue(num),
|
|
476
|
+
freeText: null,
|
|
477
|
+
record: null,
|
|
478
|
+
...(typed !== undefined ? { issueRef: typed } : {}),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function topicInput(text) {
|
|
482
|
+
return { issue: null, freeText: text, record: null };
|
|
483
|
+
}
|
|
484
|
+
describe('resolveGroundingAnchor', () => {
|
|
485
|
+
it('a single bare-number issue anchors `issue` with a #<n> ref', () => {
|
|
486
|
+
expect(resolveGroundingAnchor([issueInput(2700, '2700')])).toEqual({
|
|
487
|
+
kind: GROUNDING_ANCHOR_ISSUE,
|
|
488
|
+
ref: '#2700',
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
it('keeps an owner/repo#N or URL input AS TYPED (a fetched issue carries only a number)', () => {
|
|
492
|
+
expect(resolveGroundingAnchor([issueInput(2700, 'mmnto-ai/totem#2700')])).toEqual({
|
|
493
|
+
kind: GROUNDING_ANCHOR_ISSUE,
|
|
494
|
+
ref: 'mmnto-ai/totem#2700',
|
|
495
|
+
});
|
|
496
|
+
expect(resolveGroundingAnchor([issueInput(42, 'https://github.com/mmnto-ai/totem/issues/42')])).toEqual({
|
|
497
|
+
kind: GROUNDING_ANCHOR_ISSUE,
|
|
498
|
+
ref: 'https://github.com/mmnto-ai/totem/issues/42',
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
it('falls back to #<number> when no typed form was recorded', () => {
|
|
502
|
+
expect(resolveGroundingAnchor([issueInput(7)])).toEqual({
|
|
503
|
+
kind: GROUNDING_ANCHOR_ISSUE,
|
|
504
|
+
ref: '#7',
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
it('comma-joins several issue refs', () => {
|
|
508
|
+
expect(resolveGroundingAnchor([issueInput(1, '1'), issueInput(2, 'mmnto-ai/totem#2')])).toEqual({
|
|
509
|
+
kind: GROUNDING_ANCHOR_ISSUE,
|
|
510
|
+
ref: '#1, mmnto-ai/totem#2',
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
it('a bound record anchors `record` with the repo-relative path and the sha256 of its bytes', () => {
|
|
514
|
+
expect(resolveGroundingAnchor([{ issue: null, freeText: null, record: makeRecord() }])).toEqual({
|
|
515
|
+
kind: GROUNDING_ANCHOR_RECORD,
|
|
516
|
+
ref: '.totem/specs/2700.md',
|
|
517
|
+
sha256: RECORD_SHA,
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
it('topics only anchor `free-text`, several joined with a pipe', () => {
|
|
521
|
+
expect(resolveGroundingAnchor([topicInput('cache invalidation')])).toEqual({
|
|
522
|
+
kind: GROUNDING_ANCHOR_FREE_TEXT,
|
|
523
|
+
ref: 'cache invalidation',
|
|
524
|
+
});
|
|
525
|
+
expect(resolveGroundingAnchor([topicInput('alpha'), topicInput('beta')])).toEqual({
|
|
526
|
+
kind: GROUNDING_ANCHOR_FREE_TEXT,
|
|
527
|
+
ref: 'alpha | beta',
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
it('issues AND topics anchor the honest `mixed` kind — issue refs first, then topics', () => {
|
|
531
|
+
expect(resolveGroundingAnchor([issueInput(9, '9'), topicInput('alpha'), topicInput('beta')])).toEqual({
|
|
532
|
+
kind: GROUNDING_ANCHOR_MIXED,
|
|
533
|
+
ref: '#9 | alpha | beta',
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
// A topic is the one anchor ref built out of raw argv, so it is the one that
|
|
537
|
+
// can carry a control character the user typed. `GroundingAnchorSchema`
|
|
538
|
+
// refuses such a ref — inside `saveRunArtifact`, under `runOrchestrator`'s
|
|
539
|
+
// warn-and-continue catch, so the run would survive and only the ARTIFACT
|
|
540
|
+
// would be silently lost. Collapsing at the mint site keeps the request
|
|
541
|
+
// parsable for a ref the CLI itself produced.
|
|
542
|
+
it.each([
|
|
543
|
+
['a tab', 0x09],
|
|
544
|
+
['a newline', 0x0a],
|
|
545
|
+
['a DEL', 0x7f],
|
|
546
|
+
['a C1 NEL', 0x85],
|
|
547
|
+
])('collapses %s in a free-text topic to `?`, keeping the anchor parsable', (_label, code) => {
|
|
548
|
+
const topic = `cache${String.fromCharCode(code)}invalidation`;
|
|
549
|
+
const anchor = resolveGroundingAnchor([topicInput(topic)]);
|
|
550
|
+
expect(anchor).toEqual({ kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'cache?invalidation' });
|
|
551
|
+
expect(GroundingAnchorSchema.safeParse(anchor).success).toBe(true);
|
|
552
|
+
});
|
|
553
|
+
it('leaves printable non-ASCII in a topic alone — a free-text ref is the topic AS TYPED', () => {
|
|
554
|
+
expect(resolveGroundingAnchor([topicInput('ancrage café')]).ref).toBe('ancrage café');
|
|
555
|
+
});
|
|
556
|
+
it('collapses the topic half of a `mixed` ref too', () => {
|
|
557
|
+
const anchor = resolveGroundingAnchor([
|
|
558
|
+
issueInput(9, '9'),
|
|
559
|
+
topicInput(`alpha${String.fromCharCode(0x0a)}beta`),
|
|
560
|
+
]);
|
|
561
|
+
expect(anchor.ref).toBe('#9 | alpha?beta');
|
|
562
|
+
expect(GroundingAnchorSchema.safeParse(anchor).success).toBe(true);
|
|
563
|
+
});
|
|
564
|
+
// The topic arm is not the only ref taken verbatim from argv. The issue-URL
|
|
565
|
+
// match is NOT end-anchored (`.../issues/(\d+)` with trailing text allowed),
|
|
566
|
+
// so `https://host/o/r/issues/1<newline>x` resolves to issue 1 and the typed
|
|
567
|
+
// spelling — newline and all — becomes the ref. Unsanitized it costs the run
|
|
568
|
+
// its artifact exactly as an unsanitized topic did.
|
|
569
|
+
it('collapses a control character in a TYPED issue ref (the URL match is not end-anchored)', () => {
|
|
570
|
+
const typed = `https://github.com/mmnto-ai/totem/issues/1${String.fromCharCode(0x0a)}[Totem] forged`;
|
|
571
|
+
const anchor = resolveGroundingAnchor([issueInput(1, typed)]);
|
|
572
|
+
expect(anchor).toEqual({
|
|
573
|
+
kind: GROUNDING_ANCHOR_ISSUE,
|
|
574
|
+
ref: 'https://github.com/mmnto-ai/totem/issues/1?[Totem] forged',
|
|
575
|
+
});
|
|
576
|
+
expect(GroundingAnchorSchema.safeParse(anchor).success).toBe(true);
|
|
577
|
+
});
|
|
578
|
+
it('leaves a printable typed issue ref byte-identical', () => {
|
|
579
|
+
expect(resolveGroundingAnchor([issueInput(2700, 'mmnto-ai/totem#2700')]).ref).toBe('mmnto-ai/totem#2700');
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
// ─── evaluateGroundingFloor (mmnto-ai/totem#2700) ────────
|
|
583
|
+
function relevantHit(relevance, overrides = {}) {
|
|
584
|
+
return makeSpec({
|
|
585
|
+
...(relevance !== undefined ? { relevance } : {}),
|
|
586
|
+
...overrides,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
const FLOOR = 0.25;
|
|
590
|
+
describe('evaluateGroundingFloor', () => {
|
|
591
|
+
it('0 retrieved items REFUSES — nothing grounds the run (the charter rule, not an MCP mirror)', () => {
|
|
592
|
+
const verdict = evaluateGroundingFloor(emptyContext(), FLOOR);
|
|
593
|
+
expect(verdict).toEqual({
|
|
594
|
+
refuse: true,
|
|
595
|
+
hits: 0,
|
|
596
|
+
bestRelevance: null,
|
|
597
|
+
withheld: [],
|
|
598
|
+
floorExempt: 0,
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
it('every signal-bearing hit below the floor, none exempt, REFUSES', () => {
|
|
602
|
+
const verdict = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(0.2), relevantHit(0.11)] }, FLOOR);
|
|
603
|
+
expect(verdict.refuse).toBe(true);
|
|
604
|
+
expect(verdict.hits).toBe(2);
|
|
605
|
+
expect(verdict.bestRelevance).toBeCloseTo(0.2, 10);
|
|
606
|
+
expect(verdict.floorExempt).toBe(0);
|
|
607
|
+
});
|
|
608
|
+
it('one hit AT the floor PROCEEDS (the floor is inclusive)', () => {
|
|
609
|
+
const verdict = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(0.1), relevantHit(FLOOR)] }, FLOOR);
|
|
610
|
+
expect(verdict.refuse).toBe(false);
|
|
611
|
+
expect(verdict.withheld).toEqual([]);
|
|
612
|
+
});
|
|
613
|
+
it('one floor-EXEMPT hit beside below-floor signal PROCEEDS (a keyword-only hit is never withheld for a weak sibling)', () => {
|
|
614
|
+
const verdict = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(0.05)], code: [relevantHit(undefined)] }, FLOOR);
|
|
615
|
+
expect(verdict.refuse).toBe(false);
|
|
616
|
+
expect(verdict.floorExempt).toBe(1);
|
|
617
|
+
expect(verdict.withheld).toEqual([]);
|
|
618
|
+
});
|
|
619
|
+
it('no relevance anywhere PROCEEDS — a pure-FTS corpus is never demoted', () => {
|
|
620
|
+
const verdict = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(undefined), relevantHit(undefined)] }, FLOOR);
|
|
621
|
+
expect(verdict.refuse).toBe(false);
|
|
622
|
+
expect(verdict.bestRelevance).toBeNull();
|
|
623
|
+
expect(verdict.floorExempt).toBe(2);
|
|
624
|
+
});
|
|
625
|
+
// A non-finite relevance is what the CORE builder drops: the item it writes
|
|
626
|
+
// carries no relevance at all, exactly an FTS-only hit's shape. The floor
|
|
627
|
+
// must read it the same way, or the artifact and the judgment disagree.
|
|
628
|
+
it.each([NaN, Infinity, -Infinity])('a non-finite relevance (%s) is floor-EXEMPT, never counted as signal', (relevance) => {
|
|
629
|
+
const verdict = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(relevance)] }, FLOOR);
|
|
630
|
+
expect(verdict.floorExempt).toBe(1);
|
|
631
|
+
expect(verdict.bestRelevance).toBeNull();
|
|
632
|
+
expect(verdict.withheld).toEqual([]);
|
|
633
|
+
expect(verdict.refuse).toBe(false);
|
|
634
|
+
});
|
|
635
|
+
it('a NaN hit beside a genuinely weak one is not disclosed as a withheld candidate', () => {
|
|
636
|
+
const verdict = evaluateGroundingFloor({
|
|
637
|
+
...emptyContext(),
|
|
638
|
+
specs: [relevantHit(0.05, { filePath: 'docs/weak.md' }), relevantHit(NaN)],
|
|
639
|
+
}, FLOOR);
|
|
640
|
+
// One exempt hit is enough to proceed — and nothing is withheld.
|
|
641
|
+
expect(verdict.floorExempt).toBe(1);
|
|
642
|
+
expect(verdict.refuse).toBe(false);
|
|
643
|
+
expect(verdict.withheld).toEqual([]);
|
|
644
|
+
});
|
|
645
|
+
it('counts hits across ALL FOUR partitions', () => {
|
|
646
|
+
const verdict = evaluateGroundingFloor({
|
|
647
|
+
specs: [relevantHit(0.9)],
|
|
648
|
+
sessions: [relevantHit(0.8)],
|
|
649
|
+
code: [relevantHit(0.7)],
|
|
650
|
+
lessons: [relevantHit(0.6)],
|
|
651
|
+
}, FLOOR);
|
|
652
|
+
expect(verdict.hits).toBe(4);
|
|
653
|
+
expect(verdict.bestRelevance).toBeCloseTo(0.9, 10);
|
|
654
|
+
});
|
|
655
|
+
it('the withheld list carries every below-floor candidate as path + relevance (linked hits keep their store)', () => {
|
|
656
|
+
const verdict = evaluateGroundingFloor({
|
|
657
|
+
...emptyContext(),
|
|
658
|
+
specs: [
|
|
659
|
+
relevantHit(0.2, { filePath: 'docs/a.md' }),
|
|
660
|
+
relevantHit(0.1, { filePath: 'doctrine/b.md', sourceRepo: 'strategy' }),
|
|
661
|
+
],
|
|
662
|
+
}, FLOOR);
|
|
663
|
+
expect(verdict.withheld).toEqual([
|
|
664
|
+
{ filePath: 'docs/a.md', relevance: 0.2 },
|
|
665
|
+
{ filePath: 'doctrine/b.md', sourceRepo: 'strategy', relevance: 0.1 },
|
|
666
|
+
]);
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
// ─── formatGroundingRefusal (mmnto-ai/totem#2700) ────────
|
|
670
|
+
describe('formatGroundingRefusal', () => {
|
|
671
|
+
it('a 0-hit refusal names the topic, the 0 hits, and the floor VALUE and PLACE', () => {
|
|
672
|
+
const verdict = evaluateGroundingFloor(emptyContext(), FLOOR);
|
|
673
|
+
const { message } = formatGroundingRefusal('an-unanchored-slug', verdict, FLOOR);
|
|
674
|
+
expect(message).toContain('an-unanchored-slug');
|
|
675
|
+
expect(message).toContain('0 hits');
|
|
676
|
+
expect(message).toContain('floor 0.250 — searchRelevanceFloor in totem.config.ts (schema default 0.25 when unset)');
|
|
677
|
+
});
|
|
678
|
+
it('a below-floor refusal names the best relevance and DISCLOSES every withheld candidate', () => {
|
|
679
|
+
const verdict = evaluateGroundingFloor({
|
|
680
|
+
...emptyContext(),
|
|
681
|
+
specs: [
|
|
682
|
+
relevantHit(0.2, { filePath: 'docs/a.md' }),
|
|
683
|
+
relevantHit(0.1, { filePath: 'doctrine/b.md', sourceRepo: 'strategy' }),
|
|
684
|
+
],
|
|
685
|
+
}, FLOOR);
|
|
686
|
+
const { message } = formatGroundingRefusal('weak topic', verdict, FLOOR);
|
|
687
|
+
expect(message).toContain('best relevance 0.200');
|
|
688
|
+
expect(message).toContain('1. docs/a.md — relevance 0.200');
|
|
689
|
+
expect(message).toContain('2. [strategy] doctrine/b.md — relevance 0.100');
|
|
690
|
+
});
|
|
691
|
+
it('the hint names both cures and the --raw inspection path', () => {
|
|
692
|
+
const { recoveryHint } = formatGroundingRefusal('topic', evaluateGroundingFloor(emptyContext(), FLOOR), FLOOR);
|
|
693
|
+
expect(recoveryHint).toContain('totem spec <issue>');
|
|
694
|
+
expect(recoveryHint).toContain('totem spec --from <record>');
|
|
695
|
+
expect(recoveryHint).toContain('--raw');
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
// ─── The record arm of assemblePrompt + its query ────────
|
|
699
|
+
describe('assemblePrompt — the record arm (mmnto-ai/totem#2700)', () => {
|
|
700
|
+
it('renders the RECORD banner with the path and the digest head, and the body verbatim', async () => {
|
|
701
|
+
const record = makeRecord({ body: '# Design record\n\nThe ruled contract.\n' });
|
|
702
|
+
const result = await assemblePrompt([{ issue: null, freeText: null, record }], emptyContext(), 'system prompt');
|
|
703
|
+
expect(result).toContain(`=== RECORD .totem/specs/2700.md (sha256 ${'a'.repeat(12)}) ===`);
|
|
704
|
+
expect(result).toContain('<record_body>');
|
|
705
|
+
expect(result).toContain('The ruled contract.');
|
|
706
|
+
});
|
|
707
|
+
it('does not emit an ISSUE or TOPIC section for a record', async () => {
|
|
708
|
+
const result = await assemblePrompt([{ issue: null, freeText: null, record: makeRecord() }], emptyContext(), 'system prompt');
|
|
709
|
+
expect(result).not.toContain('=== TOPIC ===');
|
|
710
|
+
expect(result).not.toContain('=== ISSUE #');
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
describe('buildRecordSearchQuery', () => {
|
|
714
|
+
it('queries on the record`s first heading plus the head of its body', () => {
|
|
715
|
+
const query = buildRecordSearchQuery(makeRecord({ body: '# Anchored evidence\n\nThe body head.\n' }));
|
|
716
|
+
expect(query.startsWith('Anchored evidence')).toBe(true);
|
|
717
|
+
expect(query).toContain('The body head.');
|
|
718
|
+
});
|
|
719
|
+
it('degrades to the body head when the record carries no heading', () => {
|
|
720
|
+
const query = buildRecordSearchQuery(makeRecord({ body: 'no heading at all' }));
|
|
721
|
+
expect(query).toBe('no heading at all');
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
// ─── --from validation (mmnto-ai/totem#2700) ─────────────
|
|
725
|
+
describe('validateSpecInvocation', () => {
|
|
726
|
+
it('refuses no inputs and no --from, carrying the usage line', () => {
|
|
727
|
+
let thrown;
|
|
728
|
+
try {
|
|
729
|
+
validateSpecInvocation([], {}, TotemConfigError);
|
|
730
|
+
}
|
|
731
|
+
catch (err) {
|
|
732
|
+
thrown = err;
|
|
733
|
+
}
|
|
734
|
+
expect(thrown).toMatchObject({ code: 'CONFIG_INVALID' });
|
|
735
|
+
expect(String(thrown.message)).toContain('at least one issue/topic');
|
|
736
|
+
expect(String(thrown.recoveryHint)).toContain('Usage: totem spec [inputs...] [--from <record>]');
|
|
737
|
+
});
|
|
738
|
+
it('refuses --from together with positional inputs', () => {
|
|
739
|
+
expect(() => validateSpecInvocation(['2700'], { from: 'record.md' }, TotemConfigError)).toThrowError(/--from <record> cannot be combined with positional inputs/);
|
|
740
|
+
});
|
|
741
|
+
it('accepts inputs alone and --from alone', () => {
|
|
742
|
+
expect(() => validateSpecInvocation(['2700'], {}, TotemConfigError)).not.toThrow();
|
|
743
|
+
expect(() => validateSpecInvocation([], { from: 'record.md' }, TotemConfigError)).not.toThrow();
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
/**
|
|
747
|
+
* Whether this platform (and this account) can make each link kind — a Windows
|
|
748
|
+
* account without SeCreateSymbolicLinkPrivilege cannot symlink, and a
|
|
749
|
+
* filesystem without hardlinks cannot link. Probed ONCE, outside any test, so
|
|
750
|
+
* every `skipIf` below is a real capability check rather than a swallowed
|
|
751
|
+
* failure inside an assertion. Shared by the containment suite (`loadSpecRecord`
|
|
752
|
+
* must see THROUGH a link) and the `--out` alias suite (a link is a second name
|
|
753
|
+
* for the record).
|
|
754
|
+
*/
|
|
755
|
+
const linkable = (() => {
|
|
756
|
+
const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-link-probe-'));
|
|
757
|
+
const target = path.join(probeDir, 'target');
|
|
758
|
+
fs.writeFileSync(target, 'probe');
|
|
759
|
+
const attempt = (make) => {
|
|
760
|
+
try {
|
|
761
|
+
make();
|
|
762
|
+
return true;
|
|
763
|
+
}
|
|
764
|
+
catch (err) {
|
|
765
|
+
void err;
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
const hard = attempt(() => fs.linkSync(target, path.join(probeDir, 'hard')));
|
|
770
|
+
const sym = attempt(() => fs.symlinkSync(target, path.join(probeDir, 'sym')));
|
|
771
|
+
cleanTmpDir(probeDir);
|
|
772
|
+
return { hard, sym };
|
|
773
|
+
})();
|
|
774
|
+
describe('loadSpecRecord', () => {
|
|
775
|
+
let tmpDir;
|
|
776
|
+
const deps = { resolveGitRoot: () => null };
|
|
777
|
+
beforeEach(() => {
|
|
778
|
+
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-spec-record-')));
|
|
779
|
+
});
|
|
780
|
+
afterEach(() => {
|
|
781
|
+
cleanTmpDir(tmpDir);
|
|
782
|
+
});
|
|
783
|
+
it('binds the record: repo-relative forward-slash path, sha256 of the bytes, the body from the SAME buffer', () => {
|
|
784
|
+
const nested = path.join(tmpDir, '.totem', 'specs');
|
|
785
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
786
|
+
const file = path.join(nested, '2700.md');
|
|
787
|
+
const bytes = Buffer.from('# Record\n\nBody.\n', 'utf-8');
|
|
788
|
+
fs.writeFileSync(file, bytes);
|
|
789
|
+
const loaded = loadSpecRecord(file, tmpDir, deps, TotemConfigError);
|
|
790
|
+
expect(loaded.record.path).toBe('.totem/specs/2700.md');
|
|
791
|
+
expect(loaded.record.sha256).toBe(crypto.createHash('sha256').update(bytes).digest('hex'));
|
|
792
|
+
expect(loaded.record.body).toBe(bytes.toString('utf-8'));
|
|
793
|
+
expect(loaded.absolutePath).toBe(path.resolve(file));
|
|
794
|
+
});
|
|
795
|
+
it('refuses a missing path, naming it', () => {
|
|
796
|
+
const missing = path.join(tmpDir, 'nope.md');
|
|
797
|
+
let thrown;
|
|
798
|
+
try {
|
|
799
|
+
loadSpecRecord(missing, tmpDir, deps, TotemConfigError);
|
|
800
|
+
}
|
|
801
|
+
catch (err) {
|
|
802
|
+
thrown = err;
|
|
803
|
+
}
|
|
804
|
+
expect(thrown).toMatchObject({ code: 'CONFIG_INVALID' });
|
|
805
|
+
expect(String(thrown.message)).toContain('--from record not found');
|
|
806
|
+
expect(String(thrown.message)).toContain(missing);
|
|
807
|
+
});
|
|
808
|
+
it('refuses a directory', () => {
|
|
809
|
+
const dir = path.join(tmpDir, 'adir');
|
|
810
|
+
fs.mkdirSync(dir);
|
|
811
|
+
expect(() => loadSpecRecord(dir, tmpDir, deps, TotemConfigError)).toThrowError(/--from record is not a file/);
|
|
812
|
+
});
|
|
813
|
+
it('refuses an empty record', () => {
|
|
814
|
+
const file = path.join(tmpDir, 'empty.md');
|
|
815
|
+
fs.writeFileSync(file, '');
|
|
816
|
+
expect(() => loadSpecRecord(file, tmpDir, deps, TotemConfigError)).toThrowError(/--from record is empty/);
|
|
817
|
+
});
|
|
818
|
+
it('refuses a whitespace-only record', () => {
|
|
819
|
+
const file = path.join(tmpDir, 'blank.md');
|
|
820
|
+
fs.writeFileSync(file, ' \n\t\n \n');
|
|
821
|
+
expect(() => loadSpecRecord(file, tmpDir, deps, TotemConfigError)).toThrowError(/--from record is empty/);
|
|
822
|
+
});
|
|
823
|
+
// ── The ref is relative to the GIT ROOT, not the cwd (mmnto-ai/totem#2700) ──
|
|
824
|
+
//
|
|
825
|
+
// Every case above stubs `resolveGitRoot: () => null`, which collapses root
|
|
826
|
+
// and cwd and leaves the git-root half of the path untested. The pre-commit
|
|
827
|
+
// reader resolves the ref from the WORKTREE TOP, so a `root = cwd` mutation
|
|
828
|
+
// would publish a ref no hook could open.
|
|
829
|
+
it('binds the ref against the git root even when the command runs from a subdirectory', () => {
|
|
830
|
+
const sub = path.join(tmpDir, 'sub');
|
|
831
|
+
fs.mkdirSync(sub, { recursive: true });
|
|
832
|
+
const nested = path.join(tmpDir, '.totem', 'specs');
|
|
833
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
834
|
+
const file = path.join(nested, 'x.md');
|
|
835
|
+
fs.writeFileSync(file, '# X\n\nBody.\n');
|
|
836
|
+
const loaded = loadSpecRecord(file, sub, { resolveGitRoot: () => tmpDir }, TotemConfigError);
|
|
837
|
+
// A `root = cwd` mutation yields `../.totem/specs/x.md` — and would now be
|
|
838
|
+
// refused outright by the containment gate below.
|
|
839
|
+
expect(loaded.record.path).toBe('.totem/specs/x.md');
|
|
840
|
+
});
|
|
841
|
+
// ── Containment: the record must live inside the git root ──
|
|
842
|
+
it('refuses a record OUTSIDE the git root, naming the path and the root', () => {
|
|
843
|
+
const root = path.join(tmpDir, 'repo');
|
|
844
|
+
fs.mkdirSync(root, { recursive: true });
|
|
845
|
+
const outside = path.join(tmpDir, 'sibling.md');
|
|
846
|
+
fs.writeFileSync(outside, '# S\n\nBody.\n');
|
|
847
|
+
let thrown;
|
|
848
|
+
try {
|
|
849
|
+
loadSpecRecord('../sibling.md', root, { resolveGitRoot: () => root }, TotemConfigError);
|
|
850
|
+
}
|
|
851
|
+
catch (err) {
|
|
852
|
+
thrown = err;
|
|
853
|
+
}
|
|
854
|
+
expect(thrown).toMatchObject({ code: 'CONFIG_INVALID' });
|
|
855
|
+
const message = String(thrown.message);
|
|
856
|
+
expect(message).toContain('outside the repository');
|
|
857
|
+
expect(message).toContain(outside);
|
|
858
|
+
expect(message).toContain(root);
|
|
859
|
+
});
|
|
860
|
+
it('a file named `..notes.md` INSIDE the root is contained and binds normally', () => {
|
|
861
|
+
const file = path.join(tmpDir, '..notes.md');
|
|
862
|
+
fs.writeFileSync(file, '# N\n\nBody.\n');
|
|
863
|
+
const loaded = loadSpecRecord(file, tmpDir, { resolveGitRoot: () => tmpDir }, TotemConfigError);
|
|
864
|
+
expect(loaded.record.path).toBe('..notes.md');
|
|
865
|
+
});
|
|
866
|
+
// Containment must run BEFORE the record is stat'd or read: otherwise the
|
|
867
|
+
// out-of-root refusal is decided by whatever the read happens to say, and a
|
|
868
|
+
// path that escapes the repo surfaces as "not a file" (or an EISDIR-class
|
|
869
|
+
// read error) rather than as the containment refusal that carries the cure.
|
|
870
|
+
it('an out-of-root DIRECTORY is refused as uncontained, not as "not a file"', () => {
|
|
871
|
+
const root = path.join(tmpDir, 'repo');
|
|
872
|
+
fs.mkdirSync(root, { recursive: true });
|
|
873
|
+
const outsideDir = path.join(tmpDir, 'sibling-dir');
|
|
874
|
+
fs.mkdirSync(outsideDir, { recursive: true });
|
|
875
|
+
let thrown;
|
|
876
|
+
try {
|
|
877
|
+
loadSpecRecord('../sibling-dir', root, { resolveGitRoot: () => root }, TotemConfigError);
|
|
878
|
+
}
|
|
879
|
+
catch (err) {
|
|
880
|
+
thrown = err;
|
|
881
|
+
}
|
|
882
|
+
expect(thrown).toMatchObject({ code: 'CONFIG_INVALID' });
|
|
883
|
+
const message = String(thrown.message);
|
|
884
|
+
expect(message).toContain('outside the repository');
|
|
885
|
+
expect(message).not.toContain('is not a file');
|
|
886
|
+
});
|
|
887
|
+
// ── Containment sees THROUGH a link (mmnto-ai/totem#2700) ──
|
|
888
|
+
//
|
|
889
|
+
// A symlink inside the repo is lexically contained, so normalization alone
|
|
890
|
+
// reads it as legal — and would publish a ref the pre-commit reader resolves
|
|
891
|
+
// to a file outside the tree. Containment therefore also compares the two
|
|
892
|
+
// REALPATHS. `record.path` still carries the LEXICAL spelling of the file as
|
|
893
|
+
// given: the hook resolves links on its own side, and rewriting the ref to a
|
|
894
|
+
// link's target would publish a path the operator never wrote.
|
|
895
|
+
it.skipIf(!linkable.sym)('refuses an in-repo SYMLINK whose target is OUTSIDE the root, naming both paths', () => {
|
|
896
|
+
const root = path.join(tmpDir, 'repo');
|
|
897
|
+
fs.mkdirSync(root, { recursive: true });
|
|
898
|
+
const outside = path.join(tmpDir, 'outside.md');
|
|
899
|
+
fs.writeFileSync(outside, '# Outside\n\nBody.\n');
|
|
900
|
+
const link = path.join(root, 'linked.md');
|
|
901
|
+
fs.symlinkSync(outside, link);
|
|
902
|
+
let thrown;
|
|
903
|
+
try {
|
|
904
|
+
loadSpecRecord('linked.md', root, { resolveGitRoot: () => root }, TotemConfigError);
|
|
905
|
+
}
|
|
906
|
+
catch (err) {
|
|
907
|
+
thrown = err;
|
|
908
|
+
}
|
|
909
|
+
expect(thrown).toMatchObject({ code: 'CONFIG_INVALID' });
|
|
910
|
+
const message = String(thrown.message);
|
|
911
|
+
expect(message).toContain('outside the repository');
|
|
912
|
+
// The link as given AND what it resolves to — the second is the whole
|
|
913
|
+
// reason the first was refused. The resolved form is the filesystem's
|
|
914
|
+
// canonical path (a Windows runner's temp dir is handed out as an 8.3
|
|
915
|
+
// short name like RUNNER~1, which realpath expands), so compare against
|
|
916
|
+
// the same canonicalization the code applies, never the raw temp path.
|
|
917
|
+
expect(message).toContain(link);
|
|
918
|
+
expect(message).toContain(`resolves to ${fs.realpathSync.native(outside)}`);
|
|
919
|
+
});
|
|
920
|
+
it.skipIf(!linkable.sym)('accepts an in-repo SYMLINK to an in-repo file, binding the LEXICAL path', () => {
|
|
921
|
+
const root = path.join(tmpDir, 'repo');
|
|
922
|
+
const nested = path.join(root, '.totem', 'specs');
|
|
923
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
924
|
+
const target = path.join(nested, '2700.md');
|
|
925
|
+
fs.writeFileSync(target, '# Record\n\nBody.\n');
|
|
926
|
+
const link = path.join(root, 'linked.md');
|
|
927
|
+
fs.symlinkSync(target, link);
|
|
928
|
+
const loaded = loadSpecRecord('linked.md', root, { resolveGitRoot: () => root }, TotemConfigError);
|
|
929
|
+
expect(loaded.record.path).toBe('linked.md');
|
|
930
|
+
expect(loaded.record.body).toBe('# Record\n\nBody.\n');
|
|
931
|
+
});
|
|
932
|
+
// ── A leading BOM is a decoding artifact, not part of the document ──
|
|
933
|
+
it('strips ONE leading BOM from the body while hashing the RAW bytes', () => {
|
|
934
|
+
const file = path.join(tmpDir, 'bom.md');
|
|
935
|
+
// Built numerically: an authored `\u` escape lands in a source file as a
|
|
936
|
+
// raw control byte through some editing paths (mmnto-ai/totem#2692).
|
|
937
|
+
const bom = String.fromCharCode(0xfeff);
|
|
938
|
+
const text = `${bom}# Anchored evidence\n\nThe body head.\n`;
|
|
939
|
+
const bytes = Buffer.from(text, 'utf-8');
|
|
940
|
+
fs.writeFileSync(file, bytes);
|
|
941
|
+
const loaded = loadSpecRecord(file, tmpDir, deps, TotemConfigError);
|
|
942
|
+
// The body the prompt and the query see opens on the heading itself.
|
|
943
|
+
expect(loaded.record.body.startsWith('#')).toBe(true);
|
|
944
|
+
expect(loaded.record.body).toBe('# Anchored evidence\n\nThe body head.\n');
|
|
945
|
+
expect(buildRecordSearchQuery(loaded.record).startsWith('Anchored evidence')).toBe(true);
|
|
946
|
+
// The digest is over the RAW bytes, BOM included: the pre-commit reader
|
|
947
|
+
// hashes the file as it sits on disk, so a stripped-before-hash digest
|
|
948
|
+
// would read as "revised since binding" on the very first commit.
|
|
949
|
+
expect(loaded.record.sha256).toBe(crypto.createHash('sha256').update(bytes).digest('hex'));
|
|
950
|
+
expect(loaded.record.sha256).not.toBe(crypto.createHash('sha256').update(Buffer.from(loaded.record.body, 'utf-8')).digest('hex'));
|
|
951
|
+
});
|
|
952
|
+
it('renders a BOM-prefixed record into the prompt with no mark in record_body', async () => {
|
|
953
|
+
const file = path.join(tmpDir, 'bom-prompt.md');
|
|
954
|
+
const bom = String.fromCharCode(0xfeff);
|
|
955
|
+
fs.writeFileSync(file, `${bom}# Anchored evidence\n\nThe body head.\n`, 'utf-8');
|
|
956
|
+
const loaded = loadSpecRecord(file, tmpDir, deps, TotemConfigError);
|
|
957
|
+
const prompt = await assemblePrompt([{ issue: null, freeText: null, record: loaded.record }], emptyContext(), 'SYSTEM');
|
|
958
|
+
expect(prompt).toContain(`<record_body>\n${loaded.record.body}\n</record_body>`);
|
|
959
|
+
expect(prompt).toContain('<record_body>\n# Anchored evidence');
|
|
960
|
+
expect(prompt).not.toContain(bom);
|
|
961
|
+
});
|
|
962
|
+
});
|
|
963
|
+
describe('isRecordPathOutsideRoot', () => {
|
|
964
|
+
// The cross-drive arm (`path.relative` returning an ABSOLUTE path when the
|
|
965
|
+
// record lives on another volume) cannot be staged portably through
|
|
966
|
+
// loadSpecRecord, so the predicate is exercised directly. It consults both
|
|
967
|
+
// path flavors because its input is already normalized to forward slashes:
|
|
968
|
+
// `D:/x.md` is not a repo-relative path on any platform.
|
|
969
|
+
// `a/../../x.md` is the case a first-segment test misses: it escapes the root
|
|
970
|
+
// without SAYING `..` first. The strict pre-commit reader is probed with the
|
|
971
|
+
// same shape (resolved against the worktree top), so neither side can be the
|
|
972
|
+
// looser of the two.
|
|
973
|
+
it.each([
|
|
974
|
+
'..',
|
|
975
|
+
'../sibling.md',
|
|
976
|
+
'../../etc/passwd',
|
|
977
|
+
'a/../../x.md',
|
|
978
|
+
'/etc/passwd',
|
|
979
|
+
'D:/records/x.md',
|
|
980
|
+
])('refuses %s as outside the root', (relativePath) => {
|
|
981
|
+
expect(isRecordPathOutsideRoot(relativePath)).toBe(true);
|
|
982
|
+
});
|
|
983
|
+
it.each(['.totem/specs/2700.md', '..notes.md', 'a/../b.md', 'x.md'])('accepts %s as contained', (relativePath) => {
|
|
984
|
+
expect(isRecordPathOutsideRoot(relativePath)).toBe(false);
|
|
985
|
+
});
|
|
986
|
+
});
|
|
987
|
+
describe('assertOutDoesNotOverwriteRecord', () => {
|
|
988
|
+
it('refuses an --out that resolves to the record — never drafts over it', () => {
|
|
989
|
+
expect(() => assertOutDoesNotOverwriteRecord('./specs/2700.md', path.resolve('/repo', 'specs/2700.md'), '/repo', TotemConfigError)).toThrowError(/never drafts over the record/);
|
|
990
|
+
});
|
|
991
|
+
it('allows any other --out, and a missing --out', () => {
|
|
992
|
+
expect(() => assertOutDoesNotOverwriteRecord('draft.md', path.resolve('/repo', 'specs/2700.md'), '/repo', TotemConfigError)).not.toThrow();
|
|
993
|
+
expect(() => assertOutDoesNotOverwriteRecord(undefined, path.resolve('/repo', 'specs/2700.md'), '/repo', TotemConfigError)).not.toThrow();
|
|
994
|
+
});
|
|
995
|
+
// ── Aliases: a second NAME for the record is still the record ──
|
|
996
|
+
//
|
|
997
|
+
// The path-spelling comparison alone is defeated by either link kind: a
|
|
998
|
+
// symlink has a different resolved path, and a hardlink's two names share no
|
|
999
|
+
// path relationship at all — both would let the draft clobber the record.
|
|
1000
|
+
describe('link aliases', () => {
|
|
1001
|
+
let tmpDir;
|
|
1002
|
+
beforeEach(() => {
|
|
1003
|
+
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-out-alias-')));
|
|
1004
|
+
});
|
|
1005
|
+
afterEach(() => {
|
|
1006
|
+
cleanTmpDir(tmpDir);
|
|
1007
|
+
});
|
|
1008
|
+
/** The record, written fresh, plus the absolute path of a would-be alias. */
|
|
1009
|
+
function stage(aliasName) {
|
|
1010
|
+
const record = path.join(tmpDir, 'record.md');
|
|
1011
|
+
fs.writeFileSync(record, '# Record\n\nThe ruled contract.\n');
|
|
1012
|
+
return { record, alias: path.join(tmpDir, aliasName) };
|
|
1013
|
+
}
|
|
1014
|
+
it.skipIf(!linkable.hard)('refuses a HARDLINK to the record — the two names share an inode, not a path', () => {
|
|
1015
|
+
const { record, alias } = stage('hard-alias.md');
|
|
1016
|
+
fs.linkSync(record, alias);
|
|
1017
|
+
expect(() => assertOutDoesNotOverwriteRecord(alias, record, tmpDir, TotemConfigError)).toThrowError(/never drafts over the record/);
|
|
1018
|
+
});
|
|
1019
|
+
it.skipIf(!linkable.sym)('refuses a SYMLINK to the record', () => {
|
|
1020
|
+
const { record, alias } = stage('sym-alias.md');
|
|
1021
|
+
fs.symlinkSync(record, alias);
|
|
1022
|
+
expect(() => assertOutDoesNotOverwriteRecord(alias, record, tmpDir, TotemConfigError)).toThrowError(/never drafts over the record/);
|
|
1023
|
+
});
|
|
1024
|
+
it('an unrelated EXISTING file beside the record is still allowed', () => {
|
|
1025
|
+
const { record, alias } = stage('other.md');
|
|
1026
|
+
fs.writeFileSync(alias, 'a different file\n');
|
|
1027
|
+
expect(() => assertOutDoesNotOverwriteRecord(alias, record, tmpDir, TotemConfigError)).not.toThrow();
|
|
1028
|
+
});
|
|
1029
|
+
it('a --out that does not exist yet is allowed (the common case)', () => {
|
|
1030
|
+
const { record, alias } = stage('not-yet.md');
|
|
1031
|
+
expect(() => assertOutDoesNotOverwriteRecord(alias, record, tmpDir, TotemConfigError)).not.toThrow();
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
});
|
|
1035
|
+
// ─── specCommand, executed (mmnto-ai/totem#2700) ─────────
|
|
1036
|
+
describe('specCommand — anchored evidence, executed against stubbed seams', () => {
|
|
1037
|
+
let tmpDir;
|
|
1038
|
+
let originalCwd;
|
|
1039
|
+
/** Every `log.warn` message the command emitted, in order. */
|
|
1040
|
+
let warnings = [];
|
|
1041
|
+
/** Every `log.dim` message the command emitted, in order. */
|
|
1042
|
+
let dims = [];
|
|
1043
|
+
/** Files currently under the run store the orchestrator would write to. */
|
|
1044
|
+
function runArtifactNames() {
|
|
1045
|
+
const dir = path.join(tmpDir, '.totem', 'artifacts', 'runs');
|
|
1046
|
+
return fs.existsSync(dir) ? fs.readdirSync(dir) : [];
|
|
1047
|
+
}
|
|
1048
|
+
function writeRecord(body, name = 'record.md') {
|
|
1049
|
+
const file = path.join(tmpDir, name);
|
|
1050
|
+
fs.writeFileSync(file, body, 'utf-8');
|
|
1051
|
+
return file;
|
|
1052
|
+
}
|
|
1053
|
+
function sha256Of(file) {
|
|
1054
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
1055
|
+
}
|
|
1056
|
+
/** The artifact request the (single) orchestrator call carried. */
|
|
1057
|
+
function artifactRequest() {
|
|
1058
|
+
expect(harness.orchestratorArgs.length).toBe(1);
|
|
1059
|
+
return harness.orchestratorArgs[0]['artifact'];
|
|
1060
|
+
}
|
|
1061
|
+
beforeEach(() => {
|
|
1062
|
+
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-spec-cmd-')));
|
|
1063
|
+
originalCwd = process.cwd();
|
|
1064
|
+
process.chdir(tmpDir);
|
|
1065
|
+
harness.searchResults = {};
|
|
1066
|
+
harness.orchestratorArgs = [];
|
|
1067
|
+
harness.orchestratorContent = 'DRAFT';
|
|
1068
|
+
harness.connects = 0;
|
|
1069
|
+
harness.writes = [];
|
|
1070
|
+
harness.config = {
|
|
1071
|
+
totemDir: '.totem',
|
|
1072
|
+
lanceDir: '.lancedb',
|
|
1073
|
+
searchRelevanceFloor: FLOOR,
|
|
1074
|
+
embedding: { provider: 'gemini', model: 'test' },
|
|
1075
|
+
};
|
|
1076
|
+
warnings = [];
|
|
1077
|
+
dims = [];
|
|
1078
|
+
vi.spyOn(log, 'warn').mockImplementation((_tag, msg) => {
|
|
1079
|
+
warnings.push(msg);
|
|
1080
|
+
});
|
|
1081
|
+
vi.spyOn(log, 'dim').mockImplementation((_tag, msg) => {
|
|
1082
|
+
dims.push(msg);
|
|
1083
|
+
});
|
|
1084
|
+
vi.spyOn(log, 'info').mockImplementation(() => { });
|
|
1085
|
+
vi.spyOn(log, 'success').mockImplementation(() => { });
|
|
1086
|
+
});
|
|
1087
|
+
afterEach(() => {
|
|
1088
|
+
vi.restoreAllMocks();
|
|
1089
|
+
process.chdir(originalCwd);
|
|
1090
|
+
cleanTmpDir(tmpDir);
|
|
1091
|
+
});
|
|
1092
|
+
it('refuses "no inputs and no --from" BEFORE the store connects', async () => {
|
|
1093
|
+
await expect(specCommand([], {})).rejects.toThrowError(/at least one issue\/topic/);
|
|
1094
|
+
expect(harness.connects).toBe(0);
|
|
1095
|
+
expect(harness.orchestratorArgs).toEqual([]);
|
|
1096
|
+
});
|
|
1097
|
+
it.each([
|
|
1098
|
+
['a missing record', () => path.join(tmpDir, 'nope.md'), /--from record not found/],
|
|
1099
|
+
[
|
|
1100
|
+
'a directory',
|
|
1101
|
+
() => {
|
|
1102
|
+
const dir = path.join(tmpDir, 'adir');
|
|
1103
|
+
fs.mkdirSync(dir);
|
|
1104
|
+
return dir;
|
|
1105
|
+
},
|
|
1106
|
+
/--from record is not a file/,
|
|
1107
|
+
],
|
|
1108
|
+
['an empty record', () => writeRecord(''), /--from record is empty/],
|
|
1109
|
+
['a whitespace-only record', () => writeRecord(' \n \n'), /--from record is empty/],
|
|
1110
|
+
])('refuses %s BEFORE the store connects and before any LLM call', async (_label, make, re) => {
|
|
1111
|
+
await expect(specCommand([], { from: make() })).rejects.toThrowError(re);
|
|
1112
|
+
expect(harness.connects).toBe(0);
|
|
1113
|
+
expect(harness.orchestratorArgs).toEqual([]);
|
|
1114
|
+
expect(runArtifactNames()).toEqual([]);
|
|
1115
|
+
});
|
|
1116
|
+
it('refuses --from together with positional inputs BEFORE the store connects', async () => {
|
|
1117
|
+
const record = writeRecord('# R\n\nbody\n');
|
|
1118
|
+
await expect(specCommand(['2700'], { from: record })).rejects.toThrowError(/cannot be combined with positional inputs/);
|
|
1119
|
+
expect(harness.connects).toBe(0);
|
|
1120
|
+
expect(harness.orchestratorArgs).toEqual([]);
|
|
1121
|
+
});
|
|
1122
|
+
it('refuses an --out that resolves to the record BEFORE the store connects', async () => {
|
|
1123
|
+
const record = writeRecord('# R\n\nbody\n');
|
|
1124
|
+
await expect(specCommand([], { from: record, out: record })).rejects.toThrowError(/never drafts over the record/);
|
|
1125
|
+
expect(harness.connects).toBe(0);
|
|
1126
|
+
expect(harness.orchestratorArgs).toEqual([]);
|
|
1127
|
+
expect(runArtifactNames()).toEqual([]);
|
|
1128
|
+
});
|
|
1129
|
+
it('a --from run anchors on the record, leaves its bytes UNCHANGED, and drafts to stdout', async () => {
|
|
1130
|
+
const record = writeRecord('# Design record\n\nThe ruled contract.\n');
|
|
1131
|
+
const before = sha256Of(record);
|
|
1132
|
+
harness.searchResults = { spec: [relevantHit(0.7)] };
|
|
1133
|
+
await specCommand([], { from: record, stdout: true });
|
|
1134
|
+
expect(sha256Of(record)).toBe(before);
|
|
1135
|
+
const artifact = artifactRequest();
|
|
1136
|
+
expect(artifact['anchor']).toEqual({
|
|
1137
|
+
kind: GROUNDING_ANCHOR_RECORD,
|
|
1138
|
+
ref: 'record.md',
|
|
1139
|
+
sha256: before,
|
|
1140
|
+
});
|
|
1141
|
+
expect(artifact['floor']).toBe(FLOOR);
|
|
1142
|
+
expect(harness.orchestratorArgs[0]['runMetadata']).toMatchObject({
|
|
1143
|
+
caller: 'spec',
|
|
1144
|
+
promptSource: PROMPT_SOURCE_BUILTIN,
|
|
1145
|
+
});
|
|
1146
|
+
// The prompt carries the very bytes the digest binds — the WHOLE record,
|
|
1147
|
+
// not a fragment of it. A `toContain` on one sentence would still pass if
|
|
1148
|
+
// the record were truncated or re-wrapped on the way into the prompt, and
|
|
1149
|
+
// `anchor.sha256` would then name bytes the model never saw.
|
|
1150
|
+
const prompt = String(harness.orchestratorArgs[0]['prompt']);
|
|
1151
|
+
const bytes = fs.readFileSync(record, 'utf-8');
|
|
1152
|
+
expect(prompt).toContain(`<record_body>\n${bytes}\n</record_body>`);
|
|
1153
|
+
expect(harness.writes).toEqual([{ content: 'DRAFT' }]);
|
|
1154
|
+
});
|
|
1155
|
+
it('a --from run without --out or --stdout says there is NO derived path for a record', async () => {
|
|
1156
|
+
const record = writeRecord('# R\n\nbody\n');
|
|
1157
|
+
harness.searchResults = { spec: [relevantHit(0.7)] };
|
|
1158
|
+
await specCommand([], { from: record });
|
|
1159
|
+
expect(harness.writes).toEqual([{ content: 'DRAFT' }]);
|
|
1160
|
+
expect(dims).toContain('No derived path for a record — use --out <path> to keep the draft.');
|
|
1161
|
+
});
|
|
1162
|
+
it('records promptSource "override" when a custom system prompt drafted the run', async () => {
|
|
1163
|
+
const promptsDir = path.join(tmpDir, '.totem', 'prompts');
|
|
1164
|
+
fs.mkdirSync(promptsDir, { recursive: true });
|
|
1165
|
+
fs.writeFileSync(path.join(promptsDir, 'spec.md'), '# Custom prompt\n', 'utf-8');
|
|
1166
|
+
harness.searchResults = { spec: [relevantHit(0.7)] };
|
|
1167
|
+
await specCommand(['2700'], { stdout: true });
|
|
1168
|
+
expect(harness.orchestratorArgs[0]['runMetadata']).toMatchObject({
|
|
1169
|
+
promptSource: PROMPT_SOURCE_OVERRIDE,
|
|
1170
|
+
});
|
|
1171
|
+
});
|
|
1172
|
+
it('an ISSUE-anchored run proceeds with anchor kind `issue` and emits NO not-evidence warning', async () => {
|
|
1173
|
+
harness.searchResults = { spec: [relevantHit(0.7)] };
|
|
1174
|
+
await specCommand(['2700'], { stdout: true });
|
|
1175
|
+
expect(artifactRequest()['anchor']).toEqual({ kind: GROUNDING_ANCHOR_ISSUE, ref: '#2700' });
|
|
1176
|
+
expect(warnings.some((line) => line.includes('NOT gate evidence'))).toBe(false);
|
|
1177
|
+
});
|
|
1178
|
+
it('a free-text run above the floor PROCEEDS but is warned as NOT gate evidence', async () => {
|
|
1179
|
+
harness.searchResults = { spec: [relevantHit(0.7)] };
|
|
1180
|
+
await specCommand(['some topic'], { stdout: true });
|
|
1181
|
+
expect(artifactRequest()['anchor']).toEqual({
|
|
1182
|
+
kind: GROUNDING_ANCHOR_FREE_TEXT,
|
|
1183
|
+
ref: 'some topic',
|
|
1184
|
+
});
|
|
1185
|
+
expect(warnings.some((line) => line.includes('NOT gate evidence'))).toBe(true);
|
|
1186
|
+
expect(warnings.some((line) => line.includes(GROUNDING_ANCHOR_FREE_TEXT))).toBe(true);
|
|
1187
|
+
});
|
|
1188
|
+
it('an issue + topic run anchors `mixed`, proceeds, and is warned as NOT gate evidence', async () => {
|
|
1189
|
+
harness.searchResults = { spec: [relevantHit(0.7)] };
|
|
1190
|
+
await specCommand(['2700', 'a loose topic'], { stdout: true });
|
|
1191
|
+
expect(artifactRequest()['anchor']).toEqual({
|
|
1192
|
+
kind: GROUNDING_ANCHOR_MIXED,
|
|
1193
|
+
ref: '#2700 | a loose topic',
|
|
1194
|
+
});
|
|
1195
|
+
expect(warnings.some((line) => line.includes(GROUNDING_ANCHOR_MIXED))).toBe(true);
|
|
1196
|
+
expect(warnings.some((line) => line.includes('NOT gate evidence'))).toBe(true);
|
|
1197
|
+
});
|
|
1198
|
+
// "Mints nothing" is proved by the orchestrator never being REACHED —
|
|
1199
|
+
// `runOrchestrator` is the only writer of a run artifact and it is stubbed
|
|
1200
|
+
// here, so a run-store file count could not fail whatever the command did.
|
|
1201
|
+
it('a free-text run with 0 hits REFUSES and mints nothing (the orchestrator is never reached)', async () => {
|
|
1202
|
+
await expect(specCommand(['nonsense slug'], { stdout: true })).rejects.toThrowError(/Retrieval returned 0 hits/);
|
|
1203
|
+
expect(harness.orchestratorArgs).toEqual([]);
|
|
1204
|
+
});
|
|
1205
|
+
it('a free-text run entirely below the floor REFUSES, naming the floor and every withheld candidate', async () => {
|
|
1206
|
+
harness.searchResults = { spec: [relevantHit(0.1, { filePath: 'docs/a.md' })] };
|
|
1207
|
+
let thrown;
|
|
1208
|
+
try {
|
|
1209
|
+
await specCommand(['weak slug'], { stdout: true });
|
|
1210
|
+
}
|
|
1211
|
+
catch (err) {
|
|
1212
|
+
thrown = err;
|
|
1213
|
+
}
|
|
1214
|
+
expect(thrown).toMatchObject({ code: 'GATE_INVALID' });
|
|
1215
|
+
const message = String(thrown.message);
|
|
1216
|
+
expect(message).toContain('weak slug');
|
|
1217
|
+
expect(message).toContain('best relevance 0.100');
|
|
1218
|
+
expect(message).toContain('floor 0.250 — searchRelevanceFloor in totem.config.ts (schema default 0.25 when unset)');
|
|
1219
|
+
expect(message).toContain('docs/a.md — relevance 0.100');
|
|
1220
|
+
expect(harness.orchestratorArgs).toEqual([]);
|
|
1221
|
+
});
|
|
1222
|
+
it('an ISSUE-anchored run with 0 hits is NEVER refused (an issue is grounding)', async () => {
|
|
1223
|
+
await specCommand(['2700'], { stdout: true });
|
|
1224
|
+
expect(harness.orchestratorArgs.length).toBe(1);
|
|
1225
|
+
});
|
|
1226
|
+
it('a --from run with 0 hits is NEVER refused (a record is grounding)', async () => {
|
|
1227
|
+
const record = writeRecord('# R\n\nbody\n');
|
|
1228
|
+
await specCommand([], { from: record, stdout: true });
|
|
1229
|
+
expect(harness.orchestratorArgs.length).toBe(1);
|
|
1230
|
+
});
|
|
1231
|
+
it('--raw is EXEMPT from the refusal: it reaches the orchestrator, which prints context and mints nothing', async () => {
|
|
1232
|
+
await specCommand(['nonsense slug'], { raw: true });
|
|
1233
|
+
expect(harness.orchestratorArgs.length).toBe(1);
|
|
1234
|
+
expect(harness.orchestratorArgs[0]['options']).toMatchObject({ raw: true });
|
|
1235
|
+
});
|
|
1236
|
+
it('the proceed path records the floor it was judged against on the artifact', async () => {
|
|
1237
|
+
harness.config = { ...harness.config, searchRelevanceFloor: 0.6 };
|
|
1238
|
+
harness.searchResults = { spec: [relevantHit(0.9)] };
|
|
1239
|
+
await specCommand(['2700'], { stdout: true });
|
|
1240
|
+
expect(artifactRequest()['floor']).toBe(0.6);
|
|
1241
|
+
});
|
|
359
1242
|
});
|
|
360
1243
|
//# sourceMappingURL=spec.test.js.map
|