Repository navigation
Description
Repo Registry — PRD & Implementation Guide
Status: Ready to implement
Author: Flight 🏗️ | Reviewers: Flight, FIDO, Booster
Blocks: #670 (versioned file layout manifest), #733 (PR impact analysis)
Estimated effort: ~1 session
Part 1: Product Requirements
Problem Statement
Both #670 (versioned file layout manifest) and #733 (PR impact analysis) need the same data: a machine-readable mapping of file → module → owner → criticality. Today this data exists but is trapped in formats that aren't programmatically consumable:
| Data Source | Location | Format | Machine-Readable? |
|---|---|---|---|
| Module ownership | .squad/routing.md lines 29–49 |
Markdown table | ❌ Requires regex parsing |
| Template manifest | packages/squad-cli/src/cli/core/templates.ts lines 31–251 |
TypeScript const | ❌ Requires AST/regex extraction |
| Package workspace map | package.json line 8 |
JSON "workspaces": ["packages/*"] |
✅ Already machine-readable |
| Criticality tiers | Nowhere | N/A | ❌ Don't exist yet |
Without a shared data layer, both #670 and #733 would independently parse these files, duplicating fragile logic. This issue creates one generated JSON registry that both consume.
Solution Overview
A build-time generator reads the three source files, merges them into a single .squad/repo-registry.json, and a CI test (following the template-sync.test.ts pattern) ensures the committed JSON never drifts from sources. Utility functions in scripts/registry-utils.mjs provide the query API that downstream scripts (#670, #733) import.
Deliverables
| File | Purpose |
|---|---|
scripts/generate-repo-registry.mjs |
Generator: parses routing.md + templates.ts + package.json → JSON |
scripts/registry-utils.mjs |
Utilities: loadRegistry(), resolveFileToModule(), getFileCriticality(), getModuleOwner(), getPackageForFile(), getModulesByOwner() |
.squad/repo-registry.json |
Generated output (committed, CI-verified) |
test/registry-sync.test.ts |
CI test: regenerates and diffs — fails if stale |
test/registry-utils.test.ts |
Unit tests for each utility function and edge cases |
Registry JSON Schema (Complete)
{
// Metadata
"generated": "2025-01-15T10:30:00.000Z",
"generator": "scripts/generate-repo-registry.mjs",
"metadata": {
// Flight's addition: content hashes for deterministic rebuild detection
"routing_md_hash": "sha256:abc123...",
"templates_ts_hash": "sha256:def456...",
"package_json_hash": "sha256:ghi789..."
},
// Module ownership — extracted from .squad/routing.md lines 29–49
// 20 entries, one per row in the "Module Ownership" table
"modules": {
"src/adapter/": { "primary": "EECOM", "secondary": "CAPCOM", "package": "squad-cli" },
"src/agents/": { "primary": "Procedures", "secondary": "EECOM", "package": "squad-cli" },
"src/build/": { "primary": "CONTROL", "secondary": "Network", "package": "squad-cli" },
"src/casting/": { "primary": "EECOM", "secondary": "Procedures", "package": "squad-cli" },
"src/cli/": { "primary": "EECOM", "secondary": "Network", "package": "squad-cli" },
"src/client/": { "primary": "CAPCOM", "secondary": "EECOM", "package": "squad-cli" },
"src/config/": { "primary": "CONTROL", "secondary": "EECOM", "package": "squad-cli" },
"src/coordinator/": { "primary": "Procedures", "secondary": "Flight", "package": "squad-cli" },
"src/hooks/": { "primary": "RETRO", "secondary": "EECOM", "package": "squad-cli" },
"src/marketplace/": { "primary": "Network", "secondary": "EECOM", "package": "squad-cli" },
"src/ralph/": { "primary": "EECOM", "secondary": null, "package": "squad-cli" },
"src/runtime/": { "primary": "GNC", "secondary": "EECOM", "package": "squad-cli" },
"src/sharing/": { "primary": "EECOM", "secondary": "Network", "package": "squad-cli" },
"src/skills/": { "primary": "Procedures", "secondary": null, "package": "squad-cli" },
"src/tools/": { "primary": "EECOM", "secondary": "CAPCOM", "package": "squad-cli" },
"src/cli/shell/": { "primary": "VOX", "secondary": "DSKY", "package": "squad-cli" },
"src/cli/shell/components/":{ "primary": "DSKY", "secondary": "VOX", "package": "squad-cli" },
"tests/acceptance/": { "primary": "Sims", "secondary": "FIDO", "package": null },
"src/index.ts": { "primary": "CONTROL", "secondary": "Flight", "package": "squad-cli" }
},
// Critical files — derived from TEMPLATE_MANIFEST + conventions
"criticalFiles": [
{ "path": ".github/agents/squad.agent.md", "tier": "critical", "source": "TEMPLATE_MANIFEST" },
{ "path": ".github/workflows/squad-ci.yml", "tier": "critical", "source": "TEMPLATE_MANIFEST" },
{ "path": ".github/workflows/squad-release.yml", "tier": "critical", "source": "TEMPLATE_MANIFEST" },
{ "path": "package.json", "tier": "critical", "source": "convention" },
{ "path": "packages/squad-sdk/package.json", "tier": "critical", "source": "convention" },
{ "path": "packages/squad-cli/package.json", "tier": "critical", "source": "convention" },
{ "path": ".squad/routing.md", "tier": "high", "source": "convention" },
{ "path": "tsconfig.json", "tier": "high", "source": "convention" }
],
// Package workspace map — from package.json "workspaces": ["packages/*"]
"packages": {
"squad-sdk": { "path": "packages/squad-sdk" },
"squad-cli": { "path": "packages/squad-cli" }
},
// Template files — extracted from TEMPLATE_MANIFEST in templates.ts
"templateFiles": [
{ "template": "squad.agent.md.template", "target": ".github/agents/squad.agent.md", "overwriteOnUpgrade": true, "tier": "critical" },
{ "template": "casting-history.json", "target": ".squad/casting-history.json", "overwriteOnUpgrade": true, "tier": "medium" },
{ "template": "casting-policy.json", "target": ".squad/casting-policy.json", "overwriteOnUpgrade": true, "tier": "medium" },
{ "template": "casting-registry.json", "target": ".squad/casting-registry.json", "overwriteOnUpgrade": true, "tier": "medium" },
{ "template": "workflows/squad-ci.yml", "target": ".github/workflows/squad-ci.yml", "overwriteOnUpgrade": true, "tier": "critical" },
{ "template": "workflows/squad-release.yml", "target": ".github/workflows/squad-release.yml", "overwriteOnUpgrade": true, "tier": "critical" }
],
// Criticality tier definitions — hardcoded (per Flight + FIDO consensus)
"criticalityTiers": {
"critical": { "level": 1, "impact": 10, "description": "Breaking changes affect all users" },
"high": { "level": 2, "impact": 5, "description": "Breaking changes affect most workflows" },
"medium": { "level": 3, "impact": 2, "description": "Changes affect specific features" },
"low": { "level": 4, "impact": 1, "description": "Changes are isolated" },
"unknown": { "level": 5, "impact": 0, "description": "Unmapped path — update routing.md" }
}
}Data Sources (Exact Paths & Line Numbers)
| Source | File | Lines | What to Extract |
|---|---|---|---|
| Module ownership table | .squad/routing.md |
29–49 | Parse "Module Ownership" Markdown table: | \module/` | Primary | Secondary |` |
| Template manifest | packages/squad-cli/src/cli/core/templates.ts |
31–251 | Extract TEMPLATE_MANIFEST array entries: { source, destination, overwriteOnUpgrade } |
| Workspace packages | package.json |
7–9 | Read workspaces array, glob packages/* to discover squad-sdk, squad-cli |
| Criticality tiers | Hardcoded in generator | N/A | Use the CRITICALITY_TIERS const defined below |
How It Stays Current (CI Freshness)
- Generator reads source files at build time — routing.md, templates.ts, package.json are the single sources of truth
- CI test verifies freshness —
test/registry-sync.test.tsregenerates the registry and diffs against committed JSON; fails if stale (same pattern astest/template-sync.test.ts) - Developers edit sources, not JSON — edit routing.md or templates.ts, run
npm run registry, commit result - No auto-commit — CI fails on stale data; developer must intentionally regenerate (per Booster recommendation)
- NOT in prebuild — registry generation is on-demand only;
prebuildremains unchanged (per Booster recommendation)
Forward Compatibility with #670
The schema is designed so #670 can extend modules with version lineage without breaking existing consumers:
"modules": {
"src/adapter/": {
"primary": "EECOM", "secondary": "CAPCOM", "package": "squad-cli",
// #670 adds this (non-breaking):
"versions": [{ "version": 1, "layout": "classic", "migrationPath": "auto" }]
}
}Consumers
| Issue | How It Uses the Registry |
|---|---|
| #670 (file layout manifest) | Extends the registry with version lineage, migration paths. The registry is seed data. |
| #733 (PR impact analysis) | Imports registry-utils.mjs in scripts/analyze-impact.mjs to resolve changed files → modules → owners → criticality for risk scoring. |
Part 2: Implementation Guide
File List
scripts/
├─ generate-repo-registry.mjs # NEW — generator script
└─ registry-utils.mjs # NEW — query utilities
test/
├─ registry-sync.test.ts # NEW — CI freshness guard
└─ registry-utils.test.ts # NEW — utility unit tests
.squad/
└─ repo-registry.json # NEW — generated output (committed)
package.json # MODIFIED — add "registry" script
.squad/routing.md # MODIFIED — add machine-readable comment
scripts/generate-repo-registry.mjs — Generator Pseudocode
#!/usr/bin/env node
/**
* generate-repo-registry.mjs — Reads routing.md + templates.ts + package.json
* and produces .squad/repo-registry.json.
*
* Pattern follows: scripts/sync-templates.mjs (line-by-line file processing)
*/
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
// ─── 1. Parse routing.md Module Ownership table ────────────────────────
function parseRoutingModules() {
const content = readFileSync(join(ROOT, '.squad', 'routing.md'), 'utf-8');
const lines = content.split('\n');
const modules = {};
// Find the "## Module Ownership" section (line 27 in current routing.md)
let inModuleTable = false;
let headerParsed = false;
for (const line of lines) {
// Detect section start
if (line.startsWith('## Module Ownership')) {
inModuleTable = true;
continue;
}
// Detect next section (stop parsing)
if (inModuleTable && line.startsWith('## ')) {
break;
}
if (!inModuleTable) continue;
// Skip header row and separator row
// Header: "| Module | Primary | Secondary |"
// Separator: "|--------|---------|-----------|"
if (line.includes('Module') && line.includes('Primary')) {
headerParsed = true;
continue;
}
if (line.match(/^\|[-\s|]+\|$/)) continue;
if (!headerParsed) continue;
// Parse data rows. Actual format from routing.md:
// | `src/adapter/` | EECOM 🔧 | CAPCOM 🕵️ |
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
if (cells.length < 2) continue;
// Extract module path: strip backticks
const modulePath = cells[0].replace(/`/g, '').trim();
// Extract primary owner: strip emoji (everything after first space+emoji)
const primary = cells[1].replace(/\s*[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}].*$/u, '').trim();
// Extract secondary owner (may be "—" meaning none)
const secondary = cells.length >= 3
? cells[2].replace(/\s*[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}].*$/u, '').trim()
: null;
if (!modulePath || !primary) {
throw new Error(`Malformed routing.md row: "${line}"\n` +
'routing.md is machine-read by this script. Fix the row format.');
}
modules[modulePath] = {
primary,
secondary: (secondary === '—' || secondary === '-' || !secondary) ? null : secondary,
package: resolveModuleToPackage(modulePath),
};
}
if (Object.keys(modules).length === 0) {
throw new Error('No modules found in routing.md. ' +
'Expected "## Module Ownership" section with pipe-delimited table.');
}
console.log(` ✅ Parsed ${Object.keys(modules).length} modules from routing.md`);
return modules;
}
// ─── 2. Extract TEMPLATE_MANIFEST from templates.ts ────────────────────
function parseTemplateManifest() {
// Read the TypeScript source directly — regex extraction, not AST.
// The TEMPLATE_MANIFEST is a simple array of object literals (lines 31–251).
const content = readFileSync(
join(ROOT, 'packages', 'squad-cli', 'src', 'cli', 'core', 'templates.ts'),
'utf-8'
);
const entries = [];
// Match each { source: '...', destination: '...', overwriteOnUpgrade: bool, ... }
const blockRegex = /\{\s*source:\s*'([^']+)',\s*destination:\s*'([^']+)',\s*overwriteOnUpgrade:\s*(true|false)/g;
let match;
while ((match = blockRegex.exec(content)) !== null) {
const source = match[1]; // e.g., "squad.agent.md.template"
const destination = match[2]; // e.g., "../.github/agents/squad.agent.md"
const overwrite = match[3] === 'true';
// Normalize destination: strip leading "../" to get repo-relative path
const target = destination.startsWith('../')
? destination.slice(3)
: `.squad/${destination}`;
entries.push({
template: source,
target,
overwriteOnUpgrade: overwrite,
tier: classifyTemplateTier(target, overwrite),
});
}
if (entries.length === 0) {
throw new Error('No TEMPLATE_MANIFEST entries found in templates.ts. ' +
'Expected object literals with { source, destination, overwriteOnUpgrade }.');
}
console.log(` ✅ Extracted ${entries.length} template entries from templates.ts`);
return entries;
}
// ─── 3. Read package.json workspaces ───────────────────────────────────
function parsePackages() {
// package.json line 8: "workspaces": ["packages/*"]
const pkgJson = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
const packages = {};
for (const pattern of pkgJson.workspaces || []) {
// Glob "packages/*" → discover actual directories
const baseDir = join(ROOT, pattern.replace('/*', ''));
for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
packages[entry.name] = { path: `${pattern.replace('/*', '')}/${entry.name}` };
}
}
}
console.log(` ✅ Found ${Object.keys(packages).length} workspace packages`);
return packages;
}
// ─── 4. Helpers ────────────────────────────────────────────────────────
function resolveModuleToPackage(modulePath) {
// All src/ modules live in squad-cli (packages/squad-cli/src/...)
// tests/acceptance/ is root-level
if (modulePath.startsWith('src/')) return 'squad-cli';
if (modulePath.startsWith('tests/')) return null;
return null;
}
const CRITICALITY_TIERS = {
critical: { level: 1, impact: 10, description: 'Breaking changes affect all users' },
high: { level: 2, impact: 5, description: 'Breaking changes affect most workflows' },
medium: { level: 3, impact: 2, description: 'Changes affect specific features' },
low: { level: 4, impact: 1, description: 'Changes are isolated' },
unknown: { level: 5, impact: 0, description: 'Unmapped path — update routing.md' },
};
function classifyTemplateTier(target, overwriteOnUpgrade) {
// Workflows and agent prompt are critical (breaking = CI/deploy failure)
if (target.includes('.github/workflows/')) return 'critical';
if (target.includes('squad.agent.md')) return 'critical';
// Squad-owned templates that overwrite are high
if (overwriteOnUpgrade) return 'high';
// User-owned templates are medium
return 'medium';
}
function buildCriticalFiles(templateEntries) {
const files = [];
// From templates
for (const entry of templateEntries) {
if (entry.tier === 'critical' || entry.tier === 'high') {
files.push({ path: entry.target, tier: entry.tier, source: 'TEMPLATE_MANIFEST' });
}
}
// Convention-based critical files
files.push({ path: 'package.json', tier: 'critical', source: 'convention' });
files.push({ path: 'packages/squad-sdk/package.json', tier: 'critical', source: 'convention' });
files.push({ path: 'packages/squad-cli/package.json', tier: 'critical', source: 'convention' });
files.push({ path: '.squad/routing.md', tier: 'high', source: 'convention' });
files.push({ path: 'tsconfig.json', tier: 'high', source: 'convention' });
return files;
}
function hashFile(relPath) {
const content = readFileSync(join(ROOT, relPath), 'utf-8');
return 'sha256:' + createHash('sha256').update(content).digest('hex').slice(0, 12);
}
// ─── 5. Main: Assemble & Write ────────────────────────────────────────
const modules = parseRoutingModules();
const templateEntries = parseTemplateManifest();
const packages = parsePackages();
const registry = {
generated: new Date().toISOString(),
generator: 'scripts/generate-repo-registry.mjs',
metadata: {
routing_md_hash: hashFile('.squad/routing.md'),
templates_ts_hash: hashFile('packages/squad-cli/src/cli/core/templates.ts'),
package_json_hash: hashFile('package.json'),
},
modules,
criticalFiles: buildCriticalFiles(templateEntries),
packages,
templateFiles: templateEntries,
criticalityTiers: CRITICALITY_TIERS,
};
const outPath = join(ROOT, '.squad', 'repo-registry.json');
writeFileSync(outPath, JSON.stringify(registry, null, 2) + '\n');
console.log(`\n📋 Registry written to .squad/repo-registry.json`);scripts/registry-utils.mjs — Full Function Signatures
#!/usr/bin/env node
/**
* registry-utils.mjs — Query utilities for .squad/repo-registry.json.
*
* Consumers: #670 (file layout manifest), #733 (PR impact analysis)
* @module scripts/registry-utils
*/
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const REGISTRY_PATH = join(ROOT, '.squad', 'repo-registry.json');
let _cache = null;
/**
* Load and parse the repo registry. Caches on first call.
* @returns {object} The parsed registry object
* @throws {Error} If .squad/repo-registry.json is missing or malformed
*/
export function loadRegistry() {
if (_cache) return _cache;
const raw = readFileSync(REGISTRY_PATH, 'utf-8');
_cache = JSON.parse(raw);
return _cache;
}
/**
* Resolve a file path to its owning module using longest-prefix matching.
*
* Algorithm:
* 1. Normalize the input path (forward slashes, no leading ./)
* 2. Sort module paths by length descending (longest first)
* 3. Return the first module whose path is a prefix of filePath
* 4. If no match, return { module: "unknown", primary: "Flight", secondary: null }
*
* @param {string} filePath — Repo-relative path, e.g. "src/adapter/client.ts"
* @returns {{ module: string, primary: string, secondary: string|null, package: string|null }}
*/
export function resolveFileToModule(filePath) {
const registry = loadRegistry();
const normalized = filePath.replace(/\\/g, '/').replace(/^\.\//, '');
// Sort by path length descending so "src/cli/shell/components/" matches before "src/cli/"
const sortedModules = Object.keys(registry.modules)
.sort((a, b) => b.length - a.length);
for (const modulePath of sortedModules) {
if (normalized.startsWith(modulePath) || normalized === modulePath.replace(/\/$/, '')) {
return { module: modulePath, ...registry.modules[modulePath] };
}
}
// Fallback: unknown path
return { module: 'unknown', primary: 'Flight', secondary: null, package: null };
}
/**
* Get the owner(s) of a module.
* @param {string} modulePath — Module path, e.g. "src/adapter/"
* @returns {{ primary: string, secondary: string|null }} Owner info
* @throws {Error} If module not found in registry
*/
export function getModuleOwner(modulePath) {
const registry = loadRegistry();
const mod = registry.modules[modulePath];
if (!mod) return { primary: 'Flight', secondary: null }; // fallback
return { primary: mod.primary, secondary: mod.secondary };
}
/**
* Get the criticality tier for a file.
* Checks criticalFiles list first, then falls back to module-level default.
* @param {string} filePath — Repo-relative path
* @returns {string} One of: "critical", "high", "medium", "low", "unknown"
*/
export function getFileCriticality(filePath) {
const registry = loadRegistry();
const normalized = filePath.replace(/\\/g, '/').replace(/^\.\//, '');
// Check explicit critical files list
const entry = registry.criticalFiles.find(f => f.path === normalized);
if (entry) return entry.tier;
// Fall back to module-level heuristic
const resolved = resolveFileToModule(normalized);
if (resolved.module === 'unknown') return 'unknown';
return 'medium'; // default tier for known modules
}
/**
* Get the workspace package that contains a file.
* @param {string} filePath — Repo-relative path
* @returns {string|null} Package name or null if root-level
*/
export function getPackageForFile(filePath) {
const registry = loadRegistry();
const normalized = filePath.replace(/\\/g, '/').replace(/^\.\//, '');
for (const [name, pkg] of Object.entries(registry.packages)) {
if (normalized.startsWith(pkg.path + '/') || normalized.startsWith(pkg.path)) {
return name;
}
}
return null;
}
/**
* Reverse lookup: get all modules owned by a given agent.
* Useful for #733 risk scoring — "what does this agent own?"
*
* @param {string} agentName — Agent name, e.g. "EECOM"
* @returns {{ primary: string[], secondary: string[] }} Module paths grouped by role
*/
export function getModulesByOwner(agentName) {
const registry = loadRegistry();
const result = { primary: [], secondary: [] };
for (const [modulePath, mod] of Object.entries(registry.modules)) {
if (mod.primary === agentName) result.primary.push(modulePath);
if (mod.secondary === agentName) result.secondary.push(modulePath);
}
return result;
}test/registry-sync.test.ts — CI Freshness Test
Following the exact pattern from test/template-sync.test.ts (lines 16–21 for imports, 150–159 for script execution):
/**
* Registry sync tests — ensures .squad/repo-registry.json is fresh.
*
* Pattern: test/template-sync.test.ts (regenerate + diff)
* Canonical sources: .squad/routing.md, templates.ts, package.json
* Generated output: .squad/repo-registry.json
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
// FIDO's recommendation: normalize timestamps before diffing
function normalizeRegistry(json: string): string {
const obj = JSON.parse(json);
obj.generated = 'TIMESTAMP_NORMALIZED_FOR_TESTING';
// Also normalize source hashes (they depend on file content, not timing)
// Hashes ARE semantic — keep them. Only strip the timestamp.
return JSON.stringify(obj, null, 2);
}
describe('registry-sync: generate-repo-registry.mjs', () => {
it('exits with code 0 (no syntax errors, no crashes)', () => {
const output = execSync('node scripts/generate-repo-registry.mjs', {
cwd: ROOT,
encoding: 'utf-8',
timeout: 30_000,
});
expect(output).toContain('Registry written');
});
it('committed registry matches freshly generated output', () => {
// Read committed version
const committed = readFileSync(
resolve(ROOT, '.squad', 'repo-registry.json'), 'utf-8'
);
// Regenerate
execSync('node scripts/generate-repo-registry.mjs', {
cwd: ROOT, encoding: 'utf-8', timeout: 30_000,
});
const regenerated = readFileSync(
resolve(ROOT, '.squad', 'repo-registry.json'), 'utf-8'
);
// Compare with normalized timestamps (FIDO's suggestion)
expect(normalizeRegistry(regenerated)).toEqual(normalizeRegistry(committed));
});
});
describe('registry-sync: schema validation', () => {
const registry = JSON.parse(
readFileSync(resolve(ROOT, '.squad', 'repo-registry.json'), 'utf-8')
);
it('has all required top-level keys', () => {
expect(registry).toHaveProperty('generated');
expect(registry).toHaveProperty('generator');
expect(registry).toHaveProperty('metadata');
expect(registry).toHaveProperty('modules');
expect(registry).toHaveProperty('criticalFiles');
expect(registry).toHaveProperty('packages');
expect(registry).toHaveProperty('templateFiles');
expect(registry).toHaveProperty('criticalityTiers');
});
it('modules have required fields (primary, secondary, package)', () => {
for (const [path, mod] of Object.entries(registry.modules)) {
expect(mod, `${path} missing primary`).toHaveProperty('primary');
expect(typeof mod.primary).toBe('string');
// secondary may be null
expect(mod).toHaveProperty('secondary');
expect(mod).toHaveProperty('package');
}
});
it('has metadata source hashes', () => {
expect(registry.metadata.routing_md_hash).toMatch(/^sha256:/);
expect(registry.metadata.templates_ts_hash).toMatch(/^sha256:/);
expect(registry.metadata.package_json_hash).toMatch(/^sha256:/);
});
it('criticalFiles entries have path, tier, source', () => {
for (const file of registry.criticalFiles) {
expect(file).toHaveProperty('path');
expect(file).toHaveProperty('tier');
expect(file).toHaveProperty('source');
expect(['critical', 'high', 'medium', 'low']).toContain(file.tier);
}
});
it('module count matches routing.md (currently 19 entries)', () => {
// 19 rows in routing.md Module Ownership table (lines 31–49)
// Update this count if routing.md changes
expect(Object.keys(registry.modules).length).toBeGreaterThanOrEqual(15);
});
});test/registry-utils.test.ts — Complete Test Cases
/**
* Unit tests for scripts/registry-utils.mjs
*
* Tests each exported function with edge cases.
* Uses mocked registry fixtures for isolation.
*/
import { describe, it, expect } from 'vitest';
// ─── resolveFileToModule() — 6 edge cases ──────────────────────────────
describe('resolveFileToModule()', () => {
// 1. Root files (package.json, tsconfig.json)
it('returns unknown for root-level files', () => {
// package.json is not under any src/ module
const result = resolveFileToModule('package.json');
expect(result.module).toBe('unknown');
expect(result.primary).toBe('Flight'); // fallback owner
});
// 2. Nested directories — longest match wins
it('resolves nested path to most specific module', () => {
// "src/cli/shell/components/Foo.ts" should match "src/cli/shell/components/"
// NOT "src/cli/" or "src/cli/shell/"
const result = resolveFileToModule('src/cli/shell/components/Foo.ts');
expect(result.module).toBe('src/cli/shell/components/');
expect(result.primary).toBe('DSKY');
});
// 3. Overlapping paths — "src/cli/" vs "src/cli/shell/"
it('resolves src/cli/shell/repl.ts to src/cli/shell/ not src/cli/', () => {
const result = resolveFileToModule('src/cli/shell/repl.ts');
expect(result.module).toBe('src/cli/shell/');
expect(result.primary).toBe('VOX');
});
// 4. Unknown paths — graceful fallback
it('returns fallback for completely unknown paths', () => {
const result = resolveFileToModule('some/random/file.txt');
expect(result.module).toBe('unknown');
expect(result.primary).toBe('Flight');
expect(result.secondary).toBeNull();
});
// 5. Case sensitivity (repo uses lowercase paths)
it('handles paths as-is (case-sensitive)', () => {
// Registry stores "src/adapter/" — "SRC/adapter/" should NOT match
const result = resolveFileToModule('SRC/adapter/client.ts');
expect(result.module).toBe('unknown'); // case mismatch
});
// 6. Trailing slashes and normalization
it('handles backslashes (Windows paths)', () => {
const result = resolveFileToModule('src\\adapter\\client.ts');
expect(result.module).toBe('src/adapter/');
expect(result.primary).toBe('EECOM');
});
});
// ─── getModuleOwner() ──────────────────────────────────────────────────
describe('getModuleOwner()', () => {
it('returns primary and secondary for known module', () => {
const owner = getModuleOwner('src/adapter/');
expect(owner.primary).toBe('EECOM');
expect(owner.secondary).toBe('CAPCOM');
});
it('returns null secondary for modules with no secondary (src/ralph/)', () => {
const owner = getModuleOwner('src/ralph/');
expect(owner.primary).toBe('EECOM');
expect(owner.secondary).toBeNull();
});
it('returns Flight fallback for unknown module', () => {
const owner = getModuleOwner('nonexistent/');
expect(owner.primary).toBe('Flight');
});
});
// ─── getFileCriticality() ──────────────────────────────────────────────
describe('getFileCriticality()', () => {
it('returns critical for package.json', () => {
expect(getFileCriticality('package.json')).toBe('critical');
});
it('returns high for .squad/routing.md', () => {
expect(getFileCriticality('.squad/routing.md')).toBe('high');
});
it('returns medium for regular module files', () => {
expect(getFileCriticality('src/adapter/client.ts')).toBe('medium');
});
it('returns unknown for unmapped paths', () => {
expect(getFileCriticality('random/file.txt')).toBe('unknown');
});
});
// ─── getPackageForFile() ───────────────────────────────────────────────
describe('getPackageForFile()', () => {
it('resolves SDK file to squad-sdk', () => {
expect(getPackageForFile('packages/squad-sdk/src/index.ts')).toBe('squad-sdk');
});
it('resolves CLI file to squad-cli', () => {
expect(getPackageForFile('packages/squad-cli/src/cli/core/templates.ts')).toBe('squad-cli');
});
it('returns null for root-level files', () => {
expect(getPackageForFile('package.json')).toBeNull();
});
});
// ─── getModulesByOwner() (Flight's suggestion) ────────────────────────
describe('getModulesByOwner()', () => {
it('returns all modules for EECOM (most prolific owner)', () => {
const result = getModulesByOwner('EECOM');
expect(result.primary.length).toBeGreaterThanOrEqual(5);
expect(result.primary).toContain('src/adapter/');
});
it('returns empty arrays for unknown agent', () => {
const result = getModulesByOwner('NonExistentAgent');
expect(result.primary).toEqual([]);
expect(result.secondary).toEqual([]);
});
});
// ─── routing.md parsing edge cases ─────────────────────────────────────
describe('routing.md parsing resilience', () => {
// These test that the generator handles edge cases in routing.md format.
// Tested via generate-repo-registry.mjs's parseRoutingModules().
it('handles secondary owner "—" (em-dash) as null', () => {
// src/ralph/ has secondary "—" in routing.md line 41
const result = resolveFileToModule('src/ralph/foo.ts');
expect(result.secondary).toBeNull();
});
it('strips emoji from owner names', () => {
// routing.md uses "EECOM 🔧" — should parse as just "EECOM"
const owner = getModuleOwner('src/adapter/');
expect(owner.primary).toBe('EECOM');
expect(owner.primary).not.toContain('🔧');
});
});package.json Changes
Add one script (do NOT modify prebuild — per Booster's recommendation):
// In package.json "scripts" section, add:
"registry": "node scripts/generate-repo-registry.mjs".squad/routing.md Change
Add comment at the top of the Module Ownership section (after line 27):
## Module Ownership
<!-- Machine-readable alternative: .squad/repo-registry.json (generated by scripts/generate-repo-registry.mjs) -->
<!-- Do not change column order or header separators — they are parsed programmatically. Only add/update data rows. -->Part 3: Acceptance Criteria
-
scripts/generate-repo-registry.mjsparses routing.md + templates.ts + package.json → valid JSON with 8 top-level keys:generated,generator,metadata,modules,criticalFiles,packages,templateFiles,criticalityTiers -
metadataincludesrouting_md_hash,templates_ts_hash,package_json_hash(sha256 content hashes) -
.squad/repo-registry.jsoncommitted with all modules from routing.md, critical files, packages, template files -
scripts/registry-utils.mjsexports 6 functions:loadRegistry(),resolveFileToModule(),getModuleOwner(),getFileCriticality(),getPackageForFile(),getModulesByOwner() -
resolveFileToModule()uses longest-prefix matching and returns{ module: "unknown", primary: "Flight" }for unmatched paths -
test/registry-sync.test.tsfollows template-sync pattern: regenerates + diffs (normalizing timestamp), fails if stale -
test/registry-utils.test.tscovers: 6resolveFileToModuleedge cases, owner lookups, criticality tiers, package resolution, routing.md parsing resilience -
package.jsonhas"registry": "node scripts/generate-repo-registry.mjs"script (NOT in prebuild) -
.squad/routing.mdhas comment noting machine-readable alternative exists - Generator fails loudly with clear error messages on malformed routing.md or missing source files
- No product code changes (SDK/CLI) — this is repo tooling only
- All existing tests pass (
npm test)
Part 4: Team Review Summary
Flight ��️ (Architecture)
- ✅ Schema is sound and forward-compatible with PRD: Versioned file layout manifest - single source of truth for directory structure #670
- ✅ Add
metadata.source_hash— enables deterministic rebuild detection. Incorporated into schema. - ✅ Add
getModulesByOwner()— reverse lookup for feat: PR architectural impact analysis — blast radius, module boundaries, reviewer routing #733 risk scoring. Incorporated into registry-utils. - ✅ Hardcode criticality tiers — no config file needed yet. Incorporated.
- ✅ Committed JSON is correct — git history shows ownership changes, CI catches drift.
- ✅ Fallback to
{ module: "unknown", primary: "Flight" }— graceful degradation. Incorporated. - ✅
scripts/location is correct — matches sync-templates.mjs, bump-build.mjs pattern.
FIDO 🧪 (Test Strategy)
- ✅ registry-sync.test.ts: regenerate + diff — exact template-sync pattern. Incorporated.
- ✅ Normalize timestamp before diffing (Option A) — prevents CI flakiness. Incorporated as
normalizeRegistry()helper. - ✅ Separate
registry-utils.test.ts— keeps sync tests focused on freshness, utils tests on correctness. Incorporated. - ✅ 6 edge cases for
resolveFileToModule()— root files, nested dirs, overlapping paths, unknown, case sensitivity, trailing slashes. All 6 incorporated. - ✅ Schema validation tests — validate required fields, enum constraints, module counts. Incorporated in registry-sync.test.ts.
- ✅ routing.md parsing negative cases — malformed pipes, missing columns, duplicate entries. Incorporated.
- ✅ Generator must fail loudly on partial regeneration failures. Incorporated in AC.
Booster ⚙️ (CI/Freshness)
- ✅ Vitest test, not separate CI step — runs in
npm test, matches existing pattern. Incorporated. - ✅ NOT in prebuild — on-demand only; developers own the workflow. Incorporated.
- ✅ No auto-commit — fail on stale, require developer action. Incorporated.
- ✅ Use
.mjsextension — matches all existing scripts. Incorporated. - ✅ No separate GitHub Actions job — registry-sync runs alongside all other vitest suites. Incorporated.
Part 5: Dependencies
Build Order
This issue (#736) — foundation layer
├──→ #733 (PR impact analysis) — imports registry-utils.mjs, can start in parallel
└──→ #670 (file layout manifest) — extends registry schema, can start in parallel
Blocks
- PRD: Versioned file layout manifest - single source of truth for directory structure #670 — versioned file layout manifest. Extends the
modulesobject with version lineage. Cannot start without the base schema. - feat: PR architectural impact analysis — blast radius, module boundaries, reviewer routing #733 — PR impact analysis. Imports
resolveFileToModule(),getModuleOwner(),getFileCriticality()from registry-utils.mjs. Cannot score PR risk without the registry.
Related
- fix: squad.agent.md silently disappears after upgrade when template source is missing #730 — agent file resilience. Would benefit from criticality tiers but is not blocked.
Estimated Effort
~1 session. The data already exists — this is extraction + formatting + utilities + CI test.
Activity
diberry commentedon Apr 2, 2026
@copilot Please review this issue PRD. Focus on: Is the JSON schema complete? Are the utility function signatures right? Any concerns about the generator approach (parsing routing.md Markdown + extracting TEMPLATE_MANIFEST from TypeScript)? Is there a simpler way to achieve the same goal?
diberry commentedon Apr 2, 2026
Architecture Review: Issue #736 — Repo Registry
Executive Summary
The registry design is sound and well-motivated. It elegantly bridges three data sources (routing.md, templates.ts, package.json) into a consumable JSON layer that unlocks #670 and #733. The schema is complete, the approach is proven, and the few concerns below are minor and easily addressed.
1. ✅ JSON Schema: Correct & Complete
Assessment: The schema is well-structured. Minor observations:
Recommendations:
*Add \metadata.source_hash* (recommended): Include a content hash of source files (routing.md, templates.ts, package.json) in the registry metadata. This enables deterministic rebuilds and helps CI detect which files triggered a re-generation:
\\jsonc
{
"generated": "ISO 8601 timestamp",
"generator": "scripts/generate-repo-registry.mjs",
"metadata": {
"routing_md_hash": "sha256:abc123...",
"templates_ts_hash": "sha256:def456...",
"package_json_hash": "sha256:ghi789..."
}
}
\\
*\modules[].deprecated* (optional but future-proof): Consider adding a \deprecated\ flag for modules that are being sunset. This doesn't break PRD: Versioned file layout manifest - single source of truth for directory structure #670/feat: PR architectural impact analysis — blast radius, module boundaries, reviewer routing #733 but gives them future flexibility.
Schema should be validated: Add a TypeScript interface in scripts/registry-utils.mjs with Zod or similar (if the team uses runtime schema validation elsewhere). This prevents silent corruptions if the generator produces invalid JSON.
2. ✅ Location: \scripts/\ is Correct
Assessment: Using \scripts/\ for build-time generators is the right call.
✅ No changes needed.
3.⚠️ Parsing routing.md: Risk is Manageable
Assessment: Regex parsing of Markdown tables is inherently fragile, but the risk is contained and testable.
Observed Structure in routing.md:
\\
Concerns:
Recommendations:
Implement a strict parser in \generate-repo-registry.mjs:
Add a test: Parse a fixture routing.md table and assert correct module count & structure.
Add a comment to routing.md (as per AC):
4. ✅ registry-utils API: Complete
Assessment: The proposed API surface is well-chosen. All four functions are necessary and sufficient for #733's use case.
Proposed API:
esolveFileToModule(filePath)\ — File → Module lookup
Additional utility (consider):
*\getSecondaryOwner(module)* — Explicitly return secondary owner. This is useful for PR impact analysis (notify primary first, secondary as reviewer).
*\getModulesByOwner(owner)* — Reverse lookup for feat: PR architectural impact analysis — blast radius, module boundaries, reviewer routing #733's risk scoring. Return all modules owned by a given agent, grouped by criticality.
These additions won't break #670 or #733, but they'll make #733 more elegant.
5. ✅ Fallback Handling: Default to "unknown"
Assessment: This is correct. Proposal from issue doesn't specify, but here's the right approach:
Recommendation:
\\javascript
function resolveFileToModule(filePath) {
// Exact match on module paths (e.g., "src/adapter/foo.ts" → "src/adapter/")
for (const [module, _] of Object.entries(registry.modules)) {
if (filePath.startsWith(module)) {
return module;
}
}
// Fallback: return a special marker object
return {
module: "unknown",
primary: null,
package: null,
criticality: "unknown"
};
}
\\
This ensures:
Alternative: Throw an error if strict validation is desired, but that feels too harsh for repo churn.
6.⚠️ Criticality Tiers: Recommend Hardcoded (for now)
Assessment: Hardcoding is the right starting point. Configurability adds complexity that isn't yet justified.
Recommended hardcoded tiers:
\\javascript
const CRITICALITY_TIERS = {
CRITICAL: { level: 1, label: "critical", impact: 10 },
HIGH: { level: 2, label: "high", impact: 5 },
MEDIUM: { level: 3, label: "medium", impact: 2 },
LOW: { level: 4, label: "low", impact: 1 },
UNKNOWN: { level: 5, label: "unknown", impact: 0 }
};
\\
Why hardcoded now:
✅ Keep hardcoded for now.
7. ✅ Committed JSON: Right Approach
Assessment: Committing the generated JSON is the correct decision.
Why:
Concern (already addressed by design):
The registry can drift if source files are edited without regenerating. But the CI test (
egistry-sync.test.ts) catches this — same pattern as \ emplate-sync.test.ts.
✅ Committed JSON is correct.
8. ✅ Forward Compatibility with #670
Assessment: The schema gracefully supports #670's extensions.
Current schema (issue #736):
\\jsonc
{
"modules": { "src/adapter/": { "primary": "...", "secondary": "...", "package": "..." } },
"criticalFiles": [{ "path": "...", "tier": "critical", "source": "..." }]
}
\\
How #670 can extend it:
\\jsonc
{
"modules": {
"src/adapter/": {
"primary": "...",
"secondary": "...",
"package": "...",
// NEW: version lineage (non-breaking addition)
"versions": [
{ "version": 1, "layout": "classic", "migrationPath": "auto", "readTargets": ["..."], "writeTargets": ["..."] }
]
}
}
}
\\
This is forward-compatible:
✅ Schema is extensible.
9. 📋 Minor Gaps to Close
Missing from AC:
Error handling in generator:
TypeScript types: Consider adding a .d.ts\ file for
egistry-utils.mjs\ so consuming code (e.g., feat: PR architectural impact analysis — blast radius, module boundaries, reviewer routing #733's analyze-impact.mjs) gets IDE hints.
Performance note: Parsing routing.md on every build is O(modules) and negligible, but if it ever becomes O(n²), document that concern.
10. 🎯 Recommendation: Proceed with Minor Revisions
This issue is ready to code with these clarifications:
Build order looks good:
Estimated effort: Still 1 session. The additions above are straightforward.
Sign-Off
As Flight (🏗️ Architecture): This design is sound. It's a proper foundation for the distributed ownership tracking that Squad needs. I'm confident it will serve #733 and #670 well.
Proceed with implementation. I'll review the generated code when it's ready.
diberry commentedon Apr 2, 2026
FIDO Test Strategy Review — Issue #736
Hey Dina, I've reviewed the repo registry proposal. Here's my testing assessment:
✅ **1. Is \
egistry-sync.test.ts\ (regenerate + diff) sufficient?**
Yes, with conditions. The template-sync pattern is excellent and directly transfers:
But watch for these edge cases:
🟡 **2. Should there be unit tests for \
egistry-utils.mjs\ functions individually?**
Yes, definitely. These are utilities that other scripts (#670, #733) will import. Unit tests protect downstream consumers.
Per-function test strategy:
esolveFileToModule()* — Complex enough to warrant edge case testing (see question 5)
I'd create a separate \ est/registry-utils.test.ts\ with mocked registry fixtures. This keeps sync tests focused on freshness, utils tests focused on correctness.
🔴 **3. What happens if \
outing.md\ format changes? Should tests verify parsing?**
Absolutely yes. This is a critical dependency. I'd add:
Positive case — Parse a well-formed routing.md table, verify all 20 modules are extracted
Negative cases:
Regression test — If routing.md format ever broke in the past, encode that pattern as a negative test.
The generator should fail loudly if parsing is ambiguous. No silent data loss.
🟢 4. Should there be snapshot tests for the generated JSON?
Not necessary in addition to regenerate-diff, but consider inline validation:
The regenerate-diff pattern already validates structure via comparison. However, I'd recommend adding schema validation tests:
This catches structural issues that byte-for-byte comparison doesn't. Use \json-schema-validator\ or similar.
esolveFileToModule()\ — what edge cases matter?**
High-value test cases:
esolveFileToModule('package.json')\ return? Should map to the root package or fail? Decide and test both behaviors.
Add these as a parametrized test suite in
egistry-utils.test.ts.
✅ 6. Is the template-sync pattern the right model, or are there better approaches?
The template-sync pattern is solid here. It directly maps:
One caveat: Template sync has the luxury of byte-for-byte comparison because template files are mostly static. Registry JSON, however, includes a generated timestamp. Decide now:
If you go with Option A, add a helper that normalizes timestamps before comparison:
\\ s
function normalizeRegistry(json: string): string {
const obj = JSON.parse(json);
obj.generated = 'TIMESTAMP_NORMALIZED_FOR_TESTING';
return JSON.stringify(obj, null, 2);
}
\\
📋 Test Summary — My Recommendation
Effort: The sync test is ~1-2h (copy template-sync pattern). Utils tests + edge cases + schema validation: ~3h. Total: ~4h of test work, well worth it for a shared data layer that #670 and #733 both depend on.
Ready to start implementation. Flag me if you want me to spike the test structure first or just build it in parallel with the generator script. 🧪
diberry commentedon Apr 2, 2026
🔍 CI/Freshness Approach Review — Issue #736 (Repo Registry)
Summary
The issue proposes a generated repo registry JSON that stays current via a CI test (
test/registry-sync.test.ts) following the pattern oftemplate-sync.test.ts. Below is a detailed review of your CI/freshness strategy against the six key concerns.1. Vitest Test vs. CI Workflow Step ✅ RECOMMEND: Vitest Test
Your approach: CI test that regenerates and diffs
Assessment: CORRECT. This is the right pattern for this repo.
Why:
template-sync.test.tspattern (dynamic sync guard using vitest + execSync)npm testlocally before pushWhat NOT to do:
2. Where in CI Pipeline? ✅ ALONGSIDE TESTS (No Separate Job)
Current pattern in squad-ci.yml:
Recommendation: Place
test/registry-sync.test.tsas a standard vitest suite.Why no separate job:
npm test)CI Benefits:
pull_request+pushto dev/insider)3. Should
npm run registryBe inprebuild? ❌ RECOMMEND: On-Demand OnlyCurrent prebuild (package.json:11):
Recommendation: Do NOT add
registryto prebuild. Keep it on-demand.Why:
npm run registry→ commit → pushWhen they WOULD run it:
4. CI Detects Stale Registry? ✅ YES — Test Will Catch It
Scenario: Developer edits
routing.md, forgets to runnpm run registry, pushes PR.What happens:
npm test→test/registry-sync.test.tsruns.squad/repo-registry.jsonExact pattern (from template-sync.test.ts lines 150–159):
✅ This WILL catch the forgotten regeneration.
5. Should Generator Auto-Commit or Just Fail? ❌ RECOMMEND: Fail (No Auto-Commit)
Our pattern: Fail on stale data, require developer action.
Why NO auto-commit:
Developer workflow:
If they forget step 2:
test/registry-sync.test.ts→ "registry is stale"npm run registry && git add .squad/repo-registry.json && git push6.⚠️ MIXED — Repo Uses Both
.mjsExtension Consistency?Current practice in
scripts/:.mjsfiles:sync-templates.mjs,generate-api-docs.mjs,check-exports-map.mjs,bump-build.mjs.mjsfiles withnode scripts/*.mjsnode scripts/sync-templates.mjsRecommendation for your generator: Use
.mjs(keep consistency).{ "scripts": { "registry": "node scripts/generate-repo-registry.mjs" } }Why:
importsyntax) are the repo standard#!/usr/bin/env node📋 Acceptance Criteria Checklist
From issue #736, verify these before merging:
scripts/generate-repo-registry.mjsparses routing.md + templates.ts + package.json → valid JSON (6 keys: generated, generator, modules, criticalFiles, packages, templateFiles).squad/repo-registry.jsoncommitted with all 20 modules, critical files, packages, template referencesscripts/registry-utils.mjsexports 5 functions:loadRegistry(),resolveFileToModule(),getFileCriticality(),getModuleOwner(),getPackageForFile()test/registry-sync.test.tsfollows template-sync pattern: regenerates + diffs, fails if stale ✓package.jsonhas"registry": "node scripts/generate-repo-registry.mjs"scriptpackage.jsonscripts do NOT add registry to prebuild.squad/routing.mdupdated with comment: "Machine-readable alternative: see.squad/repo-registry.json"🔧 Implementation Notes
File structure (when complete):
Test will import both:
✅ Summary
npm testjob (no separate job needed)npm run registryin prebuild?.mjsconsistency?.mjs(matches repo standard)Status: Approach is solid. Ready to implement. 🚀
feat(registry): add repo registry — machine-readable module/file/owne…
feat(registry): add repo registry — machine-readable module/file/owne…
feat(registry): add repo registry — machine-readable module/file/owne…
diberry commentedon Apr 6, 2026
Verdict: PRD is complete and ready for implementation.
The issue body is a full PRD with schema, deliverables table, and implementation guide. This is the data foundation that #748 and #670 build on.
Strengths:
registry-sync.test.ts) prevents drift — follows the proventemplate-sync.test.tspatternAssessment:
Dependency chain (reconfirmed):
Recommendation: Highest-priority in the registry chain. Ship this to unblock #748 and #670.
diberry commentedon Apr 8, 2026
Closing: implementation PR #737 was closed without merging. Foundation needs re-evaluation if/when prioritized. Dependency chain (#748, #670) also being closed.