@productbrain/cli 0.1.0-beta.4863 → 0.1.0-beta.4869
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/__tests__/capture.test.js +373 -20
- package/dist/__tests__/capture.test.js.map +1 -1
- package/dist/commands/capture.d.ts +3 -1
- package/dist/commands/capture.d.ts.map +1 -1
- package/dist/commands/capture.js +88 -116
- package/dist/commands/capture.js.map +1 -1
- package/dist/formatters/capture.d.ts.map +1 -1
- package/dist/formatters/capture.js +3 -1
- package/dist/formatters/capture.js.map +1 -1
- package/dist/generated/relationTypes.generated.d.ts +12 -0
- package/dist/generated/relationTypes.generated.d.ts.map +1 -0
- package/dist/generated/relationTypes.generated.js +11 -0
- package/dist/generated/relationTypes.generated.js.map +1 -0
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/lib/__tests__/legalRelationTypes.test.d.ts +2 -0
- package/dist/lib/__tests__/legalRelationTypes.test.d.ts.map +1 -0
- package/dist/lib/__tests__/legalRelationTypes.test.js +98 -0
- package/dist/lib/__tests__/legalRelationTypes.test.js.map +1 -0
- package/dist/lib/captureGrounding.d.ts +53 -0
- package/dist/lib/captureGrounding.d.ts.map +1 -0
- package/dist/lib/captureGrounding.js +108 -0
- package/dist/lib/captureGrounding.js.map +1 -0
- package/dist/lib/legalRelationTypes.d.ts +25 -0
- package/dist/lib/legalRelationTypes.d.ts.map +1 -0
- package/dist/lib/legalRelationTypes.js +94 -0
- package/dist/lib/legalRelationTypes.js.map +1 -0
- package/dist/registerEntryCommands.js +2 -2
- package/dist/registerEntryCommands.js.map +1 -1
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
7
|
import { runCapture } from '../commands/capture.js';
|
|
8
8
|
import { CLIError, ErrorCode } from '../lib/errors.js';
|
|
9
|
+
import { setOutputMode } from '../lib/runner.js';
|
|
9
10
|
const kernelCallWithSessionMock = vi.fn();
|
|
10
11
|
vi.mock('../lib/client.js', () => ({
|
|
11
12
|
kernelCallWithSession: (...args) => kernelCallWithSessionMock(...args),
|
|
@@ -23,34 +24,264 @@ describe('runCapture --link', () => {
|
|
|
23
24
|
kernelCallWithSessionMock.mockReset();
|
|
24
25
|
mockSession = { sessionId: 'sess-test' };
|
|
25
26
|
});
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// WP-688 S4 (TASK-105, element 11): --link with no --link-type no longer defaults to
|
|
28
|
+
// surfaces_tension_in — it refuses instead. The capture itself still succeeds (createEntry
|
|
29
|
+
// ran for real); only the relation write is skipped, replaced by a PREVIEW probe used to
|
|
30
|
+
// list the source entry's legal verbs in the refusal message.
|
|
31
|
+
it('--link without --link-type refuses (no default), preserving the created entry', async () => {
|
|
32
|
+
kernelCallWithSessionMock.mockImplementation((fn, args) => {
|
|
29
33
|
if (fn === 'chain.suggestLinksForCapture')
|
|
30
34
|
return Promise.resolve([]);
|
|
31
35
|
if (fn === 'chain.createEntry')
|
|
32
36
|
return Promise.resolve({ docId: 'doc-1', entryId: 'TEN-710' });
|
|
33
|
-
if (fn === 'chain.
|
|
34
|
-
|
|
37
|
+
if (fn === 'chain.getEntry') {
|
|
38
|
+
const entryId = args?.entryId;
|
|
39
|
+
if (entryId === 'TEN-710')
|
|
40
|
+
return Promise.resolve({ entryId: 'TEN-710', name: 'x', status: 'draft', canonicalKey: 'tension' });
|
|
41
|
+
return Promise.resolve(null);
|
|
42
|
+
}
|
|
43
|
+
// bdABL: the type row is resolved by scanning the semantic-types collection and
|
|
44
|
+
// matching on data.canonical_key — NOT by guessing entryId === `TYPE-<key>`. This
|
|
45
|
+
// row deliberately uses a non-conventional entryId (`ST-1`) to prove that.
|
|
46
|
+
if (fn === 'chain.listEntries') {
|
|
47
|
+
return Promise.resolve([{ entryId: 'ST-1', name: 'tension', status: 'active', data: { canonical_key: 'tension', valid_relation_types: ['related_to', 'depends_on', 'surfaces_tension_in'], description: 'x', cardinality_rule: 'multiple' } }]);
|
|
48
|
+
}
|
|
35
49
|
return Promise.resolve({});
|
|
36
50
|
});
|
|
37
51
|
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
38
|
-
await runCapture({ text: 'TEN: missing validation', link: 'BET-151' });
|
|
39
|
-
//
|
|
40
|
-
expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('chain.resolveCollection', expect.anything());
|
|
41
|
-
// Verify prefix was stripped from entry name and routed to correct collection
|
|
52
|
+
await expect(runCapture({ text: 'TEN: missing validation', link: 'BET-151' })).rejects.toThrow(CLIError);
|
|
53
|
+
// The entry was created for real — only the LINK is refused.
|
|
42
54
|
expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntry', expect.objectContaining({
|
|
43
55
|
collectionSlug: 'tensions',
|
|
44
56
|
name: 'missing validation',
|
|
45
57
|
}));
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
58
|
+
// Legal verbs are READ by scanning the semantic-types collection (chain.listEntries)
|
|
59
|
+
// and matching data.canonical_key — the same resolution loadTypeMap itself uses,
|
|
60
|
+
// never a probe, never a guessed entryId.
|
|
61
|
+
expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.getEntry', { entryId: 'TEN-710' });
|
|
62
|
+
expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.listEntries', { collectionSlug: 'semantic-types' });
|
|
63
|
+
// Never a createEntryRelation call at all — a real write, preview, or probe.
|
|
64
|
+
expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('chain.createEntryRelation', expect.anything());
|
|
65
|
+
writeSpy.mockRestore();
|
|
66
|
+
});
|
|
67
|
+
it('--link without --link-type: refusal message lists the source entry\'s declared relation types', async () => {
|
|
68
|
+
kernelCallWithSessionMock.mockImplementation((fn, args) => {
|
|
69
|
+
if (fn === 'chain.suggestLinksForCapture')
|
|
70
|
+
return Promise.resolve([]);
|
|
71
|
+
if (fn === 'chain.createEntry')
|
|
72
|
+
return Promise.resolve({ docId: 'doc-1b', entryId: 'TEN-711' });
|
|
73
|
+
if (fn === 'chain.getEntry') {
|
|
74
|
+
const entryId = args?.entryId;
|
|
75
|
+
if (entryId === 'TEN-711')
|
|
76
|
+
return Promise.resolve({ entryId: 'TEN-711', name: 'x', status: 'draft', canonicalKey: 'tension' });
|
|
77
|
+
// bcRUMR: the target must actually resolve for this test to exercise the
|
|
78
|
+
// pb-relate-guidance branch rather than the target-missing branch.
|
|
79
|
+
if (entryId === 'BET-151')
|
|
80
|
+
return Promise.resolve({ entryId: 'BET-151', name: 'y', status: 'active', canonicalKey: 'work_package' });
|
|
81
|
+
return Promise.resolve(null);
|
|
82
|
+
}
|
|
83
|
+
if (fn === 'chain.listEntries') {
|
|
84
|
+
return Promise.resolve([{ entryId: 'TYPE-tension', name: 'tension', status: 'active', data: { canonical_key: 'tension', valid_relation_types: ['related_to', 'depends_on', 'surfaces_tension_in'], description: 'x', cardinality_rule: 'multiple' } }]);
|
|
85
|
+
}
|
|
86
|
+
return Promise.resolve({});
|
|
50
87
|
});
|
|
88
|
+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
89
|
+
let caught;
|
|
90
|
+
try {
|
|
91
|
+
await runCapture({ text: 'TEN: missing validation', link: 'BET-151' });
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
caught = e;
|
|
95
|
+
}
|
|
96
|
+
writeSpy.mockRestore();
|
|
97
|
+
expect(caught).toBeInstanceOf(CLIError);
|
|
98
|
+
expect(caught?.code).toBe(ErrorCode.VALIDATION_FAILED);
|
|
99
|
+
expect(caught?.message).toContain('related_to, depends_on, surfaces_tension_in');
|
|
100
|
+
expect(caught?.guidance).toContain('TEN-711');
|
|
101
|
+
expect(caught?.guidance).toContain('pb relate TEN-711');
|
|
102
|
+
});
|
|
103
|
+
// WP-688 S4 review round 5 (bcRUML): a whitespace-only --link must not silently no-op —
|
|
104
|
+
// the entry still gets created, but the link itself must be explicitly refused.
|
|
105
|
+
it('--link " " (whitespace-only) refuses instead of silently dropping the link', async () => {
|
|
106
|
+
kernelCallWithSessionMock.mockImplementation((fn) => {
|
|
107
|
+
if (fn === 'chain.suggestLinksForCapture')
|
|
108
|
+
return Promise.resolve([]);
|
|
109
|
+
if (fn === 'chain.createEntry')
|
|
110
|
+
return Promise.resolve({ docId: 'doc-ws', entryId: 'TEN-720' });
|
|
111
|
+
return Promise.resolve({});
|
|
112
|
+
});
|
|
113
|
+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
114
|
+
let caught;
|
|
115
|
+
try {
|
|
116
|
+
await runCapture({ text: 'TEN: whitespace link', link: ' ' });
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
caught = e;
|
|
120
|
+
}
|
|
121
|
+
writeSpy.mockRestore();
|
|
122
|
+
expect(caught).toBeInstanceOf(CLIError);
|
|
123
|
+
expect(caught?.message).toContain('--link requires a non-empty entry ID');
|
|
124
|
+
// Never a relate/probe call — the target never resolved to anything real.
|
|
125
|
+
expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('chain.createEntryRelation', expect.anything());
|
|
126
|
+
});
|
|
127
|
+
// WP-688 S4 review round 5 (bcRpJQ): --link "" is falsy at the outer `if (options.link)` —
|
|
128
|
+
// must check `!== undefined` instead, or this whole block (and the bcRUML refusal) never runs.
|
|
129
|
+
it('--link "" (empty string) refuses instead of silently dropping the link', async () => {
|
|
130
|
+
kernelCallWithSessionMock.mockImplementation((fn) => {
|
|
131
|
+
if (fn === 'chain.suggestLinksForCapture')
|
|
132
|
+
return Promise.resolve([]);
|
|
133
|
+
if (fn === 'chain.createEntry')
|
|
134
|
+
return Promise.resolve({ docId: 'doc-empty', entryId: 'TEN-722' });
|
|
135
|
+
return Promise.resolve({});
|
|
136
|
+
});
|
|
137
|
+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
138
|
+
let caught;
|
|
139
|
+
try {
|
|
140
|
+
await runCapture({ text: 'TEN: empty link', link: '' });
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
caught = e;
|
|
144
|
+
}
|
|
51
145
|
writeSpy.mockRestore();
|
|
146
|
+
expect(caught).toBeInstanceOf(CLIError);
|
|
147
|
+
expect(caught?.message).toContain('--link requires a non-empty entry ID');
|
|
148
|
+
expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('chain.createEntryRelation', expect.anything());
|
|
52
149
|
});
|
|
53
|
-
|
|
150
|
+
// WP-688 S4 review round 5 (bcRUMR): the target entry doesn't exist — the recovery
|
|
151
|
+
// guidance must say so rather than recommending `pb relate <verb> <missing-id>`.
|
|
152
|
+
it('--link <missing-id> without --link-type reports the target was not found, not a verb-recovery command', async () => {
|
|
153
|
+
kernelCallWithSessionMock.mockImplementation((fn, args) => {
|
|
154
|
+
if (fn === 'chain.suggestLinksForCapture')
|
|
155
|
+
return Promise.resolve([]);
|
|
156
|
+
if (fn === 'chain.createEntry')
|
|
157
|
+
return Promise.resolve({ docId: 'doc-miss', entryId: 'TEN-721' });
|
|
158
|
+
if (fn === 'chain.getEntry') {
|
|
159
|
+
const entryId = args?.entryId;
|
|
160
|
+
if (entryId === 'TEN-721')
|
|
161
|
+
return Promise.resolve({ entryId: 'TEN-721', name: 'x', status: 'draft', canonicalKey: 'tension' });
|
|
162
|
+
return Promise.resolve(null); // GHOST-1 does not exist
|
|
163
|
+
}
|
|
164
|
+
if (fn === 'chain.listEntries') {
|
|
165
|
+
return Promise.resolve([{ entryId: 'TYPE-tension', name: 'tension', status: 'active', data: { canonical_key: 'tension', valid_relation_types: ['informs'], description: 'x', cardinality_rule: 'multiple' } }]);
|
|
166
|
+
}
|
|
167
|
+
return Promise.resolve({});
|
|
168
|
+
});
|
|
169
|
+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
170
|
+
let caught;
|
|
171
|
+
try {
|
|
172
|
+
await runCapture({ text: 'TEN: bad target', link: 'GHOST-1' });
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
caught = e;
|
|
176
|
+
}
|
|
177
|
+
writeSpy.mockRestore();
|
|
178
|
+
expect(caught?.guidance).toContain('not found');
|
|
179
|
+
expect(caught?.guidance).toContain('GHOST-1');
|
|
180
|
+
expect(caught?.guidance).not.toContain('pb relate');
|
|
181
|
+
});
|
|
182
|
+
// WP-688 S4 review round 3 (bc4Pj): `replaces`/`do_not_confuse_with` are declared for
|
|
183
|
+
// scoring but hard-refused at the generic relate door with a DIFFERENT recovery command
|
|
184
|
+
// (setSupersededBy / addDoNotConfuseWith, guards.ts:308-321) — listing them as usable via
|
|
185
|
+
// `pb relate` would name a guaranteed-to-fail re-run.
|
|
186
|
+
it('excludes replaces and do_not_confuse_with from the refusal\'s legal-verb list (both are door-blocked)', async () => {
|
|
187
|
+
kernelCallWithSessionMock.mockImplementation((fn, args) => {
|
|
188
|
+
if (fn === 'chain.suggestLinksForCapture')
|
|
189
|
+
return Promise.resolve([]);
|
|
190
|
+
if (fn === 'chain.createEntry')
|
|
191
|
+
return Promise.resolve({ docId: 'doc-1c', entryId: 'DEC-1' });
|
|
192
|
+
if (fn === 'chain.getEntry') {
|
|
193
|
+
const entryId = args?.entryId;
|
|
194
|
+
if (entryId === 'DEC-1')
|
|
195
|
+
return Promise.resolve({ entryId: 'DEC-1', name: 'x', status: 'draft', canonicalKey: 'decision' });
|
|
196
|
+
return Promise.resolve(null);
|
|
197
|
+
}
|
|
198
|
+
if (fn === 'chain.listEntries') {
|
|
199
|
+
return Promise.resolve([{ entryId: 'TYPE-decision', name: 'decision', status: 'active', data: { canonical_key: 'decision', valid_relation_types: ['informs', 'governs', 'replaces', 'conflicts_with'], description: 'x', cardinality_rule: 'multiple' } }]);
|
|
200
|
+
}
|
|
201
|
+
return Promise.resolve({});
|
|
202
|
+
});
|
|
203
|
+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
204
|
+
let caught;
|
|
205
|
+
try {
|
|
206
|
+
await runCapture({ text: 'DEC: no link-type', link: 'STD-1' });
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
caught = e;
|
|
210
|
+
}
|
|
211
|
+
writeSpy.mockRestore();
|
|
212
|
+
expect(caught?.message).toContain('informs, governs, conflicts_with');
|
|
213
|
+
expect(caught?.message).not.toContain('replaces');
|
|
214
|
+
});
|
|
215
|
+
// WP-688 S4 review round 5 (bdB5K): registry reconciliation deliberately preserves a
|
|
216
|
+
// workspace's customized valid_relation_types even when it contains a verb outside the
|
|
217
|
+
// kernel ALLOWED_RELATION_TYPES allowlist (registryReconcile.test.ts:131-147) — a supported
|
|
218
|
+
// persisted state, not malformed data. guardRelation still refuses it (guards.ts:302-307),
|
|
219
|
+
// so it must never be listed as a `pb relate`-usable recovery verb.
|
|
220
|
+
it('excludes a declared verb outside the kernel allowlist from the refusal\'s legal-verb list', async () => {
|
|
221
|
+
kernelCallWithSessionMock.mockImplementation((fn, args) => {
|
|
222
|
+
if (fn === 'chain.suggestLinksForCapture')
|
|
223
|
+
return Promise.resolve([]);
|
|
224
|
+
if (fn === 'chain.createEntry')
|
|
225
|
+
return Promise.resolve({ docId: 'doc-1d', entryId: 'GOAL-1' });
|
|
226
|
+
if (fn === 'chain.getEntry') {
|
|
227
|
+
const entryId = args?.entryId;
|
|
228
|
+
if (entryId === 'GOAL-1')
|
|
229
|
+
return Promise.resolve({ entryId: 'GOAL-1', name: 'x', status: 'draft', canonicalKey: 'goal' });
|
|
230
|
+
return Promise.resolve(null);
|
|
231
|
+
}
|
|
232
|
+
if (fn === 'chain.listEntries') {
|
|
233
|
+
return Promise.resolve([{ entryId: 'TYPE-goal', name: 'goal', status: 'active', data: { canonical_key: 'goal', valid_relation_types: ['informs', 'depends_on', 'custom_hand_added'], description: 'x', cardinality_rule: 'multiple' } }]);
|
|
234
|
+
}
|
|
235
|
+
return Promise.resolve({});
|
|
236
|
+
});
|
|
237
|
+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
238
|
+
let caught;
|
|
239
|
+
try {
|
|
240
|
+
await runCapture({ text: 'GOAL: no link-type', link: 'BET-1' });
|
|
241
|
+
}
|
|
242
|
+
catch (e) {
|
|
243
|
+
caught = e;
|
|
244
|
+
}
|
|
245
|
+
writeSpy.mockRestore();
|
|
246
|
+
expect(caught?.message).toContain('informs, depends_on');
|
|
247
|
+
expect(caught?.message).not.toContain('custom_hand_added');
|
|
248
|
+
});
|
|
249
|
+
// WP-688 S4 review round 5 (cQFZ_): belongs_to/authority_domain toward a domain-tagged
|
|
250
|
+
// target require ratification metadata (stagingEntryId + source) that `pb relate` cannot
|
|
251
|
+
// supply (guards.ts:349-363) — never recommend them as recovery verbs for such a target.
|
|
252
|
+
it('excludes domain-tag verbs from the refusal\'s legal-verb list when the target is a domain', async () => {
|
|
253
|
+
kernelCallWithSessionMock.mockImplementation((fn, args) => {
|
|
254
|
+
if (fn === 'chain.suggestLinksForCapture')
|
|
255
|
+
return Promise.resolve([]);
|
|
256
|
+
if (fn === 'chain.createEntry')
|
|
257
|
+
return Promise.resolve({ docId: 'doc-1e', entryId: 'INS-1' });
|
|
258
|
+
if (fn === 'chain.getEntry') {
|
|
259
|
+
const entryId = args?.entryId;
|
|
260
|
+
if (entryId === 'INS-1')
|
|
261
|
+
return Promise.resolve({ entryId: 'INS-1', name: 'x', status: 'draft', canonicalKey: 'insight' });
|
|
262
|
+
if (entryId === 'DOM-1')
|
|
263
|
+
return Promise.resolve({ entryId: 'DOM-1', name: 'product-design', status: 'active', canonicalKey: 'domain' });
|
|
264
|
+
return Promise.resolve(null);
|
|
265
|
+
}
|
|
266
|
+
if (fn === 'chain.listEntries') {
|
|
267
|
+
return Promise.resolve([{ entryId: 'TYPE-insight', name: 'insight', status: 'active', data: { canonical_key: 'insight', valid_relation_types: ['informs', 'belongs_to', 'authority_domain'], description: 'x', cardinality_rule: 'multiple' } }]);
|
|
268
|
+
}
|
|
269
|
+
return Promise.resolve({});
|
|
270
|
+
});
|
|
271
|
+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
272
|
+
let caught;
|
|
273
|
+
try {
|
|
274
|
+
await runCapture({ text: 'INS: no link-type', link: 'DOM-1' });
|
|
275
|
+
}
|
|
276
|
+
catch (e) {
|
|
277
|
+
caught = e;
|
|
278
|
+
}
|
|
279
|
+
writeSpy.mockRestore();
|
|
280
|
+
expect(caught?.message).toContain('Legal relation types for this entry: informs.');
|
|
281
|
+
expect(caught?.message).not.toContain('belongs_to');
|
|
282
|
+
expect(caught?.message).not.toContain('authority_domain');
|
|
283
|
+
});
|
|
284
|
+
it('uses the supplied relation type when --link-type provided (no probe, real write)', async () => {
|
|
54
285
|
// Prefix "INS:" is extracted → skips classification, routes directly to insights
|
|
55
286
|
kernelCallWithSessionMock.mockImplementation((fn) => {
|
|
56
287
|
if (fn === 'chain.suggestLinksForCapture')
|
|
@@ -58,18 +289,140 @@ describe('runCapture --link', () => {
|
|
|
58
289
|
if (fn === 'chain.createEntry')
|
|
59
290
|
return Promise.resolve({ docId: 'doc-2', entryId: 'INS-50' });
|
|
60
291
|
if (fn === 'chain.createEntryRelation')
|
|
61
|
-
return Promise.resolve({});
|
|
292
|
+
return Promise.resolve({ relationId: 'rel-1' });
|
|
62
293
|
return Promise.resolve({});
|
|
63
294
|
});
|
|
64
295
|
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
65
|
-
await runCapture({ text: 'INS: discovered X', link: 'DEC-264',
|
|
296
|
+
await runCapture({ text: 'INS: discovered X', link: 'DEC-264', linkType: 'informed_by' });
|
|
66
297
|
expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('chain.resolveCollection', expect.anything());
|
|
298
|
+
// A REAL (non-preview) write — the exact verb supplied, echoed verbatim, no probe call.
|
|
67
299
|
expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntryRelation', {
|
|
68
300
|
fromEntryId: 'INS-50',
|
|
69
301
|
toEntryId: 'DEC-264',
|
|
70
302
|
type: 'informed_by',
|
|
71
303
|
});
|
|
304
|
+
const previewCalls = kernelCallWithSessionMock.mock.calls.filter(([fn, args]) => fn === 'chain.createEntryRelation' && args?.preview);
|
|
305
|
+
expect(previewCalls).toHaveLength(0);
|
|
306
|
+
writeSpy.mockRestore();
|
|
307
|
+
});
|
|
308
|
+
// WP-688 S4 (element 11): the human receipt prints the REAL verb — no `?? 'surfaces_tension_in'`
|
|
309
|
+
// fallback (formatters/capture.ts:105).
|
|
310
|
+
it('the human-readable receipt prints the real relation type, never the dead surfaces_tension_in fallback', async () => {
|
|
311
|
+
kernelCallWithSessionMock.mockImplementation((fn) => {
|
|
312
|
+
if (fn === 'chain.suggestLinksForCapture')
|
|
313
|
+
return Promise.resolve([]);
|
|
314
|
+
if (fn === 'chain.createEntry')
|
|
315
|
+
return Promise.resolve({ docId: 'doc-2b', entryId: 'INS-51' });
|
|
316
|
+
if (fn === 'chain.createEntryRelation')
|
|
317
|
+
return Promise.resolve({ relationId: 'rel-1' });
|
|
318
|
+
return Promise.resolve({});
|
|
319
|
+
});
|
|
320
|
+
let stdoutOutput = '';
|
|
321
|
+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
|
322
|
+
stdoutOutput += String(chunk);
|
|
323
|
+
return true;
|
|
324
|
+
});
|
|
325
|
+
setOutputMode('pretty'); // force the human receipt path (test harness's stdout is non-TTY)
|
|
326
|
+
try {
|
|
327
|
+
await runCapture({ text: 'INS: discovered Y', link: 'DEC-265', linkType: 'validates' });
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
setOutputMode('auto');
|
|
331
|
+
writeSpy.mockRestore();
|
|
332
|
+
}
|
|
333
|
+
expect(stdoutOutput).toContain('Linked: → DEC-265 (validates)');
|
|
334
|
+
expect(stdoutOutput).not.toContain('surfaces_tension_in');
|
|
335
|
+
});
|
|
336
|
+
// WP-688 S4 review round 2 (bc04z): a diverted relation call (chain-debt / governs-consent
|
|
337
|
+
// proposal, or the DEC-1399 auto-link gate) returns WITHOUT a relationId — only a real,
|
|
338
|
+
// persisted write may claim `linkedTo`/`relationType`.
|
|
339
|
+
it('a governs-consent proposal diversion (no relationId) is never reported as a persisted link', async () => {
|
|
340
|
+
kernelCallWithSessionMock.mockImplementation((fn) => {
|
|
341
|
+
if (fn === 'chain.suggestLinksForCapture')
|
|
342
|
+
return Promise.resolve([]);
|
|
343
|
+
if (fn === 'chain.createEntry')
|
|
344
|
+
return Promise.resolve({ docId: 'doc-3', entryId: 'WPKG-1' });
|
|
345
|
+
if (fn === 'chain.createEntryRelation') {
|
|
346
|
+
return Promise.resolve({ status: 'proposal_created', proposalId: 'PROP-1', existing: false });
|
|
347
|
+
}
|
|
348
|
+
return Promise.resolve({});
|
|
349
|
+
});
|
|
350
|
+
let stdoutOutput = '';
|
|
351
|
+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
|
352
|
+
stdoutOutput += String(chunk);
|
|
353
|
+
return true;
|
|
354
|
+
});
|
|
355
|
+
await runCapture({ text: 'WPKG: governance test', link: 'STD-1', linkType: 'governs', json: true });
|
|
356
|
+
writeSpy.mockRestore();
|
|
357
|
+
const output = JSON.parse(stdoutOutput.split('\n').find((line) => {
|
|
358
|
+
try {
|
|
359
|
+
JSON.parse(line);
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
}));
|
|
366
|
+
expect(output.relationType).toBeUndefined();
|
|
367
|
+
// WP-688 S4 review fix (bc7cr): the re-eval call STILL fires — it's the only thing that
|
|
368
|
+
// un-defers the LLM schedule deferLLMSchedule set (a relate WAS attempted) — but its
|
|
369
|
+
// verdict is discarded: no relation landed, so relation-dependent criteria stay unscored.
|
|
370
|
+
expect(kernelCallWithSessionMock).toHaveBeenCalledWith('quality.evaluateHeuristicAndSchedule', {
|
|
371
|
+
entryId: 'WPKG-1',
|
|
372
|
+
context: 'capture',
|
|
373
|
+
});
|
|
374
|
+
expect(output.coaching).toBeUndefined();
|
|
375
|
+
});
|
|
376
|
+
// WP-688 S4 review round 2 (bc045): --link without --link-type must not defer the LLM
|
|
377
|
+
// schedule — the refusal branch never runs the re-eval that would un-defer it, so a
|
|
378
|
+
// deferred-then-never-run schedule would leave the entry permanently stuck.
|
|
379
|
+
it('--link without --link-type does NOT defer the LLM schedule (nothing will ever un-defer it)', async () => {
|
|
380
|
+
kernelCallWithSessionMock.mockImplementation((fn) => {
|
|
381
|
+
if (fn === 'chain.suggestLinksForCapture')
|
|
382
|
+
return Promise.resolve([]);
|
|
383
|
+
if (fn === 'chain.createEntry')
|
|
384
|
+
return Promise.resolve({ docId: 'doc-4', entryId: 'TEN-20' });
|
|
385
|
+
if (fn === 'chain.createEntryRelation')
|
|
386
|
+
return Promise.resolve({ preview: true, advisories: [] });
|
|
387
|
+
return Promise.resolve({});
|
|
388
|
+
});
|
|
389
|
+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
390
|
+
await expect(runCapture({ text: 'TEN: refused link, no defer', link: 'BET-1' })).rejects.toThrow(CLIError);
|
|
391
|
+
writeSpy.mockRestore();
|
|
392
|
+
const createCall = kernelCallWithSessionMock.mock.calls.find(([fn]) => fn === 'chain.createEntry');
|
|
393
|
+
expect(createCall?.[1]).not.toHaveProperty('deferLLMSchedule');
|
|
394
|
+
});
|
|
395
|
+
// WP-688 S4 review round 2 (bc049): both a fields-rejection and a --link-type refusal can
|
|
396
|
+
// be true on the SAME capture — the entry was created either way, so both recovery
|
|
397
|
+
// instructions must reach the operator, not just whichever check runs first.
|
|
398
|
+
it('surfaces BOTH the fields-rejected error and the link-type refusal when they co-occur', async () => {
|
|
399
|
+
kernelCallWithSessionMock.mockImplementation((fn) => {
|
|
400
|
+
if (fn === 'chain.suggestLinksForCapture')
|
|
401
|
+
return Promise.resolve([]);
|
|
402
|
+
if (fn === 'chain.createEntry') {
|
|
403
|
+
return Promise.resolve({
|
|
404
|
+
docId: 'doc-5', entryId: 'TEN-21',
|
|
405
|
+
normalization: { remapped: {}, rejected: ['badField'], accepted: [] },
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
if (fn === 'chain.createEntryRelation')
|
|
409
|
+
return Promise.resolve({ preview: true, advisories: [] });
|
|
410
|
+
return Promise.resolve({});
|
|
411
|
+
});
|
|
412
|
+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
413
|
+
let caught;
|
|
414
|
+
try {
|
|
415
|
+
await runCapture({ text: 'TEN: both failures at once', link: 'BET-1' });
|
|
416
|
+
}
|
|
417
|
+
catch (e) {
|
|
418
|
+
caught = e;
|
|
419
|
+
}
|
|
72
420
|
writeSpy.mockRestore();
|
|
421
|
+
expect(caught).toBeInstanceOf(CLIError);
|
|
422
|
+
expect(caught?.message).toContain('Fields rejected: badField');
|
|
423
|
+
expect(caught?.message).toContain('--link requires --link-type');
|
|
424
|
+
expect(caught?.guidance).toContain('pb fields');
|
|
425
|
+
expect(caught?.guidance).toContain('TEN-21');
|
|
73
426
|
});
|
|
74
427
|
});
|
|
75
428
|
// WP-684 TASK-77 (E1.3): one-step typed create — --entry-type forwards to chain.createEntry's
|
|
@@ -303,7 +656,7 @@ describe('runCapture coaching passthrough (WP-475 E1)', () => {
|
|
|
303
656
|
if (fn === 'chain.createEntry')
|
|
304
657
|
return Promise.resolve({ docId: 'doc-c3', entryId: 'DEC-79', coaching });
|
|
305
658
|
if (fn === 'chain.createEntryRelation')
|
|
306
|
-
return Promise.resolve({});
|
|
659
|
+
return Promise.resolve({ relationId: 'rel-1' });
|
|
307
660
|
if (fn === 'quality.evaluateHeuristicAndSchedule')
|
|
308
661
|
return Promise.resolve(reEvalCoaching);
|
|
309
662
|
return Promise.resolve({});
|
|
@@ -313,7 +666,7 @@ describe('runCapture coaching passthrough (WP-475 E1)', () => {
|
|
|
313
666
|
stdoutOutput += String(chunk);
|
|
314
667
|
return true;
|
|
315
668
|
});
|
|
316
|
-
await runCapture({ text: 'DEC: chose with a link', link: 'TEN-1', json: true });
|
|
669
|
+
await runCapture({ text: 'DEC: chose with a link', link: 'TEN-1', linkType: 'informs', json: true });
|
|
317
670
|
writeSpy.mockRestore();
|
|
318
671
|
// Deterministic --link fix: the CREATE call defers the LLM schedule (the
|
|
319
672
|
// post-relation re-eval owns the single LLM run)…
|
|
@@ -347,7 +700,7 @@ describe('runCapture coaching passthrough (WP-475 E1)', () => {
|
|
|
347
700
|
if (fn === 'chain.createEntry')
|
|
348
701
|
return Promise.resolve({ docId: 'doc-c4', entryId: 'DEC-80', coaching });
|
|
349
702
|
if (fn === 'chain.createEntryRelation')
|
|
350
|
-
return Promise.resolve({});
|
|
703
|
+
return Promise.resolve({ relationId: 'rel-1' });
|
|
351
704
|
if (fn === 'quality.evaluateHeuristicAndSchedule')
|
|
352
705
|
return Promise.reject(new Error('network'));
|
|
353
706
|
return Promise.resolve({});
|
|
@@ -357,7 +710,7 @@ describe('runCapture coaching passthrough (WP-475 E1)', () => {
|
|
|
357
710
|
stdoutOutput += String(chunk);
|
|
358
711
|
return true;
|
|
359
712
|
});
|
|
360
|
-
await runCapture({ text: 'DEC: chose with a broken re-eval', link: 'TEN-2', json: true });
|
|
713
|
+
await runCapture({ text: 'DEC: chose with a broken re-eval', link: 'TEN-2', linkType: 'informs', json: true });
|
|
361
714
|
writeSpy.mockRestore();
|
|
362
715
|
const jsonLines = stdoutOutput.split('\n').filter((line) => {
|
|
363
716
|
try {
|