| | @@ -1,5 +1,7 @@ |
| | import { test } from 'node:test'; |
| | import assert from 'node:assert/strict'; |
| + | import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; |
| + | import { tmpdir } from 'node:os'; |
| | import { fileURLToPath } from 'node:url'; |
| | import { dirname, join } from 'node:path'; |
| | |
| | @@ -10,6 +12,15 @@ import { scanText, applyDecisions, shadowScan, maskFor, resolveFindings } from ' |
| | import { renderMarkdown, promptPack } from '../src/render-md.js'; |
| | import { renderJson } from '../src/render-json.js'; |
| | import { renderHandoff } from '../src/handoff.js'; |
| + | import { renderReportMarkdown } from '../src/report.js'; |
| + | import { |
| + | analyzeTree, |
| + | renderFailuresJson, |
| + | renderLessonsMarkdown, |
| + | renderEvalsJsonl, |
| + | renderMemoryMarkdown, |
| + | } from '../src/analyze.js'; |
| + | import { main } from '../src/cli.js'; |
| | import { mungePath } from '../src/discover.js'; |
| | import { sha256 } from '../src/util.js'; |
| | |
| | @@ -97,6 +108,8 @@ test('redaction: rule coverage on known formats', () => { |
| | ['-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaA==\n-----END OPENSSH PRIVATE KEY-----', 'private-key-block'], |
| | ['eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U', 'jwt'], |
| | ['password = "correct-horse-battery"', 'secret-assignment'], |
| + | ['SECRET="correct horse battery staple"', 'secret-assignment'], |
| + | ['https://user:p:a:ss@example.com/path', 'url-basic-auth'], |
| | ]; |
| | for (const [sample, expected] of cases) { |
| | const hits = scanText(`some text ${sample} more text`).map((f) => f.ruleId); |
| | @@ -104,6 +117,21 @@ test('redaction: rule coverage on known formats', () => { |
| | } |
| | }); |
| | |
| + | test('redaction: split provider tokens are caught before shadow scan', () => { |
| + | const dirty = 'token sk-proj-abcdefghijklmnop\nqrstu1234567890ABCDE end'; |
| + | const findings = scanText(dirty); |
| + | assert.ok(findings.some((f) => f.ruleId === 'openai-key'), `openai-key missed in ${findings}`); |
| + | const masked = applyDecisions(dirty, findings, { |
| + | [sha256(findings.find((f) => f.ruleId === 'openai-key').match)]: { |
| + | action: 'redact', |
| + | replacement: '[REDACTED:openai-key]', |
| + | ruleId: 'openai-key', |
| + | }, |
| + | }); |
| + | assert.equal(shadowScan(masked, {}).length, 0); |
| + | assert.ok(!masked.includes('sk-proj-')); |
| + | }); |
| + | |
| | test('redaction: benign text produces no high/medium findings', () => { |
| | const benign = |
| | 'Refactor the parser in src/parse.js to handle commit 3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a and bump to v2.1.0-beta.3. The README.md needs a section on CONTRIBUTING.'; |
| | @@ -113,6 +141,7 @@ test('redaction: benign text produces no high/medium findings', () => { |
| | |
| | test('renderers: markdown, json, handoff are consistent and footer-credited', async () => { |
| | const { tree } = await fixtureTree(); |
| + | analyzeTree(tree); |
| | const md = renderMarkdown(tree, { projectName: 'demo' }); |
| | assert.ok(md.startsWith('# 🌳 Prompt Tree - demo')); |
| | assert.ok(md.includes('## Goal')); |
| | @@ -120,10 +149,13 @@ test('renderers: markdown, json, handoff are consistent and footer-credited', as |
| | assert.ok(md.includes('generated by [treetrace]') || md.includes('Generated by [treetrace]')); |
| | |
| | const json = renderJson(tree, { projectName: 'demo' }); |
| - | assert.equal(json.schemaVersion, '0.1'); |
| + | assert.equal(json.schemaVersion, '0.2'); |
| | assert.equal(json.nodes.length, tree.nodes.length); |
| | assert.equal(json.edges.length, tree.nodes.filter((n) => n.parent).length); |
| | assert.ok(json.nodes.every((n) => n.id && n.kind && typeof n.text === 'string')); |
| + | assert.ok(json.analysis.failureSignals >= 1); |
| + | assert.ok(json.correctionChains.length >= 1); |
| + | assert.ok(json.nodes.some((n) => Array.isArray(n.failureSignals))); |
| | |
| | const pack = promptPack(tree.nodes); |
| | assert.ok(pack.includes('1.')); |
| | @@ -131,6 +163,80 @@ test('renderers: markdown, json, handoff are consistent and footer-credited', as |
| | const handoff = renderHandoff(tree, { projectName: 'demo' }); |
| | assert.ok(handoff.includes('## Original goal')); |
| | assert.ok(handoff.includes('Constraints learned the hard way')); |
| + | assert.ok(handoff.includes('Agent memory lessons')); |
| + | |
| + | const report = renderReportMarkdown(tree, { projectName: 'demo', generatedAt: '2026-01-01T00:00:00.000Z' }); |
| + | assert.ok(report.startsWith('# TreeTrace Report - demo')); |
| + | assert.ok(report.includes('## Output map')); |
| + | assert.ok(report.includes('## Handoff brief')); |
| + | assert.ok(report.includes('TREETRACE_REPORT.md')); |
| + | }); |
| + | |
| + | test('analysis renderers produce failures, lessons, evals, and memory', async () => { |
| + | const { tree } = await fixtureTree(); |
| + | const failures = renderFailuresJson(tree, { projectName: 'demo', generatedAt: '2026-01-01T00:00:00.000Z' }); |
| + | assert.equal(failures.schemaVersion, '0.2'); |
| + | assert.ok(failures.failures.length >= 1); |
| + | assert.ok(failures.correctionChains.length >= 1); |
| + | |
| + | const lessons = renderLessonsMarkdown(tree, { projectName: 'demo' }); |
| + | assert.ok(lessons.includes('# TreeTrace Lessons')); |
| + | assert.ok(lessons.includes('Source nodes:')); |
| + | |
| + | const evals = renderEvalsJsonl(tree).trim().split('\n').map((line) => JSON.parse(line)); |
| + | assert.ok(evals.length >= 1); |
| + | assert.ok(evals.every((e) => e.source === 'treetrace' && e.sourceNodeIds.length >= 1)); |
| + | |
| + | const memory = renderMemoryMarkdown(tree, { projectName: 'demo' }); |
| + | assert.ok(memory.includes('TreeTrace Agent Memory')); |
| + | assert.ok(memory.includes('Durable project constraints')); |
| + | }); |
| + | |
| + | test('analysis: tiny transcript without corrections does not invent failures', () => { |
| + | const session = parsePlainTranscript('User: build a tiny CLI\nAssistant: done', 'tiny'); |
| + | const nodes = classifyPrompts([session]); |
| + | const tree = buildTree([session], nodes); |
| + | const analysis = analyzeTree(tree); |
| + | assert.equal(analysis.summary.totalFailureSignals, 0); |
| + | assert.deepEqual(analysis.failures, []); |
| + | }); |
| + | |
| + | test('cli: default run writes analysis artifacts with redaction', async () => { |
| + | const dir = mkdtempSync(join(tmpdir(), 'treetrace-')); |
| + | try { |
| + | await main(['--file', FIXTURE, '--dir', dir, '--redact-auto', '--quiet']); |
| + | for (const file of [ |
| + | 'TREETRACE_REPORT.md', |
| + | 'PROMPT_TREE.md', |
| + | '.treetrace/tree.json', |
| + | '.treetrace/failures.json', |
| + | '.treetrace/lessons.md', |
| + | '.treetrace/evals.jsonl', |
| + | '.treetrace/agent-memory.md', |
| + | ]) { |
| + | assert.ok(existsSync(join(dir, file)), `${file} missing`); |
| + | } |
| + | const failures = JSON.parse(readFileSync(join(dir, '.treetrace/failures.json'), 'utf8')); |
| + | assert.equal(failures.schemaVersion, '0.2'); |
| + | assert.ok(failures.failures.length >= 1); |
| + | |
| + | const evalLine = readFileSync(join(dir, '.treetrace/evals.jsonl'), 'utf8').trim().split('\n')[0]; |
| + | assert.equal(JSON.parse(evalLine).source, 'treetrace'); |
| + | |
| + | const exported = [ |
| + | 'PROMPT_TREE.md', |
| + | 'TREETRACE_REPORT.md', |
| + | '.treetrace/tree.json', |
| + | '.treetrace/failures.json', |
| + | '.treetrace/lessons.md', |
| + | '.treetrace/evals.jsonl', |
| + | '.treetrace/agent-memory.md', |
| + | ].map((file) => readFileSync(join(dir, file), 'utf8')).join('\n'); |
| + | assert.ok(!exported.includes('sk-ant-'), 'anthropic key leaked'); |
| + | assert.ok(!exported.includes('hunter2pass'), 'basic-auth password leaked'); |
| + | } finally { |
| + | rmSync(dir, { recursive: true, force: true }); |
| + | } |
| | }); |
| | |
| | test('plain transcript fallback parses User:/Assistant: markers', () => { |