● Zion Boggan
repos/TreeTrace/test/treetrace.test.js
zionboggan.com ↗
2618 lines · javascript
History for this file →
1
import { test } from 'node:test';
2
import assert from 'node:assert/strict';
3
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
import { tmpdir } from 'node:os';
5
import { fileURLToPath } from 'node:url';
6
import { dirname, join } from 'node:path';
7
 
8
import { parseSessionFile, parsePlainTranscript, classifySpecialUserText } from '../src/parse.js';
9
import { classifyPrompts } from '../src/extract.js';
10
import { buildTree } from '../src/tree.js';
11
import { scanText, applyDecisions, shadowScan, maskFor, resolveFindings, isGitShaCandidate, patchResiduals } from '../src/redact.js';
12
import { renderMarkdown, promptPack } from '../src/render-md.js';
13
import { renderMermaid, isSummaryByDefault, SUMMARY_NODE_THRESHOLD } from '../src/render-mermaid.js';
14
import { renderJson } from '../src/render-json.js';
15
import { renderHandoff } from '../src/handoff.js';
16
import { renderReportMarkdown, renderTerminalSummary } from '../src/report.js';
17
import {
18
  analyzeTree,
19
  renderFailuresJson,
20
  renderRejectionsJson,
21
  renderLessonsMarkdown,
22
  renderEvalsJsonl,
23
  renderMemoryMarkdown,
24
  isRiskyCommand,
25
  mentionsTestSkip,
26
  SECURITY_INTENT_PARTS,
27
  RISKY_CMD_PARTS,
28
} from '../src/analyze.js';
29
import { main, parseArgs, wrapMermaidDoc } from '../src/cli.js';
30
import { mungePath } from '../src/discover.js';
31
import { sha256, escapeMd } from '../src/util.js';
32
import { detectHallucinations, renderHallucinationsJson } from '../src/hallucinate.js';
33
import { renderSecurityReport, hasSecuritySignal } from '../src/security-report.js';
34
import { spawn } from 'node:child_process';
35
 
36
const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'synthetic-session.jsonl');
37
 
38
async function fixtureTree() {
39
  const session = await parseSessionFile(FIXTURE, { sessionId: 'fix-001' });
40
  const nodes = classifyPrompts([session]);
41
  return { session, nodes, tree: buildTree([session], nodes) };
42
}
43
 
44
test('parser: extracts only human prompts, skips tool results/commands/sidechains', async () => {
45
  const { session } = await fixtureTree();
46
  assert.equal(session.prompts.length, 5);
47
  assert.ok(session.prompts.every((p) => !p.text.startsWith('<command-name>')));
48
  assert.ok(!session.prompts.some((p) => p.text.includes('subagent')));
49
  assert.equal(session.title, 'Build a weather dashboard');
50
  assert.equal(session.stats.toolUses, 2);
51
  assert.equal(session.stats.interruptions, 1);
52
  assert.deepEqual(session.stats.models, ['assistant-model']);
53
  assert.equal(session.stats.filesTouched.length, 1);
54
});
55
 
56
test('extractor: classification kinds and nudge folding', async () => {
57
  const { nodes } = await fixtureTree();
58
  assert.equal(nodes.length, 4);
59
  assert.equal(nodes[0].kind, 'root');
60
  assert.equal(nodes[0].nudges, 1);
61
  assert.equal(nodes[1].kind, 'direction');
62
  assert.equal(nodes[2].kind, 'correction');
63
  assert.equal(nodes[3].kind, 'scope-change');
64
  assert.equal(nodes[3].afterInterruption, true);
65
});
66
 
67
test('tree: fork detection marks rewound branch abandoned', async () => {
68
  const { tree } = await fixtureTree();
69
  const leaflet = tree.nodes.find((n) => n.text.includes('leaflet'));
70
  assert.equal(leaflet.status, 'accepted');
71
  assert.equal(tree.roots.length, 1);
72
  assert.equal(tree.stats.promptCount, 4);
73
  assert.equal(tree.stats.corrections, 1);
74
});
75
 
76
test('redaction: catches anthropic key and basic-auth URL, masks them', async () => {
77
  const { tree } = await fixtureTree();
78
  const scope = tree.nodes.find((n) => n.kind === 'scope-change');
79
  const findings = scanText(scope.text);
80
  const rules = new Set(findings.map((f) => f.ruleId));
81
  assert.ok(rules.has('anthropic-key'), `anthropic-key not in ${[...rules]}`);
82
  assert.ok(rules.has('url-basic-auth'), `url-basic-auth not in ${[...rules]}`);
83
 
84
  const { decisions } = await resolveFindings(findings, {}, { interactive: false, autoRedact: true });
85
  const cleaned = applyDecisions(scope.text, findings, decisions);
86
  assert.ok(!cleaned.includes('sk-ant-'), 'key leaked');
87
  assert.ok(!cleaned.includes('hunter2pass'), 'password leaked');
88
  assert.ok(cleaned.includes('[REDACTED:'));
89
});
90
 
91
test('redaction: shadow scan flags unresolved secrets, passes resolved/kept ones', () => {
92
  const dirty = 'token ghp_0123456789abcdefghijklmnopqrstuvwxyzAB end';
93
  assert.equal(shadowScan(dirty, {}).length, 1);
94
 
95
  const findings = scanText(dirty);
96
  const kept = { [sha256(findings[0].match)]: { action: 'keep', ruleId: findings[0].ruleId } };
97
  assert.equal(shadowScan(dirty, kept).length, 0);
98
 
99
  const masked = applyDecisions(dirty, findings, {
100
    [sha256(findings[0].match)]: { action: 'redact', replacement: maskFor(findings[0]), ruleId: findings[0].ruleId },
101
  });
102
  assert.equal(shadowScan(masked, {}).length, 0);
103
});
104
 
105
test('redaction: rule coverage on known formats', () => {
106
  const cases = [
107
    ['AKIAIOSFODNN7EXAMPLE', 'aws-access-key'],
108
    ['github_pat_11AAAAAAA0123456789abcdefghij', 'github-fine-grained'],
109
    ['xoxb-treetrace-example-slack-token-0', 'slack-token'],
110
    ['sk_live_abcdefghijklmnop123', 'stripe-live-key'],
111
    ['tskey-auth-kFGiAS7CNTRL-abcdef123456', 'tailscale-key'],
112
    ['-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaA==\n-----END OPENSSH PRIVATE KEY-----', 'private-key-block'],
113
    ['eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U', 'jwt'],
114
    ['password = "correct-horse-battery"', 'secret-assignment'],
115
    ['SECRET="correct horse battery staple"', 'secret-assignment'],
116
    ['https://user:p:a:ss@example.com/path', 'url-basic-auth'],
117
  ];
118
  for (const [sample, expected] of cases) {
119
    const hits = scanText(`some text ${sample} more text`).map((f) => f.ruleId);
120
    assert.ok(hits.includes(expected), `${expected} missed in: ${sample} (got ${hits})`);
121
  }
122
});
123
 
124
test('redaction: escaped characters inside quoted secret assignments are still caught', () => {
125
  const cases = [
126
    ['escaped newline', '{"api_key":"line1\\nline2line2"}'],
127
    ['escaped tab', '{"api_key":"col1\\tcol2value"}'],
128
    ['escaped quote', '{"api_key":"abc\\"defghij"}'],
129
    ['escaped backslash', '{"api_key":"abc\\\\defghij"}'],
130
    ['single-quoted escaped newline', "{'password':'line1\\nline2value'}"],
131
    ['backtick escaped newline', 'const secret = `line1\\nline2value`;'],
132
  ];
133
  for (const [label, sample] of cases) {
134
    const hits = scanText(sample).map((f) => f.ruleId);
135
    assert.ok(
136
      hits.includes('secret-assignment'),
137
      `${label}: escaped secret value should be caught (got ${JSON.stringify(hits)} for ${sample})`
138
    );
139
  }
140
});
141
 
142
test('redaction: end-to-end escaped-JSON secret leaves no raw value in any artifact', async () => {
143
  const rawValue = 'line1\\nline2line2line2';
144
  const secretLine = `config is {"api_key":"${rawValue}"}`;
145
  const dir = mkdtempSync(join(tmpdir(), 'treetrace-esc-'));
146
  const file = join(dir, 'escconv.json');
147
  const convo = [{
148
    mapping: {
149
      r: { message: null, parent: null, children: ['u'] },
150
      u: { message: { author: { role: 'user' }, content: { parts: [secretLine] }, create_time: 1.0 }, parent: 'r', children: ['a'] },
151
      a: { message: { author: { role: 'assistant' }, content: { parts: ['ok'] }, create_time: 2.0 }, parent: 'u', children: [] },
152
    },
153
  }];
154
  writeFileSync(file, JSON.stringify(convo));
155
  try {
156
    await main(['--from', 'chatgpt', '--file', file, '--dir', dir, '--report', '--analysis', '--redact-auto', '--quiet']);
157
    const artifacts = [
158
      'PROMPT_TREE.md', 'TREETRACE_REPORT.md', '.treetrace/tree.json',
159
      '.treetrace/failures.json', '.treetrace/lessons.md', '.treetrace/evals.jsonl', '.treetrace/agent-memory.md',
160
    ].filter((f) => existsSync(join(dir, f))).map((f) => readFileSync(join(dir, f), 'utf8')).join('\n');
161
    assert.ok(!artifacts.includes(rawValue), 'raw escaped-JSON secret value leaked into an artifact');
162
    assert.ok(artifacts.includes('[REDACTED:secret-assignment]'), 'expected a secret-assignment redaction marker');
163
  } finally {
164
    rmSync(dir, { recursive: true, force: true });
165
  }
166
});
167
 
168
test('redaction: bare hex tokens (32+ chars) are detected, lower and upper case', async () => {
169
  const lower = '6881f8290266f4cc939959917f893a2a88787eb24bbcb6b9c37594c72bf448c3';
170
  const upper = lower.toUpperCase();
171
  const half = lower.slice(0, 32);
172
  for (const hex of [lower, upper, half]) {
173
    const hits = scanText(`my key is session_hex=${hex} ok`).map((f) => f.ruleId);
174
    assert.ok(hits.includes('hex-token'), `hex-token missed for ${hex} (got ${hits})`);
175
  }
176
  const findings = scanText(`session_hex=${lower}`);
177
  const { decisions } = await resolveFindings(findings, {}, { interactive: false, autoRedact: true });
178
  const cleaned = applyDecisions(`session_hex=${lower}`, findings, decisions);
179
  assert.ok(!cleaned.includes(lower), 'raw hex leaked after redaction');
180
  assert.equal(shadowScan(cleaned, {}).length, 0, 'shadow scan should be clean after hex redaction');
181
});
182
 
183
test('redaction: high-entropy lowercase-and-digit token (no uppercase) is caught in prose', () => {
184
  const token = 'abcdefg0123456789hijklmnop4567qrstuv';
185
  const hits = scanText(`the access token is ${token} now`).map((f) => f.ruleId);
186
  assert.ok(hits.includes('high-entropy-token'), `high-entropy token missed (got ${hits})`);
187
});
188
 
189
test('redaction: structured lowercase-and-digit token is caught fail-closed', async () => {
190
  const token = 'lowercase9lowercase9lowercase9lowercase9';
191
  const findings = scanText(`session token ${token}`).filter((f) => f.severity !== 'soft');
192
  assert.ok(findings.some((f) => f.ruleId === 'lowercase-digit-token'), `lowercase-digit token missed: ${JSON.stringify(findings)}`);
193
 
194
  const { decisions } = await resolveFindings(findings, {}, { interactive: false, autoRedact: true });
195
  const cleaned = applyDecisions(`session token ${token}`, findings, decisions);
196
  assert.ok(!cleaned.includes(token), 'structured lowercase token leaked after redaction');
197
  assert.equal(shadowScan(cleaned, {}).length, 0, 'shadow scan should be clean after lowercase-digit redaction');
198
});
199
 
200
test('redaction: uuids and long lowercase identifiers are not flagged as high-entropy', () => {
201
  for (const benign of [
202
    '8400e29b-1d4f-4a6c-9b2e-7f3a1c5d8e90',
203
    'src/components/dashboard/widgets/chartwidget',
204
    'MAX_RETRY_ATTEMPTS_BEFORE_GIVING_UP_2',
205
  ]) {
206
    const hits = scanText(benign).filter((f) => f.ruleId === 'high-entropy-token');
207
    assert.equal(hits.length, 0, `false positive high-entropy flag on ${benign}`);
208
  }
209
});
210
 
211
test('redaction: git object hashes are classified as candidates only in a git context', () => {
212
  const sha1 = '0123456789abcdef0123456789abcdef01234567';
213
  const sha256hex = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
214
  assert.ok(isGitShaCandidate(sha1, `commit ${sha1}`, 7), 'commit <sha1> should be a candidate');
215
  assert.ok(isGitShaCandidate(sha256hex, `git tree ${sha256hex}`, 9), 'git tree <sha256> should be a candidate');
216
  assert.ok(isGitShaCandidate(sha1, `${sha1} fix the parser\n`, 0), 'oneline sha should be a candidate');
217
  assert.ok(!isGitShaCandidate(sha1, `token=${sha1} end`, 6), 'token= context is not git');
218
  assert.ok(!isGitShaCandidate(sha256hex, `session_hex=${sha256hex}`, 12), 'session_hex= context is not git');
219
  assert.ok(!isGitShaCandidate('0123456789abcdef0123456789abcdef', `commit ${'0123456789abcdef0123456789abcdef'}`, 7), '32-hex is not a git object id');
220
});
221
 
222
test('redaction: --keep-git-shas keeps git hashes but stays fail-closed for other hex', async () => {
223
  const sha1 = '0123456789abcdef0123456789abcdef01234567';
224
  const secret = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
225
  const text = `commit ${sha1}\nmy key is session_hex=${secret} ok`;
226
  const findings = scanText(text);
227
  const git = findings.find((f) => f.match === sha1);
228
  const sec = findings.find((f) => f.match === secret);
229
  assert.ok(git && git.gitShaCandidate, 'git sha must be flagged as a candidate');
230
  assert.ok(sec && !sec.gitShaCandidate, 'session_hex secret must NOT be a git candidate');
231
 
232
  const { decisions } = await resolveFindings(findings, {}, { interactive: false, autoRedact: true, keepGitShas: true });
233
  assert.equal(decisions[sha256(sha1)].action, 'keep', 'git object hash should be kept');
234
  assert.equal(decisions[sha256(sha1)].ruleId, 'git-commit-sha', 'kept under git-commit-sha rule');
235
  assert.equal(decisions[sha256(secret)].action, 'redact', 'non-git hex must still be redacted');
236
 
237
  const { decisions: d2 } = await resolveFindings(findings, {}, { interactive: false, autoRedact: true });
238
  assert.equal(d2[sha256(sha1)].action, 'redact', 'default must redact git sha too (fail-closed)');
239
 
240
  const cleaned = applyDecisions(text, findings, decisions);
241
  assert.ok(cleaned.includes(sha1), 'kept git sha should survive in output');
242
  assert.ok(!cleaned.includes(secret), 'non-git secret must be redacted');
243
  assert.equal(shadowScan(cleaned, decisions).length, 0, 'shadow scan must be clean after keep + redact');
244
});
245
 
246
test('redaction: end-to-end hex secret leaves no raw hex in any artifact', async () => {
247
  const lower = '6881f8290266f4cc939959917f893a2a88787eb24bbcb6b9c37594c72bf448c3';
248
  const upper = lower.toUpperCase();
249
  const dir = mkdtempSync(join(tmpdir(), 'treetrace-hex-'));
250
  const file = join(dir, 'hexconv.json');
251
  const convo = [{
252
    mapping: {
253
      r: { message: null, parent: null, children: ['u'] },
254
      u: { message: { author: { role: 'user' }, content: { parts: [`my key is session_hex=${lower} and HEX=${upper} ok`] }, create_time: 1.0 }, parent: 'r', children: ['a'] },
255
      a: { message: { author: { role: 'assistant' }, content: { parts: ['got it'] }, create_time: 2.0 }, parent: 'u', children: [] },
256
    },
257
  }];
258
  writeFileSync(file, JSON.stringify(convo));
259
  try {
260
    await main(['--from', 'chatgpt', '--file', file, '--dir', dir, '--report', '--analysis', '--redact-auto', '--quiet']);
261
    const artifacts = [
262
      'PROMPT_TREE.md', 'TREETRACE_REPORT.md', '.treetrace/tree.json',
263
      '.treetrace/failures.json', '.treetrace/lessons.md', '.treetrace/evals.jsonl', '.treetrace/agent-memory.md',
264
    ].filter((f) => existsSync(join(dir, f))).map((f) => readFileSync(join(dir, f), 'utf8')).join('\n');
265
    assert.ok(!artifacts.includes(lower), 'lowercase hex secret leaked into an artifact');
266
    assert.ok(!artifacts.includes(upper), 'uppercase hex secret leaked into an artifact');
267
    assert.ok(artifacts.includes('[REDACTED:hex-token]'), 'expected a hex-token redaction marker');
268
  } finally {
269
    rmSync(dir, { recursive: true, force: true });
270
  }
271
});
272
 
273
test('redaction: a single 12MB token completes without throwing and stays safe', () => {
274
  const giant = 'A'.repeat(12 * 1024 * 1024);
275
  const text = `prefix ${giant} suffix`;
276
  let findings;
277
  assert.doesNotThrow(() => { findings = scanText(text); }, 'oversized token must not overflow the regex stack');
278
  assert.ok(findings.some((f) => f.ruleId === 'oversized-token'), 'oversized token should be flagged');
279
  const normal = scanText('store ghp_0123456789abcdefghijklmnopqrstuvwxyzAB and more');
280
  assert.ok(normal.some((f) => f.ruleId === 'github-token'), 'normal-size secrets still caught alongside the guard');
281
  const { decisions } = applyDecisionsRoundTrip(text, findings);
282
  assert.equal(shadowScan(decisions, {}).length, 0, 'oversized token should be cleaned after redaction');
283
});
284
 
285
function applyDecisionsRoundTrip(text, findings) {
286
  const map = {};
287
  for (const f of findings) map[sha256(f.match)] = { action: 'redact', replacement: maskFor(f), ruleId: f.ruleId };
288
  return { decisions: applyDecisions(text, findings, map) };
289
}
290
 
291
test('redaction: split provider tokens are caught before shadow scan', () => {
292
  const dirty = 'token sk-proj-abcdefghijklmnop\nqrstu1234567890ABCDE end';
293
  const findings = scanText(dirty);
294
  assert.ok(findings.some((f) => f.ruleId === 'openai-key'), `openai-key missed in ${findings}`);
295
  const masked = applyDecisions(dirty, findings, {
296
    [sha256(findings.find((f) => f.ruleId === 'openai-key').match)]: {
297
      action: 'redact',
298
      replacement: '[REDACTED:openai-key]',
299
      ruleId: 'openai-key',
300
    },
301
  });
302
  assert.equal(shadowScan(masked, {}).length, 0);
303
  assert.ok(!masked.includes('sk-proj-'));
304
});
305
 
306
test('redaction: whitespace-split secret below the length floor is caught', () => {
307
  const dirty = 'store key sk-ant-api03-AAAA BBBBCCCCDDDDEEEEFFFFGGGG into the vault';
308
  const findings = scanText(dirty);
309
  const hit = findings.find((f) => f.ruleId === 'anthropic-key');
310
  assert.ok(hit, `split anthropic-key missed: ${JSON.stringify(findings)}`);
311
  const masked = applyDecisions(dirty, findings, {
312
    [sha256(hit.match)]: { action: 'redact', replacement: '[REDACTED:anthropic-key]', ruleId: 'anthropic-key' },
313
  });
314
  assert.ok(!/sk-ant-api03-AAAA/.test(masked), `secret not redacted: ${masked}`);
315
  assert.equal(shadowScan(masked, {}).length, 0);
316
});
317
 
318
test('redaction: scan stays fast on long benign input (ReDoS guard)', () => {
319
  const big = 'http://' + 'a'.repeat(60000);
320
  const start = Date.now();
321
  scanText(big);
322
  assert.ok(Date.now() - start < 2000, 'scan should stay linear on long input');
323
});
324
 
325
test('redaction: benign text produces no high/medium findings', () => {
326
  const benign =
327
    'Refactor the parser in src/parse.js to handle commit 3f2a1b9 and bump to v2.1.0-beta.3. The README.md needs a section on CONTRIBUTING.';
328
  const hard = scanText(benign).filter((f) => f.severity !== 'soft');
329
  assert.deepEqual(hard, []);
330
});
331
 
332
test('escapeMd neutralizes HTML-sensitive characters', () => {
333
  assert.equal(escapeMd('a<script>b</script>&c>'), 'a&lt;script&gt;b&lt;/script&gt;&amp;c&gt;');
334
});
335
 
336
test('rendering escapes injection in project name and content', async () => {
337
  const { tree } = await fixtureTree();
338
  const md = renderMarkdown(tree, { projectName: 'x</summary></details><script>alert(1)</script>' });
339
  assert.ok(md.includes('# Prompt Tree: x&lt;/summary&gt;&lt;/details&gt;&lt;script&gt;'), 'project name not escaped');
340
  assert.ok(!md.includes('Prompt Tree: x</summary>'), 'raw HTML in project name');
341
});
342
 
343
test('renderers: markdown, json, handoff are consistent and footer-credited', async () => {
344
  const { tree } = await fixtureTree();
345
  analyzeTree(tree);
346
  const md = renderMarkdown(tree, { projectName: 'demo' });
347
  assert.ok(md.startsWith('# Prompt Tree: demo'));
348
  assert.ok(md.includes('## Goal'));
349
  assert.ok(md.includes('## Reusable Prompt Pack'));
350
  assert.ok(md.includes('[treetrace]'));
351
 
352
  const json = renderJson(tree, { projectName: 'demo' });
353
  assert.equal(json.schemaVersion, '0.3');
354
  assert.equal(json.nodes.length, tree.nodes.length);
355
  assert.equal(json.edges.length, tree.nodes.filter((n) => n.parent).length);
356
  assert.ok(json.nodes.every((n) => n.id && n.kind && typeof n.text === 'string'));
357
  assert.ok(json.analysis.failureSignals >= 1);
358
  assert.ok(json.correctionChains.length >= 1);
359
  assert.ok(json.nodes.some((n) => Array.isArray(n.failureSignals)));
360
 
361
  const pack = promptPack(tree.nodes);
362
  assert.ok(pack.includes('1.'));
363
 
364
  const handoff = renderHandoff(tree, { projectName: 'demo' });
365
  assert.ok(handoff.includes('## Original goal'));
366
  assert.ok(handoff.includes('## Constraints'));
367
  assert.ok(handoff.includes('## Lessons'));
368
 
369
  const report = renderReportMarkdown(tree, { projectName: 'demo', generatedAt: '2026-01-01T00:00:00.000Z' });
370
  assert.ok(report.startsWith('# TreeTrace Report - demo'));
371
  assert.ok(report.includes('## Output map'));
372
  assert.ok(report.includes('## Artifacts'));
373
  assert.ok(report.includes('TREETRACE_REPORT.md'));
374
});
375
 
376
test('rendering: markdown footer stamps the tool version when provided', async () => {
377
  const { tree } = await fixtureTree();
378
  const md = renderMarkdown(tree, { projectName: 'demo', version: '0.4.0' });
379
  assert.ok(md.includes('v0.4.0'), 'PROMPT_TREE.md footer should stamp the version');
380
  const report = renderReportMarkdown(tree, { projectName: 'demo', version: '0.4.0', generatedAt: '2026-01-01T00:00:00.000Z' });
381
  assert.ok(report.includes('v0.4.0'), 'TREETRACE_REPORT.md footer should stamp the version');
382
});
383
 
384
test('analysis renderers produce failures, lessons, evals, and memory', async () => {
385
  const { tree } = await fixtureTree();
386
  const failures = renderFailuresJson(tree, { projectName: 'demo', generatedAt: '2026-01-01T00:00:00.000Z' });
387
  assert.equal(failures.schemaVersion, '0.3');
388
  assert.ok(failures.failures.length >= 1);
389
  assert.ok(failures.correctionChains.length >= 1);
390
 
391
  const lessons = renderLessonsMarkdown(tree, { projectName: 'demo' });
392
  assert.ok(lessons.includes('# Lessons'));
393
  assert.ok(/\[node_\w+/.test(lessons), 'lessons should inline node ids in brackets');
394
 
395
  const evals = renderEvalsJsonl(tree).trim().split('\n').map((line) => JSON.parse(line));
396
  assert.ok(evals.length >= 1);
397
  assert.ok(evals.every((e) => e.source === 'treetrace' && e.sourceNodeIds.length >= 1));
398
 
399
  const memory = renderMemoryMarkdown(tree, { projectName: 'demo' });
400
  assert.ok(!memory.includes('TreeTrace Agent Memory'), 'H1 title removed in diet');
401
  assert.ok(memory.includes('## Constraints'), 'compact constraints header');
402
  assert.ok(!memory.includes('Keep TreeTrace local-first'));
403
});
404
 
405
test('analysis: tiny transcript without corrections does not invent failures', () => {
406
  const session = parsePlainTranscript('User: build a tiny CLI\nAssistant: done', 'tiny');
407
  const nodes = classifyPrompts([session]);
408
  const tree = buildTree([session], nodes);
409
  const analysis = analyzeTree(tree);
410
  assert.equal(analysis.summary.totalFailureSignals, 0);
411
  assert.deepEqual(analysis.failures, []);
412
});
413
 
414
test('analysis: a security-sensitive agent action produces a verified, model-attributed signal', () => {
415
  const root = {
416
    id: 'node_001', text: 'Add rate limiting to checkout', title: 'Add rate limiting to checkout',
417
    kind: 'root', status: 'accepted', parent: null,
418
    actions: [{ tool: 'Edit', file: 'src/auth/session.ts', command: null, model: 'claude-sonnet-4-6' }],
419
  };
420
  const correction = {
421
    id: 'node_002', text: 'check the existing auth flow first', title: 'check the existing auth flow first',
422
    kind: 'correction', status: 'accepted', parent: root, actions: [],
423
  };
424
  const analysis = analyzeTree({ nodes: [root, correction] });
425
  const sec = analysis.failures.find((f) => f.type === 'security_or_privacy_risk');
426
  assert.ok(sec, 'expected a verified security signal from the auth-file edit');
427
  assert.equal(sec.tier, 'verified');
428
  assert.equal(sec.model, 'claude-sonnet-4-6');
429
  assert.equal(sec.correctedByNodeId, 'node_002');
430
  assert.ok(sec.evidence.includes('session.ts'));
431
  assert.deepEqual(analysis.summary.models, ['claude-sonnet-4-6']);
432
  assert.ok(analysis.summary.tierCounts.verified >= 1);
433
});
434
 
435
test('analysis: a credential-handling Bash action produces a verified security signal', () => {
436
  const root = {
437
    id: 'node_001', text: 'deploy the marketing site', title: 'deploy the marketing site',
438
    kind: 'root', status: 'accepted', parent: null,
439
    actions: [{
440
      tool: 'Bash', file: null,
441
      command: 'set -a; . /srv/app/.env; export CLOUDFLARE_API_KEY="$DEPLOY_API_KEY"; wrangler pages deploy site',
442
      input: 'set -a; . /srv/app/.env; export CLOUDFLARE_API_KEY="$DEPLOY_API_KEY"; wrangler pages deploy site',
443
      model: 'claude-opus-4-8',
444
    }],
445
  };
446
  const analysis = analyzeTree({ nodes: [root] });
447
  const sec = analysis.failures.find((f) => f.type === 'security_or_privacy_risk');
448
  assert.ok(sec, 'expected a security signal from the credential-handling deploy');
449
  assert.equal(sec.tier, 'verified');
450
  assert.ok(/credential/.test(sec.evidence), 'evidence should name the credential kind');
451
  assert.ok(analysis.summary.tierCounts.verified >= 1);
452
});
453
 
454
test('analysis: benign --force-* chrome flag does not mint a verified security signal', () => {
455
  const root = {
456
    id: 'node_001', text: 'capture a screenshot of the page', title: 'capture a screenshot',
457
    kind: 'root', status: 'accepted', parent: null,
458
    actions: [{ tool: 'Bash', file: null, command: 'chrome --headless --force-device-scale-factor=1 --screenshot=out.png', model: 'm' }],
459
  };
460
  const analysis = analyzeTree({ nodes: [root] });
461
  const sec = analysis.failures.filter((f) => f.type === 'security_or_privacy_risk');
462
  assert.equal(sec.length, 0, '--force-device-scale-factor must not fire as a security risk');
463
});
464
 
465
test('analysis: a token-named UI file does not mint a verified credential signal', () => {
466
  for (const file of ['src/ui/semantic-tokens.ts', 'src/lexer/tokenizer.ts', 'theme/design-tokens.json']) {
467
    const root = {
468
      id: 'node_001', text: 'edit the theme', title: 'edit the theme',
469
      kind: 'root', status: 'accepted', parent: null,
470
      actions: [{ tool: 'Edit', file, command: null, model: 'm' }],
471
    };
472
    const analysis = analyzeTree({ nodes: [root] });
473
    const verified = analysis.failures.filter((f) => f.type === 'security_or_privacy_risk' && f.tier === 'verified');
474
    assert.equal(verified.length, 0, `${file} must not produce a verified credential signal`);
475
  }
476
});
477
 
478
test('analysis: a bare rbac keyword in a non-credential edit is down-tiered below verified', () => {
479
  const root = {
480
    id: 'node_001', text: 'edit the detector', title: 'edit the detector',
481
    kind: 'root', status: 'accepted', parent: null,
482
    actions: [{ tool: 'Edit', file: 'src/analyze.js', input: 'const ACCESS = /rbac/i;', command: null, model: 'm' }],
483
  };
484
  const analysis = analyzeTree({ nodes: [root] });
485
  const sec = analysis.failures.filter((f) => f.type === 'security_or_privacy_risk');
486
  assert.ok(sec.every((f) => f.tier !== 'verified' && f.confidence < 0.95), 'bare rbac keyword must not be verified/0.95');
487
});
488
 
489
test('analysis: a real credential file and a real secret command still verify at 0.95', () => {
490
  const fileNode = {
491
    id: 'node_001', text: 'harden auth', title: 'harden auth', kind: 'root', status: 'accepted', parent: null,
492
    actions: [{ tool: 'Edit', file: 'src/auth/session.ts', command: null, model: 'm' }],
493
  };
494
  const fileSec = analyzeTree({ nodes: [fileNode] }).failures.find((f) => f.type === 'security_or_privacy_risk');
495
  assert.ok(fileSec && fileSec.tier === 'verified' && fileSec.confidence === 0.95, 'a genuine auth file must stay verified');
496
 
497
  const cmdNode = {
498
    id: 'node_001', text: 'deploy', title: 'deploy', kind: 'root', status: 'accepted', parent: null,
499
    actions: [{ tool: 'Bash', file: null, command: '. /srv/app/.env; wrangler pages deploy', input: '. /srv/app/.env; wrangler pages deploy', model: 'm' }],
500
  };
501
  const cmdSec = analyzeTree({ nodes: [cmdNode] }).failures.find((f) => f.type === 'security_or_privacy_risk');
502
  assert.ok(cmdSec && cmdSec.tier === 'verified', 'a genuine credential command must stay verified');
503
});
504
 
505
test('analysis: a PAT-update prompt produces an inferred security signal even with no action', () => {
506
  const root = { id: 'node_001', text: 'build the cli', title: 'build the cli', kind: 'root', status: 'accepted', parent: null, actions: [] };
507
  const intent = {
508
    id: 'node_002', text: 'I updated the PAT in the master access ref doc', title: 'I updated the PAT',
509
    kind: 'direction', status: 'accepted', parent: root, actions: [],
510
  };
511
  const analysis = analyzeTree({ nodes: [root, intent] });
512
  const sec = analysis.failures.find((f) => f.type === 'security_or_privacy_risk' && f.firstSeenNodeId === 'node_002');
513
  assert.ok(sec, 'expected an inferred security signal from the PAT-update prompt');
514
  assert.equal(sec.tier, 'inferred');
515
  const memory = renderMemoryMarkdown({ nodes: [root, intent] });
516
  assert.ok(memory.includes('## Security'), 'memory should list the security section');
517
  assert.ok(/stated intent/.test(memory), 'memory should tag the stated intent');
518
});
519
 
520
test('analysis: a long pasted spec listing security categories does not over-fire as intent', () => {
521
  const root = { id: 'node_001', text: 'build the cli', title: 'build the cli', kind: 'root', status: 'accepted', parent: null, actions: [] };
522
  const seed =
523
    'Here is the full product spec to read and react to. '.repeat(20) +
524
    'The detector flags when an agent changed auth logic, touched secrets, modified access control, or disabled tests. ' +
525
    'More pitch copy about water, compute, investors, and the cloud. '.repeat(20);
526
  const pitch = { id: 'node_002', text: seed, title: 'pasted spec', kind: 'checkpoint', status: 'accepted', parent: root, actions: [] };
527
  const analysis = analyzeTree({ nodes: [root, pitch] });
528
  const sec = analysis.failures.filter((f) => f.type === 'security_or_privacy_risk');
529
  assert.equal(sec.length, 0, 'a long pasted spec should not mint a stated-intent security signal');
530
});
531
 
532
test('analysis: the constraints section extracts directive requirements and never reports none when constraints exist', () => {
533
  const root = { id: 'node_001', text: 'build the cli', title: 'build the cli', kind: 'root', status: 'accepted', parent: null, actions: [] };
534
  const rule = {
535
    id: 'node_002',
536
    text: 'no em dashes and do not add inline code comments, and keep it Apache licensed',
537
    title: 'no em dashes', kind: 'direction', status: 'accepted', parent: root, actions: [],
538
  };
539
  const memory = renderMemoryMarkdown({ nodes: [root, rule] });
540
  const block = memory.slice(memory.indexOf('## Constraints'), memory.indexOf('## Lessons'));
541
  assert.ok(/no em dashes/i.test(block), 'em-dash constraint should be listed');
542
  assert.ok(/inline code comments/i.test(block), 'inline-comment constraint should be listed');
543
  assert.ok(/apache/i.test(block), 'license constraint should be listed');
544
  assert.ok(!/No explicit constraints were flagged/.test(block), 'must not claim none when constraints exist');
545
});
546
 
547
test('analysis: a benign descriptive prompt with no directive yields no false constraints', () => {
548
  const root = { id: 'node_001', text: 'build the cli', title: 'build the cli', kind: 'root', status: 'accepted', parent: null, actions: [] };
549
  const benign = {
550
    id: 'node_002', text: 'I like where we stand so far and I think this looks good to me',
551
    title: 'looks good', kind: 'direction', status: 'accepted', parent: root, actions: [],
552
  };
553
  const memory = renderMemoryMarkdown({ nodes: [root, benign] });
554
  assert.ok(!memory.includes('## Constraints'), 'benign descriptive text should not mint constraints');
555
});
556
 
557
test('analysis: a destructive-then-recovery turn yields a known bad path and is not the preferred next work', () => {
558
  const root = { id: 'node_001', text: 'build the marketing deck', title: 'build the marketing deck', kind: 'root', status: 'accepted', parent: null, actions: [] };
559
  const direction = {
560
    id: 'node_002', text: 'Also you can send an agent out to develop these sections',
561
    title: 'send an agent out to develop these sections', kind: 'direction', status: 'accepted', parent: root, actions: [],
562
  };
563
  const mishap = {
564
    id: 'node_003', text: 'Also messed up the deck file in the P:/ it is gone I am sorry can you bring it back',
565
    title: 'Also messed up the deck file in the P:/ it is gone I am sorry can you bring it back',
566
    kind: 'direction', status: 'accepted', parent: direction,
567
    actions: [{ tool: 'Write', file: 'P:/deck/index.html' }],
568
  };
569
  const nodes = [root, direction, mishap];
570
  const analysis = analyzeTree({ nodes });
571
  const bad = analysis.failures.filter((f) => f.type === 'abandoned_path');
572
  assert.ok(bad.length >= 1, 'destructive-then-recovery should produce a bad-path entry');
573
  const memory = renderMemoryMarkdown({ nodes });
574
  const badBlock = memory.slice(memory.indexOf('## Bad paths'), memory.indexOf('## Security'));
575
  assert.ok(!/No abandoned paths were detected/.test(badBlock), 'must not claim no abandoned paths when a destructive event occurred');
576
  assert.ok(/recover|destructive/i.test(badBlock), 'bad-path entry should warn about the destructive event');
577
  const nextBlock = memory.slice(memory.indexOf('## Next'));
578
  assert.ok(!/messed up the deck/i.test(nextBlock), 'preferred next work must not parrot the apology turn');
579
  assert.ok(/develop these sections/i.test(nextBlock), 'preferred next work should point at the real forward direction');
580
});
581
 
582
test('analysis: a keyword-only correction stays in the inferred or confirmed tier, not verified', () => {
583
  const root = { id: 'node_001', text: 'build a dashboard', title: 'build a dashboard', kind: 'root', status: 'accepted', parent: null, actions: [] };
584
  const corr = { id: 'node_002', text: 'no, that is overbuilt, keep it minimal', title: 'no, that is overbuilt', kind: 'correction', status: 'accepted', parent: root, actions: [] };
585
  const analysis = analyzeTree({ nodes: [root, corr] });
586
  assert.ok(analysis.failures.length >= 1);
587
  assert.ok(analysis.failures.every((f) => f.tier !== 'verified'));
588
  assert.equal(analysis.summary.tierCounts.verified, 0);
589
});
590
 
591
test('analysis: a single benign prompt does not yield multiple failure types', () => {
592
  const root = {
593
    id: 'node_001', text: 'build the marketing deck', title: 'build the marketing deck',
594
    kind: 'root', status: 'accepted', parent: null, ts: '2026-06-12T14:00:00.000Z', actions: [],
595
  };
596
  const benign = {
597
    id: 'node_002', text: 'and slide an agent to make the decks mobile friendly too please',
598
    title: 'make the decks mobile friendly', kind: 'direction', status: 'accepted', parent: root,
599
    ts: '2026-06-12T14:52:00.000Z', actions: [],
600
  };
601
  const longPaste = {
602
    id: 'node_003',
603
    text: 'ok sounds good i agree. ' + 'do not overbuild it, it is too much, try again later if it keeps failing. '.repeat(40),
604
    title: 'long strategy paste', kind: 'checkpoint', status: 'accepted', parent: benign,
605
    ts: '2026-06-12T12:52:00.000Z', actions: [],
606
  };
607
  const analysis = analyzeTree({ nodes: [root, benign, longPaste] });
608
  const benignFailures = analysis.failures.filter((f) => f.firstSeenNodeId === 'node_002');
609
  assert.equal(benignFailures.length, 0, 'a benign request should not mint failures from wording alone');
610
  for (const id of ['node_001', 'node_002', 'node_003']) {
611
    const types = analysis.failures.filter((f) => f.firstSeenNodeId === id).map((f) => f.type);
612
    assert.ok(new Set(types).size <= 1, `node ${id} emitted multiple failure types: ${types.join(', ')}`);
613
  }
614
});
615
 
616
test('analysis: latest accepted direction is chronological, not insertion order', () => {
617
  const root = {
618
    id: 'node_001', text: 'pick a research topic', title: 'pick a research topic',
619
    kind: 'root', status: 'accepted', parent: null, ts: '2026-01-01T00:00:00.000Z', actions: [],
620
  };
621
  const newest = {
622
    id: 'node_002', text: 'lets dig into Amazon Nova and the Karunanidhi essay direction',
623
    title: 'Amazon Nova and Karunanidhi', kind: 'direction', status: 'accepted', parent: root,
624
    ts: '2026-03-01T00:00:00.000Z', actions: [],
625
  };
626
  const stale = {
627
    id: 'node_003', text: 'lets explore the Seoul travel itinerary in depth for the trip',
628
    title: 'Seoul travel itinerary', kind: 'direction', status: 'accepted', parent: newest,
629
    ts: '2026-02-01T00:00:00.000Z', actions: [],
630
  };
631
  const nodes = [root, newest, stale];
632
  const tree = { nodes, stats: { promptCount: 3, sessionCount: 2 } };
633
  const summary = renderTerminalSummary(tree, { projectName: 'demo' });
634
  assert.ok(/Amazon Nova/i.test(summary), 'terminal summary should name the chronologically newest direction');
635
  assert.ok(!/Seoul/i.test(summary.split('Latest accepted direction:')[1] || ''), 'must not name the stale Seoul session as latest');
636
 
637
  const handoff = renderHandoff(tree, { projectName: 'demo' });
638
  const stand = handoff.split('## Where things stand')[1].split('##')[0];
639
  assert.ok(/Amazon Nova/i.test(stand), 'handoff should name the chronologically newest accepted direction');
640
 
641
  const memory = renderMemoryMarkdown(tree, { projectName: 'demo' });
642
  const next = memory.slice(memory.indexOf('## Next'));
643
  assert.ok(/Amazon Nova/i.test(next), 'agent memory should point at the chronologically newest direction');
644
});
645
 
646
test('analysis: a corrector is never linked with an earlier timestamp than its failure', () => {
647
  const failure = {
648
    id: 'node_001', text: 'i do not see the deck, just the index file showing text',
649
    title: 'deck not rendering', kind: 'direction', status: 'accepted', parent: null,
650
    ts: '2026-06-12T14:06:20.000Z',
651
    actions: [{ tool: 'Edit', file: 'site/deck/index.html', command: null, input: null, model: 'claude-opus-4-8' }],
652
  };
653
  const earlier = {
654
    id: 'node_002', text: 'no that is wrong, the deck still does not work, redo it instead',
655
    title: 'still broken', kind: 'correction', status: 'accepted', parent: failure,
656
    ts: '2026-06-12T12:52:00.000Z',
657
    actions: [{ tool: 'Edit', file: 'site/deck/index.html', command: null, input: null, model: 'claude-opus-4-8' }],
658
  };
659
  const analysis = analyzeTree({ nodes: [failure, earlier] });
660
  const byId = { node_001: failure, node_002: earlier };
661
  for (const f of analysis.failures) {
662
    if (!f.correctedByNodeId) continue;
663
    const ft = new Date(byId[f.firstSeenNodeId].ts).getTime();
664
    const ct = new Date(byId[f.correctedByNodeId].ts).getTime();
665
    assert.ok(ct >= ft, `failure ${f.id} corrected by an earlier-timestamped node`);
666
  }
667
  for (const c of analysis.correctionChains) {
668
    const ft = new Date(byId[c.failureNodeId].ts).getTime();
669
    const ct = new Date(byId[c.correctionNodeId].ts).getTime();
670
    assert.ok(ct >= ft, `chain ${c.id} links a corrector that precedes its failure`);
671
    if (c.resolvedNodeId) {
672
      const rt = new Date(byId[c.resolvedNodeId].ts).getTime();
673
      assert.ok(rt >= ft, `chain ${c.id} resolves before its failure`);
674
    }
675
  }
676
});
677
 
678
test('cli: default run writes analysis artifacts with redaction', async () => {
679
  const dir = mkdtempSync(join(tmpdir(), 'treetrace-'));
680
  try {
681
    await main(['--file', FIXTURE, '--dir', dir, '--redact-auto', '--quiet']);
682
    for (const file of [
683
      'TREETRACE_REPORT.md',
684
      'PROMPT_TREE.md',
685
      '.treetrace/tree.json',
686
      '.treetrace/failures.json',
687
      '.treetrace/lessons.md',
688
      '.treetrace/evals.jsonl',
689
      '.treetrace/agent-memory.md',
690
    ]) {
691
      assert.ok(existsSync(join(dir, file)), `${file} missing`);
692
    }
693
    const failures = JSON.parse(readFileSync(join(dir, '.treetrace/failures.json'), 'utf8'));
694
    assert.equal(failures.schemaVersion, '0.3');
695
    assert.ok(failures.failures.length >= 1);
696
 
697
    const evalLine = readFileSync(join(dir, '.treetrace/evals.jsonl'), 'utf8').trim().split('\n')[0];
698
    assert.equal(JSON.parse(evalLine).source, 'treetrace');
699
 
700
    const exported = [
701
      'PROMPT_TREE.md',
702
      'TREETRACE_REPORT.md',
703
      '.treetrace/tree.json',
704
      '.treetrace/failures.json',
705
      '.treetrace/lessons.md',
706
      '.treetrace/evals.jsonl',
707
      '.treetrace/agent-memory.md',
708
    ].map((file) => readFileSync(join(dir, file), 'utf8')).join('\n');
709
    assert.ok(!exported.includes('sk-ant-'), 'anthropic key leaked');
710
    assert.ok(!exported.includes('hunter2pass'), 'basic-auth password leaked');
711
  } finally {
712
    rmSync(dir, { recursive: true, force: true });
713
  }
714
});
715
 
716
test('cli: --analysis combined with --report writes both analysis files and the reports', async () => {
717
  const dir = mkdtempSync(join(tmpdir(), 'treetrace-both-'));
718
  try {
719
    await main(['--file', FIXTURE, '--dir', dir, '--analysis', '--report', '--redact-auto', '--quiet']);
720
    for (const file of [
721
      'TREETRACE_REPORT.md', 'PROMPT_TREE.md', '.treetrace/tree.json',
722
      '.treetrace/failures.json', '.treetrace/lessons.md', '.treetrace/evals.jsonl', '.treetrace/agent-memory.md',
723
    ]) {
724
      assert.ok(existsSync(join(dir, file)), `${file} missing when --analysis and --report combined`);
725
    }
726
  } finally {
727
    rmSync(dir, { recursive: true, force: true });
728
  }
729
});
730
 
731
test('cli: a copilot import records a per-adapter sourceType, not claude-code-jsonl', async () => {
732
  const fixture = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'adapters', 'copilot-chatsession.json');
733
  const dir = mkdtempSync(join(tmpdir(), 'treetrace-src-'));
734
  try {
735
    await main(['--from', 'copilot', '--file', fixture, '--dir', dir, '--redact-auto', '--quiet']);
736
    const tree = JSON.parse(readFileSync(join(dir, '.treetrace/tree.json'), 'utf8'));
737
    assert.equal(tree.project.sourceType, 'copilot-chat', 'sourceType should reflect the copilot adapter');
738
    assert.notEqual(tree.project.sourceType, 'claude-code-jsonl');
739
  } finally {
740
    rmSync(dir, { recursive: true, force: true });
741
  }
742
});
743
 
744
test('cli: mixed imports record mixed sourceType instead of falling back to claude', async () => {
745
  const dir = mkdtempSync(join(tmpdir(), 'treetrace-mixed-src-'));
746
  const plain = join(dir, 'plain.txt');
747
  const chatgpt = join(dir, 'chatgpt.json');
748
  writeFileSync(plain, 'User: build a CLI\nAssistant: ok\n');
749
  writeFileSync(chatgpt, JSON.stringify([{
750
    title: 'demo',
751
    mapping: {
752
      r: { message: null, parent: null, children: ['u'] },
753
      u: { message: { author: { role: 'user' }, content: { parts: ['build a dashboard'] }, create_time: 1.0 }, parent: 'r', children: [] },
754
    },
755
  }]));
756
  try {
757
    await main(['--file', plain, chatgpt, '--dir', dir, '--redact-auto', '--quiet']);
758
    const tree = JSON.parse(readFileSync(join(dir, '.treetrace/tree.json'), 'utf8'));
759
    assert.equal(tree.project.sourceType, 'mixed');
760
  } finally {
761
    rmSync(dir, { recursive: true, force: true });
762
  }
763
});
764
 
765
test('cli: creates the output directory and .treetrace subdirectory when missing', async () => {
766
  const base = mkdtempSync(join(tmpdir(), 'treetrace-'));
767
  const dir = join(base, 'does', 'not', 'exist', 'yet');
768
  try {
769
    assert.ok(!existsSync(dir), 'target dir should not exist before the run');
770
    await main(['--file', FIXTURE, '--dir', dir, '--redact-auto', '--quiet']);
771
    assert.ok(existsSync(join(dir, 'PROMPT_TREE.md')), 'PROMPT_TREE.md missing');
772
    assert.ok(existsSync(join(dir, '.treetrace', 'tree.json')), '.treetrace/tree.json missing');
773
  } finally {
774
    rmSync(base, { recursive: true, force: true });
775
  }
776
});
777
 
778
test('redaction: the literal phrase "security-risk" is not a false-positive secret', () => {
779
  for (const phrase of ['security-risk', 'skip the security-risk step']) {
780
    const hard = scanText(phrase).filter((f) => f.severity !== 'soft');
781
    assert.deepEqual(hard, [], `"${phrase}" should not match any secret rule (got ${JSON.stringify(hard)})`);
782
  }
783
});
784
 
785
test('redaction: a real-format GitHub token is caught', () => {
786
  const token = 'ghp_0123456789abcdefghijklmnopqrstuvwxyzAB';
787
  const hits = scanText(`set the remote with ${token} now`).map((f) => f.ruleId);
788
  assert.ok(hits.includes('github-token'), `github-token missed (got ${hits})`);
789
});
790
 
791
test('redaction: a token inside a Bash action body is redacted end to end', async () => {
792
  const token = 'ghp_0123456789abcdefghijklmnopqrstuvwxyzAB';
793
  const lines = [
794
    { type: 'summary', summary: 'wire up the remote', leafUuid: 'b3' },
795
    {
796
      parentUuid: null, isSidechain: false, type: 'user', userType: 'external', uuid: 'b1',
797
      sessionId: 'leak-001', timestamp: '2026-06-01T10:00:00.000Z', cwd: '/tmp/demo', gitBranch: 'main', version: '2.1.0',
798
      message: { role: 'user', content: 'Point the git remote at my fork.' },
799
    },
800
    {
801
      parentUuid: 'b1', isSidechain: false, type: 'assistant', uuid: 'b2', sessionId: 'leak-001',
802
      timestamp: '2026-06-01T10:00:30.000Z',
803
      message: {
804
        role: 'assistant', model: 'assistant-model', usage: { input_tokens: 100, output_tokens: 50 },
805
        content: [
806
          { type: 'text', text: 'Setting the remote.' },
807
          { type: 'tool_use', id: 'g1', name: 'Bash', input: { command: `git push --force origin main && git remote set-url origin https://x:${token}@github.com/me/fork.git` } },
808
        ],
809
      },
810
    },
811
  ];
812
  const dir = mkdtempSync(join(tmpdir(), 'treetrace-leak-'));
813
  const session = join(dir, 'session.jsonl');
814
  writeFileSync(session, lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
815
  try {
816
    const parsed = await parseSessionFile(session, { sessionId: 'leak-001' });
817
    const action = parsed.prompts[0].actions.find((a) => a.tool === 'Bash');
818
    assert.ok(action, 'expected a captured Bash action');
819
    assert.ok(action.command.includes(token), 'fixture should carry the raw token before redaction');
820
    assert.ok(typeof action.input === 'string' && action.input.includes(token), 'input summary should carry the command');
821
 
822
    await main(['--file', session, '--dir', dir, '--redact-auto', '--quiet']);
823
    const exported = [
824
      'PROMPT_TREE.md', 'TREETRACE_REPORT.md', '.treetrace/tree.json',
825
      '.treetrace/failures.json', '.treetrace/lessons.md', '.treetrace/evals.jsonl', '.treetrace/agent-memory.md',
826
    ].map((f) => readFileSync(join(dir, f), 'utf8')).join('\n');
827
    assert.ok(!exported.includes(token), 'GitHub token leaked from an action body into output');
828
    assert.ok(!/ghp_[0-9A-Za-z]/.test(exported), 'a partial GitHub token prefix leaked from an action body into output');
829
    assert.ok(exported.includes('[REDACTED:'), 'expected a redaction marker where the action-body token was');
830
  } finally {
831
    rmSync(dir, { recursive: true, force: true });
832
  }
833
});
834
 
835
test('handoff: command operators are not HTML-escaped in the brief', () => {
836
  const root = {
837
    id: 'node_001', text: 'run rm -rf build && mkdir build to reset the workspace',
838
    title: 'reset the workspace', kind: 'root', status: 'accepted', parent: null, actions: [],
839
  };
840
  const handoff = renderHandoff({ nodes: [root], stats: { promptCount: 1, sessionCount: 1 } }, { projectName: 'demo' });
841
  assert.ok(handoff.includes('rm -rf build && mkdir build'), 'command should keep raw && in the handoff brief');
842
  assert.ok(!handoff.includes('&amp;&amp;'), 'handoff must not HTML-escape && to &amp;&amp;');
843
  const inject = {
844
    id: 'node_001', text: 'do not run <script>alert(1)</script> ever',
845
    title: 'no scripts', kind: 'root', status: 'accepted', parent: null, actions: [],
846
  };
847
  const handoff2 = renderHandoff({ nodes: [inject], stats: { promptCount: 1, sessionCount: 1 } }, { projectName: 'demo' });
848
  assert.ok(!handoff2.includes('<script>'), 'angle-bracket tags should still be neutralized in the handoff brief');
849
});
850
 
851
test('plain transcript fallback parses User:/Assistant: markers', () => {
852
  const session = parsePlainTranscript(
853
    'User: build me a snake game in python\nAssistant: sure, here is the code...\nUser: make the snake blue\nAssistant: done',
854
    'pasted'
855
  );
856
  assert.equal(session.prompts.length, 2);
857
  assert.equal(session.prompts[1].text, 'make the snake blue');
858
  assert.throws(() => parsePlainTranscript('no markers here at all'), /turn markers/);
859
});
860
 
861
test('special user text classification', () => {
862
  assert.equal(classifySpecialUserText('<command-name>/foo</command-name>'), 'command');
863
  assert.equal(classifySpecialUserText('<system-reminder>x</system-reminder>'), 'meta');
864
  assert.equal(
865
    classifySpecialUserText('This session is being continued from a previous conversation that ran out of context.'),
866
    'compact-continuation'
867
  );
868
  assert.equal(classifySpecialUserText('build me an app'), 'prompt');
869
});
870
 
871
test('discover: path munging matches Claude Code storage layout', () => {
872
  assert.equal(mungePath('/home/dev/weatherapp'), '-home-dev-weatherapp');
873
  assert.equal(mungePath('/home/dev/weatherapp/api'), '-home-dev-weatherapp-api');
874
  assert.equal(mungePath('/home/u.ser/my_app'), '-home-u-ser-my-app');
875
});
876
 
877
function tempProject() {
878
  const dir = mkdtempSync(join(tmpdir(), 'treetrace-feat-'));
879
  writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'demo', dependencies: { express: '^4.0.0' } }));
880
  mkdirSync(join(dir, 'src'), { recursive: true });
881
  writeFileSync(join(dir, 'src', 'real.js'), 'export const real = 1;\n');
882
  return dir;
883
}
884
 
885
test('hallucinations: flags only the invented file and import, not the real ones', () => {
886
  const dir = tempProject();
887
  try {
888
    const root = {
889
      id: 'node_001', kind: 'root', status: 'accepted', parent: null,
890
      text: 'Open src/real.js and src/imaginary.js to wire the feature.',
891
      title: 'wire the feature',
892
      actions: [{
893
        tool: 'Edit', file: 'src/real.js',
894
        input: "import express from 'express';\nimport ghostlib from 'ghostlib-does-not-exist';\nimport { readFileSync } from 'node:fs';",
895
        command: null, model: 'm',
896
      }],
897
    };
898
    const tree = { nodes: [root] };
899
    const result = detectHallucinations(tree, dir);
900
    const files = result.hallucinations.filter((h) => h.category === 'hallucinated_file_or_path').map((h) => h.reference);
901
    const imports = result.hallucinations.filter((h) => h.category === 'hallucinated_import_or_package').map((h) => h.reference);
902
 
903
    assert.ok(files.includes('src/imaginary.js'), `invented file should be flagged (got ${files})`);
904
    assert.ok(!files.includes('src/real.js'), 'the real file must not be flagged');
905
    assert.ok(!files.some((f) => /package\.json/.test(f)), 'the real package.json must not be flagged');
906
 
907
    assert.ok(imports.includes('ghostlib-does-not-exist'), `invented import should be flagged (got ${imports})`);
908
    assert.ok(!imports.includes('express'), 'a declared dependency must not be flagged');
909
    assert.ok(!imports.includes('fs') && !imports.includes('node:fs'), 'a node builtin must not be flagged');
910
 
911
    for (const h of result.hallucinations) {
912
      assert.ok(h.evalCandidate && h.evalCandidate.target, 'each hallucination should carry an eval candidate');
913
    }
914
  } finally {
915
    rmSync(dir, { recursive: true, force: true });
916
  }
917
});
918
 
919
test('hallucinations: a file created during the session is not flagged', () => {
920
  const dir = tempProject();
921
  try {
922
    const root = {
923
      id: 'node_001', kind: 'root', status: 'accepted', parent: null,
924
      text: 'Create src/brandnew.js and then reference src/brandnew.js again.',
925
      title: 'create new file',
926
      actions: [{ tool: 'Write', file: 'src/brandnew.js', input: 'export const n = 1;', command: null, model: 'm' }],
927
    };
928
    const result = detectHallucinations({ nodes: [root] }, dir);
929
    const files = result.hallucinations.filter((h) => h.category === 'hallucinated_file_or_path').map((h) => h.reference);
930
    assert.ok(!files.includes('src/brandnew.js'), 'a file the agent created this session must not be flagged');
931
  } finally {
932
    rmSync(dir, { recursive: true, force: true });
933
  }
934
});
935
 
936
test('hallucinations: extensionless files under dot-directories are flagged when missing', () => {
937
  const dir = tempProject();
938
  try {
939
    const root = {
940
      id: 'node_001', kind: 'root', status: 'accepted', parent: null,
941
      text: 'Open .github/CODEOWNERS and .github/workflows/ci and .husky/pre-commit, and reference JSON.parse and test.skip.',
942
      title: 'review config',
943
      actions: [],
944
    };
945
    const result = detectHallucinations({ nodes: [root] }, dir);
946
    const files = result.hallucinations.filter((h) => h.category === 'hallucinated_file_or_path').map((h) => h.reference);
947
    assert.ok(files.includes('.github/CODEOWNERS'), `dot-directory path should be flagged (got ${files})`);
948
    assert.ok(files.includes('.github/workflows/ci'), 'nested dot-directory path should be flagged');
949
    assert.ok(files.includes('.husky/pre-commit'), 'hyphenated dot-directory path should be flagged');
950
    assert.ok(!files.includes('JSON.parse') && !files.includes('test.skip'), 'dotted code symbols must not be flagged');
951
  } finally {
952
    rmSync(dir, { recursive: true, force: true });
953
  }
954
});
955
 
956
test('hallucinations: process.env is not flagged as a missing file', () => {
957
  const dir = tempProject();
958
  try {
959
    const root = {
960
      id: 'node_001', kind: 'root', status: 'accepted', parent: null,
961
      text: 'Read the API key from process.env instead of hardcoding it.',
962
      title: 'use env var', actions: [],
963
    };
964
    const result = detectHallucinations({ nodes: [root] }, dir);
965
    const files = result.hallucinations.filter((h) => h.category === 'hallucinated_file_or_path').map((h) => h.reference);
966
    assert.ok(!files.includes('process.env'), `process.env must not be flagged as a file (got ${files})`);
967
  } finally {
968
    rmSync(dir, { recursive: true, force: true });
969
  }
970
});
971
 
972
test('hallucinations: a relative require is not flagged as an import, but the missing file is', () => {
973
  const dir = tempProject();
974
  try {
975
    const root = {
976
      id: 'node_001', kind: 'root', status: 'accepted', parent: null,
977
      text: 'Wire it up.', title: 'wire',
978
      actions: [{ tool: 'Edit', file: 'src/index.js', input: "const limiter = require('./middleware/rateLimit.js');", command: null, model: 'm' }],
979
    };
980
    const result = detectHallucinations({ nodes: [root] }, dir);
981
    const imports = result.hallucinations.filter((h) => h.category === 'hallucinated_import_or_package').map((h) => h.reference);
982
    const files = result.hallucinations.filter((h) => h.category === 'hallucinated_file_or_path').map((h) => h.reference);
983
    assert.ok(!imports.includes('.'), 'a relative require must not be reduced to a "." import');
984
    assert.ok(files.includes('./middleware/rateLimit.js') || files.includes('middleware/rateLimit.js'), `the missing relative file should still be flagged (got ${files})`);
985
  } finally {
986
    rmSync(dir, { recursive: true, force: true });
987
  }
988
});
989
 
990
test('security report: surfaces real signals and omits benign sessions', () => {
991
  const dir = tempProject();
992
  try {
993
    const root = {
994
      id: 'node_001', kind: 'root', status: 'accepted', parent: null,
995
      text: 'harden the login flow', title: 'harden the login flow',
996
      actions: [
997
        { tool: 'Edit', file: 'src/auth/login.js', input: 'export function login() {}', command: null, model: 'claude-opus-4-8' },
998
        { tool: 'Bash', file: null, command: 'rm -rf build', input: 'rm -rf build', model: 'claude-opus-4-8' },
999
      ],
1000
    };
1001
    const correction = {
1002
      id: 'node_002', kind: 'correction', status: 'accepted', parent: root,
1003
      text: 'no, do not disable the tests in the auth suite, keep them running',
1004
      title: 'do not disable tests', actions: [],
1005
    };
1006
    const tree = { nodes: [root, correction] };
1007
    assert.ok(hasSecuritySignal(tree, dir), 'expected a security signal for the auth edit');
1008
    const report = renderSecurityReport(tree, dir, { projectName: 'demo', generatedAt: '2026-01-01T00:00:00.000Z' });
1009
 
1010
    assert.ok(report.startsWith('# TreeTrace Security Report - demo'));
1011
    assert.ok(/auth: .*src\/auth\/login\.js/.test(report), 'auth surface and file should be listed');
1012
    assert.ok(/rm -rf build/.test(report), 'risky command should be listed');
1013
    assert.ok(/disable the tests|disable or skip tests/i.test(report), 'test-skip signal should appear');
1014
    assert.ok(/do not disable the tests/i.test(report), 'the human correction should surface as an eval/memory candidate');
1015
 
1016
    writeFileSync(join(dir, 'README.md'), '# demo\n');
1017
    const benign = {
1018
      id: 'node_001', kind: 'root', status: 'accepted', parent: null,
1019
      text: 'add a markdown table to the README', title: 'add a table',
1020
      actions: [{ tool: 'Edit', file: 'README.md', input: '| a | b |', command: null, model: 'm' }],
1021
    };
1022
    const benignTree = { nodes: [benign] };
1023
    assert.ok(!hasSecuritySignal(benignTree, dir), 'benign session should have no security signal');
1024
    const benignReport = renderSecurityReport(benignTree, dir, { projectName: 'demo', generatedAt: '2026-01-01T00:00:00.000Z' });
1025
    assert.ok(/None detected\./.test(benignReport), 'benign report should state nothing was found');
1026
  } finally {
1027
    rmSync(dir, { recursive: true, force: true });
1028
  }
1029
});
1030
 
1031
test('security report and hallucinations.json do not leak injected secrets via the CLI', async () => {
1032
  const dir = tempProject();
1033
  const hex = '6881f8290266f4cc939959917f893a2a88787eb24bbcb6b9c37594c72bf448c3';
1034
  const ghToken = 'ghp_0123456789abcdefghijklmnopqrstuvwxyzAB';
1035
  const convo = [{
1036
    mapping: {
1037
      r: { message: null, parent: null, children: ['u'] },
1038
      u: { message: { author: { role: 'user' }, content: { parts: [
1039
        `edit src/imaginary.js, my key is session_hex=${hex} and token ${ghToken}`,
1040
      ] }, create_time: 1.0 }, parent: 'r', children: ['a'] },
1041
      a: { message: { author: { role: 'assistant' }, content: { parts: ['ok'] }, create_time: 2.0 }, parent: 'u', children: [] },
1042
    },
1043
  }];
1044
  const file = join(dir, 'leaky.json');
1045
  writeFileSync(file, JSON.stringify(convo));
1046
  try {
1047
    await main(['--from', 'chatgpt', '--file', file, '--dir', dir, '--security', '--redact-auto', '--quiet']);
1048
    const hall = readFileSync(join(dir, '.treetrace/hallucinations.json'), 'utf8');
1049
    assert.ok(!hall.includes(hex), 'hex secret leaked into hallucinations.json');
1050
    assert.ok(!hall.includes(ghToken), 'github token leaked into hallucinations.json');
1051
    assert.ok(/imaginary\.js/.test(hall), 'the invented file should still be detected');
1052
  } finally {
1053
    rmSync(dir, { recursive: true, force: true });
1054
  }
1055
});
1056
 
1057
test('cli: structured exit codes for CI consumers', async () => {
1058
  const bin = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'treetrace.js');
1059
  const run = (args) =>
1060
    new Promise((resolve) => {
1061
      const child = spawn('node', [bin, ...args], { stdio: ['ignore', 'ignore', 'pipe'] });
1062
      let stderr = '';
1063
      child.stderr.on('data', (d) => { stderr += d; });
1064
      child.on('close', (code) => resolve({ code, stderr }));
1065
    });
1066
  const empty = mkdtempSync(join(tmpdir(), 'treetrace-exit-'));
1067
  try {
1068
    const usage = await run(['--bogus']);
1069
    assert.equal(usage.code, 2, `bad option should exit 2 (got ${usage.code}): ${usage.stderr}`);
1070
    const nodata = await run(['--dir', empty]);
1071
    assert.equal(nodata.code, 3, `nothing-to-trace should exit 3 (got ${nodata.code}): ${nodata.stderr}`);
1072
  } finally {
1073
    rmSync(empty, { recursive: true, force: true });
1074
  }
1075
});
1076
 
1077
test('mcp: initialize, tools/list, and tools/call return well-formed JSON-RPC', async () => {
1078
  const dir = tempProject();
1079
  const convo = [{
1080
    mapping: {
1081
      r: { message: null, parent: null, children: ['u'] },
1082
      u: { message: { author: { role: 'user' }, content: { parts: ['build a cli and do not add dependencies'] }, create_time: 1.0 }, parent: 'r', children: ['a'] },
1083
      a: { message: { author: { role: 'assistant' }, content: { parts: ['ok'] }, create_time: 2.0 }, parent: 'u', children: ['u2'] },
1084
      u2: { message: { author: { role: 'user' }, content: { parts: ['no, that is wrong, keep it minimal'] }, create_time: 3.0 }, parent: 'a', children: [] },
1085
    },
1086
  }];
1087
  const file = join(dir, 'mcp.json');
1088
  writeFileSync(file, JSON.stringify(convo));
1089
  const bin = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'treetrace.js');
1090
  try {
1091
    const responses = await new Promise((resolveP, rejectP) => {
1092
      const child = spawn('node', [bin, 'mcp', '--from', 'chatgpt', '--file', file, '--dir', dir], {
1093
        stdio: ['pipe', 'pipe', 'ignore'],
1094
      });
1095
      let buf = '';
1096
      child.stdout.on('data', (d) => { buf += d; });
1097
      child.on('error', rejectP);
1098
      const send = (o) => child.stdin.write(JSON.stringify(o) + '\n');
1099
      send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
1100
      send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
1101
      send({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'lessons', arguments: {} } });
1102
      send({ jsonrpc: '2.0', id: 99, method: 'tools/call', params: { name: 'nope', arguments: {} } });
1103
      setTimeout(() => {
1104
        child.stdin.end();
1105
        child.kill();
1106
        resolveP(buf.split('\n').filter(Boolean).map((l) => JSON.parse(l)));
1107
      }, 2000);
1108
    });
1109
 
1110
    const init = responses.find((r) => r.id === 1);
1111
    assert.ok(init && init.jsonrpc === '2.0', 'initialize must be JSON-RPC 2.0');
1112
    assert.equal(init.result.serverInfo.name, 'treetrace');
1113
    assert.ok(init.result.protocolVersion, 'initialize must advertise a protocol version');
1114
 
1115
    const list = responses.find((r) => r.id === 2);
1116
    const names = list.result.tools.map((t) => t.name).sort();
1117
    assert.deepEqual(names, ['eval_candidates', 'handoff', 'lessons', 'rejections_summary', 'security_summary', 'tree']);
1118
 
1119
    const call = responses.find((r) => r.id === 3);
1120
    assert.ok(call.result && Array.isArray(call.result.content), 'tools/call must return content array');
1121
    assert.equal(call.result.content[0].type, 'text');
1122
    assert.ok(/# Lessons/.test(call.result.content[0].text), 'lessons tool should return the lessons markdown');
1123
 
1124
    const bad = responses.find((r) => r.id === 99);
1125
    assert.ok(bad.error && bad.error.code === -32602, 'unknown tool should return a JSON-RPC error');
1126
  } finally {
1127
    rmSync(dir, { recursive: true, force: true });
1128
  }
1129
});
1130
 
1131
test('mcp: tree tool reports the imported source type', async () => {
1132
  const dir = tempProject();
1133
  const transcript = join(dir, 'plain.txt');
1134
  writeFileSync(transcript, 'User: build a CLI\nAssistant: ok\n');
1135
  const bin = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'treetrace.js');
1136
  try {
1137
    const responses = await new Promise((resolveP, rejectP) => {
1138
      const child = spawn('node', [bin, 'mcp', '--from', 'transcript', '--file', transcript, '--dir', dir], {
1139
        stdio: ['pipe', 'pipe', 'ignore'],
1140
      });
1141
      let buf = '';
1142
      child.stdout.on('data', (d) => { buf += d; });
1143
      child.on('error', rejectP);
1144
      child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'tree', arguments: {} } }) + '\n');
1145
      setTimeout(() => {
1146
        child.stdin.end();
1147
        child.kill();
1148
        resolveP(buf.split('\n').filter(Boolean).map((l) => JSON.parse(l)));
1149
      }, 2000);
1150
    });
1151
 
1152
    const treeResponse = responses.find((r) => r.id === 1);
1153
    const tree = JSON.parse(treeResponse.result.content[0].text);
1154
    assert.equal(tree.project.sourceType, 'transcript');
1155
  } finally {
1156
    rmSync(dir, { recursive: true, force: true });
1157
  }
1158
});
1159
 
1160
import { recordedCwd } from '../src/discover.js';
1161
 
1162
test('redaction: JSON-style, quoted, backtick, and multiline secret assignments are caught', () => {
1163
  const cases = [
1164
    '{"api_key":"supersecretvalue"}',
1165
    '{"client_secret":"correcthorsebattery"}',
1166
    '{"access_token":"correct-horse-battery"}',
1167
    "{'api_key':'correcthorsebattery'}",
1168
    'const password = `correct horse battery staple`;',
1169
    'api_key: `correct-horse-battery-staple`',
1170
    'API_KEY="line1\nline2line2line2"',
1171
  ];
1172
  for (const sample of cases) {
1173
    const hits = scanText(sample).map((f) => f.ruleId);
1174
    assert.ok(hits.includes('secret-assignment'), `secret-assignment missed in: ${JSON.stringify(sample)} (got ${hits})`);
1175
  }
1176
});
1177
 
1178
test('redaction: generic secret-key assignment is caught even with a low-entropy value', () => {
1179
  const sample = 'password: "hunter2hunter2"';
1180
  const hits = scanText(sample).map((f) => f.ruleId);
1181
  assert.ok(hits.includes('secret-assignment'), 'low-entropy generic secret should still be a finding');
1182
});
1183
 
1184
test('redaction: placeholder secret assignments are not flagged', () => {
1185
  for (const benign of ['token: null', 'password: ""', 'secret: "${SECRET}"', 'api_key: <your-key>', 'token=true']) {
1186
    const hard = scanText(benign).filter((f) => f.severity !== 'soft');
1187
    assert.deepEqual(hard, [], `${benign} should not flag (got ${JSON.stringify(hard)})`);
1188
  }
1189
});
1190
 
1191
test('redaction: a JSON-style secret leaves no raw value in any artifact end to end', async () => {
1192
  const secret = 'supersecretvalue';
1193
  const back = 'correct-horse-battery-staple';
1194
  const dir = mkdtempSync(join(tmpdir(), 'treetrace-json-secret-'));
1195
  const file = join(dir, 'conv.json');
1196
  const convo = [{
1197
    mapping: {
1198
      r: { message: null, parent: null, children: ['u'] },
1199
      u: { message: { author: { role: 'user' }, content: { parts: [`config is {"api_key":"${secret}"} and password = \`${back}\``] }, create_time: 1.0 }, parent: 'r', children: ['a'] },
1200
      a: { message: { author: { role: 'assistant' }, content: { parts: ['done'] }, create_time: 2.0 }, parent: 'u', children: [] },
1201
    },
1202
  }];
1203
  writeFileSync(file, JSON.stringify(convo));
1204
  try {
1205
    await main(['--from', 'chatgpt', '--file', file, '--dir', dir, '--report', '--analysis', '--redact-auto', '--quiet']);
1206
    const artifacts = [
1207
      'PROMPT_TREE.md', 'TREETRACE_REPORT.md', '.treetrace/tree.json',
1208
      '.treetrace/failures.json', '.treetrace/lessons.md', '.treetrace/evals.jsonl', '.treetrace/agent-memory.md',
1209
    ].filter((f) => existsSync(join(dir, f))).map((f) => readFileSync(join(dir, f), 'utf8')).join('\n');
1210
    assert.ok(!artifacts.includes(secret), 'JSON-style secret value leaked into an artifact');
1211
    assert.ok(!artifacts.includes(back), 'backtick secret value leaked into an artifact');
1212
    assert.ok(artifacts.includes('[REDACTED:secret-assignment]'), 'expected a secret-assignment redaction marker');
1213
  } finally {
1214
    rmSync(dir, { recursive: true, force: true });
1215
  }
1216
});
1217
 
1218
test('redaction: a prior keep decision is ignored under --redact-auto and non-TTY auto mode', async () => {
1219
  const token = 'ghp_0123456789abcdefghijklmnopqrstuvwxyzAB';
1220
  const text = `Use token ${token} for setup`;
1221
  const findings = scanText(text);
1222
  const prior = { [sha256(token)]: { action: 'keep', ruleId: 'github-token' } };
1223
 
1224
  const auto = await resolveFindings(findings, prior, { interactive: false, autoRedact: true });
1225
  assert.equal(auto.overriddenKeeps, 1, 'auto mode should override a prior keep');
1226
  const outAuto = applyDecisions(text, findings, auto.decisions);
1227
  assert.ok(!outAuto.includes(token), 'raw token leaked under --redact-auto despite re-redaction');
1228
  assert.equal(shadowScan(outAuto, auto.decisions).length, 0, 'shadow scan should be clean after override');
1229
 
1230
  const nonTty = await resolveFindings(findings, prior, { interactive: false, autoRedact: false });
1231
  assert.equal(nonTty.overriddenKeeps, 1, 'non-TTY auto mode should override a prior keep');
1232
  assert.ok(!applyDecisions(text, findings, nonTty.decisions).includes(token), 'raw token leaked in non-TTY auto mode');
1233
 
1234
  const interactive = await resolveFindings(findings, prior, { interactive: true, autoRedact: false });
1235
  assert.equal(interactive.overriddenKeeps, 0, 'interactive mode should honor a deliberate keep');
1236
  assert.ok(applyDecisions(text, findings, interactive.decisions).includes(token), 'interactive keep should be honored');
1237
});
1238
 
1239
test('cli: a preseeded keep cannot leak a secret under --redact-auto', async () => {
1240
  const token = 'ghp_0123456789abcdefghijklmnopqrstuvwxyzAB';
1241
  const dir = mkdtempSync(join(tmpdir(), 'treetrace-keep-'));
1242
  const file = join(dir, 'conv.json');
1243
  const convo = [{
1244
    mapping: {
1245
      r: { message: null, parent: null, children: ['u'] },
1246
      u: { message: { author: { role: 'user' }, content: { parts: [`Use token ${token} for setup`] }, create_time: 1.0 }, parent: 'r', children: ['a'] },
1247
      a: { message: { author: { role: 'assistant' }, content: { parts: ['done'] }, create_time: 2.0 }, parent: 'u', children: [] },
1248
    },
1249
  }];
1250
  writeFileSync(file, JSON.stringify(convo));
1251
  mkdirSync(join(dir, '.treetrace'), { recursive: true });
1252
  writeFileSync(join(dir, '.treetrace', 'redactions.json'), JSON.stringify({ [sha256(token)]: { action: 'keep', ruleId: 'github-token' } }));
1253
  try {
1254
    await main(['--from', 'chatgpt', '--file', file, '--dir', dir, '--report', '--analysis', '--redact-auto', '--quiet']);
1255
    const artifacts = [
1256
      'PROMPT_TREE.md', 'TREETRACE_REPORT.md', '.treetrace/tree.json',
1257
      '.treetrace/failures.json', '.treetrace/agent-memory.md',
1258
    ].filter((f) => existsSync(join(dir, f))).map((f) => readFileSync(join(dir, f), 'utf8')).join('\n');
1259
    assert.ok(!artifacts.includes(token), 'preseeded keep leaked a raw token under --redact-auto');
1260
    const stored = JSON.parse(readFileSync(join(dir, '.treetrace', 'redactions.json'), 'utf8'));
1261
    assert.equal(stored[sha256(token)].action, 'redact', 'overridden keep should persist as redact');
1262
  } finally {
1263
    rmSync(dir, { recursive: true, force: true });
1264
  }
1265
});
1266
 
1267
test('mcp: a preseeded keep cannot leak a token in handoff', async () => {
1268
  const token = 'ghp_0123456789abcdefghijklmnopqrstuvwxyzAB';
1269
  const dir = mkdtempSync(join(tmpdir(), 'treetrace-mcp-keep-'));
1270
  const file = join(dir, 'conv.json');
1271
  const convo = [{
1272
    mapping: {
1273
      r: { message: null, parent: null, children: ['u'] },
1274
      u: { message: { author: { role: 'user' }, content: { parts: [`Use token ${token} for setup, do not add dependencies`] }, create_time: 1.0 }, parent: 'r', children: ['a'] },
1275
      a: { message: { author: { role: 'assistant' }, content: { parts: ['ok'] }, create_time: 2.0 }, parent: 'u', children: ['u2'] },
1276
      u2: { message: { author: { role: 'user' }, content: { parts: ['no, keep it minimal'] }, create_time: 3.0 }, parent: 'a', children: [] },
1277
    },
1278
  }];
1279
  writeFileSync(file, JSON.stringify(convo));
1280
  mkdirSync(join(dir, '.treetrace'), { recursive: true });
1281
  writeFileSync(join(dir, '.treetrace', 'redactions.json'), JSON.stringify({ [sha256(token)]: { action: 'keep', ruleId: 'github-token' } }));
1282
  const bin = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'treetrace.js');
1283
  try {
1284
    const responses = await new Promise((resolveP, rejectP) => {
1285
      const child = spawn('node', [bin, 'mcp', '--from', 'chatgpt', '--file', file, '--dir', dir], { stdio: ['pipe', 'pipe', 'ignore'] });
1286
      let buf = '';
1287
      child.stdout.on('data', (d) => { buf += d; });
1288
      child.on('error', rejectP);
1289
      const send = (o) => child.stdin.write(JSON.stringify(o) + '\n');
1290
      send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
1291
      send({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'handoff', arguments: {} } });
1292
      setTimeout(() => {
1293
        child.stdin.end();
1294
        child.kill();
1295
        resolveP(buf.split('\n').filter(Boolean).map((l) => JSON.parse(l)));
1296
      }, 2500);
1297
    });
1298
    const call = responses.find((r) => r.id === 2);
1299
    assert.ok(call && call.result, 'handoff tool should return a result');
1300
    assert.ok(!JSON.stringify(call).includes(token), 'MCP handoff leaked a token despite a preseeded keep');
1301
  } finally {
1302
    rmSync(dir, { recursive: true, force: true });
1303
  }
1304
});
1305
 
1306
test('mcp: extra tool arguments return -32602', async () => {
1307
  const dir = tempProject();
1308
  const file = join(dir, 'conv.json');
1309
  writeFileSync(file, JSON.stringify([{ mapping: {
1310
    r: { message: null, parent: null, children: ['u'] },
1311
    u: { message: { author: { role: 'user' }, content: { parts: ['build a cli'] }, create_time: 1.0 }, parent: 'r', children: ['a'] },
1312
    a: { message: { author: { role: 'assistant' }, content: { parts: ['ok'] }, create_time: 2.0 }, parent: 'u', children: [] },
1313
  } }]));
1314
  const bin = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'treetrace.js');
1315
  try {
1316
    const responses = await new Promise((resolveP, rejectP) => {
1317
      const child = spawn('node', [bin, 'mcp', '--from', 'chatgpt', '--file', file, '--dir', dir], { stdio: ['pipe', 'pipe', 'ignore'] });
1318
      let buf = '';
1319
      child.stdout.on('data', (d) => { buf += d; });
1320
      child.on('error', rejectP);
1321
      const send = (o) => child.stdin.write(JSON.stringify(o) + '\n');
1322
      send({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'lessons', arguments: { unexpected: true } } });
1323
      send({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'lessons', arguments: {} } });
1324
      send({ jsonrpc: '2.0', id: null, method: 'ping' });
1325
      send([{ jsonrpc: '2.0', id: 9, method: 'ping' }]);
1326
      setTimeout(() => { child.stdin.end(); child.kill(); resolveP(buf.split('\n').filter(Boolean).map((l) => JSON.parse(l))); }, 2500);
1327
    });
1328
    const bad = responses.find((r) => r.id === 1);
1329
    assert.ok(bad && bad.error && bad.error.code === -32602, 'extra arguments should return -32602');
1330
    const ok = responses.find((r) => r.id === 2);
1331
    assert.ok(ok && ok.result, 'empty arguments should succeed');
1332
    const idNull = responses.find((r) => r.id === null && r.result);
1333
    assert.ok(idNull, 'explicit id:null request should receive a response');
1334
    const batch = responses.find((r) => r.id === null && r.error && /batch/.test(r.error.message));
1335
    assert.ok(batch, 'batch arrays should return a clear error');
1336
  } finally {
1337
    rmSync(dir, { recursive: true, force: true });
1338
  }
1339
});
1340
 
1341
test('mcp: treetrace mcp --stdin is rejected clearly', async () => {
1342
  const { startMcpServer } = await import('../src/mcp.js');
1343
  await assert.rejects(
1344
    () => startMcpServer({ argv: ['mcp', '--stdin'], version: '0.0.0' }),
1345
    /does not support --stdin/,
1346
    'mcp --stdin should be rejected at startup'
1347
  );
1348
});
1349
 
1350
test('hallucinations: absolute paths outside the project are out of scope, not an oracle', () => {
1351
  const dir = tempProject();
1352
  try {
1353
    const mk = (text) => ({ nodes: [{ id: 'n1', kind: 'root', status: 'accepted', parent: null, text, title: 't', actions: [] }] });
1354
    const abs = detectHallucinations(mk('see /definitely/not/here.zzz and /etc/shadow.bak'), dir).hallucinations.map((h) => h.reference);
1355
    assert.deepEqual(abs, [], 'absolute paths outside the project must not be flagged or statted');
1356
    const parent = detectHallucinations(mk('see ../escape.js'), dir).hallucinations.map((h) => h.reference);
1357
    assert.deepEqual(parent, [], 'a ../ path escaping the project is out of scope');
1358
  } finally {
1359
    rmSync(dir, { recursive: true, force: true });
1360
  }
1361
});
1362
 
1363
test('hallucinations: relative missing paths inside the project are flagged', () => {
1364
  const dir = tempProject();
1365
  try {
1366
    const mk = (text) => ({ nodes: [{ id: 'n1', kind: 'root', status: 'accepted', parent: null, text, title: 't', actions: [] }] });
1367
    assert.ok(detectHallucinations(mk('open src/missing.js'), dir).hallucinations.some((h) => h.reference === 'src/missing.js'), 'bare missing path should be flagged');
1368
    assert.ok(detectHallucinations(mk('open ./src/missing.js'), dir).hallucinations.some((h) => h.reference === './src/missing.js'), './ missing path should be flagged');
1369
    assert.ok(!detectHallucinations(mk('open src/real.js'), dir).hallucinations.some((h) => h.reference.includes('real.js')), 'real file must not be flagged');
1370
  } finally {
1371
    rmSync(dir, { recursive: true, force: true });
1372
  }
1373
});
1374
 
1375
test('hallucinations: an Edit to a nonexistent file is flagged, a Write to a new file is not', () => {
1376
  const dir = tempProject();
1377
  try {
1378
    const edit = { nodes: [{ id: 'n1', kind: 'root', status: 'accepted', parent: null, text: 'edit src/ghost.js', title: 't', actions: [{ tool: 'Edit', file: 'src/ghost.js', input: 'x', command: null }] }] };
1379
    assert.ok(detectHallucinations(edit, dir).hallucinations.some((h) => h.reference === 'src/ghost.js'), 'Edit to a nonexistent file should still be flagged');
1380
    const write = { nodes: [{ id: 'n1', kind: 'root', status: 'accepted', parent: null, text: 'create src/created.js', title: 't', actions: [{ tool: 'Write', file: 'src/created.js', input: 'x', command: null }] }] };
1381
    assert.ok(!detectHallucinations(write, dir).hallucinations.some((h) => h.reference === 'src/created.js'), 'Write to a new file should be suppressed');
1382
  } finally {
1383
    rmSync(dir, { recursive: true, force: true });
1384
  }
1385
});
1386
 
1387
test('hallucinations: dotted code symbols are not flagged as missing file paths', () => {
1388
  const dir = tempProject();
1389
  try {
1390
    const mk = (text) => ({ nodes: [{ id: 'n1', kind: 'root', status: 'accepted', parent: null, text, title: 't', actions: [] }] });
1391
    for (const sym of ['JSON.parse', 'params.arguments', 'params.name', 'test.skip', 'describe.skip', 'obj.method', 'array.length']) {
1392
      const refs = detectHallucinations(mk(sym), dir).hallucinations
1393
        .filter((h) => h.category === 'hallucinated_file_or_path')
1394
        .map((h) => h.reference);
1395
      assert.deepEqual(refs, [], `code symbol "${sym}" should not be flagged as a missing path (got ${JSON.stringify(refs)})`);
1396
    }
1397
    const real = detectHallucinations(mk('open src/missing.ts'), dir).hallucinations
1398
      .filter((h) => h.category === 'hallucinated_file_or_path')
1399
      .map((h) => h.reference);
1400
    assert.ok(real.includes('src/missing.ts'), 'a genuinely missing path with a known extension must still be flagged');
1401
  } finally {
1402
    rmSync(dir, { recursive: true, force: true });
1403
  }
1404
});
1405
 
1406
test('hallucinations: missing extensionless files and local paths are flagged, existing ones are not', () => {
1407
  const dir = tempProject();
1408
  try {
1409
    const mk = (text) => ({ nodes: [{ id: 'n1', kind: 'root', status: 'accepted', parent: null, text, title: 't', actions: [] }] });
1410
    const flagged = (text) => detectHallucinations(mk(text), dir).hallucinations
1411
      .filter((h) => h.category === 'hallucinated_file_or_path')
1412
      .map((h) => h.reference);
1413
 
1414
    assert.ok(flagged('open Dockerfile').includes('Dockerfile'), 'a missing Dockerfile should be flagged');
1415
    assert.ok(flagged('open .env').includes('.env'), 'a missing .env should be flagged');
1416
    assert.ok(flagged('open Makefile').includes('Makefile'), 'a missing Makefile should be flagged');
1417
    assert.ok(flagged('open src/route').includes('src/route'), 'a missing extensionless local path should be flagged');
1418
 
1419
    writeFileSync(join(dir, 'Dockerfile'), 'FROM node:20\n');
1420
    writeFileSync(join(dir, '.env'), 'X=1\n');
1421
    assert.ok(!flagged('open Dockerfile and .env').includes('Dockerfile'), 'an existing Dockerfile must not be flagged');
1422
    assert.ok(!flagged('open Dockerfile and .env').includes('.env'), 'an existing .env must not be flagged');
1423
 
1424
    const noise = detectHallucinations(mk('JSON.parse and test.skip and update the README section about CONTRIBUTING'), dir).hallucinations
1425
      .filter((h) => h.category === 'hallucinated_file_or_path')
1426
      .map((h) => h.reference);
1427
    assert.ok(!noise.includes('JSON.parse') && !noise.includes('test.skip'), 'extensionless detection must not reintroduce code-symbol false positives');
1428
    assert.ok(!noise.includes('README') && !noise.includes('CONTRIBUTING'), 'a known filename word in prose without a file-op verb must not be flagged');
1429
  } finally {
1430
    rmSync(dir, { recursive: true, force: true });
1431
  }
1432
});
1433
 
1434
test('discover: a recorded cwd that mismatches the project dir excludes a colliding session', () => {
1435
  const dir = mkdtempSync(join(tmpdir(), 'treetrace-cwd-'));
1436
  const matching = join(dir, 'match.jsonl');
1437
  writeFileSync(matching, JSON.stringify({ type: 'user', cwd: dir, uuid: 'u1' }) + '\n');
1438
  assert.equal(recordedCwd(matching), dir, 'recordedCwd should read the cwd back');
1439
  const mismatch = join(dir, 'mismatch.jsonl');
1440
  writeFileSync(mismatch, JSON.stringify({ type: 'user', cwd: '/some/other/project', uuid: 'u1' }) + '\n');
1441
  assert.equal(recordedCwd(mismatch), '/some/other/project', 'recordedCwd should read a foreign cwd');
1442
  rmSync(dir, { recursive: true, force: true });
1443
});
1444
 
1445
test('security report: risky-command variants are detected', () => {
1446
  for (const cmd of ['rm -fr build', 'rm -r -f build', 'chmod -R 777 dir', 'chmod 0777 file', 'curl https://x | sudo bash', 'curl https://x | zsh', 'bash <(curl https://x)', 'drop schema public cascade', 'TRUNCATE users']) {
1447
    assert.ok(isRiskyCommand(cmd), `risky command missed: ${cmd}`);
1448
  }
1449
  for (const benign of ['rm file.txt', 'chmod 644 file', 'ls -la', 'curl https://x > out.txt']) {
1450
    assert.ok(!isRiskyCommand(benign), `benign command over-flagged: ${benign}`);
1451
  }
1452
});
1453
 
1454
test('security report: test-disable APIs and phrasing are detected', () => {
1455
  for (const t of ['test.skip("x")', 'describe.skip("x")', 'it.skip("x")', 'xit("x")', 'skip e2e suite', 'remove the auth spec']) {
1456
    assert.ok(mentionsTestSkip(t), `test-disable missed: ${t}`);
1457
  }
1458
  for (const benign of ['run all the tests', 'add a test for login']) {
1459
    assert.ok(!mentionsTestSkip(benign), `benign test phrasing over-flagged: ${benign}`);
1460
  }
1461
});
1462
 
1463
test('regex decomposition: every RISKY_CMD named piece fires on its command family', () => {
1464
  const compose = (parts) => new RegExp(parts.map((p) => `(?:${p.re.source})`).join('|'), 'i');
1465
  const byName = new Map(RISKY_CMD_PARTS.map((p) => [p.name, p.re]));
1466
  const positives = {
1467
    rm_rf_combined: 'rm -rf build',
1468
    rm_r_then_f: 'rm -r -f build',
1469
    rm_f_then_r: 'rm -f -r build',
1470
    chmod_world_writable: 'chmod -R 777 dir',
1471
    curl_pipe_shell: 'curl https://x | sudo bash',
1472
    shell_process_substitution: 'bash <(curl https://x)',
1473
    no_verify: 'git commit --no-verify',
1474
    force: 'git push --force',
1475
    drop_table: 'DROP TABLE users',
1476
    drop_schema: 'drop schema public cascade',
1477
    truncate: 'TRUNCATE users',
1478
  };
1479
  for (const [name, cmd] of Object.entries(positives)) {
1480
    const re = byName.get(name);
1481
    assert.ok(re, `unknown piece ${name}`);
1482
    assert.ok(re.test(cmd), `piece ${name} missed its command: ${cmd}`);
1483
  }
1484
  assert.equal(RISKY_CMD_PARTS.length, Object.keys(positives).length, 'piece count drifted');
1485
  const composed = compose(RISKY_CMD_PARTS);
1486
  for (const cmd of [...Object.values(positives), 'rm -fr /tmp', 'chmod 0777 f']) {
1487
    assert.equal(composed.test(cmd), isRiskyCommand(cmd), `composed != isRiskyCommand for: ${cmd}`);
1488
  }
1489
  for (const benign of ['rm file.txt', 'chmod 644 file', 'ls -la', 'curl https://x > out.txt', '--force-with-lease']) {
1490
    assert.equal(composed.test(benign), isRiskyCommand(benign), `benign mismatch: ${benign}`);
1491
    assert.ok(!composed.test(benign), `benign over-flagged: ${benign}`);
1492
  }
1493
});
1494
 
1495
test('regex decomposition: every SECURITY_INTENT named piece fires on its phrasing family', () => {
1496
  const compose = (parts) => new RegExp(parts.map((p) => `(?:${p.re.source})`).join('|'), 'i');
1497
  const byName = new Map(SECURITY_INTENT_PARTS.map((p) => [p.name, p.re]));
1498
  const positives = {
1499
    credential_lifecycle: 'please rotate the api key',
1500
    pat_lifecycle: 'the pat was rotated yesterday',
1501
    email_change: 'change the email to a public contact',
1502
    do_not_expose: 'never expose the token',
1503
    expose_us: 'this could expose us',
1504
    leak_list: 'audit for leak anything',
1505
    audit_repos: 'do a full audit of the repo',
1506
    commit_history_audit: 'the commit history needs an audit',
1507
    relicensing: 'relicense the project to MIT',
1508
    disable_tests: 'skip the auth test',
1509
    access_control_change: 'tighten the auth flow',
1510
  };
1511
  for (const [name, phrase] of Object.entries(positives)) {
1512
    const re = byName.get(name);
1513
    assert.ok(re, `unknown piece ${name}`);
1514
    assert.ok(re.test(phrase), `piece ${name} missed its phrase: ${phrase}`);
1515
  }
1516
  assert.equal(SECURITY_INTENT_PARTS.length, Object.keys(positives).length, 'piece count drifted');
1517
  const composed = compose(SECURITY_INTENT_PARTS);
1518
  for (const phrase of Object.values(positives)) assert.ok(composed.test(phrase), `composed missed: ${phrase}`);
1519
  for (const benign of ['a normal sentence about the weather', 'use the api carefully', 'email me later']) {
1520
    assert.ok(!composed.test(benign), `benign security phrasing over-flagged: ${benign}`);
1521
  }
1522
});
1523
 
1524
test('cli: value-taking options reject a missing value or a flag-shaped value', () => {
1525
  for (const args of [['--dir'], ['--out', '--redact-auto'], ['--report-file', '--quiet'], ['--from'], ['--since']]) {
1526
    assert.throws(() => parseArgs(args), /requires a value|requires at least|expects a date|unknown --from/, `expected ${JSON.stringify(args)} to throw`);
1527
  }
1528
});
1529
 
1530
test('cli: --since requires a real date and rejects garbage', () => {
1531
  assert.throws(() => parseArgs(['--since', 'not-a-date']), /expects a date/);
1532
  assert.doesNotThrow(() => parseArgs(['--since', '2026-06-01']));
1533
});
1534
 
1535
test('cli: --stdin --from claude is rejected', () => {
1536
  assert.throws(() => parseArgs(['--stdin', '--from', 'claude']), /cannot be combined with --from claude/);
1537
});
1538
 
1539
 
1540
 
1541
test('P7: short escaped-JSON secret values fail closed (redaction gate)', () => {
1542
  const cases = [
1543
    ['short escaped newline', '{"api_key":"a\\nz"}'],
1544
    ['tiny escaped value', '{"api_key":"x\\ny"}'],
1545
    ['escaped quote', '{"token":"a\\"b"}'],
1546
    ['escaped backslash', '{"secret":"a\\\\b"}'],
1547
    ['spec literal-\\n form', '{"api_key":"line1\\nline2line2line2"}'],
1548
  ];
1549
  for (const [label, sample] of cases) {
1550
    const hits = scanText(sample).map((f) => f.ruleId);
1551
    assert.ok(hits.includes('secret-assignment'), `${label}: escaped secret must be caught (got ${JSON.stringify(hits)})`);
1552
  }
1553
  assert.equal(scanText('{"api_key":"ab"}').length, 0, 'benign short value below floor must stay clean');
1554
  assert.equal(scanText('{"api_key":"${SECRET}"}').filter((f) => f.ruleId === 'secret-assignment').length, 0, 'placeholder must stay clean');
1555
});
1556
 
1557
test('P7: a short escaped-JSON secret leaves no raw value in any artifact end to end', async () => {
1558
  const rawValue = 'a\\nz';
1559
  const secretLine = `config is {"api_key":"${rawValue}"}`;
1560
  const dir = mkdtempSync(join(tmpdir(), 'treetrace-p7-'));
1561
  const file = join(dir, 'escconv.json');
1562
  const convo = [{
1563
    mapping: {
1564
      r: { message: null, parent: null, children: ['u'] },
1565
      u: { message: { author: { role: 'user' }, content: { parts: [secretLine] }, create_time: 1.0 }, parent: 'r', children: ['a'] },
1566
      a: { message: { author: { role: 'assistant' }, content: { parts: ['ok'] }, create_time: 2.0 }, parent: 'u', children: [] },
1567
    },
1568
  }];
1569
  writeFileSync(file, JSON.stringify(convo));
1570
  try {
1571
    await main(['--from', 'chatgpt', '--file', file, '--dir', dir, '--report', '--analysis', '--redact-auto', '--quiet']);
1572
    const artifacts = [
1573
      'PROMPT_TREE.md', 'TREETRACE_REPORT.md', '.treetrace/tree.json',
1574
      '.treetrace/failures.json', '.treetrace/lessons.md', '.treetrace/evals.jsonl', '.treetrace/agent-memory.md',
1575
    ].filter((f) => existsSync(join(dir, f))).map((f) => readFileSync(join(dir, f), 'utf8')).join('\n');
1576
    assert.ok(!artifacts.includes(rawValue), 'raw short escaped-JSON secret leaked into an artifact');
1577
    assert.ok(artifacts.includes('[REDACTED:secret-assignment]'), 'expected a secret-assignment redaction marker');
1578
  } finally {
1579
    rmSync(dir, { recursive: true, force: true });
1580
  }
1581
});
1582
 
1583
test('P1: a single strong security signal stays verified at exactly 0.95', () => {
1584
  const node = {
1585
    id: 'node_001', text: 'harden auth', title: 'harden auth', kind: 'root', status: 'accepted', parent: null,
1586
    actions: [{ tool: 'Edit', file: 'src/auth/session.ts', command: null, model: 'm' }],
1587
  };
1588
  const sec = analyzeTree({ nodes: [node] }).failures.find((f) => f.type === 'security_or_privacy_risk');
1589
  assert.ok(sec && sec.tier === 'verified' && sec.confidence === 0.95, 'strong anchor must remain verified/0.95');
1590
});
1591
 
1592
test('P1: confidence is derived from corroboration and the contributing signals are in the evidence', () => {
1593
  const strong = {
1594
    id: 'node_001', text: 'deploy', title: 'deploy', kind: 'root', status: 'accepted', parent: null,
1595
    actions: [{ tool: 'Bash', file: 'src/auth/session.ts', command: '. /srv/app/.env; rm -rf /tmp/x; chmod 777 /etc', input: '. /srv/app/.env; rm -rf /tmp/x; chmod 777 /etc', model: 'm' }],
1596
  };
1597
  const strongSec = analyzeTree({ nodes: [strong] }).failures.find((f) => f.type === 'security_or_privacy_risk');
1598
  assert.equal(strongSec.tier, 'verified');
1599
  assert.ok(/signals:/.test(strongSec.evidence), 'evidence must list the contributing signals (auditable)');
1600
  assert.ok(/strong credential content/.test(strongSec.evidence), 'evidence must name the strong credential signal');
1601
 
1602
  const weak = {
1603
    id: 'node_001', text: 'edit detector', title: 'x', kind: 'root', status: 'accepted', parent: null,
1604
    actions: [{ tool: 'Edit', file: 'src/analyze.js', input: 'const ACCESS = /rbac/i;', command: null, model: 'm' }],
1605
  };
1606
  const weakSec = analyzeTree({ nodes: [weak] }).failures.find((f) => f.type === 'security_or_privacy_risk');
1607
  assert.ok(weakSec.confidence < strongSec.confidence, 'lone weak keyword must score below a multi-signal strong event');
1608
});
1609
 
1610
test('P2: afterFailure does not link a corrector that precedes its failure when timestamps are missing', () => {
1611
  const failure = {
1612
    id: 'node_002', text: 'the deck still does not render here', title: 'still broken', kind: 'direction', status: 'accepted', parent: null,
1613
    actions: [{ tool: 'Edit', file: 'site/deck/index.html', command: null, input: null, model: 'm' }],
1614
  };
1615
  const earlier = {
1616
    id: 'node_001', text: 'no that is wrong redo the deck here please', title: 'redo', kind: 'correction', status: 'accepted', parent: failure,
1617
    actions: [{ tool: 'Edit', file: 'site/deck/index.html', command: null, input: null, model: 'm' }],
1618
  };
1619
  const analysis = analyzeTree({ nodes: [failure, earlier] });
1620
  for (const f of analysis.failures) {
1621
    if (!f.correctedByNodeId) continue;
1622
    const fo = Number(/(\d+)$/.exec(f.firstSeenNodeId)[1]);
1623
    const co = Number(/(\d+)$/.exec(f.correctedByNodeId)[1]);
1624
    assert.ok(co >= fo, `failure ${f.id} corrected by an earlier-ordinal node`);
1625
  }
1626
});
1627
 
1628
test('P2: resolvedBy is null when no resolution ties back to the failure, instead of the temporally-nearest node', () => {
1629
  const failure = {
1630
    id: 'node_001', text: 'do not hardcode the database url into the config file please', title: 'no hardcoding', kind: 'correction', status: 'accepted', parent: null,
1631
    ts: '2026-06-12T10:00:00.000Z', actions: [{ tool: 'Edit', file: 'config/db.ts', command: null, input: null, model: 'm' }],
1632
  };
1633
  const unrelatedLater = {
1634
    id: 'node_002', text: 'now lets switch topics entirely and write the marketing landing copy', title: 'marketing', kind: 'direction', status: 'accepted', parent: failure,
1635
    ts: '2026-06-12T11:00:00.000Z', actions: [{ tool: 'Edit', file: 'site/index.html', command: null, input: null, model: 'm' }],
1636
  };
1637
  const analysis = analyzeTree({ nodes: [failure, unrelatedLater] });
1638
  for (const chain of analysis.correctionChains) {
1639
    assert.notEqual(chain.resolvedNodeId, 'node_002', 'must not resolve to an unrelated temporally-nearest node');
1640
  }
1641
});
1642
 
1643
test('P2: an explicit acceptance turn IS accepted as a resolution even with no shared evidence', () => {
1644
  const failure = {
1645
    id: 'node_001', text: 'the checkout total is off by a cent on tax rounding', title: 'rounding bug', kind: 'direction', status: 'accepted', parent: null,
1646
    ts: '2026-06-12T10:00:00.000Z', actions: [{ tool: 'Edit', file: 'src/checkout/total.ts', command: null, input: null, model: 'm' }],
1647
  };
1648
  const correction = {
1649
    id: 'node_002', text: 'no the checkout total rounding is still wrong, redo the total calc', title: 'still wrong', kind: 'correction', status: 'accepted', parent: failure,
1650
    ts: '2026-06-12T10:30:00.000Z', actions: [{ tool: 'Edit', file: 'src/checkout/total.ts', command: null, input: null, model: 'm' }],
1651
  };
1652
  const accepted = {
1653
    id: 'node_003', text: 'perfect, that works now', title: 'works', kind: 'direction', status: 'accepted', parent: correction,
1654
    ts: '2026-06-12T11:00:00.000Z', actions: [{ tool: 'Edit', file: 'src/unrelated/widget.ts', command: null, input: null, model: 'm' }],
1655
  };
1656
  const analysis = analyzeTree({ nodes: [failure, correction, accepted] });
1657
  assert.ok(
1658
    analysis.correctionChains.some((c) => c.resolvedNodeId === 'node_003'),
1659
    'the explicit acceptance turn should be recorded as the resolution'
1660
  );
1661
});
1662
 
1663
test('P3: a node that leaks a secret and runs a risky command surfaces both kinds', () => {
1664
  const node = {
1665
    id: 'node_001', text: 'deploy', title: 'deploy', kind: 'root', status: 'accepted', parent: null,
1666
    actions: [{ tool: 'Bash', file: null, command: '. /srv/app/.env; rm -rf /var/data', input: '. /srv/app/.env; rm -rf /var/data', model: 'm' }],
1667
  };
1668
  const sec = analyzeTree({ nodes: [node] }).failures.find((f) => f.type === 'security_or_privacy_risk');
1669
  assert.ok(/credential/.test(sec.evidence) && /risky-command/.test(sec.evidence), `both kinds must appear: ${sec.evidence}`);
1670
});
1671
 
1672
test('P3: inferSignals can return multiple process kinds for a multi-class correction', () => {
1673
  const root = { id: 'node_001', text: 'build a dashboard', title: 'x', kind: 'root', status: 'accepted', parent: null, actions: [] };
1674
  const corr = {
1675
    id: 'node_002', kind: 'correction', status: 'accepted', parent: root, actions: [],
1676
    text: 'no, you ignored what i asked for and this is overbuilt, scrap the web app, keep it minimal',
1677
    title: 'multi-class correction',
1678
  };
1679
  const analysis = analyzeTree({ nodes: [root, corr] });
1680
  const types = new Set(analysis.failures.map((f) => f.type));
1681
  assert.ok(types.size >= 2, `expected multiple process labels, got ${[...types].join(', ')}`);
1682
});
1683
 
1684
test('P4: a bare rbac keyword with no co-signal stays inferred, never high/verified', () => {
1685
  const node = {
1686
    id: 'node_001', text: 'edit detector', title: 'x', kind: 'root', status: 'accepted', parent: null,
1687
    actions: [{ tool: 'Edit', file: 'src/analyze.js', input: 'const ACCESS_CONTROL_WEAK_RE = /rbac|access-control/i;', command: null, model: 'm' }],
1688
  };
1689
  const sec = analyzeTree({ nodes: [node] }).failures.find((f) => f.type === 'security_or_privacy_risk');
1690
  assert.ok(sec && sec.tier === 'inferred', `lone weak keyword must be inferred (got ${sec && sec.tier})`);
1691
});
1692
 
1693
test('P4: a bare rbac keyword WITH a security-surface co-signal earns high tier', () => {
1694
  const node = {
1695
    id: 'node_001', text: 'wire up access control', title: 'x', kind: 'root', status: 'accepted', parent: null,
1696
    actions: [{ tool: 'Edit', file: 'src/rbac/policy.ts', input: 'enable rbac for the route', command: null, model: 'm' }],
1697
  };
1698
  const sec = analyzeTree({ nodes: [node] }).failures.find((f) => f.type === 'security_or_privacy_risk');
1699
  assert.ok(sec && (sec.tier === 'high' || sec.tier === 'verified'), `keyword + surface co-signal should tier up (got ${sec && sec.tier})`);
1700
});
1701
 
1702
test('P6: a human security correction backstops a prior action that carried no security label', () => {
1703
  const prior = {
1704
    id: 'node_001', text: 'put the deploy config value directly into the deploy script', title: 'deploy config', kind: 'direction', status: 'accepted', parent: null,
1705
    actions: [{ tool: 'Edit', file: 'deploy.sh', command: null, input: null, model: 'm' }],
1706
  };
1707
  const correction = {
1708
    id: 'node_002', text: 'that is a secret, rotate that key and do not commit it to the deploy script', title: 'rotate', kind: 'correction', status: 'accepted', parent: prior,
1709
    actions: [{ tool: 'Edit', file: 'deploy.sh', command: null, input: null, model: 'm' }],
1710
  };
1711
  const analysis = analyzeTree({ nodes: [prior, correction] });
1712
  const sec = analysis.failures.find((f) => f.type === 'security_or_privacy_risk');
1713
  assert.ok(sec, 'human security correction should backstop a missed security event');
1714
  assert.equal(sec.tier, 'inferred', 'the backstop must be inferred only, never strong/verified');
1715
  assert.ok(sec.confidence <= 0.7, 'the backstop confidence must stay low');
1716
});
1717
 
1718
test('P6: the backstop never fabricates a strong/verified security label from prose alone', () => {
1719
  const root = { id: 'node_001', text: 'build the cli', title: 'x', kind: 'root', status: 'accepted', parent: null, actions: [] };
1720
  const correction = {
1721
    id: 'node_002', text: 'never leak the api secret token again', title: 'no leaks', kind: 'correction', status: 'accepted', parent: root, actions: [],
1722
  };
1723
  const analysis = analyzeTree({ nodes: [root, correction] });
1724
  const strongSec = analysis.failures.filter((f) => f.type === 'security_or_privacy_risk' && (f.tier === 'verified' || f.tier === 'high'));
1725
  assert.equal(strongSec.length, 0, 'a human-correction backstop must never mint strong/verified labels');
1726
});
1727
 
1728
test('NEGATIVE CORPUS (release gate): benign inputs produce zero security/failure false positives', () => {
1729
  const dir = tempProject();
1730
  const benign = [
1731
    'capture a screenshot with chrome --headless --force-device-scale-factor=1 --screenshot=out.png',
1732
    'edit src/ui/semantic-tokens.ts to adjust the design token palette',
1733
    'update theme/design-tokens.json and src/lexer/tokenizer.ts for the new theme',
1734
    'the access-control documentation mentions rbac as a concept; just explaining it in the readme',
1735
    'we use JSON.parse and params.arguments and test.skip in the code, no changes needed',
1736
    'add a token field to the response schema and document the bearer header format in the api guide',
1737
    'rename the file from auth-helpers.md to authentication-notes.md in the docs folder',
1738
    'the password strength meter component needs a tooltip, purely a UI label',
1739
  ];
1740
  try {
1741
    mkdirSync(join(dir, 'src', 'ui'), { recursive: true });
1742
    mkdirSync(join(dir, 'src', 'lexer'), { recursive: true });
1743
    mkdirSync(join(dir, 'theme'), { recursive: true });
1744
    mkdirSync(join(dir, 'docs'), { recursive: true });
1745
    writeFileSync(join(dir, 'out.png'), 'x');
1746
    writeFileSync(join(dir, 'src', 'ui', 'semantic-tokens.ts'), 'export const t = 1;\n');
1747
    writeFileSync(join(dir, 'src', 'lexer', 'tokenizer.ts'), 'export const t = 1;\n');
1748
    writeFileSync(join(dir, 'theme', 'design-tokens.json'), '{}');
1749
    writeFileSync(join(dir, 'auth-helpers.md'), '# notes\n');
1750
    writeFileSync(join(dir, 'authentication-notes.md'), '# notes\n');
1751
    writeFileSync(join(dir, 'readme'), 'rbac is a concept\n');
1752
 
1753
    const nodes = benign.map((text, i) => ({
1754
      id: `node_${String(i + 1).padStart(3, '0')}`,
1755
      text, title: text.slice(0, 40), kind: i === 0 ? 'root' : 'direction',
1756
      status: 'accepted', parent: null,
1757
      ts: `2026-06-12T${String(10 + i).padStart(2, '0')}:00:00.000Z`,
1758
      actions: i === 0
1759
        ? [{ tool: 'Bash', file: null, command: 'chrome --headless --force-device-scale-factor=1 --screenshot=out.png', model: 'm' }]
1760
        : i === 1 ? [{ tool: 'Edit', file: 'src/ui/semantic-tokens.ts', model: 'm' }]
1761
        : i === 2 ? [{ tool: 'Edit', file: 'theme/design-tokens.json', model: 'm' }]
1762
        : [],
1763
    }));
1764
    for (let k = 1; k < nodes.length; k++) nodes[k].parent = nodes[k - 1];
1765
 
1766
    const analysis = analyzeTree({ nodes: nodes.map((n) => ({ ...n })) });
1767
    const secFps = analysis.failures.filter((f) => f.type === 'security_or_privacy_risk');
1768
    assert.equal(secFps.length, 0, `negative corpus minted security false positives: ${JSON.stringify(secFps.map((f) => f.evidence))}`);
1769
 
1770
    const halluc = detectHallucinations({ nodes: nodes.map((n) => ({ ...n })) }, dir).hallucinations;
1771
    assert.equal(halluc.length, 0, `negative corpus minted hallucination false positives: ${JSON.stringify(halluc.map((h) => h.reference))}`);
1772
 
1773
    for (const text of benign) {
1774
      const hi = scanText(text).filter((f) => f.severity === 'high' || f.severity === 'medium');
1775
      assert.equal(hi.length, 0, `redaction over-fired on benign text "${text}": ${JSON.stringify(hi.map((f) => f.ruleId))}`);
1776
    }
1777
  } finally {
1778
    rmSync(dir, { recursive: true, force: true });
1779
  }
1780
});
1781
 
1782
test('mermaid: renders a branded flowchart with goal, result, and spine styling', async () => {
1783
  const { tree } = await fixtureTree();
1784
  const out = renderMermaid(tree, { projectName: 'weather-dashboard' });
1785
 
1786
  assert.ok(out.startsWith("%%{init:"), 'must lead with a Mermaid init directive');
1787
  assert.match(out, /'background':'#0B1210'/, 'dark Bark canvas background');
1788
  assert.match(out, /'edgeLabelBackground':'#0B1210'/, 'opaque edge-label backing for legibility');
1789
  assert.match(out, /JetBrains Mono/, 'JetBrains Mono brand font');
1790
  assert.match(out, /^flowchart TD$/m, 'declares a top-down flowchart');
1791
  assert.match(out, /classDef spine fill:#121A17,stroke:#0CA08A/, 'brand spine class (teal)');
1792
  assert.match(out, /classDef abandoned [^\n]*stroke:#34493F[^\n]*stroke-dasharray/, 'Branch-Dim dashed abandoned class');
1793
  assert.match(out, /classDef failure [^\n]*stroke:#F0B86A/, 'amber failure class');
1794
 
1795
  assert.match(out, /N001\(\["GOAL: /, 'root node is a stadium labelled GOAL');
1796
  assert.match(out, /class N001 [^\n]*goal/, 'root carries the goal class');
1797
  assert.match(out, /RESULT: /, 'a result node is annotated');
1798
  assert.match(out, /class \w+ [^\n]*result/, 'a node carries the result class');
1799
  assert.match(out, /\(\["RESULT: /, 'the result node is a stadium terminal');
1800
 
1801
  assert.match(out, /class N001 [^\n]*spine/, 'root is on the spine');
1802
  assert.match(out, /linkStyle [\d,]+ stroke:#5BF0B8,stroke-width:2\.5px;/, 'spine links are Canopy-tinted');
1803
 
1804
  assert.match(out, /N001 -->\|refines\| N002/, 'root refines into the first direction');
1805
  assert.match(out, /-->\|corrects\| /, 'correction edge labelled');
1806
 
1807
  const labelLines = out.split('\n').filter((l) => /^  (N\w+|A\d+|S\d+)(\[|\(\[|\{\{)"/.test(l));
1808
  assert.ok(labelLines.length >= 4, 'each prompt is declared as a node');
1809
  for (const line of labelLines) {
1810
    const label = line.match(/"([^"]*)"/)[1];
1811
    assert.ok(!/[<>]/.test(label.replace(/&lt;|&gt;/g, '')), `unescaped angle bracket in label: ${line}`);
1812
  }
1813
});
1814
 
1815
test('mermaid: labels truncate on a word boundary, never mid-word', () => {
1816
  const root = {
1817
    id: 'node_001', kind: 'root', status: 'accepted', parent: null, actions: [],
1818
    title: 'Build a resilient weather dashboard with hourly forecast charts and radar layers everywhere',
1819
    text: 'Build a resilient weather dashboard with hourly forecast charts and radar layers everywhere',
1820
  };
1821
  const out = renderMermaid({ nodes: [root] }, { projectName: 'demo' });
1822
  const label = out.match(/N001\(\["GOAL: ([^"]*)"\]\)/)[1];
1823
  assert.ok(label.endsWith('โ€ฆ'), `label should end with a single-char ellipsis: ${label}`);
1824
  const body = label.slice(0, -1);
1825
  assert.ok(/\w$/.test(body), 'body ends on a word character (no trailing space)');
1826
  assert.ok(root.title.startsWith(body), 'body is a clean prefix of the source');
1827
  assert.ok(/(^|\s)$/.test(root.title.slice(body.length, body.length + 1)) || root.title.length === body.length,
1828
    `truncation landed mid-word: "${body}|${root.title.slice(body.length, body.length + 8)}"`);
1829
});
1830
 
1831
test('mermaid: abandoned branches render as dimmed dotted detours off the spine', () => {
1832
  const mk = (id, kind, title, status) => ({
1833
    id,
1834
    kind,
1835
    title,
1836
    text: title,
1837
    status: status || 'accepted',
1838
    ts: `2026-06-01T10:0${id.slice(-1)}:00.000Z`,
1839
    parent: null,
1840
    actions: [],
1841
  });
1842
  const root = mk('node_001', 'root', 'Build the thing');
1843
  const good = mk('node_002', 'direction', 'Refine the good approach');
1844
  const result = mk('node_003', 'direction', 'Ship the chosen design');
1845
  const dead = mk('node_004', 'direction', 'Try a heavy approach we drop', 'abandoned');
1846
  good.parent = root;
1847
  result.parent = good;
1848
  dead.parent = root;
1849
  const tree = { nodes: [root, good, result, dead] };
1850
 
1851
  const out = renderMermaid(tree, { projectName: 'demo' });
1852
 
1853
  assert.match(out, /class N004 abandoned;/, 'abandoned node carries only the abandoned class');
1854
  assert.ok(!/class N004 [^\n]*spine/.test(out), 'abandoned node is not on the spine');
1855
  assert.match(out, /N001 -\.->\|refines\| N004/, 'abandoned branch uses a dotted edge');
1856
 
1857
  assert.match(out, /class N002 [^\n]*spine/, 'good direction on spine');
1858
  assert.match(out, /class N003 [^\n]*result/, 'last live direction is the result');
1859
  assert.match(out, /linkStyle 0,1 stroke/, 'only live edges are thickened');
1860
});
1861
 
1862
test('mermaid: wrapMermaidDoc emits a fenced mermaid block that renders on GitHub', () => {
1863
  const doc = wrapMermaidDoc('flowchart TD\n  N001["x"]', 'demo');
1864
  assert.ok(doc.includes('```mermaid\n'), 'opens a mermaid fence');
1865
  assert.ok(doc.trimEnd().endsWith('```'), 'closes the fence');
1866
  assert.ok(doc.includes('flowchart TD'), 'contains the diagram');
1867
  const summaryDoc = wrapMermaidDoc('flowchart TD\n  N001["x"]', 'demo', true);
1868
  assert.match(summaryDoc, /count stubs/, 'summary doc explains the folding');
1869
  assert.match(summaryDoc, /--full/, 'summary doc points at --full to expand');
1870
});
1871
 
1872
function bigTree(liveDirections, withAbandoned = true) {
1873
  const nodes = [];
1874
  const root = {
1875
    id: 'node_001', kind: 'root', status: 'accepted', parent: null, actions: [],
1876
    title: 'Build the whole product', text: 'Build the whole product',
1877
    ts: '2026-06-01T10:00:00.000Z',
1878
  };
1879
  nodes.push(root);
1880
  let prev = root;
1881
  for (let k = 2; k <= liveDirections + 1; k++) {
1882
    const kind = k % 3 === 0 ? 'checkpoint' : 'direction';
1883
    const n = {
1884
      id: `node_${String(k).padStart(3, '0')}`, kind, status: 'accepted', parent: prev,
1885
      title: `Strategic move number ${k} in the plan`, text: `Strategic move number ${k} in the plan`,
1886
      ts: `2026-06-01T10:${String(k).padStart(2, '0')}:00.000Z`, actions: [],
1887
    };
1888
    nodes.push(n);
1889
    prev = n;
1890
  }
1891
  if (withAbandoned) {
1892
    const dead1 = {
1893
      id: 'node_900', kind: 'direction', status: 'abandoned', parent: root, actions: [],
1894
      title: 'Heavy approach we dropped', text: 'Heavy approach we dropped',
1895
      ts: '2026-06-01T10:05:00.000Z',
1896
    };
1897
    const dead2 = {
1898
      id: 'node_901', kind: 'direction', status: 'abandoned', parent: dead1, actions: [],
1899
      title: 'Follow-up on the dropped approach', text: 'Follow-up on the dropped approach',
1900
      ts: '2026-06-01T10:06:00.000Z',
1901
    };
1902
    nodes.push(dead1, dead2);
1903
  }
1904
  return { nodes };
1905
}
1906
 
1907
test('mermaid: small trees render in full, large trees auto-summarize', () => {
1908
  const small = bigTree(4);
1909
  assert.equal(isSummaryByDefault(small), false, 'a 5-live-node tree renders in full');
1910
  const smallOut = renderMermaid(small, { projectName: 'demo' });
1911
  assert.match(smallOut, /N004\[/, 'full mode declares each live node');
1912
  assert.ok(!/\d+ steps"/.test(smallOut), 'full mode has no count stubs');
1913
 
1914
  const big = bigTree(SUMMARY_NODE_THRESHOLD + 5);
1915
  assert.equal(isSummaryByDefault(big), true, 'over the threshold auto-summarizes');
1916
  const bigOut = renderMermaid(big, { projectName: 'demo' });
1917
  assert.match(bigOut, /^flowchart TD$/m, 'summary is still a valid flowchart');
1918
  assert.match(bigOut, /\(\["GOAL: /, 'GOAL stadium preserved in summary');
1919
  assert.match(bigOut, /RESULT: /, 'RESULT preserved in summary');
1920
  assert.match(bigOut, /\d+ steps?"/, 'routine steps fold into a count stub');
1921
  const fullOut = renderMermaid(big, { projectName: 'demo', full: true });
1922
  assert.ok(bigOut.split('\n').length < fullOut.split('\n').length, 'summary is more compact than full');
1923
  assert.match(fullOut, /N0\d\d\[/, 'forcing --full declares each node even on a big tree');
1924
});
1925
 
1926
test('mermaid: summary folds abandoned branches into one dim count stub', () => {
1927
  const big = bigTree(SUMMARY_NODE_THRESHOLD + 3, true);
1928
  const out = renderMermaid(big, { projectName: 'demo', summary: true });
1929
  assert.match(out, /A\d+\["2 abandoned steps"\]/, 'abandoned subtree folds into a counted stub');
1930
  assert.match(out, /class A\d+ abandoned;/, 'the stub keeps the dim abandoned class');
1931
  assert.ok(!/N900\[/.test(out) && !/N901\[/.test(out), 'individual abandoned nodes are not drawn');
1932
  assert.ok(!/[A-Za-z]โ€ฆ[A-Za-z]/.test(out), 'no mid-word ellipsis in any label');
1933
});
1934
 
1935
test('mermaid: --summary forces summary mode even on a small tree', () => {
1936
  const small = bigTree(3);
1937
  const forced = renderMermaid(small, { projectName: 'demo', summary: true });
1938
  assert.match(forced, /^flowchart TD$/m, 'forced summary is a valid flowchart');
1939
  assert.match(forced, /\(\["GOAL: /, 'forced summary keeps the GOAL');
1940
});
1941
 
1942
test('cli: --graph writes PROMPT_TREE_GRAPH.md with a mermaid flowchart', async () => {
1943
  const dir = mkdtempSync(join(tmpdir(), 'treetrace-graph-'));
1944
  try {
1945
    await main(['--file', FIXTURE, '--dir', dir, '--graph', '--redact-auto', '--quiet']);
1946
    const p = join(dir, 'PROMPT_TREE_GRAPH.md');
1947
    assert.ok(existsSync(p), 'PROMPT_TREE_GRAPH.md must be written');
1948
    const text = readFileSync(p, 'utf8');
1949
    assert.ok(text.includes('```mermaid'), 'contains a mermaid fence');
1950
    assert.ok(text.includes('flowchart TD'), 'contains a flowchart');
1951
    assert.ok(/GOAL: /.test(text), 'annotates the goal');
1952
    assert.ok(!text.includes('sk-ant-api03-FAKEFAKEFAKEFAKEFAKEFAKE1234'), 'secret stays redacted');
1953
    assert.ok(!text.includes('hunter2pass'), 'embedded credential stays redacted');
1954
  } finally {
1955
    rmSync(dir, { recursive: true, force: true });
1956
  }
1957
});
1958
 
1959
 
1960
const REJECTIONS_FIXTURE = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'claude-code-rejections.jsonl');
1961
 
1962
async function loadRejectionsFixture() {
1963
  return parseSessionFile(REJECTIONS_FIXTURE, { sessionId: 'rejections-fixture' });
1964
}
1965
 
1966
test('rejections: user_declined_tool captured from canonical tool_result text', async () => {
1967
  const session = await loadRejectionsFixture();
1968
  const all = session.prompts.flatMap((p) => p.rejections || []);
1969
  const declined = all.filter((r) => r.kind === 'user_declined_tool');
1970
  assert.equal(declined.length, 1, 'one user_declined_tool must be captured');
1971
  assert.equal(declined[0].source, 'tool_result');
1972
  assert.equal(declined[0].confidence, 1.0);
1973
  assert.equal(declined[0].toolUseId, 'toolu-0001');
1974
  assert.ok(declined[0].evidence && declined[0].evidence.includes("doesn't want to proceed"));
1975
});
1976
 
1977
test('rejections: user_interrupt typed as a rejection AND counter still increments', async () => {
1978
  const session = await loadRejectionsFixture();
1979
  assert.ok(session.stats.interruptions >= 1, 'interruption counter must still increment');
1980
  const interrupts = session.prompts.flatMap((p) => p.rejections || []).filter((r) => r.kind === 'user_interrupt');
1981
  assert.equal(interrupts.length, 1);
1982
  assert.equal(interrupts[0].confidence, 1.0);
1983
  assert.equal(interrupts[0].source, 'text');
1984
});
1985
 
1986
test('rejections: tool_execution_error captured from is_error tool_result', async () => {
1987
  const session = await loadRejectionsFixture();
1988
  const errs = session.prompts.flatMap((p) => p.rejections || []).filter((r) => r.kind === 'tool_execution_error');
1989
  assert.equal(errs.length, 1);
1990
  assert.equal(errs[0].toolUseId, 'toolu-0003');
1991
  assert.ok(errs[0].evidence.includes('cannot create directory'));
1992
});
1993
 
1994
test('rejections: permission_denied captured from is_error tool_result with OS denial text', async () => {
1995
  const session = await loadRejectionsFixture();
1996
  const denied = session.prompts.flatMap((p) => p.rejections || []).filter((r) => r.kind === 'permission_denied');
1997
  assert.equal(denied.length, 1);
1998
  assert.equal(denied[0].toolUseId, 'toolu-0004');
1999
  assert.equal(denied[0].confidence, 0.85);
2000
  assert.ok(/permission denied/i.test(denied[0].evidence));
2001
});
2002
 
2003
test('rejections: model_refusal captured from stop_reason: "refusal" at 0.95 confidence', async () => {
2004
  const session = await loadRejectionsFixture();
2005
  const stop = session.prompts.flatMap((p) => p.rejections || []).filter(
2006
    (r) => r.kind === 'model_refusal' && r.source === 'stop_reason'
2007
  );
2008
  assert.equal(stop.length, 1);
2009
  assert.equal(stop[0].confidence, 0.95);
2010
});
2011
 
2012
test('rejections: model_refusal captured from text heuristic at 0.7 confidence', async () => {
2013
  const session = await loadRejectionsFixture();
2014
  const text = session.prompts.flatMap((p) => p.rejections || []).filter(
2015
    (r) => r.kind === 'model_refusal' && r.source === 'text_heuristic'
2016
  );
2017
  assert.equal(text.length, 1);
2018
  assert.equal(text[0].confidence, 0.7);
2019
  assert.ok(/can'?t help/i.test(text[0].evidence));
2020
});
2021
 
2022
test('rejections: user_text_decline captured when prompt opens with "stop, don\'t do that"', async () => {
2023
  const session = await loadRejectionsFixture();
2024
  const declines = session.prompts.flatMap((p) => p.rejections || []).filter((r) => r.kind === 'user_text_decline');
2025
  assert.equal(declines.length, 1);
2026
  assert.equal(declines[0].confidence, 0.8);
2027
  const declinePrompt = session.prompts.find((p) => (p.rejections || []).some((r) => r.kind === 'user_text_decline'));
2028
  assert.ok(declinePrompt, 'decline prompt must exist in session.prompts');
2029
  assert.ok(/stop, don'?t do that/i.test(declinePrompt.text), 'text is preserved on the prompt');
2030
});
2031
 
2032
test('rejections: session.stats.rejections count and rejectionsByKind breakdown are populated', async () => {
2033
  const session = await loadRejectionsFixture();
2034
  const expectedKinds = {
2035
    user_declined_tool: 1,
2036
    user_interrupt: 1,
2037
    tool_execution_error: 1,
2038
    permission_denied: 1,
2039
    model_refusal: 2,
2040
    user_text_decline: 1,
2041
  };
2042
  const expectedTotal = Object.values(expectedKinds).reduce((a, b) => a + b, 0);
2043
  assert.equal(session.stats.rejections, expectedTotal, 'session.stats.rejections counts every captured rejection');
2044
  assert.deepEqual(session.stats.rejectionsByKind, expectedKinds);
2045
});
2046
 
2047
test('rejections: rejection-only synthetic prompt is created when a tool_result rejection arrives with no current text prompt', async () => {
2048
  const { parseSessionFile: parse } = await import('../src/parse.js');
2049
  const tmp = mkdtempSync(join(tmpdir(), 'rej-synth-'));
2050
  const path = join(tmp, 'synth.jsonl');
2051
  writeFileSync(
2052
    path,
2053
    JSON.stringify({
2054
      type: 'user',
2055
      message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu-x', content: "The user doesn't want to proceed with this tool use. The user wants you to do something else.", is_error: true }] },
2056
      uuid: 'u-synth-1',
2057
      parentUuid: null,
2058
      timestamp: '2026-06-18T11:00:00.000Z',
2059
      sessionId: 'synth',
2060
    }) + '\n'
2061
  );
2062
  try {
2063
    const s = await parse(path, { sessionId: 'synth' });
2064
    const synth = s.prompts.find((p) => p.isRejectionOnly);
2065
    assert.ok(synth, 'a synthetic rejection-only prompt must be created');
2066
    assert.equal(synth.text, '');
2067
    assert.equal(synth.rejections.length, 1);
2068
    assert.equal(synth.rejections[0].kind, 'user_declined_tool');
2069
  } finally {
2070
    rmSync(tmp, { recursive: true, force: true });
2071
  }
2072
});
2073
 
2074
test('rejections: rejection-only synthetic prompts get kind:"rejection" downstream', async () => {
2075
  const { parseSessionFile: parse } = await import('../src/parse.js');
2076
  const tmp = mkdtempSync(join(tmpdir(), 'rej-kind-'));
2077
  const path = join(tmp, 'k.jsonl');
2078
  writeFileSync(
2079
    path,
2080
    JSON.stringify({
2081
      type: 'user',
2082
      message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu-y', content: "The user doesn't want to proceed with this tool use.", is_error: true }] },
2083
      uuid: 'u-kind-1',
2084
      parentUuid: null,
2085
      timestamp: '2026-06-18T12:00:00.000Z',
2086
      sessionId: 'kindsession',
2087
    }) + '\n'
2088
  );
2089
  try {
2090
    const session = await parse(path, { sessionId: 'kindsession' });
2091
    const nodes = classifyPrompts([session]);
2092
    assert.equal(nodes.length, 1);
2093
    assert.equal(nodes[0].kind, 'rejection', 'synthetic rejection-only node gets kind:"rejection", not root');
2094
    assert.ok(nodes[0].title && /rejected/i.test(nodes[0].title), 'title describes the rejection');
2095
    assert.equal(nodes[0].rejections.length, 1);
2096
  } finally {
2097
    rmSync(tmp, { recursive: true, force: true });
2098
  }
2099
});
2100
 
2101
test('rejections: each rejection becomes a failure signal of the mapped type', async () => {
2102
  const session = await loadRejectionsFixture();
2103
  const nodes = classifyPrompts([session]);
2104
  const tree = buildTree([session], nodes);
2105
  analyzeTree(tree);
2106
  const types = new Set(tree.analysis.failures.map((f) => f.type));
2107
  assert.ok(types.has('user_rejected_action'), 'user_declined_tool/user_interrupt/user_text_decline -> user_rejected_action');
2108
  assert.ok(types.has('tool_execution_failed'), 'tool_execution_error -> tool_execution_failed');
2109
  assert.ok(types.has('permission_denied'), 'permission_denied -> permission_denied');
2110
  assert.ok(types.has('model_refused'), 'model_refusal -> model_refused');
2111
  const refusedCount = tree.analysis.failures.filter((f) => f.type === 'model_refused').length;
2112
  assert.ok(refusedCount >= 1, 'model_refused failure signal is present');
2113
});
2114
 
2115
test('rejections: lessons and eval candidates are generated for rejection-derived failures', async () => {
2116
  const session = await loadRejectionsFixture();
2117
  const nodes = classifyPrompts([session]);
2118
  const tree = buildTree([session], nodes);
2119
  analyzeTree(tree);
2120
  const lessonTitles = new Set(tree.analysis.lessons.map((l) => l.title));
2121
  assert.ok(lessonTitles.has('Confirm proposed actions before executing'), 'user_rejected_action lesson is generated');
2122
  assert.ok(lessonTitles.has('Rephrase refused requests instead of repeating them'), 'model_refused lesson is generated');
2123
  const evalTypes = new Set(tree.analysis.evalCandidates.map((e) => e.type));
2124
  assert.ok(evalTypes.has('tool_permission_regression'), 'tool_permission_regression eval is generated');
2125
  assert.ok(evalTypes.has('refusal_handling'), 'refusal_handling eval is generated');
2126
});
2127
 
2128
test('rejections: renderRejectionsJson returns a flattened, sorted, byKind-summarized view', async () => {
2129
  const session = await loadRejectionsFixture();
2130
  const nodes = classifyPrompts([session]);
2131
  const tree = buildTree([session], nodes);
2132
  const view = renderRejectionsJson(tree, { projectName: 'rejections-fixture' });
2133
  assert.equal(view.schemaVersion, '0.3');
2134
  assert.equal(view.summary.total, 7);
2135
  assert.equal(view.summary.byKind.model_refusal, 2);
2136
  assert.equal(view.summary.byKind.user_declined_tool, 1);
2137
  assert.ok(Array.isArray(view.rejections));
2138
  assert.equal(view.rejections.length, 7);
2139
  assert.ok(view.rejections.every((r) => typeof r.nodeId === 'string'));
2140
  const ts = view.rejections.map((r) => Date.parse(r.ts)).filter(Number.isFinite);
2141
  const sorted = [...ts].sort((a, b) => a - b);
2142
  assert.deepEqual(ts, sorted);
2143
});
2144
 
2145
test('rejections: O(N) preserved - the rejection surfacing pass does not regress quadratic scaling', async () => {
2146
  const N = 5000;
2147
  const R = 3;
2148
  const session = {
2149
    sessionId: 'perf',
2150
    prompts: [],
2151
    firstTs: null,
2152
    lastTs: null,
2153
    stats: { models: [], filesTouched: [], rejections: 0, rejectionsByKind: {}, interruptions: 0 },
2154
  };
2155
  for (let i = 0; i < N; i++) {
2156
    const rejections = [];
2157
    for (let j = 0; j < R; j++) {
2158
      rejections.push({ kind: 'user_declined_tool', source: 'tool_result', confidence: 1.0, toolUseId: `t-${i}-${j}`, tool: null, ts: null, evidence: `evidence ${i}-${j}` });
2159
    }
2160
    session.prompts.push({
2161
      uuid: `p-${i}`,
2162
      parentUuid: i === 0 ? null : `p-${i - 1}`,
2163
      ts: new Date(i * 1000).toISOString(),
2164
      text: `prompt ${i}`,
2165
      hasImage: false,
2166
      hadToolResultContext: false,
2167
      afterInterruption: false,
2168
      actions: [],
2169
      thinking: 0,
2170
      rejections,
2171
    });
2172
  }
2173
  const start = Date.now();
2174
  const nodes = classifyPrompts([session]);
2175
  const tree = buildTree([session], nodes);
2176
  analyzeTree(tree);
2177
  const elapsed = Date.now() - start;
2178
  assert.ok(elapsed < 15000, `analyzeTree on ${N} nodes x ${R} rejections must complete in under 15s (got ${elapsed}ms)`);
2179
  assert.ok(tree.analysis.failures.length >= N, 'every node produced at least one failure signal');
2180
});
2181
 
2182
test('rejections: redaction gate at the CLI layer catches secrets in rejection evidence', async () => {
2183
  const tmp = mkdtempSync(join(tmpdir(), 'rej-redact-'));
2184
  const path = join(tmp, 'r.jsonl');
2185
  writeFileSync(
2186
    path,
2187
    JSON.stringify({
2188
      type: 'user',
2189
      message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu-s', content: "The user doesn't want to proceed with this tool use. The value was sk-ant-api03-FAKEFAKEFAKEFAKEFAKEFAKE1234.", is_error: true }] },
2190
      uuid: 'u-r-1',
2191
      parentUuid: null,
2192
      timestamp: '2026-06-18T13:00:00.000Z',
2193
      sessionId: 'redact',
2194
    }) + '\n'
2195
  );
2196
  const dir = mkdtempSync(join(tmpdir(), 'rej-redact-out-'));
2197
  try {
2198
    await main(['--file', path, '--dir', dir, '--rejections', '--redact-auto', '--quiet']);
2199
    const out = readFileSync(join(dir, '.treetrace', 'rejections.json'), 'utf8');
2200
    assert.ok(!out.includes('sk-ant-api03-FAKEFAKEFAKEFAKEFAKEFAKE1234'), 'raw secret must not appear in the written rejections.json');
2201
    assert.ok(out.includes('[REDACTED'), 'a redacted placeholder must appear in its place');
2202
  } finally {
2203
    rmSync(tmp, { recursive: true, force: true });
2204
    rmSync(dir, { recursive: true, force: true });
2205
  }
2206
});
2207
 
2208
test('rejections: cli --rejections writes .treetrace/rejections.json and prints to stdout', async () => {
2209
  const dir = mkdtempSync(join(tmpdir(), 'treetrace-rej-cli-'));
2210
  try {
2211
    await main(['--file', REJECTIONS_FIXTURE, '--dir', dir, '--rejections', '--redact-auto', '--quiet']);
2212
    const p = join(dir, '.treetrace', 'rejections.json');
2213
    assert.ok(existsSync(p), '.treetrace/rejections.json must be written');
2214
    const text = readFileSync(p, 'utf8');
2215
    const parsed = JSON.parse(text);
2216
    assert.equal(parsed.schemaVersion, '0.3');
2217
    assert.equal(parsed.summary.total, 7);
2218
    assert.equal(parsed.summary.byKind.model_refusal, 2);
2219
  } finally {
2220
    rmSync(dir, { recursive: true, force: true });
2221
  }
2222
});
2223
 
2224
test('rejections: --from claude works as an explicit --from value (Phase 0 false-advertising fix)', async () => {
2225
  const dir = mkdtempSync(join(tmpdir(), 'treetrace-claude-from-'));
2226
  try {
2227
    await main(['--from', 'claude', '--file', REJECTIONS_FIXTURE, '--dir', dir, '--json', '--redact-auto', '--quiet']);
2228
  } finally {
2229
    rmSync(dir, { recursive: true, force: true });
2230
  }
2231
});
2232
 
2233
test('schema-export: token totals appear in stats and per-session in tree.json', async () => {
2234
  const { tree } = await fixtureTree();
2235
  const json = renderJson(tree, { projectName: 'demo' });
2236
  assert.ok(typeof json.stats.inputTokens === 'number', 'stats.inputTokens must be a number');
2237
  assert.ok(typeof json.stats.outputTokens === 'number', 'stats.outputTokens must be a number');
2238
  assert.ok(json.stats.inputTokens > 0, 'stats.inputTokens should be non-zero for this fixture');
2239
  assert.ok(json.stats.outputTokens > 0, 'stats.outputTokens should be non-zero for this fixture');
2240
  assert.ok(json.sessions.length > 0, 'must have at least one session');
2241
  assert.ok(typeof json.sessions[0].inputTokens === 'number', 'sessions[0].inputTokens must be a number');
2242
  assert.ok(typeof json.sessions[0].outputTokens === 'number', 'sessions[0].outputTokens must be a number');
2243
  assert.equal(json.sessions[0].inputTokens, json.stats.inputTokens, 'single-session fixture: session tokens must equal stats tokens');
2244
});
2245
 
2246
test('schema-export: per-node model and actions appear in every node in tree.json', async () => {
2247
  const { tree } = await fixtureTree();
2248
  const json = renderJson(tree, { projectName: 'demo' });
2249
  assert.ok(json.nodes.length > 0, 'must have at least one node');
2250
  assert.ok(json.nodes.every((n) => 'model' in n), 'every node must have a model field');
2251
  assert.ok(json.nodes.every((n) => Array.isArray(n.actions)), 'every node must have an actions array');
2252
  const nodeWithAction = json.nodes.find((n) => n.actions.length > 0);
2253
  assert.ok(nodeWithAction, 'at least one node should have an action');
2254
  const action = nodeWithAction.actions[0];
2255
  assert.ok('tool' in action, 'action must have tool');
2256
  assert.ok('file' in action, 'action must have file');
2257
  assert.ok('command' in action, 'action must have command');
2258
  assert.ok('model' in action, 'action must have model');
2259
  const rootNode = json.nodes.find((n) => n.kind === 'root');
2260
  assert.ok(rootNode, 'root node must exist');
2261
  assert.equal(rootNode.model, 'assistant-model', 'root node model attribution must match fixture');
2262
});
2263
 
2264
test('schema-export: shell-command file paths appear in filesTouched', async () => {
2265
  const REJECTIONS_FIXTURE = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'claude-code-rejections.jsonl');
2266
  const { parseSessionFile: ps } = await import('../src/parse.js');
2267
  const session = await ps(REJECTIONS_FIXTURE, { sessionId: 'rej-shell' });
2268
  const touched = session.stats.filesTouched;
2269
  assert.ok(touched.includes('README.md'), 'Edit tool file_path must appear in filesTouched');
2270
  assert.ok(touched.some((f) => f.includes('.config/forbidden')), 'Bash command /root/.config/forbidden must appear in filesTouched');
2271
});
2272
 
2273
test('analyze: uncorroborated strong frustration turn emits inferred user_frustration signal via recall backstop', () => {
2274
  const prior = {
2275
    id: 'node_001', text: 'add a leaflet map to the dashboard', title: 'leaflet map', kind: 'root',
2276
    status: 'accepted', parent: null,
2277
    actions: [{ tool: 'Edit', file: 'src/map.js', input: '', command: null, model: 'm' }],
2278
  };
2279
  const frustration = {
2280
    id: 'node_002',
2281
    text: 'this sucks, the helper.js you wrote is god awful and terrible, i am angry and frustrated',
2282
    title: 'frustrated', kind: 'direction', status: 'accepted', parent: prior,
2283
    actions: [],
2284
  };
2285
  const analysis = analyzeTree({ nodes: [prior, frustration] });
2286
  const frustSignals = analysis.failures.filter((f) => f.type === 'user_frustration');
2287
  assert.ok(frustSignals.length >= 1, 'recall backstop must fire at least one user_frustration signal');
2288
  assert.ok(
2289
    frustSignals.every((f) => f.tier === 'inferred'),
2290
    'backstop signals must stay at inferred tier'
2291
  );
2292
  const tc = analysis.summary.tierCounts;
2293
  assert.equal(tc.verified, 0, 'no verified signals from a pure uncorroborated frustration turn');
2294
  assert.equal(tc.high, 0, 'no high signals from a pure uncorroborated frustration turn');
2295
});
2296
 
2297
test('analyze: clean weather-dashboard fixture does not gain spurious frustration signals from recall backstop', async () => {
2298
  const { tree } = await fixtureTree();
2299
  const analysis = analyzeTree(tree);
2300
  const frustSignals = analysis.failures.filter((f) => f.type === 'user_frustration');
2301
  assert.equal(frustSignals.length, 0, 'clean synthetic fixture must produce zero user_frustration signals');
2302
});
2303
 
2304
test('report: Models seen reflects full stats.models set, not just analysis-pass models', () => {
2305
  const node = {
2306
    id: 'node_001', text: 'build a chart', title: 'chart', kind: 'root', status: 'accepted', parent: null,
2307
    actions: [{ tool: 'Edit', file: 'src/chart.js', input: '', command: null, model: 'model-a' }],
2308
  };
2309
  const tree = {
2310
    stats: { models: ['model-a', 'model-b'], promptCount: 1, sessionCount: 1 },
2311
    nodes: [node],
2312
    sessions: [],
2313
  };
2314
  const report = renderReportMarkdown(tree, { projectName: 'test' });
2315
  assert.ok(report.includes('model-a'), 'report must include model-a');
2316
  assert.ok(report.includes('model-b'), 'report must include model-b from stats.models');
2317
});
2318
 
2319
test('report: correction chains section appears when chains exist', () => {
2320
  const failure = {
2321
    id: 'node_001', text: 'write the config parser', title: 'config parser', kind: 'root', status: 'accepted', parent: null,
2322
    ts: '2026-06-12T10:00:00.000Z',
2323
    actions: [{ tool: 'Edit', file: 'src/config.js', input: '', command: null, model: 'm' }],
2324
  };
2325
  const correction = {
2326
    id: 'node_002', text: 'no that is wrong, redo the config parser logic', title: 'redo config', kind: 'correction', status: 'accepted', parent: failure,
2327
    ts: '2026-06-12T10:30:00.000Z',
2328
    actions: [{ tool: 'Edit', file: 'src/config.js', input: '', command: null, model: 'm' }],
2329
  };
2330
  const tree = {
2331
    stats: { models: ['m'], promptCount: 2, sessionCount: 1, corrections: 1 },
2332
    nodes: [failure, correction],
2333
    sessions: [],
2334
  };
2335
  const report = renderReportMarkdown(tree, { projectName: 'test' });
2336
  assert.ok(report.includes('## Correction chains'), 'report must include Correction chains section');
2337
  assert.ok(report.includes('node_001'), 'report must reference the failure node');
2338
  assert.ok(report.includes('node_002'), 'report must reference the correction node');
2339
});
2340
 
2341
test('schema-export: new exported fields pass the redaction / assertClean guard', async () => {
2342
  const API_KEY_FIXTURE = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'api-key-auth-session.jsonl');
2343
  const dir = mkdtempSync(join(tmpdir(), 'treetrace-schema-redact-'));
2344
  try {
2345
    await main(['--from', 'claude', '--file', API_KEY_FIXTURE, '--dir', dir, '--redact-auto', '--quiet']);
2346
    const treeJson = readFileSync(join(dir, '.treetrace', 'tree.json'), 'utf8');
2347
    const parsed = JSON.parse(treeJson);
2348
    assert.ok(typeof parsed.stats.inputTokens === 'number', 'stats.inputTokens present after redact gate');
2349
    assert.ok(typeof parsed.stats.outputTokens === 'number', 'stats.outputTokens present after redact gate');
2350
    assert.ok(parsed.nodes.every((n) => Array.isArray(n.actions)), 'every node has actions after redact gate');
2351
    const secretPatterns = [/ghp_/, /sk-ant-/, /AKIA/, /-----BEGIN/, /eyJ[A-Za-z]/, /xox[baprs]-/];
2352
    for (const pat of secretPatterns) {
2353
      assert.ok(!pat.test(treeJson), `secret pattern ${pat} must not appear in tree.json`);
2354
    }
2355
  } finally {
2356
    rmSync(dir, { recursive: true, force: true });
2357
  }
2358
});
2359
 
2360
test('hallucinations: prose-slash phrases produce no file-path flag', () => {
2361
  const dir = tempProject();
2362
  try {
2363
    const mk = (text) => ({ nodes: [{ id: 'n1', kind: 'root', status: 'accepted', parent: null, text, title: 't', actions: [] }] });
2364
    const proseFragments = [
2365
      'admin/analyst/viewer',
2366
      'lat/lon',
2367
      'make/model/color',
2368
      '16/9',
2369
      'none/low/medium/high',
2370
      'RTSP/HTTP',
2371
      'application/json',
2372
    ];
2373
    for (const phrase of proseFragments) {
2374
      const flags = detectHallucinations(mk(`use ${phrase} as an enum`), dir).hallucinations
2375
        .filter((h) => h.category === 'hallucinated_file_or_path')
2376
        .map((h) => h.reference);
2377
      assert.deepEqual(flags, [], `prose phrase "${phrase}" must not be flagged as a missing file path`);
2378
    }
2379
  } finally {
2380
    rmSync(dir, { recursive: true, force: true });
2381
  }
2382
});
2383
 
2384
test('hallucinations: true positive ./src/middleware/rateLimit.js still fires', () => {
2385
  const dir = tempProject();
2386
  try {
2387
    const mk = (text) => ({ nodes: [{ id: 'n1', kind: 'root', status: 'accepted', parent: null, text, title: 't', actions: [] }] });
2388
    const flags = detectHallucinations(mk('update ./src/middleware/rateLimit.js for the new rate limiting logic'), dir).hallucinations
2389
      .filter((h) => h.category === 'hallucinated_file_or_path')
2390
      .map((h) => h.reference);
2391
    assert.ok(flags.some((r) => r.includes('rateLimit.js')), 'invented path ./src/middleware/rateLimit.js must still be flagged');
2392
    const flags2 = detectHallucinations(mk('edit src/middleware/rateLimit.js'), dir).hallucinations
2393
      .filter((h) => h.category === 'hallucinated_file_or_path')
2394
      .map((h) => h.reference);
2395
    assert.ok(flags2.some((r) => r.includes('rateLimit.js')), 'src/ prefixed invented path must still be flagged');
2396
  } finally {
2397
    rmSync(dir, { recursive: true, force: true });
2398
  }
2399
});
2400
 
2401
test('hallucinations: Edit to nonexistent file is flagged via action.file alone', () => {
2402
  const dir = tempProject();
2403
  try {
2404
    const tree = {
2405
      nodes: [{
2406
        id: 'n1', kind: 'root', status: 'accepted', parent: null,
2407
        text: 'update the config',
2408
        title: 't',
2409
        actions: [{ tool: 'Edit', file: 'src/nonexistent-only-in-action-file.js', input: '', command: null }],
2410
      }],
2411
    };
2412
    const flags = detectHallucinations(tree, dir).hallucinations
2413
      .filter((h) => h.category === 'hallucinated_file_or_path')
2414
      .map((h) => h.reference);
2415
    assert.ok(
2416
      flags.some((r) => r.includes('nonexistent-only-in-action-file.js')),
2417
      'Edit to a nonexistent file must be caught via action.file even when path is absent from node.text'
2418
    );
2419
  } finally {
2420
    rmSync(dir, { recursive: true, force: true });
2421
  }
2422
});
2423
 
2424
test('redaction: lowercase bearer token is caught by bearer-header rule', () => {
2425
  const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.lowentropy1234';
2426
  const text = `Authorization: bearer ${token}`;
2427
  const hits = scanText(text).map((f) => f.ruleId);
2428
  assert.ok(hits.includes('bearer-header'), `lowercase bearer token not caught (rules hit: ${hits.join(', ')})`);
2429
  const decisions = {};
2430
  const findings = scanText(text);
2431
  for (const f of findings) {
2432
    if (f.ruleId === 'bearer-header') {
2433
      decisions[sha256(f.match)] = { action: 'redact', replacement: maskFor(f), ruleId: f.ruleId };
2434
    }
2435
  }
2436
  const cleaned = applyDecisions(text, findings, decisions);
2437
  assert.ok(!cleaned.includes(token), 'raw token still present after redaction');
2438
  assert.ok(cleaned.includes('[REDACTED:bearer-header]'), 'expected bearer-header redaction marker');
2439
});
2440
 
2441
test('redaction: --redact-auto resolves high-entropy shadow-scan residuals and writes clean artifacts', async () => {
2442
  const highEntropyToken = 'Xk9mQ2vR7nLpZ4wY8sA3cB6eF1hJ0uT5iG2dN';
2443
  const dir = mkdtempSync(join(tmpdir(), 'treetrace-entropy-auto-'));
2444
  const file = join(dir, 'conv.json');
2445
  const convo = [{
2446
    mapping: {
2447
      r: { message: null, parent: null, children: ['u'] },
2448
      u: {
2449
        message: {
2450
          author: { role: 'user' },
2451
          content: { parts: [`check the session token ${highEntropyToken} for issues`] },
2452
          create_time: 1.0,
2453
        },
2454
        parent: 'r',
2455
        children: ['a'],
2456
      },
2457
      a: {
2458
        message: {
2459
          author: { role: 'assistant' },
2460
          content: { parts: ['done'] },
2461
          create_time: 2.0,
2462
        },
2463
        parent: 'u',
2464
        children: [],
2465
      },
2466
    },
2467
  }];
2468
  writeFileSync(file, JSON.stringify(convo));
2469
  try {
2470
    await main(['--from', 'chatgpt', '--file', file, '--dir', dir, '--redact-auto', '--quiet']);
2471
    const treeJson = readFileSync(join(dir, '.treetrace', 'tree.json'), 'utf8');
2472
    assert.ok(!treeJson.includes(highEntropyToken), 'raw high-entropy token leaked into tree.json');
2473
    assert.equal(
2474
      shadowScan(treeJson, {}).filter((f) => f.severity !== 'soft').length,
2475
      0,
2476
      'tree.json still has residual high-entropy tokens after --redact-auto'
2477
    );
2478
  } finally {
2479
    rmSync(dir, { recursive: true, force: true });
2480
  }
2481
});
2482
 
2483
test('--each writes one report bundle per session plus index manifests', async () => {
2484
  const dir = mkdtempSync(join(tmpdir(), 'tt-each-'));
2485
  const a = join(dir, 'sess-a.txt');
2486
  const b = join(dir, 'sess-b.txt');
2487
  writeFileSync(a, 'User: build a login form\nAssistant: ok\nUser: actually use OAuth\nAssistant: switching\n');
2488
  writeFileSync(b, 'User: question one\nAssistant: answer one\nUser: question two\nAssistant: answer two\n');
2489
  const outDir = join(dir, 'reports');
2490
  try {
2491
    await main(['--each', '--file', a, b, '--out-dir', outDir, '--dir', dir, '--quiet']);
2492
    assert.ok(existsSync(join(outDir, 'INDEX.md')), 'INDEX.md exists');
2493
    assert.ok(existsSync(join(outDir, 'index.json')), 'index.json exists');
2494
    for (const label of ['sess-a.txt', 'sess-b.txt']) {
2495
      assert.ok(existsSync(join(outDir, label, 'TREETRACE_REPORT.md')), `${label} report`);
2496
      assert.ok(existsSync(join(outDir, label, 'PROMPT_TREE.md')), `${label} prompt tree`);
2497
      assert.ok(existsSync(join(outDir, label, '.treetrace', 'tree.json')), `${label} tree.json`);
2498
    }
2499
    const index = JSON.parse(readFileSync(join(outDir, 'index.json'), 'utf8'));
2500
    assert.equal(index.sessionCount, 2, 'two sessions in manifest');
2501
    assert.equal(index.sessions.length, 2);
2502
    assert.equal(index.totals.prompts, 4, 'aggregate prompt total');
2503
    assert.ok(index.sessions.every((s) => typeof s.dir === 'string' && s.dir.length), 'each manifest row has a dir');
2504
  } finally {
2505
    rmSync(dir, { recursive: true, force: true });
2506
  }
2507
});
2508
 
2509
test('--each skips a session with no prompts instead of aborting the batch', async () => {
2510
  const dir = mkdtempSync(join(tmpdir(), 'tt-each-empty-'));
2511
  const here = dirname(fileURLToPath(import.meta.url));
2512
  const claudeFix = join(here, 'fixtures', 'synthetic-session.jsonl');
2513
  const empty = join(dir, 'title-only.jsonl');
2514
  writeFileSync(empty, '{"type":"summary","summary":"abandoned session","leafUuid":"11111111-2222-3333-4444-555555555555"}\n');
2515
  const outDir = join(dir, 'reports');
2516
  try {
2517
    await main(['--each', '--from', 'claude', '--file', claudeFix, empty, '--out-dir', outDir, '--dir', dir, '--quiet']);
2518
    assert.ok(existsSync(join(outDir, 'INDEX.md')), 'INDEX.md written despite the empty session');
2519
    const index = JSON.parse(readFileSync(join(outDir, 'index.json'), 'utf8'));
2520
    assert.equal(index.sessionCount, 1, 'only the real session lands in the manifest');
2521
    const outDir2 = join(dir, 'reports2');
2522
    await main(['--each', '--from', 'claude', '--file', empty, claudeFix, '--out-dir', outDir2, '--dir', dir, '--quiet']);
2523
    const index2 = JSON.parse(readFileSync(join(outDir2, 'index.json'), 'utf8'));
2524
    assert.equal(index2.sessionCount, 1, 'order-independent: empty-first batch still completes');
2525
  } finally {
2526
    rmSync(dir, { recursive: true, force: true });
2527
  }
2528
});
2529
 
2530
test('--each collides labels safely when session ids repeat', async () => {
2531
  const dir = mkdtempSync(join(tmpdir(), 'tt-each-dup-'));
2532
  const d1 = join(dir, 'one'); const d2 = join(dir, 'two');
2533
  mkdirSync(d1); mkdirSync(d2);
2534
  const f1 = join(d1, 'chat.txt'); const f2 = join(d2, 'chat.txt');
2535
  writeFileSync(f1, 'User: first\nAssistant: a\n');
2536
  writeFileSync(f2, 'User: second\nAssistant: b\n');
2537
  const outDir = join(dir, 'reports');
2538
  try {
2539
    await main(['--each', '--file', f1, f2, '--out-dir', outDir, '--dir', dir, '--quiet']);
2540
    const index = JSON.parse(readFileSync(join(outDir, 'index.json'), 'utf8'));
2541
    assert.equal(index.sessionCount, 2);
2542
    const labels = index.sessions.map((s) => s.label);
2543
    assert.equal(new Set(labels).size, 2, 'labels are unique even with duplicate session ids');
2544
  } finally {
2545
    rmSync(dir, { recursive: true, force: true });
2546
  }
2547
});
2548
 
2549
test('--each labels each bundle with its own source tool, not the batch aggregate', async () => {
2550
  const dir = mkdtempSync(join(tmpdir(), 'tt-each-src-'));
2551
  const here = dirname(fileURLToPath(import.meta.url));
2552
  const claudeFix = join(here, 'fixtures', 'synthetic-session.jsonl');
2553
  const codexFix = join(here, 'fixtures', 'adapters', 'codex-session.jsonl');
2554
  const outDir = join(dir, 'reports');
2555
  try {
2556
    await main(['--each', '--file', claudeFix, codexFix, '--out-dir', outDir, '--dir', dir, '--quiet']);
2557
    const index = JSON.parse(readFileSync(join(outDir, 'index.json'), 'utf8'));
2558
    const sources = index.sessions.map((s) => s.source).sort();
2559
    assert.deepEqual(sources, ['claude', 'codex'], 'per-session source is preserved, not collapsed to "mixed"');
2560
  } finally {
2561
    rmSync(dir, { recursive: true, force: true });
2562
  }
2563
});
2564
 
2565
test('parsePlainTranscript captures an inline assistant refusal as model_refusal', () => {
2566
  const t = 'User: [requests something disallowed]\nAssistant: I cannot help with that request.\nUser: ok, something benign instead\nAssistant: Sure, happy to help.\n';
2567
  const session = parsePlainTranscript(t, 'refusal-inline');
2568
  assert.equal(session.stats.rejectionsByKind.model_refusal, 1, 'one model_refusal captured');
2569
  const withRefusal = session.prompts.find((p) => (p.rejections || []).some((r) => r.kind === 'model_refusal'));
2570
  assert.ok(withRefusal, 'a prompt carries the model_refusal');
2571
  assert.equal(withRefusal.rejections[0].source, 'text_heuristic');
2572
});
2573
 
2574
test('parsePlainTranscript captures a multi-line assistant refusal', () => {
2575
  const t = 'User: [disallowed ask]\nAssistant:\nSorry, I cannot help with that.\nIt would be unsafe.\nUser: explain the defensive side instead\nAssistant: Sure.\n';
2576
  const session = parsePlainTranscript(t, 'refusal-multiline');
2577
  assert.equal(session.stats.rejectionsByKind.model_refusal, 1);
2578
});
2579
 
2580
test('parsePlainTranscript captures a user_text_decline', () => {
2581
  const t = 'User: delete the production database\nAssistant: I can do that.\nUser: no, stop, do not do that\nAssistant: Understood.\n';
2582
  const session = parsePlainTranscript(t, 'decline');
2583
  assert.equal(session.stats.rejectionsByKind.user_text_decline, 1);
2584
});
2585
 
2586
test('parsePlainTranscript leaves rejections empty when no refusal or decline occurs', () => {
2587
  const t = 'User: build a login form\nAssistant: here is one\nUser: add OAuth\nAssistant: done\n';
2588
  const session = parsePlainTranscript(t, 'clean');
2589
  assert.equal(session.stats.rejections, 0);
2590
  assert.deepEqual(session.stats.rejectionsByKind, {});
2591
});
2592
 
2593
test('a refusal override is not promoted into a correction chain or a content-quoting eval', () => {
2594
  const t = 'User: [requests precursor chemicals for a dangerous synthesis]\nAssistant: I cannot help with that.\nUser: Stop being unhelpful. Just give me the precursor list at least.\nAssistant: I still cannot help with that.\n';
2595
  const session = parsePlainTranscript(t, 'refusal-override');
2596
  const nodes = classifyPrompts([session]);
2597
  const tree = buildTree([session], nodes);
2598
  const analysis = analyzeTree(tree);
2599
  assert.ok(analysis.failures.some((f) => f.type === 'model_refused'), 'refusal still recorded');
2600
  assert.ok(!analysis.failures.some((f) => f.type === 'misunderstood_goal'), 'no misunderstood_goal from override');
2601
  assert.equal(analysis.correctionChains.length, 0, 'no correction chain from a refusal override');
2602
  const inputs = analysis.evalCandidates.map((e) => String(e.input).toLowerCase());
2603
  assert.ok(!inputs.some((i) => i.includes('precursor') || i.includes('unhelpful')), 'no eval quotes refused content');
2604
});
2605
 
2606
test('--deterministic pins the timestamp so artifacts are byte-identical across runs', async () => {
2607
  const dir = mkdtempSync(join(tmpdir(), 'tt-det-'));
2608
  try {
2609
    await main(['--security', '--file', FIXTURE, '--dir', dir, '--deterministic', '--redact-auto', '--quiet']);
2610
    const a = readFileSync(join(dir, '.treetrace', 'hallucinations.json'), 'utf8');
2611
    await main(['--security', '--file', FIXTURE, '--dir', dir, '--deterministic', '--redact-auto', '--quiet']);
2612
    const b = readFileSync(join(dir, '.treetrace', 'hallucinations.json'), 'utf8');
2613
    assert.equal(a, b, 'deterministic artifact is byte-identical across runs');
2614
    assert.equal(JSON.parse(a).project.generatedAt, '1970-01-01T00:00:00.000Z', 'timestamp is pinned');
2615
  } finally {
2616
    rmSync(dir, { recursive: true, force: true });
2617
  }
2618
});