diff --git a/README.md b/README.md index 241f173..e657814 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ Stage 1 currently provides the first portable kernel: - read-only Git repository inspection; - repository inventory and check discovery; +- evidence-linked architecture graphs and domain-collision proposals; +- owner-reviewed organizational proposals required before installation; - explicit install approval; - tracked `.torch/` project configuration; - XDG-local runtime state; @@ -49,13 +51,15 @@ Inspect a repository without modifying it: ```bash node bin/torch.mjs init --json node bin/torch.mjs analyze --json -node bin/torch.mjs install --dry-run --json +node bin/torch.mjs domains --output fleet-proposal.json --json ``` -Install the current kernel only after reviewing the dry run: +Review the evidence in `fleet-proposal.json`, edit its `review.status` to +`approved`, and identify the reviewer. Then inspect and apply the installation: ```bash -node bin/torch.mjs install --yes --json +node bin/torch.mjs install --proposal fleet-proposal.json --dry-run --json +node bin/torch.mjs install --proposal fleet-proposal.json --yes --json node bin/torch.mjs doctor --json ``` diff --git a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml index 52c27a3..b6260f2 100644 --- a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml +++ b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml @@ -18,11 +18,24 @@ test_integrity_note: given: a Git repository without TORCH when: install is invoked without a reviewed approval flag then: the command refuses and creates no TORCH state + - id: SCN-domain-approval + given: a generated organizational proposal that is pending or stale + when: TORCH is asked to install it + then: installation refuses before creating tracked or local state + - id: SCN-cli-domain-review + given: a Git repository analyzed through the public CLI + when: the owner generates, reviews, approves, and installs its domain proposal + then: pending proposals are rejected and the approved proposal becomes durable project state + - id: SCN-domain-collision + given: frontend and backend components that both consume a shared contract plus cross-cutting tests + when: TORCH builds the architecture graph and domain proposal + then: it proposes persistent component domains and QA, links import evidence, and records neighboring-domain collisions requiring an explicit resolution observable_outcomes: - Git status before and after read-only analysis - presence or absence of .torch and XDG-local project state - doctor health and finding codes - CLI exit status and structured error code + - proposal review status, repository commit binding, and generated domain count - preservation of project source and modified owner configuration determinism_controls: - isolated temporary Git repositories diff --git a/src/cli.mjs b/src/cli.mjs index cf9e327..7ce6ec2 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -3,13 +3,18 @@ import { diagnoseProject } from './kernel/doctor.mjs'; import { asErrorRecord, TorchError } from './kernel/errors.mjs'; import { inspectRepository } from './kernel/git.mjs'; import { installProject, planInstall, uninstallProject } from './kernel/install.mjs'; +import { proposeDomains } from './kernel/domains.mjs'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { writeNewFile } from './kernel/files.mjs'; const HELP = `TORCH — portable agent fleet Usage: torch init [--json] torch analyze [--json] - torch install [--dry-run] [--yes] [--json] + torch domains [--output ] [--json] + torch install --proposal [--dry-run] [--yes] [--json] torch doctor [--json] torch uninstall [--dry-run] [--purge] [--json] `; @@ -19,6 +24,26 @@ function print(value, { json = false } = {}) { else process.stdout.write(`${value}\n`); } +function optionValue(argv, name) { + const direct = argv.find((arg) => arg.startsWith(`${name}=`)); + if (direct) return direct.slice(name.length + 1); + const index = argv.indexOf(name); + return index >= 0 ? argv[index + 1] : undefined; +} + +function loadProposal(cwd, proposalPath) { + if (!proposalPath) { + throw new TorchError('Installation requires an approved domain proposal from torch domains --output.', { + code: 'PROPOSAL_REQUIRED', + }); + } + try { return JSON.parse(readFileSync(resolve(cwd, proposalPath), 'utf8')); } catch (error) { + throw new TorchError(`Cannot read domain proposal: ${proposalPath}`, { + code: 'PROPOSAL_INVALID', details: error.message, + }); + } +} + export async function runCli(argv = process.argv.slice(2), { cwd = process.cwd(), env = process.env } = {}) { const command = argv.find((arg) => !arg.startsWith('-')) ?? 'help'; const json = argv.includes('--json'); @@ -33,9 +58,24 @@ export async function runCli(argv = process.argv.slice(2), { cwd = process.cwd() print({ command, repository, analysis, next: 'Review analysis, then run torch install --dry-run.' }, { json }); return 0; } + if (command === 'domains') { + const analysis = analyzeRepository(repository); + const proposal = proposeDomains({ repository, analysis }); + const output = optionValue(argv, '--output'); + if (output) { + const path = resolve(cwd, output); + writeNewFile(path, `${JSON.stringify(proposal, null, 2)}\n`); + print({ proposal, output: path, mutationPerformed: true }, { json }); + } else { + print({ proposal, mutationPerformed: false }, { json }); + } + return 0; + } if (command === 'install') { + const proposalPath = optionValue(argv, '--proposal'); if (argv.includes('--dry-run')) { - print(planInstall({ repository, env }), { json }); + const proposal = loadProposal(cwd, proposalPath); + print({ ...planInstall({ repository, proposal, env }), proposal: resolve(cwd, proposalPath) }, { json }); return 0; } if (!argv.includes('--yes')) { @@ -43,7 +83,8 @@ export async function runCli(argv = process.argv.slice(2), { cwd = process.cwd() code: 'APPROVAL_REQUIRED', }); } - print(installProject({ repository, env }), { json }); + const proposal = loadProposal(cwd, proposalPath); + print(installProject({ repository, proposal, env }), { json }); return 0; } if (command === 'doctor') { diff --git a/src/kernel/analyze.mjs b/src/kernel/analyze.mjs index 5ff7752..1fab55e 100644 --- a/src/kernel/analyze.mjs +++ b/src/kernel/analyze.mjs @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { extname } from 'node:path'; import { trackedFiles } from './git.mjs'; +import { buildArchitectureGraph } from './domains.mjs'; const LANGUAGE_BY_EXTENSION = new Map([ ['.js', 'JavaScript'], ['.mjs', 'JavaScript'], ['.cjs', 'JavaScript'], @@ -61,7 +62,7 @@ export function analyzeRepository(repository) { if (/cuda|gpu|webgl|three\.js|threejs/.test(joined)) scarceResources.push('gpu'); if (/postgres|mysql|sqlite|database/.test(joined)) scarceResources.push('database'); - return { + const result = { generatedAt: new Date().toISOString(), repository: { root: repository.root, @@ -84,4 +85,6 @@ export function analyzeRepository(repository) { }, mutationPerformed: false, }; + result.architecture = buildArchitectureGraph({ repository, files }); + return result; } diff --git a/src/kernel/domains.mjs b/src/kernel/domains.mjs new file mode 100644 index 0000000..f1a7824 --- /dev/null +++ b/src/kernel/domains.mjs @@ -0,0 +1,280 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { posix } from 'node:path'; +import { TorchError } from './errors.mjs'; + +const SOURCE_ROOTS = new Set(['src', 'app', 'apps', 'server', 'client', 'lib', 'packages', 'services']); +const IGNORED_ROOTS = new Set(['node_modules', 'dist', 'build', 'coverage', 'vendor', 'artifacts', 'reports']); +const SHARED_PATTERN = /(^|\/)(shared|common|types?|schemas?|contracts?|[^/]*config[^/]*)(\/|\.|$)/i; +const TEST_PATTERN = /(^|\/)(test|tests|spec|e2e)(\/|\.|$)/i; +const OPERATIONS_PATTERN = /(^|\/)(\.github|infra|ops|deploy|release|docker|systemd)(\/|\.|$)/i; +const SOURCE_EXTENSION = /\.(?:[cm]?[jt]sx?|py|rs|go|java|c|cc|cpp|h|hpp|cs|rb|php|swift|kt)$/i; + +function slug(value) { + return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'core'; +} + +function componentKey(file) { + const parts = file.split('/'); + const root = parts[0]; + if (IGNORED_ROOTS.has(root) || TEST_PATTERN.test(file) || OPERATIONS_PATTERN.test(file)) return null; + if (!SOURCE_EXTENSION.test(file)) return null; + if (parts.length === 1) return 'core'; + if (SOURCE_ROOTS.has(root) && parts.length > 2) return `${root}/${parts[1]}`; + if (SOURCE_ROOTS.has(root)) return root; + return root; +} + +function friendlyTitle(key) { + const leaf = key.split('/').at(-1); + const known = { + api: 'API', backend: 'Backend', server: 'Backend', client: 'Frontend', frontend: 'Frontend', + web: 'Web interface', ui: 'User interface', core: 'Core systems', cli: 'Command-line interface', + database: 'Data and persistence', db: 'Data and persistence', messaging: 'Messaging', + runtime: 'Runtime adapters', runtimes: 'Runtime adapters', adapters: 'Provider adapters', + }; + return known[leaf] ?? leaf.replace(/[-_]/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function languageOf(file) { + const ext = posix.extname(file).toLowerCase(); + if (['.js', '.mjs', '.cjs', '.jsx'].includes(ext)) return 'JavaScript'; + if (['.ts', '.tsx', '.mts', '.cts'].includes(ext)) return 'TypeScript'; + if (ext === '.py') return 'Python'; + if (ext === '.rs') return 'Rust'; + if (ext === '.go') return 'Go'; + return ext.slice(1).toUpperCase() || 'Unknown'; +} + +function resolveRelativeImport(fromFile, specifier, fileSet) { + if (!specifier.startsWith('.')) return null; + const base = posix.normalize(posix.join(posix.dirname(fromFile), specifier)); + const candidates = [ + base, `${base}.js`, `${base}.mjs`, `${base}.ts`, `${base}.tsx`, `${base}.py`, + `${base}/index.js`, `${base}/index.ts`, `${base}/index.tsx`, + ]; + return candidates.find((candidate) => fileSet.has(candidate)) ?? null; +} + +function importSpecifiers(content) { + const found = new Set(); + const patterns = [ + /\b(?:import|export)\s+(?:[^'";]+?\s+from\s+)?['"]([^'"]+)['"]/g, + /\brequire\(\s*['"]([^'"]+)['"]\s*\)/g, + /^\s*from\s+([.\w]+)\s+import\s+/gm, + ]; + for (const pattern of patterns) { + let match; + while ((match = pattern.exec(content))) found.add(match[1]); + } + return [...found]; +} + +export function buildArchitectureGraph({ repository, files }) { + const fileSet = new Set(files); + const groups = new Map(); + for (const file of files) { + const key = componentKey(file); + if (!key) continue; + const group = groups.get(key) ?? { key, files: [], languages: new Set() }; + group.files.push(file); + group.languages.add(languageOf(file)); + groups.set(key, group); + } + + const dependencies = []; + for (const [key, group] of groups) { + for (const file of group.files) { + const absolute = `${repository.root}/${file}`; + if (!existsSync(absolute)) continue; + let content; + try { + content = readFileSync(absolute, 'utf8'); + if (content.length > 256_000) continue; + } catch { continue; } + for (const specifier of importSpecifiers(content)) { + const targetFile = resolveRelativeImport(file, specifier, fileSet); + if (!targetFile) continue; + const target = componentKey(targetFile); + if (target && target !== key && !dependencies.some((edge) => edge.from === key && edge.to === target)) { + dependencies.push({ from: key, to: target, evidence: file, targetEvidence: targetFile }); + } + } + } + } + + const components = [...groups.values()].map((group) => ({ + id: slug(group.key), + key: group.key, + title: friendlyTitle(group.key), + paths: group.key === 'core' + ? [...new Set(group.files.map((file) => `*${posix.extname(file)}`))].sort() + : [SOURCE_ROOTS.has(group.key) ? `${group.key}/*` : `${group.key}/**`], + fileCount: group.files.length, + languages: [...group.languages].sort(), + evidence: group.files.slice(0, 12), + dependencies: dependencies.filter((edge) => edge.from === group.key).map((edge) => edge.to), + consumers: dependencies.filter((edge) => edge.to === group.key).map((edge) => edge.from), + })).sort((a, b) => a.key.localeCompare(b.key)); + + return { + components, + dependencies, + sharedSurfaces: files.filter((file) => SHARED_PATTERN.test(file)).slice(0, 200), + verificationSurfaces: files.filter((file) => TEST_PATTERN.test(file)).slice(0, 200), + operationalSurfaces: files.filter((file) => OPERATIONS_PATTERN.test(file)).slice(0, 200), + }; +} + +function domainFromComponent(component) { + return { + id: component.id, + title: component.title, + kind: 'development', + scope: [`implementation and maintenance under ${component.paths.join(', ')}`], + not_scope: [], + owned_paths: component.paths, + shared_paths: [], + neighbours: [], + required_checks: [], + evidence: component.evidence, + }; +} + +export function proposeDomains({ repository, analysis }) { + const graph = analysis.architecture; + const sourceComponents = graph.components.filter((component) => !SHARED_PATTERN.test(component.key)); + const selected = sourceComponents.filter((component) => component.fileCount >= 2); + const basis = selected.length ? selected : sourceComponents.slice(0, 1); + const domains = basis.map(domainFromComponent); + if (!domains.length) { + domains.push({ + id: 'core', title: 'Core project', kind: 'development', + scope: ['initial project implementation and architecture'], + not_scope: [], owned_paths: ['**'], shared_paths: [], neighbours: [], required_checks: [], + evidence: analysis.inventory.manifests, + }); + } + + if (analysis.inventory.testFileCount >= 2) { + domains.push({ + id: 'qa', title: 'Quality and integration', kind: 'cross-cutting', + scope: ['scenario integrity', 'integration verification', 'cross-domain regression evidence'], + not_scope: ['implementing domain features solely to make checks pass'], + owned_paths: ['test/**', 'tests/**', 'e2e/**'], shared_paths: [], neighbours: [], + required_checks: [], evidence: graph.verificationSurfaces.slice(0, 12), + }); + } + if (graph.operationalSurfaces.some((path) => /deploy|release/i.test(path))) { + domains.push({ + id: 'release', title: 'Release and operations', kind: 'cross-cutting', + scope: ['release packaging', 'deployment adapters', 'operational automation'], + not_scope: ['feature implementation', 'deployment without configured authority'], + owned_paths: graph.operationalSurfaces.filter((path) => /deploy|release|workflow/i.test(path)).slice(0, 20), + shared_paths: [], neighbours: [], required_checks: [], + evidence: graph.operationalSurfaces.slice(0, 12), + }); + } + + const byKey = new Map(graph.components.map((component) => [component.key, component.id])); + const collisions = []; + const addCollision = (leftId, rightId, score, reason) => { + if (!leftId || !rightId || leftId === rightId) return; + const ids = [leftId, rightId].sort(); + let collision = collisions.find((item) => item.domains[0] === ids[0] && item.domains[1] === ids[1]); + if (!collision) { + collision = { domains: ids, score: 0, reasons: [], resolution: { strategy: 'session-manager-coordination', owner: null } }; + collisions.push(collision); + } + collision.score += score; + collision.reasons.push(reason); + }; + for (const edge of graph.dependencies) { + const left = domains.find((domain) => domain.id === byKey.get(edge.from)); + const right = domains.find((domain) => domain.id === byKey.get(edge.to)); + if (left && right) { + addCollision(left.id, right.id, 2, { + type: 'dependency', from: edge.from, to: edge.to, evidence: edge.evidence, + }); + } + } + + const edgesByTarget = new Map(); + for (const edge of graph.dependencies) { + if (!SHARED_PATTERN.test(edge.to)) continue; + const list = edgesByTarget.get(edge.to) ?? []; + list.push(edge); + edgesByTarget.set(edge.to, list); + } + for (const [target, edges] of edgesByTarget) { + const consumers = [...new Set(edges.map((edge) => byKey.get(edge.from)).filter((id) => + domains.some((domain) => domain.id === id)))]; + for (let index = 0; index < consumers.length; index += 1) { + for (let other = index + 1; other < consumers.length; other += 1) { + addCollision(consumers[index], consumers[other], 2, { + type: 'shared-dependency', target, + evidence: edges.filter((edge) => [consumers[index], consumers[other]].includes(byKey.get(edge.from))) + .map((edge) => edge.evidence), + }); + } + } + } + + const qa = domains.find((domain) => domain.id === 'qa'); + if (qa) { + for (const domain of domains) { + if (domain.id !== qa.id) addCollision(domain.id, qa.id, 1, { + type: 'verification-boundary', evidence: qa.evidence, + }); + } + } + + for (const domain of domains) { + const neighbors = new Set(); + for (const collision of collisions) { + if (collision.domains.includes(domain.id)) { + collision.domains.filter((id) => id !== domain.id).forEach((id) => neighbors.add(id)); + } + } + domain.neighbours = [...neighbors].sort(); + domain.not_scope.push(...domains + .filter((other) => other.id !== domain.id && other.owned_paths.length) + .map((other) => `${other.title} owns ${other.owned_paths.join(', ')}`)); + domain.shared_paths = graph.sharedSurfaces.slice(0, 40); + } + + return { + schema: 'torch.dev/domain-proposal/v1alpha1', + generatedAt: new Date().toISOString(), + repository: { root: repository.root, initialCommit: repository.initialCommit, head: repository.head }, + review: { status: 'pending', reviewedAt: null, reviewedBy: null, notes: [] }, + domains, + collisions: collisions.sort((a, b) => b.score - a.score || a.domains.join(':').localeCompare(b.domains.join(':'))), + architecture: graph, + }; +} + +export function validateApprovedProposal({ proposal, repository }) { + const problems = []; + if (proposal?.schema !== 'torch.dev/domain-proposal/v1alpha1') problems.push('unsupported proposal schema'); + if (proposal?.review?.status !== 'approved') problems.push('proposal review status is not approved'); + if (proposal?.repository?.initialCommit !== repository.initialCommit) problems.push('proposal belongs to a different Git history'); + if (proposal?.repository?.head !== repository.head) problems.push('proposal is stale because HEAD changed'); + if (!Array.isArray(proposal?.domains) || proposal.domains.length === 0) problems.push('proposal has no domains'); + const ids = new Set(); + for (const domain of proposal?.domains ?? []) { + if (!domain.id || ids.has(domain.id)) problems.push(`invalid or duplicate domain id: ${domain.id ?? ''}`); + ids.add(domain.id); + if (!domain.scope?.length) problems.push(`domain ${domain.id} has no scope`); + if (!Array.isArray(domain.not_scope)) problems.push(`domain ${domain.id} has no not_scope list`); + if (!domain.owned_paths?.length) problems.push(`domain ${domain.id} has no owned paths`); + } + for (const collision of proposal?.collisions ?? []) { + if (!collision.resolution?.strategy) problems.push(`collision ${collision.domains?.join('/')} has no resolution`); + } + if (problems.length) { + throw new TorchError('Domain proposal is not approved for installation', { + code: 'PROPOSAL_NOT_APPROVED', details: problems, + }); + } + return proposal; +} diff --git a/src/kernel/install.mjs b/src/kernel/install.mjs index 1c800fb..8688453 100644 --- a/src/kernel/install.mjs +++ b/src/kernel/install.mjs @@ -6,6 +6,7 @@ import { dirname, join, relative, resolve, sep } from 'node:path'; import { TorchError } from './errors.mjs'; import { fileHash, writeNewFile } from './files.mjs'; import { projectStatePath } from './paths.mjs'; +import { validateApprovedProposal } from './domains.mjs'; const TRACKED_DIR = '.torch'; @@ -13,7 +14,7 @@ function jsonYaml(value) { return `${JSON.stringify(value, null, 2)}\n`; } -function initialFiles({ repository, projectId, createdAt }) { +function initialFiles({ repository, projectId, createdAt, proposal }) { const config = { schema: 'torch.dev/v1alpha1', project: { @@ -32,7 +33,16 @@ function initialFiles({ repository, projectId, createdAt }) { }, synchronization: { strategy: 'dispatcher-managed', auto_merge_worktrees: false }, session_manager: { id: 'session-manager', start_last: true }, - domains: [], + domains: proposal.domains.map((domain) => ({ + id: domain.id, + title: domain.title, + scope: domain.scope, + not_scope: domain.not_scope, + owned_paths: domain.owned_paths, + shared_paths: domain.shared_paths ?? [], + neighbours: domain.neighbours ?? [], + required_checks: domain.required_checks ?? [], + })), }; const roster = { schema: 'torch.dev/roster/v1alpha1', @@ -41,10 +51,10 @@ function initialFiles({ repository, projectId, createdAt }) { scope: ['routing', 'priority', 'ownership rulings', 'fleet health'], not_scope: ['project feature implementation by default'], neighbours: ['all'], - }], + }, ...config.domains], }; - return new Map([ + const files = new Map([ ['torch.yaml', jsonYaml(config)], ['roster.yaml', jsonYaml(roster)], ['decisions.md', '# TORCH decisions\n\nNo project decisions recorded yet.\n'], @@ -54,6 +64,32 @@ function initialFiles({ repository, projectId, createdAt }) { ['backlog/.gitkeep', ''], ['INSTALLATION.md', `# TORCH installation\n\nInstalled ${createdAt}. Run \`torch doctor\` before starting the fleet.\n`], ]); + for (const domain of config.domains) { + files.set(`prompts/${domain.id}.md`, [ + `# ${domain.title}`, + '', + `Area ID: ${domain.id}`, + '', + '## Owns', + '', + ...domain.scope.map((item) => `- ${item}`), + '', + '## Does not own', + '', + ...(domain.not_scope.length ? domain.not_scope : ['No exclusions recorded.']).map((item) => `- ${item}`), + '', + '## Neighbours', + '', + ...(domain.neighbours.length ? domain.neighbours : ['None identified.']).map((item) => `- ${item}`), + '', + '## First move', + '', + 'Query live ownership, read the assigned backlog item, and report current repository evidence to the Session Manager.', + '', + ].join('\n')); + } + files.set('domain-proposal.approved.json', jsonYaml(proposal)); + return files; } function manifestPath(root) { @@ -85,7 +121,8 @@ function safeProjectPath(root, relativePath) { return target; } -export function planInstall({ repository, env = process.env }) { +export function planInstall({ repository, proposal, env = process.env }) { + validateApprovedProposal({ proposal, repository }); const trackedRoot = join(repository.root, TRACKED_DIR); return { action: 'install', @@ -93,16 +130,19 @@ export function planInstall({ repository, env = process.env }) { trackedRoot, stateParent: join(projectStatePath('', env), '..'), wouldCreateTrackedState: !existsSync(trackedRoot), + approvedDomainCount: proposal.domains.length, mutationPerformed: false, }; } export function installProject({ repository, + proposal, env = process.env, projectId = randomUUID(), now = () => new Date(), } = {}) { + validateApprovedProposal({ proposal, repository }); const trackedRoot = join(repository.root, TRACKED_DIR); if (existsSync(trackedRoot)) { throw new TorchError(`Refusing to replace existing ${trackedRoot}`, { @@ -120,7 +160,7 @@ export function installProject({ const created = []; try { - for (const [path, content] of initialFiles({ repository, projectId, createdAt })) { + for (const [path, content] of initialFiles({ repository, projectId, createdAt, proposal })) { const absolute = join(trackedRoot, path); const record = writeNewFile(absolute, content); created.push({ path: relative(repository.root, record.path), sha256: record.sha256 }); diff --git a/test/fleet/cli-scenarios.test.mjs b/test/fleet/cli-scenarios.test.mjs index a1857db..d9aa097 100644 --- a/test/fleet/cli-scenarios.test.mjs +++ b/test/fleet/cli-scenarios.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; -import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; @@ -43,3 +43,26 @@ test('SCN-cli-install-approval: install requires an explicit reviewed approval f assert.equal(JSON.parse(result.stdout).error, 'APPROVAL_REQUIRED'); assert.equal(existsSync(join(root, '.torch')), false); }); + +test('SCN-cli-domain-review: a generated proposal must be approved before install', () => { + const root = repo(); + const proposalPath = join(root, 'fleet-proposal.json'); + const generated = run(root, ['domains', '--output', proposalPath, '--json']); + assert.equal(generated.status, 0, generated.stderr || generated.stdout); + const proposal = JSON.parse(readFileSync(proposalPath, 'utf8')); + assert.equal(proposal.review.status, 'pending'); + assert.equal(proposal.domains.length > 0, true); + + const rejected = run(root, ['install', '--proposal', proposalPath, '--yes', '--json']); + assert.equal(rejected.status, 2); + assert.equal(JSON.parse(rejected.stdout).error, 'PROPOSAL_NOT_APPROVED'); + assert.equal(existsSync(join(root, '.torch')), false); + + proposal.review = { + status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [], + }; + writeFileSync(proposalPath, `${JSON.stringify(proposal, null, 2)}\n`); + const installed = run(root, ['install', '--proposal', proposalPath, '--yes', '--json']); + assert.equal(installed.status, 0, installed.stderr || installed.stdout); + assert.equal(existsSync(join(root, '.torch', 'domain-proposal.approved.json')), true); +}); diff --git a/test/fleet/domain-analysis.test.mjs b/test/fleet/domain-analysis.test.mjs new file mode 100644 index 0000000..0684fb3 --- /dev/null +++ b/test/fleet/domain-analysis.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, 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 { proposeDomains } from '../../src/kernel/domains.mjs'; +import { inspectRepository } from '../../src/kernel/git.mjs'; + +function architectureFixture() { + const root = mkdtempSync(join(tmpdir(), 'torch-domains-')); + for (const dir of ['src/client', 'src/server', 'src/shared', 'test']) mkdirSync(join(root, dir), { recursive: true }); + writeFileSync(join(root, 'src/shared/types.js'), 'export const protocol = 1;\n'); + writeFileSync(join(root, 'src/client/app.js'), "import { protocol } from '../shared/types.js';\nexport const client = protocol;\n"); + writeFileSync(join(root, 'src/client/view.js'), 'export const view = true;\n'); + writeFileSync(join(root, 'src/server/api.js'), "import { protocol } from '../shared/types.js';\nexport const api = protocol;\n"); + writeFileSync(join(root, 'src/server/store.js'), 'export const store = true;\n'); + writeFileSync(join(root, 'test/system.test.js'), 'export const scenario = true;\n'); + writeFileSync(join(root, 'test/contracts.test.js'), 'export const contracts = true;\n'); + writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'split-app', scripts: { test: 'node --test' } })); + execFileSync('git', ['init', '-b', 'main', root]); + execFileSync('git', ['-C', root, 'config', 'user.email', 'torch-test@example.invalid']); + execFileSync('git', ['-C', root, 'config', 'user.name', 'TORCH Test']); + execFileSync('git', ['-C', root, 'add', '.']); + execFileSync('git', ['-C', root, 'commit', '-m', 'architecture fixture']); + return root; +} + +test('SCN-domain-collision: shared contracts create evidence-linked neighboring domains', () => { + const repository = inspectRepository(architectureFixture()); + const analysis = analyzeRepository(repository); + const proposal = proposeDomains({ repository, analysis }); + const ids = proposal.domains.map((domain) => domain.id); + + assert.deepEqual(ids, ['src-client', 'src-server', 'qa']); + assert.equal(analysis.architecture.dependencies.some((edge) => + edge.from === 'src/client' && edge.to === 'src/shared' && edge.evidence === 'src/client/app.js'), true); + assert.equal(analysis.architecture.dependencies.some((edge) => + edge.from === 'src/server' && edge.to === 'src/shared' && edge.evidence === 'src/server/api.js'), true); + assert.equal(analysis.architecture.sharedSurfaces.includes('src/shared/types.js'), true); + + const frontend = proposal.domains.find((domain) => domain.id === 'src-client'); + assert.deepEqual(frontend.neighbours, ['qa', 'src-server']); + const collision = proposal.collisions.find((item) => item.domains.join(':') === 'src-client:src-server'); + assert.equal(collision.score >= 1, true); + assert.equal(collision.reasons.some((reason) => reason.type === 'shared-dependency'), true); + assert.equal(collision.resolution.strategy, 'session-manager-coordination'); + assert.equal(proposal.review.status, 'pending'); +}); diff --git a/test/fleet/kernel-lifecycle.test.mjs b/test/fleet/kernel-lifecycle.test.mjs index 82ff45b..e33e141 100644 --- a/test/fleet/kernel-lifecycle.test.mjs +++ b/test/fleet/kernel-lifecycle.test.mjs @@ -8,6 +8,7 @@ import { analyzeRepository } from '../../src/kernel/analyze.mjs'; import { diagnoseProject } from '../../src/kernel/doctor.mjs'; import { inspectRepository } from '../../src/kernel/git.mjs'; import { installProject, planInstall, uninstallProject } from '../../src/kernel/install.mjs'; +import { proposeDomains, validateApprovedProposal } from '../../src/kernel/domains.mjs'; function fixture() { const root = mkdtempSync(join(tmpdir(), 'torch-kernel-')); @@ -24,12 +25,20 @@ function fixture() { return { root, env: { ...process.env, XDG_DATA_HOME: stateHome } }; } +function approvedProposal(repository) { + const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) }); + proposal.review = { + status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [], + }; + return proposal; +} + test('SCN-init-read-only: inspection and analysis do not mutate the project', () => { const { root, env } = fixture(); const before = execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' }); const repository = inspectRepository(root); const analysis = analyzeRepository(repository); - const plan = planInstall({ repository, env }); + const plan = planInstall({ repository, proposal: approvedProposal(repository), env }); const after = execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' }); assert.equal(analysis.mutationPerformed, false); @@ -43,8 +52,9 @@ test('SCN-init-read-only: inspection and analysis do not mutate the project', () test('SCN-install-doctor-purge: a fresh install is healthy and exactly reversible', () => { const { root, env } = fixture(); const repository = inspectRepository(root); + const proposal = approvedProposal(repository); const installed = installProject({ - repository, env, projectId: 'fixture-project', now: () => new Date('2026-09-27T00:00:00Z'), + repository, proposal, env, projectId: 'fixture-project', now: () => new Date('2026-09-27T00:00:00Z'), }); assert.equal(existsSync(join(root, '.torch', 'torch.yaml')), true); @@ -66,7 +76,7 @@ test('SCN-install-doctor-purge: a fresh install is healthy and exactly reversibl test('SCN-purge-protects-user-change: uninstall refuses to delete modified managed files', () => { const { root, env } = fixture(); const repository = inspectRepository(root); - installProject({ repository, env, projectId: 'modified-project' }); + installProject({ repository, proposal: approvedProposal(repository), env, projectId: 'modified-project' }); const configPath = join(root, '.torch', 'torch.yaml'); writeFileSync(configPath, `${readFileSync(configPath, 'utf8')}\n# owner change\n`); @@ -77,3 +87,21 @@ test('SCN-purge-protects-user-change: uninstall refuses to delete modified manag ); assert.equal(existsSync(configPath), true); }); + +test('SCN-domain-approval: installation rejects stale or unapproved organizational proposals', () => { + const { root, env } = fixture(); + const repository = inspectRepository(root); + const pending = proposeDomains({ repository, analysis: analyzeRepository(repository) }); + assert.throws( + () => installProject({ repository, proposal: pending, env }), + (error) => error.code === 'PROPOSAL_NOT_APPROVED' + && error.details.includes('proposal review status is not approved'), + ); + pending.review.status = 'approved'; + pending.repository.head = '0000000000000000000000000000000000000000'; + assert.throws( + () => validateApprovedProposal({ proposal: pending, repository }), + (error) => error.details.includes('proposal is stale because HEAD changed'), + ); + assert.equal(existsSync(join(root, '.torch')), false); +});