mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-28 16:31:56 +00:00
Add reviewed fleet domain proposals
This commit is contained in:
1 parent
cce059fc3a
commit
b609ae44ce
9 files changed
+499
-17
No files matched your search
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+44
-3
@@ -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 <path>] [--json]
|
||||
torch install --proposal <path> [--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') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ?? '<missing>'}`);
|
||||
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;
|
||||
}
|
||||
+46
-6
@@ -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('<new-project-id>', 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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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', '[email protected]']);
|
||||
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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user