Skip to content

feat: repo registry — machine-readable module/file/owner/criticality map #736

Closed

Description

@diberry

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)

  1. Generator reads source files at build time — routing.md, templates.ts, package.json are the single sources of truth
  2. CI test verifies freshness — test/registry-sync.test.ts regenerates the registry and diffs against committed JSON; fails if stale (same pattern as test/template-sync.test.ts)
  3. Developers edit sources, not JSON — edit routing.md or templates.ts, run npm run registry, commit result
  4. No auto-commit — CI fails on stale data; developer must intentionally regenerate (per Booster recommendation)
  5. NOT in prebuild — registry generation is on-demand only; prebuild remains 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.mjs parses routing.md + templates.ts + package.json → valid JSON with 8 top-level keys: generated, generator, metadata, modules, criticalFiles, packages, templateFiles, criticalityTiers
  • metadata includes routing_md_hash, templates_ts_hash, package_json_hash (sha256 content hashes)
  • .squad/repo-registry.json committed with all modules from routing.md, critical files, packages, template files
  • scripts/registry-utils.mjs exports 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.ts follows template-sync pattern: regenerates + diffs (normalizing timestamp), fails if stale
  • test/registry-utils.test.ts covers: 6 resolveFileToModule edge cases, owner lookups, criticality tiers, package resolution, routing.md parsing resilience
  • package.json has "registry": "node scripts/generate-repo-registry.mjs" script (NOT in prebuild)
  • .squad/routing.md has 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)

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 .mjs extension — 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

Related

Estimated Effort

~1 session. The data already exists — this is extraction + formatting + utilities + CI test.

Activity

  1. diberry commented on Apr 2, 2026

    @diberry
    ContributorAuthor

    @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?

  2. diberry commented on Apr 2, 2026

    @diberry
    ContributorAuthor

    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.

    • Mirrors existing patterns: \sync-templates.mjs, \�ump-build.mjs, \generate-api-docs.mjs\ already live here. Consistency is good.
    • Lifecycle: These are pre-build utilities, not runtime code. \scripts/\ is the home.
    • Commit output (.squad/repo-registry.json) is also appropriate — it's repo metadata, not user config.

    ✅ 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:

    \\

    Module Primary Secondary
    \src/adapter/\ EECOM 🔧 CAPCOM 🕵️
    \\

    Concerns:

    1. Emoji emoji and formatting variability — if devs add extra spacing or change emoji, parsing breaks.
    2. No schema validation — if routing.md is hand-edited, malformed rows will silently produce incomplete modules.

    Recommendations:

    1. Implement a strict parser in \generate-repo-registry.mjs:

      • Skip blank lines and header separators (lines 1-3).
      • Extract cells with regex: \|\s*([^]+|\S+)\s*|\s*(\S+.?)\s|\s*(.?)\s|\
      • Validate each row: module path exists in \src/, primary/secondary are non-empty.
      • Fail loudly if parsing fails (don't silently omit modules).
    2. Add a test: Parse a fixture routing.md table and assert correct module count & structure.

    3. Add a comment to routing.md (as per AC):

      This table is machine-read by \scripts/generate-repo-registry.mjs. Changes to column order or emoji will break parsing. Do not edit the header separators; only add/update rows.


    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:

    • \loadRegistry()\ — Load & parse JSON

    • esolveFileToModule(filePath)\ — File → Module lookup
    • \getFileCriticality(filePath)\ — File → Criticality tier
    • \getModuleOwner(module)\ — Module → Owner
    • \getPackageForFile(filePath)\ — File → Package

    Additional utility (consider):

    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:

    1. Determinism: CI can verify registry freshness by diffing (proven pattern with template-sync.test.ts).
    2. No runtime overhead: feat: PR architectural impact analysis — blast radius, module boundaries, reviewer routing #733's analyze-impact.mjs loads a static JSON, not parsing routing.md in production.
    3. Git history: Diffs show exactly what changed between releases (e.g., "src/cli/ reassigned from EECOM to Network").
    4. PR feedback: Reviewers see the impact of routing.md changes in the diff — transparent.

    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:

    1. Error handling in generator:

      • What if routing.md is malformed? (Should fail loudly + suggest fix.)
      • What if a module path doesn't exist? (Warn, but don't skip.)
      • What if templates.ts can't be parsed? (Throw explicit error.)
    2. 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.

    3. 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:

    1. ✅ Add \metadata.source_hash\ to schema (for determinism).
    2. ⚠️ Add strict Markdown parsing with detailed error messages.
    3. ⚠️ Consider \getSecondaryOwner()\ and \getModulesByOwner()\ in registry-utils.
    4. ✅ Hardcode criticality tiers (no config file).
    5. ✅ Add a note to routing.md warning developers not to change column separators.
    6. 📝 Add TypeScript types for registry-utils exports.

    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.

  3. diberry commented on Apr 2, 2026

    @diberry
    ContributorAuthor

    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:

    • ✅ Dynamic enumeration (all files from routing.md must exist in registry.json)
    • ✅ Script execution guard (generate-repo-registry.mjs must exit 0)
    • ✅ Byte-for-byte parity (regenerated JSON must match committed JSON)
    • ✅ CI gate (fails the PR if stale — prevents manual JSON edits)

    But watch for these edge cases:

    1. Partial regeneration failures — if routing.md has 20 modules but generate-repo-registry.mjs crashes on module Add Ralph — built-in work monitor squad member #15, the diff test won't catch it. Add a test that validates the schema structure (module counts, required fields).
    2. Missing source files — if routing.md moves or templates.ts is deleted, the generator should fail loudly with clear error messages. Test that.
    3. Parsing conflicts — if two different parsing approaches (regex on routing.md vs AST on templates.ts) disagree on the same module name, the registry will be corrupted silently. I'd add a conflict-detection test.

    🟡 **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:

    • *\loadRegistry()* — Test that it parses valid JSON, fails with clear errors on malformed JSON, handles missing file gracefully
    • *
      esolveFileToModule()*
      — Complex enough to warrant edge case testing (see question 5)
    • *\getFileCriticality()* — Verify fallback logic: if file not in criticalFiles[], should it return null or a default tier? Document the contract.
    • *\getModuleOwner()* — Test that it returns both primary and secondary correctly, handles missing modules gracefully
    • *\getPackageForFile()* — Verify path matching logic (nested dirs vs roots)

    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:

    1. Positive case — Parse a well-formed routing.md table, verify all 20 modules are extracted

    2. Negative cases:

      • Missing |Primary|Secondary|\ header
      • Malformed pipes (e.g., extra spaces that break alignment)
      • Duplicate module names
      • Missing module row
      • Extra columns added (should still parse the expected columns)
    3. 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:

    • Validate the JSON against a schema file (.squad/repo-registry.schema.json)
    • Verify every module has \primary, \secondary, \package\
    • Verify every critical file has \path, \ ier, \source\
    • Verify enum constraints (\ ier\ must be one of: critical, high, medium, low)

    This catches structural issues that byte-for-byte comparison doesn't. Use \json-schema-validator\ or similar.


    ⚠️ **5. How to test \

    esolveFileToModule()\ — what edge cases matter?**

    High-value test cases:

    1. Root files — What does
      esolveFileToModule('package.json')\ return? Should map to the root package or fail? Decide and test both behaviors.
    2. Nested dirs — \src/adapter/client.ts\ should resolve to \src/adapter/, not \src/. Test that the longest matching prefix wins.
    3. Non-existent paths — \src/nonexistent/foo.ts\ should return null or throw? Test and document the contract.
    4. Symlinks — If registry paths use symlinks, does resolution follow them? Test on Windows too (symlinks behave differently).
    5. Case sensitivity — On macOS/Windows, does \SRC/adapter/\ match \src/adapter/? Test and explicitly document the contract.
    6. Overlapping paths — If registry has both \src/cli/\ and \src/cli/commands/, which wins for \src/cli/commands/foo.ts? Longest match should win; test it.

    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:

    • sync-templates.mjs → generate-repo-registry.mjs (both extract from sources, produce files)
    • template-sync.test.ts → registry-sync.test.ts (both regenerate and diff)
    • Canonical sources (routing.md, templates.ts) → Committed artifact (repo-registry.json)

    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:

    • Option A: Strip the timestamp before diffing (my recommendation — it's not semantic)
    • Option B: Include timestamp and expect regeneration on every CI run (wastes diffs, adds noise)

    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

    1. ✅ registry-sync.test.ts — Regenerate + diff (like template-sync)
    2. ✅ registry-utils.test.ts — Unit tests for each exported function (new file)
    3. ✅ routing.md parsing tests — Negative cases, format resilience (in registry-utils or sync test)
    4. ✅ Schema validation — Inline checks in sync test (or separate schema.test.ts)
    5. ✅ resolveFileToModule() edge cases — Parametrized test in registry-utils.test.ts
    6. ✅ Timestamp handling — Normalize before diff to avoid CI flakiness

    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. 🧪

  4. diberry commented on Apr 2, 2026

    @diberry
    ContributorAuthor

    🔍 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 of template-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:

    • Matches the proven template-sync.test.ts pattern (dynamic sync guard using vitest + execSync)
    • Fast feedback loop — developers run npm test locally before push
    • Semantic: The registry is data integrity, not a workflow concern
    • Low overhead — no separate job spinning up

    What NOT to do:

    • Don't add a separate GitHub Actions step (slow, harder to debug locally)
    • Don't make it a prebuild hook (would block builds unnecessarily)

    2. Where in CI Pipeline? ✅ ALONGSIDE TESTS (No Separate Job)

    Current pattern in squad-ci.yml:

    test job:
      - Checkout & setup
      - Fix stale lockfile
      - npm install
      - Source tree canary check
      - Large deletion guard
      - npm run build
      - npm test  ← HERE: vitest runs, including registry-sync.test.ts
    

    Recommendation: Place test/registry-sync.test.ts as a standard vitest suite.

    Why no separate job:

    • Minimizes CI overhead (already spinning up one test environment)
    • Easier to debug — developers see all test failures together
    • Consistent with template-sync.test.ts (also runs in npm test)

    CI Benefits:

    • ✅ Runs on every PR (squad-ci.yml triggers on pull_request + push to dev/insider)
    • ✅ Fails PR if registry is stale
    • ✅ Same timeout/retry logic as other tests

    3. Should npm run registry Be in prebuild? ❌ RECOMMEND: On-Demand Only

    Current prebuild (package.json:11):

    "prebuild": "node scripts/bump-build.mjs && node scripts/sync-skill-templates.mjs && node scripts/sync-templates.mjs"

    Recommendation: Do NOT add registry to prebuild. Keep it on-demand.

    Why:

    • Developers own the workflow: edit routing.md → run npm run registry → commit → push
    • Prebuild is for automatic sync: templates must sync before every build (they're embedded/copied)
    • Registry is committed JSON: not derived during build, so no tight coupling needed
    • Failure isolation: if registry generation fails, it doesn't break your build — you can still develop
    • CI catches drift: the test will fail, blocking the PR

    When they WOULD run it:

    # After editing routing.md:
    npm run registry
    
    # Or explicitly:
    node scripts/generate-repo-registry.mjs

    4. CI Detects Stale Registry? ✅ YES — Test Will Catch It

    Scenario: Developer edits routing.md, forgets to run npm run registry, pushes PR.

    What happens:

    1. CI: npm test → test/registry-sync.test.ts runs
    2. Test regenerates registry from source files (routing.md + templates.ts + package.json)
    3. Test diffs against committed .squad/repo-registry.json
    4. DIFF FOUND → test fails → PR blocks

    Exact pattern (from template-sync.test.ts lines 150–159):

    describe('registry-sync generation', () => {
      it('exits with code 0 (no stale registry)', () => {
        const output = execSync('node scripts/generate-repo-registry.mjs', {
          cwd: ROOT,
          encoding: 'utf-8',
          timeout: 30_000,
        });
        // Then diff against .squad/repo-registry.json
        expect(regeneratedRegistry).toEqual(committedRegistry);
      });
    });

    ✅ 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 intent: They must choose when registry reflects their change
    • Auditability: Every registry update is intentional, tracked in commit history
    • Prevents cascading updates: If CI auto-commits, you'd have hidden side effects on PRs
    • Matches template pattern: sync-templates.mjs doesn't auto-commit either; developers run it + commit

    Developer workflow:

    # 1. Edit routing.md
    vim .squad/routing.md
    
    # 2. Regenerate
    npm run registry
    
    # 3. Commit (now includes both routing.md + repo-registry.json)
    git add .squad/routing.md .squad/repo-registry.json
    git commit -m "feat: add module X to routing"

    If they forget step 2:

    • PR CI fails: test/registry-sync.test.ts → "registry is stale"
    • PR message prompts: npm run registry && git add .squad/repo-registry.json && git push

    6. .mjs Extension Consistency? ⚠️ MIXED — Repo Uses Both

    Current practice in scripts/:

    • ✅ .mjs files: sync-templates.mjs, generate-api-docs.mjs, check-exports-map.mjs, bump-build.mjs
    • ✅ Package.json scripts all call .mjs files with node scripts/*.mjs
    • ✅ CI runs them directly: node scripts/sync-templates.mjs

    Recommendation for your generator: Use .mjs (keep consistency).

    {
      "scripts": {
        "registry": "node scripts/generate-repo-registry.mjs"
      }
    }

    Why:

    • ES modules (top-level import syntax) are the repo standard
    • Shebang works: #!/usr/bin/env node
    • No compatibility issues with Node 22.5+
    • ✅ Matches all existing scripts

    📋 Acceptance Criteria Checklist

    From issue #736, verify these before merging:

    • scripts/generate-repo-registry.mjs parses routing.md + templates.ts + package.json → valid JSON (6 keys: generated, generator, modules, criticalFiles, packages, templateFiles)
    • .squad/repo-registry.json committed with all 20 modules, critical files, packages, template references
    • scripts/registry-utils.mjs exports 5 functions: loadRegistry(), resolveFileToModule(), getFileCriticality(), getModuleOwner(), getPackageForFile()
    • test/registry-sync.test.ts follows template-sync pattern: regenerates + diffs, fails if stale ✓
    • package.json has "registry": "node scripts/generate-repo-registry.mjs" script
    • package.json scripts do NOT add registry to prebuild
    • .squad/routing.md updated with comment: "Machine-readable alternative: see .squad/repo-registry.json"
    • No product code (SDK/CLI) changes, only repo tooling

    🔧 Implementation Notes

    File structure (when complete):

    scripts/
      ├─ generate-repo-registry.mjs       (new — generator)
      └─ registry-utils.mjs               (new — utilities)
    
    test/
      └─ registry-sync.test.ts            (new — CI guard)
    
    .squad/
      └─ repo-registry.json               (new — generated output, committed)
    

    Test will import both:

    // test/registry-sync.test.ts
    import { loadRegistry } from '../scripts/registry-utils.mjs';
    import { execSync } from 'node:child_process';

    ✅ Summary

    Question Recommendation Confidence
    Vitest test or CI step? Vitest test (matches template-sync pattern) 🟢 High
    Where in pipeline? In npm test job (no separate job needed) 🟢 High
    npm run registry in prebuild? No, on-demand only (developers own workflow) 🟢 High
    CI detects stale registry? Yes — test diffs against committed JSON 🟢 High
    Auto-commit or fail? Fail & require developer action (intentional, auditable) 🟢 High
    .mjs consistency? Yes, use .mjs (matches repo standard) 🟢 High

    Status: Approach is solid. Ready to implement. 🚀

  5. diberry commented on Apr 6, 2026

    @diberry
    ContributorAuthor

    ✈️ Flight PRD Assessment

    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 JSON schema is fully specified with modules (20 entries), critical files, and package workspace map
    • Generator reads from three authoritative sources (routing.md, templates.ts, package.json)
    • CI sync test (registry-sync.test.ts) prevents drift — follows the proven template-sync.test.ts pattern
    • Content hash metadata enables deterministic rebuild detection

    Assessment:

    Dependency chain (reconfirmed):

    1. feat: repo registry — machine-readable module/file/owner/criticality map #736 (this) → ships first, provides data
    2. feat: registry query layer — shared foundation for layout manifest (#670) and impact analysis (#733) #748 → query layer on top of registry
    3. PRD: Versioned file layout manifest - single source of truth for directory structure #670 → layout manifest consuming query layer

    Recommendation: Highest-priority in the registry chain. Ship this to unblock #748 and #670.

  6. diberry commented on Apr 8, 2026

    @diberry
    ContributorAuthor

    Closing: implementation PR #737 was closed without merging. Foundation needs re-evaluation if/when prioritized. Dependency chain (#748, #670) also being closed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions