Add spec-aware fleet design startup

This commit is contained in:
thePR0M3TH3AN committed 2026-09-27 16:56:39 -04:00
1 parent c2af3917f5
commit 87001555ea
10 files changed
+362 -9

No files matched your search

+2 -1
View File
@@ -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;
+55
View File
@@ -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>'}`);
+129
View File
@@ -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;
}