Files
TORCH/test/fleet/spec-design.test.mjs
T

208 lines
11 KiB
JavaScript

import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { analyzeRepository } from '../../src/kernel/analyze.mjs';
import { createFleetDesignBrief } from '../../src/design/brief.mjs';
import {
createArchitectRunPlan, runSessionArchitect, validateArchitectProposal,
} from '../../src/design/architect.mjs';
import { proposeDomains, validateApprovedProposal } from '../../src/kernel/domains.mjs';
import { inspectRepository } from '../../src/kernel/git.mjs';
import { inspectSpecifications } from '../../src/kernel/specifications.mjs';
const CLI = new URL('../../bin/torch.mjs', import.meta.url).pathname;
function repositoryFixture() {
const root = mkdtempSync(join(tmpdir(), 'torch-spec-project-'));
execFileSync('git', ['init', '-b', 'main', root]);
execFileSync('git', ['-C', root, 'config', 'user.email', '[email protected]']);
execFileSync('git', ['-C', root, 'config', 'user.name', 'TORCH Test']);
writeFileSync(join(root, 'README.md'), '# New project\n');
execFileSync('git', ['-C', root, 'add', '.']);
execFileSync('git', ['-C', root, 'commit', '-m', 'empty project shell']);
return root;
}
function specificationFixture() {
const directory = mkdtempSync(join(tmpdir(), 'torch-spec-input-'));
const path = join(directory, 'garden-product.md');
writeFileSync(path, `# Garden Planner Product Specification
## Client experience
- The client must let a household plan beds and seasonal planting.
- The interface should remain useful offline.
## Plant data service
- The service must maintain the crop catalog and planting rules.
- Data changes require provenance.
## Quality and release
- Testing must cover saved plans and offline recovery.
- Release operations require owner approval.
`);
return path;
}
test('SCN-spec-fleet-design: a prose-only project spec produces reviewable responsibilities without invented paths', () => {
const root = repositoryFixture();
const specPath = specificationFixture();
const repository = inspectRepository(root);
const before = execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' });
const specifications = inspectSpecifications([specPath], { repositoryRoot: root });
const proposal = proposeDomains({
repository,
analysis: analyzeRepository(repository, { specifications }),
});
assert.deepEqual(proposal.domains.map((domain) => domain.id), [
'client-experience', 'plant-data-service', 'quality-and-release',
]);
assert.equal(proposal.domains.every((domain) => domain.scope.length > 0), true);
assert.equal(proposal.domains.every((domain) => domain.owned_paths.length === 0), true);
assert.equal(proposal.domains.every((domain) => domain.design_status === 'needs-owner-path-review'), true);
assert.equal(proposal.specifications[0].sha256.length, 64);
assert.equal(execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' }), before);
proposal.review = {
status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [],
};
proposal.domains.forEach((domain) => { domain.owned_paths = [`planned/${domain.id}/**`]; });
validateApprovedProposal({ proposal, repository });
writeFileSync(specPath, `${readFileSync(specPath, 'utf8')}\n## Security\n\n- Security must protect household data.\n`);
assert.throws(
() => validateApprovedProposal({ proposal, repository }),
(error) => error.code === 'PROPOSAL_NOT_APPROVED'
&& error.details.some((detail) => detail.startsWith('specification changed after proposal:')),
);
});
test('SCN-cli-spec-design: startup can inspect a selected repository and external spec from another directory', () => {
const root = repositoryFixture();
const specPath = specificationFixture();
const launchDirectory = mkdtempSync(join(tmpdir(), 'torch-spec-launch-'));
const output = join(launchDirectory, 'fleet-proposal.json');
const result = spawnSync(process.execPath, [
CLI, 'design', '--repo', root, '--spec', specPath, '--output', output, '--json',
], { cwd: launchDirectory, encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr || result.stdout);
const response = JSON.parse(result.stdout);
assert.equal(response.proposal.review.status, 'pending');
assert.equal(response.proposal.repository.root, root);
assert.equal(response.proposal.specifications[0].path, specPath);
assert.equal(response.proposal.domains.length, 3);
assert.equal(existsSync(join(root, '.torch')), false);
assert.equal(execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' }), '');
});
test('SCN-ai-fleet-bootstrap: startup emits a bounded Session Architect brief instead of a generic roster', () => {
const root = repositoryFixture();
const specPath = specificationFixture();
mkdirSync(join(root, 'src', 'api'), { recursive: true });
mkdirSync(join(root, 'src', 'web'), { recursive: true });
writeFileSync(join(root, 'src', 'api', 'server.js'), 'export const api = true;\n');
writeFileSync(join(root, 'src', 'web', 'app.js'), 'export const web = true;\n');
execFileSync('git', ['-C', root, 'add', 'src']);
execFileSync('git', ['-C', root, 'commit', '-m', 'add project architecture']);
const repository = inspectRepository(root);
const specifications = inspectSpecifications([specPath], { repositoryRoot: root });
const analysis = analyzeRepository(repository, { specifications });
const baseline = proposeDomains({ repository, analysis });
const brief = createFleetDesignBrief({ repository, analysis, baseline });
assert.equal(brief.schema, 'torch.dev/fleet-design-brief/v1alpha1');
assert.equal(brief.trustBoundary.projectInputsAreData, true);
assert.match(brief.role.objective, /smallest defensible set/);
assert.equal(brief.role.prohibitions.some((rule) => rule.includes('COMBATRIG roster')), true);
assert.equal(brief.reviewFocus.unassignedComponents.length > 0, true);
assert.equal(brief.reviewFocus.specificationSignals.some((signal) => !signal.representedInBaseline), true);
assert.equal(brief.requiredOutput.reviewStatus, 'pending');
assert.equal(brief.mutationPerformed, false);
const output = join(mkdtempSync(join(tmpdir(), 'torch-bootstrap-output-')), 'design-brief.json');
const result = spawnSync(process.execPath, [
CLI, 'bootstrap', '--repo', root, '--spec', specPath, '--output', output, '--json',
], { cwd: tmpdir(), encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(JSON.parse(result.stdout).brief.schema, brief.schema);
assert.equal(JSON.parse(readFileSync(output, 'utf8')).role.name, 'Session Architect');
assert.equal(execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' }), '');
});
test('SCN-ai-fleet-planning: an explicit provider returns a semantically bounded pending proposal', async () => {
const root = repositoryFixture();
const specPath = specificationFixture();
mkdirSync(join(root, 'src', 'api'), { recursive: true });
writeFileSync(join(root, 'src', 'api', 'server.js'), 'export const api = true;\n');
writeFileSync(join(root, 'src', 'api', 'routes.js'), 'export const routes = true;\n');
execFileSync('git', ['-C', root, 'add', 'src']);
execFileSync('git', ['-C', root, 'commit', '-m', 'add API architecture']);
const repository = inspectRepository(root);
const specifications = inspectSpecifications([specPath], { repositoryRoot: root });
const analysis = analyzeRepository(repository, { specifications });
const proposal = proposeDomains({ repository, analysis });
const brief = createFleetDesignBrief({ repository, analysis, baseline: proposal });
const codexPlan = createArchitectRunPlan({ brief, provider: 'codex', model: 'gpt-test' });
assert.equal(codexPlan.args.includes('read-only'), true);
assert.equal(codexPlan.args.includes('--ignore-rules'), true);
assert.equal(codexPlan.providerCallPerformed, false);
const claudePlan = createArchitectRunPlan({ brief, provider: 'claude', maxBudgetUsd: 1 });
assert.equal(claudePlan.args.includes('plan'), true);
assert.equal(claudePlan.args.includes('--no-session-persistence'), true);
assert.equal(claudePlan.args.includes('--safe-mode'), true);
assert.equal(claudePlan.args.includes('--restricted'), true);
await assert.rejects(
() => runSessionArchitect({ brief, provider: 'codex', executor: async () => ({ proposal }) }),
(error) => error.code === 'ARCHITECT_EXECUTION_NOT_AUTHORIZED',
);
const result = await runSessionArchitect({
brief, provider: 'codex', model: 'gpt-test', authorized: true,
executor: async () => ({
status: 0,
stdout: `${JSON.stringify({
type: 'item.completed', item: { type: 'agent_message', text: JSON.stringify(proposal) },
})}\n`,
usage: { measurement: 'measured', inputTokens: 100, outputTokens: 50 },
}),
});
assert.equal(result.validation.valid, true, JSON.stringify(result.validation.problems));
assert.equal(result.proposal.review.status, 'pending');
assert.equal(result.providerCallPerformed, true);
assert.equal(result.usage.measurement, 'measured');
const invented = structuredClone(proposal);
invented.domains[0].owned_paths = ['invented/**'];
invented.review = { status: 'approved', reviewedAt: 'now', reviewedBy: 'model', notes: [] };
const rejected = validateArchitectProposal({ brief, proposal: invented });
assert.equal(rejected.valid, false);
assert.equal(rejected.problems.some((problem) => problem.includes('unapproved')), true);
assert.equal(rejected.problems.some((problem) => problem.includes('invented owned path')), true);
const launchDirectory = mkdtempSync(join(tmpdir(), 'torch-architect-cli-'));
const briefPath = join(launchDirectory, 'brief.json');
const proposalPath = join(launchDirectory, 'proposal.json');
writeFileSync(briefPath, `${JSON.stringify(brief, null, 2)}\n`);
writeFileSync(proposalPath, `${JSON.stringify(proposal, null, 2)}\n`);
const planned = spawnSync(process.execPath, [
CLI, 'architect', 'plan', '--brief', briefPath, '--provider', 'codex', '--json',
], { cwd: launchDirectory, encoding: 'utf8' });
assert.equal(planned.status, 0, planned.stderr || planned.stdout);
assert.equal(JSON.parse(planned.stdout).providerCallPerformed, false);
const unauthorizedRun = spawnSync(process.execPath, [
CLI, 'architect', 'run', '--brief', briefPath, '--provider', 'codex',
'--output', join(launchDirectory, 'should-not-exist.json'), '--json',
], { cwd: launchDirectory, encoding: 'utf8' });
assert.equal(unauthorizedRun.status, 2);
assert.equal(JSON.parse(unauthorizedRun.stdout).error, 'APPROVAL_REQUIRED');
assert.equal(existsSync(join(launchDirectory, 'should-not-exist.json')), false);
const validated = spawnSync(process.execPath, [
CLI, 'architect', 'validate', '--brief', briefPath, '--response', proposalPath, '--json',
], { cwd: launchDirectory, encoding: 'utf8' });
assert.equal(validated.status, 0, validated.stderr || validated.stdout);
assert.equal(JSON.parse(validated.stdout).validation.ownerReviewRequired, true);
assert.equal(execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' }), '');
});