mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-03 03:11:57 +00:00
Add spec-aware fleet design startup
This commit is contained in:
1 parent
c2af3917f5
commit
87001555ea
10 files changed
+362
-9
No files matched your search
@@ -19,7 +19,7 @@ const MANIFESTS = [
|
||||
'pom.xml', 'build.gradle', 'Makefile', 'CMakeLists.txt', 'Dockerfile',
|
||||
];
|
||||
|
||||
export function analyzeRepository(repository) {
|
||||
export function analyzeRepository(repository, { specifications = [] } = {}) {
|
||||
const files = trackedFiles(repository.root);
|
||||
const languageCounts = new Map();
|
||||
const topLevel = new Set();
|
||||
@@ -84,6 +84,7 @@ export function analyzeRepository(repository) {
|
||||
scarceResources,
|
||||
},
|
||||
mutationPerformed: false,
|
||||
specifications,
|
||||
};
|
||||
result.architecture = buildArchitectureGraph({ repository, files });
|
||||
return result;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { posix } from 'node:path';
|
||||
import { TorchError } from './errors.mjs';
|
||||
import { validateSpecificationEvidence } from './specifications.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']);
|
||||
@@ -141,12 +142,59 @@ function domainFromComponent(component) {
|
||||
};
|
||||
}
|
||||
|
||||
function words(value) {
|
||||
return new Set(value.toLowerCase().match(/[a-z0-9]{3,}/g) ?? []);
|
||||
}
|
||||
|
||||
function overlap(left, right) {
|
||||
const a = words(left);
|
||||
const b = words(right);
|
||||
return [...a].filter((word) => b.has(word)).length;
|
||||
}
|
||||
|
||||
function specDomain(signal, specification) {
|
||||
return {
|
||||
id: signal.id,
|
||||
title: signal.title,
|
||||
kind: 'planning',
|
||||
scope: signal.responsibilities.length
|
||||
? signal.responsibilities
|
||||
: [`responsibilities described by ${specification.path}:${signal.line}`],
|
||||
not_scope: [],
|
||||
owned_paths: signal.pathReferences,
|
||||
shared_paths: [], neighbours: [], required_checks: [], runtime: 'claude',
|
||||
evidence: [`${specification.path}:${signal.line}`],
|
||||
design_status: signal.pathReferences.length ? 'evidence-backed' : 'needs-owner-path-review',
|
||||
};
|
||||
}
|
||||
|
||||
export function proposeDomains({ repository, analysis }) {
|
||||
const graph = analysis.architecture;
|
||||
const sourceComponents = graph.components.filter((component) => !SHARED_PATTERN.test(component.key));
|
||||
const specFirst = sourceComponents.length === 0;
|
||||
const selected = sourceComponents.filter((component) => component.fileCount >= 2);
|
||||
const basis = selected.length ? selected : sourceComponents.slice(0, 1);
|
||||
const domains = basis.map(domainFromComponent);
|
||||
const specifications = analysis.specifications ?? [];
|
||||
const signals = specifications.flatMap((specification) =>
|
||||
specification.domainSignals.map((signal) => ({ signal, specification })));
|
||||
|
||||
for (const { signal, specification } of signals) {
|
||||
const candidate = domains
|
||||
.map((domain) => ({ domain, score: overlap(`${domain.id} ${domain.title} ${domain.owned_paths.join(' ')}`, `${signal.title} ${signal.pathReferences.join(' ')}`) }))
|
||||
.sort((a, b) => b.score - a.score)[0];
|
||||
if (candidate?.score > 0) {
|
||||
for (const responsibility of signal.responsibilities) {
|
||||
if (!candidate.domain.scope.includes(responsibility)) candidate.domain.scope.push(responsibility);
|
||||
}
|
||||
candidate.domain.evidence.push(`${specification.path}:${signal.line}`);
|
||||
continue;
|
||||
}
|
||||
if (signal.level <= 3 && (signal.pathReferences.length || specFirst)) {
|
||||
const proposed = specDomain(signal, specification);
|
||||
if (!domains.some((domain) => domain.id === proposed.id)) domains.push(proposed);
|
||||
}
|
||||
}
|
||||
if (!domains.length) {
|
||||
domains.push({
|
||||
id: 'core', title: 'Core project', kind: 'development',
|
||||
@@ -251,6 +299,12 @@ export function proposeDomains({ repository, analysis }) {
|
||||
generatedAt: new Date().toISOString(),
|
||||
repository: { root: repository.root, initialCommit: repository.initialCommit, head: repository.head },
|
||||
review: { status: 'pending', reviewedAt: null, reviewedBy: null, notes: [] },
|
||||
specifications: specifications.map((specification) => ({
|
||||
path: specification.path,
|
||||
sha256: specification.sha256,
|
||||
bytes: specification.bytes,
|
||||
title: specification.title,
|
||||
})),
|
||||
domains,
|
||||
collisions: collisions.sort((a, b) => b.score - a.score || a.domains.join(':').localeCompare(b.domains.join(':'))),
|
||||
checks: analysis.inventory.checks.map((check) => ({
|
||||
@@ -271,6 +325,7 @@ export function validateApprovedProposal({ proposal, repository }) {
|
||||
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');
|
||||
problems.push(...validateSpecificationEvidence(proposal?.specifications ?? [], repository.root));
|
||||
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>'}`);
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
import { TorchError } from './errors.mjs';
|
||||
|
||||
const MAX_SPEC_BYTES = 2 * 1024 * 1024;
|
||||
const GENERIC_HEADINGS = new Set([
|
||||
'abstract', 'appendix', 'background', 'conclusion', 'contents', 'introduction',
|
||||
'overview', 'references', 'summary', 'table of contents',
|
||||
]);
|
||||
const DOMAIN_TERMS = /\b(?:agent|api|architecture|art|audio|backend|cli|client|content|data|database|design|documentation|domain|frontend|gameplay|integration|migration|mobile|operations|performance|qa|release|runtime|security|service|session|testing|tooling|ui|web|worker)\b/i;
|
||||
const REQUIREMENT = /\b(?:must|should|shall|required|requires|need(?:s)? to|will)\b/i;
|
||||
|
||||
function digest(content) {
|
||||
return createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
function slug(value) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 64) || 'project';
|
||||
}
|
||||
|
||||
function plain(value) {
|
||||
return value
|
||||
.replace(/<!--[^]*?-->/g, ' ')
|
||||
.replace(/```[^]*?```/g, ' ')
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
||||
.replace(/[`*_>#]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function pathReferences(content) {
|
||||
const found = new Set();
|
||||
for (const match of content.matchAll(/`([^`\n]+)`/g)) {
|
||||
const value = match[1].trim().replaceAll('\\', '/');
|
||||
if (value.length <= 180 && !value.includes(' ') && (value.includes('/') || /\.[a-z0-9]{1,8}$/i.test(value))) {
|
||||
found.add(value.replace(/^\.\//, ''));
|
||||
}
|
||||
}
|
||||
return [...found].slice(0, 40);
|
||||
}
|
||||
|
||||
function responsibilities(content) {
|
||||
const lines = content.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
const selected = [];
|
||||
for (const line of lines) {
|
||||
const bullet = line.match(/^(?:[-*+] |\d+[.)] )(.+)/)?.[1];
|
||||
const candidate = plain(bullet ?? line);
|
||||
if (candidate.length < 12 || candidate.length > 300) continue;
|
||||
if (bullet || REQUIREMENT.test(candidate)) selected.push(candidate);
|
||||
if (selected.length === 8) break;
|
||||
}
|
||||
if (!selected.length) {
|
||||
const first = plain(content).split(/(?<=[.!?])\s+/).find((sentence) => sentence.length >= 12);
|
||||
if (first) selected.push(first.slice(0, 300));
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function sections(content) {
|
||||
const headings = [...content.matchAll(/^(#{1,4})\s+(.+?)\s*$/gm)];
|
||||
return headings.map((match, index) => {
|
||||
const start = match.index + match[0].length;
|
||||
const end = headings[index + 1]?.index ?? content.length;
|
||||
const body = content.slice(start, end).trim();
|
||||
const title = plain(match[2]);
|
||||
const line = content.slice(0, match.index).split('\n').length;
|
||||
const paths = pathReferences(body);
|
||||
const explicitDomain = DOMAIN_TERMS.test(title);
|
||||
return {
|
||||
id: slug(title), title, level: match[1].length, line,
|
||||
responsibilities: responsibilities(body), pathReferences: paths,
|
||||
explicitDomain,
|
||||
};
|
||||
}).filter((section) => section.title
|
||||
&& !GENERIC_HEADINGS.has(section.title.toLowerCase())
|
||||
&& (section.responsibilities.length || section.pathReferences.length));
|
||||
}
|
||||
|
||||
function displayPath(path, repositoryRoot) {
|
||||
if (!repositoryRoot) return path;
|
||||
const name = relative(repositoryRoot, path);
|
||||
return name && !name.startsWith(`..${sep}`) && name !== '..' ? name : path;
|
||||
}
|
||||
|
||||
export function inspectSpecifications(paths = [], { cwd = process.cwd(), repositoryRoot = null } = {}) {
|
||||
const unique = [...new Set(paths.map((path) => resolve(cwd, path)))];
|
||||
return unique.map((path) => {
|
||||
if (!existsSync(path) || !statSync(path).isFile()) {
|
||||
throw new TorchError(`Specification is not a readable file: ${path}`, {
|
||||
code: 'SPECIFICATION_NOT_FOUND', details: { path },
|
||||
});
|
||||
}
|
||||
const bytes = statSync(path).size;
|
||||
if (bytes > MAX_SPEC_BYTES) {
|
||||
throw new TorchError(`Specification exceeds ${MAX_SPEC_BYTES} bytes: ${path}`, {
|
||||
code: 'SPECIFICATION_TOO_LARGE', details: { path, bytes, maximum: MAX_SPEC_BYTES },
|
||||
});
|
||||
}
|
||||
const content = readFileSync(path, 'utf8');
|
||||
const analyzedSections = sections(content);
|
||||
const requirements = responsibilities(content).slice(0, 20);
|
||||
return {
|
||||
path: displayPath(path, repositoryRoot),
|
||||
absolutePath: path,
|
||||
sha256: digest(content), bytes,
|
||||
title: analyzedSections[0]?.level === 1 ? analyzedSections[0].title : null,
|
||||
requirements,
|
||||
sections: analyzedSections.slice(0, 80),
|
||||
domainSignals: analyzedSections.filter((section) => section.explicitDomain).slice(0, 40),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function validateSpecificationEvidence(specifications = [], repositoryRoot) {
|
||||
const problems = [];
|
||||
for (const specification of specifications) {
|
||||
const path = specification.absolutePath
|
||||
?? (isAbsolute(specification.path) ? specification.path : resolve(repositoryRoot, specification.path));
|
||||
if (!existsSync(path)) {
|
||||
problems.push(`specification is missing: ${specification.path}`);
|
||||
continue;
|
||||
}
|
||||
const actual = digest(readFileSync(path));
|
||||
if (actual !== specification.sha256) problems.push(`specification changed after proposal: ${specification.path}`);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
Reference in new issue
Block a user