Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
b4d619a61This file is pinned to the selected release. Download the original to open it in your design tool.
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
/**
* Machine-readable constraint registry (SPEC §2.6). Built simultaneously with
* the docs: every stated/assumed/discovered constraint lands in both in the
* same tool turn. `affects` drives propagation.
*/
export interface Constraint {
min?: number;
max?: number;
forbidden?: string[];
value?: string | number;
source: string;
affects: string[];
/**
* Subset of `affects` whose target artifact (schematic/board/BOM) did not
* exist when the constraint was recorded. No revisit obligation is open for
* these; they re-open at the start of the first run where the artifact
* exists (reopenDeferredAffects), then the marker is removed.
*/
deferred?: string[];
}
export type ConstraintRegistry = Record<string, Constraint>;
export function constraintsPath(repoRoot: string): string {
return path.join(repoRoot, '.copperhead', 'constraints.json');
}
export async function loadConstraints(repoRoot: string): Promise<ConstraintRegistry> {
const p = constraintsPath(repoRoot);
if (!existsSync(p)) return {};
return JSON.parse(await readFile(p, 'utf8')) as ConstraintRegistry;
}
export async function saveConstraints(repoRoot: string, registry: ConstraintRegistry): Promise<void> {
const p = constraintsPath(repoRoot);
await mkdir(path.dirname(p), { recursive: true });
await writeFile(p, JSON.stringify(registry, null, 2) + '\n', 'utf8');
}
export async function saveConstraint(
repoRoot: string,
key: string,
constraint: Constraint,
): Promise<ConstraintRegistry> {
const registry = await loadConstraints(repoRoot);
registry[key] = constraint;
await saveConstraints(repoRoot, registry);
return registry;
}
/** The build artifact an `affects` item names, when it clearly names one. */
export type AffectsTarget = 'schematic' | 'board' | 'bom';
/**
* Items that name a not-yet-built artifact are deferrable; items that name a
* specific refdes, net, or doc fact return null and open a revisit obligation
* immediately. Misclassification is cheap in both directions: an unmatched
* artifact item just costs one ceremonial resolve_affected call (the old
* behavior for everything), and a deferred item still re-opens later.
*/
export function classifyAffectsTarget(item: string): AffectsTarget | null {
if (/\b(bom|part[-\s]?selection|mpn)\b/i.test(item)) return 'bom';
if (/\b(schematic|pinout|pin[-\s]?assign\w*|strapping|netlist)\b/i.test(item)) return 'schematic';
if (
/\b(layout|stackup|rout(?:e|es|ing)?|vias?|pours?|keepouts?|zones?|copper|traces?|board|pcb|mounting|thermal|silkscreen|assembly|current[-\s]?carrying)\b/i.test(
item,
)
)
return 'board';
return null;
}
interface ArtifactConfig {
schematic: string | null;
board: string | null;
docs: string;
}
export function affectsTargetExists(target: AffectsTarget, repoRoot: string, config: ArtifactConfig): boolean {
switch (target) {
case 'schematic':
return !!config.schematic;
case 'board':
return !!config.board;
case 'bom':
return existsSync(path.join(repoRoot, config.docs, 'BOM.md'));
}
}
export interface ReopenedAffects {
key: string;
item: string;
}
/**
* Run-start hook: re-open the revisit obligations that were deferred while
* their target artifact did not exist. Each re-opened item is removed from the
* registry's `deferred` marker in the same pass, so it re-opens exactly once —
* from then on it lives in the run's ledger like any other obligation.
*/
export async function reopenDeferredAffects(
repoRoot: string,
config: ArtifactConfig,
openObligation: (key: string, item: string) => void,
): Promise<ReopenedAffects[]> {
const registry = await loadConstraints(repoRoot);
const reopened: ReopenedAffects[] = [];
for (const [key, c] of Object.entries(registry)) {
if (!c.deferred?.length) continue;
const stillDeferred: string[] = [];
for (const item of c.deferred) {
const target = classifyAffectsTarget(item);
if (target && !affectsTargetExists(target, repoRoot, config)) {
stillDeferred.push(item);
continue;
}
openObligation(key, item);
reopened.push({ key, item });
}
if (stillDeferred.length) c.deferred = stillDeferred;
else delete c.deferred;
}
if (reopened.length) await saveConstraints(repoRoot, registry);
return reopened;
}
export interface ConstraintViolation {
key: string;
description: string;
source: string;
}
/**
* Mechanical validation where possible (SPEC §2.6): forbidden pins against the
* pinout, numeric budget keys surfaced for the doc-level checks. Geometry
* checks are out of scope for Phase 1.
*/
export function checkForbiddenPins(
registry: ConstraintRegistry,
pinNets: { ref: string; pinName: string; net: string | null }[],
): ConstraintViolation[] {
const violations: ConstraintViolation[] = [];
for (const [key, c] of Object.entries(registry)) {
if (!c.forbidden?.length) continue;
for (const pn of pinNets) {
if (pn.net && c.forbidden.includes(pn.pinName)) {
violations.push({
key,
description: `${pn.ref} pin ${pn.pinName} is connected to net ${pn.net} but is forbidden by ${key}`,
source: c.source,
});
}
}
}
return violations;
}