From 55e76628bfcd4a0085b665336dc7b78dabb0bb6f Mon Sep 17 00:00:00 2001 From: Lior Date: Thu, 28 May 2026 12:34:03 -0400 Subject: [PATCH 1/4] feat(tools): Add alignment clause drift detector (B-0058.4) Adds a new script 'tools/alignment/detect-clause-drift.ts' that scans the repository for references to alignment clauses (HC-N, SD-N, DIR-N) and reports their locations. This tool will be used to determine the blast radius of any proposed changes to ALIGNMENT.md, as specified in backlog item B-0058.4. --- tools/alignment/detect-clause-drift.ts | 100 +++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 tools/alignment/detect-clause-drift.ts diff --git a/tools/alignment/detect-clause-drift.ts b/tools/alignment/detect-clause-drift.ts new file mode 100644 index 0000000000..64e4e13aeb --- /dev/null +++ b/tools/alignment/detect-clause-drift.ts @@ -0,0 +1,100 @@ +import fs from 'fs'; +import path from 'path'; + +const CLAUSE_REGEX = /(HC-[0-9]+|SD-[0-9]+|DIR-[0-9]+)/g; +const IGNORE_DIRS = ['node_modules', '.git', '.vscode', '.idea', 'dist', 'build']; +const IGNORE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.pdf', '.zip', '.gz', '.tar', '.DS_Store']; + +interface Match { + file: string; + line: number; + clause: string; + text: string; +} + +function searchInFile(filePath: string): Match[] { + const matches: Match[] = []; + if (IGNORE_EXTS.some(ext => filePath.endsWith(ext))) { + return matches; + } + + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + let match; + while ((match = CLAUSE_REGEX.exec(line)) !== null) { + matches.push({ + file: filePath, + line: i + 1, + clause: match[0], + text: line.trim(), + }); + } + } + } catch (error) { + // Ignore errors from reading binary files etc. + } + + return matches; +} + +function searchInDirectory(dirPath: string): Match[] { + let allMatches: Match[] = []; + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (IGNORE_DIRS.includes(entry.name)) { + continue; + } + + if (entry.isDirectory()) { + allMatches = allMatches.concat(searchInDirectory(fullPath)); + } else if (entry.isFile()) { + allMatches = allMatches.concat(searchInFile(fullPath)); + } + } + + return allMatches; +} + +function main() { + const searchDir = process.cwd(); + console.log(`Searching for alignment clause references in ${searchDir}... +`); + + const allMatches = searchInDirectory(searchDir); + + const targetClause = process.argv[2]; + + const filteredMatches = targetClause + ? allMatches.filter(m => m.clause.toUpperCase() === targetClause.toUpperCase()) + : allMatches; + + if (filteredMatches.length === 0) { + console.log('No alignment clause references found.'); + return; + } + + const groupedByClause: { [key: string]: Match[] } = {}; + for (const match of filteredMatches) { + if (!groupedByClause[match.clause]) { + groupedByClause[match.clause] = []; + } + groupedByClause[match.clause].push(match); + } + + for (const clause in groupedByClause) { + console.log(` +--- Found ${groupedByClause[clause].length} references to ${clause} --- +`); + for (const match of groupedByClause[clause]) { + console.log(`${match.file}:${match.line} - ${match.text}`); + } + } +} + +main(); From 0dd76680a530818f26634b3a49f62f6b2c21bd6e Mon Sep 17 00:00:00 2001 From: Lior Date: Thu, 28 May 2026 17:13:29 -0400 Subject: [PATCH 2/4] feat(tools): Add tests for detect-clause-drift script --- tools/alignment/detect-clause-drift.test.ts | 31 +++++++++++++++++++++ tools/alignment/detect-clause-drift.ts | 21 +++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 tools/alignment/detect-clause-drift.test.ts diff --git a/tools/alignment/detect-clause-drift.test.ts b/tools/alignment/detect-clause-drift.test.ts new file mode 100644 index 0000000000..c3eae51b97 --- /dev/null +++ b/tools/alignment/detect-clause-drift.test.ts @@ -0,0 +1,31 @@ +import { findClauseReferences } from './detect-clause-drift'; +import * as fs from 'fs'; +import * as path from 'path'; + +describe('findClauseReferences', () => { + const testDir = './test-dir'; + + beforeEach(() => { + // Create test directory + fs.mkdirSync(testDir, { recursive: true }); + + // Create dummy files + fs.writeFileSync(path.join(testDir, 'file1.md'), 'This file references HC-1 and SD-2.'); + fs.writeFileSync(path.join(testDir, 'file2.ts'), 'This file references DIR-3.'); + fs.writeFileSync(path.join(testDir, 'file3.txt'), 'This file has no references.'); + }); + + afterEach(() => { + // Clean up test directory + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('should find all references to alignment clauses in the specified directory', async () => { + const references = await findClauseReferences(testDir); + + expect(references.size).toBe(3); + expect(references.get('HC-1')).toEqual([path.join(testDir, 'file1.md')]); + expect(references.get('SD-2')).toEqual([path.join(testDir, 'file1.md')]); + expect(references.get('DIR-3')).toEqual([path.join(testDir, 'file2.ts')]); + }); +}); diff --git a/tools/alignment/detect-clause-drift.ts b/tools/alignment/detect-clause-drift.ts index 64e4e13aeb..3a6094c1e9 100644 --- a/tools/alignment/detect-clause-drift.ts +++ b/tools/alignment/detect-clause-drift.ts @@ -61,6 +61,23 @@ function searchInDirectory(dirPath: string): Match[] { return allMatches; } +export async function findClauseReferences(dirPath: string): Promise> { + const allMatches = searchInDirectory(dirPath); + const references = new Map(); + + for (const match of allMatches) { + if (!references.has(match.clause)) { + references.set(match.clause, []); + } + const files = references.get(match.clause); + if (files && !files.includes(match.file)) { + files.push(match.file); + } + } + + return references; +} + function main() { const searchDir = process.cwd(); console.log(`Searching for alignment clause references in ${searchDir}... @@ -97,4 +114,6 @@ function main() { } } -main(); +if (require.main === module) { + main(); +} From 62dd5d9e72209be363e6ff49c17eab0b72efd85e Mon Sep 17 00:00:00 2001 From: "Otto-CLI (Claude)" Date: Thu, 28 May 2026 19:04:31 -0400 Subject: [PATCH 3/4] fix(B-0058.4): strict-null safety + bun:test import for drift detector Resolves tsc-tools failures surfaced after merging origin/main: - detect-clause-drift.ts: guard undefined line under noUncheckedIndexedAccess; use (x ??= []) idiom + group-undefined guard for grouped-clause map access - detect-clause-drift.test.ts: import describe/it/expect/beforeEach/afterEach from bun:test (were undefined globals -> TS2304/TS2593) tsc --noEmit -p tsconfig.json: 0 errors. bun test: 1 pass. Co-Authored-By: Claude Opus 4.8 --- tools/alignment/detect-clause-drift.test.ts | 1 + tools/alignment/detect-clause-drift.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tools/alignment/detect-clause-drift.test.ts b/tools/alignment/detect-clause-drift.test.ts index c3eae51b97..d6e639246e 100644 --- a/tools/alignment/detect-clause-drift.test.ts +++ b/tools/alignment/detect-clause-drift.test.ts @@ -1,3 +1,4 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import { findClauseReferences } from './detect-clause-drift'; import * as fs from 'fs'; import * as path from 'path'; diff --git a/tools/alignment/detect-clause-drift.ts b/tools/alignment/detect-clause-drift.ts index 3a6094c1e9..97722d7629 100644 --- a/tools/alignment/detect-clause-drift.ts +++ b/tools/alignment/detect-clause-drift.ts @@ -24,6 +24,7 @@ function searchInFile(filePath: string): Match[] { for (let i = 0; i < lines.length; i++) { const line = lines[i]; + if (line === undefined) continue; let match; while ((match = CLAUSE_REGEX.exec(line)) !== null) { matches.push({ @@ -98,17 +99,16 @@ function main() { const groupedByClause: { [key: string]: Match[] } = {}; for (const match of filteredMatches) { - if (!groupedByClause[match.clause]) { - groupedByClause[match.clause] = []; - } - groupedByClause[match.clause].push(match); + (groupedByClause[match.clause] ??= []).push(match); } for (const clause in groupedByClause) { + const group = groupedByClause[clause]; + if (group === undefined) continue; console.log(` ---- Found ${groupedByClause[clause].length} references to ${clause} --- +--- Found ${group.length} references to ${clause} --- `); - for (const match of groupedByClause[clause]) { + for (const match of group) { console.log(`${match.file}:${match.line} - ${match.text}`); } } From 6896b38062b0ada9119ed9dae4f62fad40f117d3 Mon Sep 17 00:00:00 2001 From: "Otto-CLI (Claude)" Date: Thu, 28 May 2026 21:18:51 -0400 Subject: [PATCH 4/4] fix(B-0058.4): address 19 Copilot findings in detect-clause-drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive co-maintenance on Lior's branch to unblock PR #5871 (per the established pattern, cf. PR #5881). Rewrites the clause-reference scanner to follow tools/alignment/*.ts conventions and clears the lint(tsc tools) required-class failure. Code fixes: - node:fs / node:path named imports (was 'import fs from "fs"', which fails under verbatimModuleSyntax) — clears the tsc errors - import.meta.main guard (was 'require.main === module', undefined in ESM) - stateless per-line matchAll over a fresh RegExp (was a module-level /g regex whose shared lastIndex skipped matches across lines) - canonical clause pattern \b(HC-[1-7]|SD-[1-9]|DIR-[1-5])\b aligned with audit_clause_coverage.ts (was HC-[0-9]+ etc., over-matching invalid IDs) - exclude references/ + bin/obj/target from the directory walk (was an unbounded full-repo walk drowning signal; references/ is gigabytes of mirrored upstream source per repo convention) - resolve git repo root so the scan covers the whole repo from any CWD - export main(argv); single-line output (no stray blank-line template); drop the dishonest async (the scan is synchronous) - header clarifies the distinction from audit_clause_drift.ts (this tool surveys WHO references clauses = blast radius; that tool diffs WHAT changed in ALIGNMENT.md). B-0058.4 row sanctions this filename. Tests: - safe OS temp dir via mkdtempSync (was a fixed ./test-dir + recursive rmSync that could delete an unexpected path under a changed CWD) - .ts import extension; sync API; added coverage for out-of-range-ID rejection, multi-clause-per-line, and ignored-dir skipping Co-Authored-By: Claude Opus 4.8 --- tools/alignment/detect-clause-drift.test.ts | 72 +++++-- tools/alignment/detect-clause-drift.ts | 211 ++++++++++++-------- 2 files changed, 181 insertions(+), 102 deletions(-) diff --git a/tools/alignment/detect-clause-drift.test.ts b/tools/alignment/detect-clause-drift.test.ts index d6e639246e..9ac985ac94 100644 --- a/tools/alignment/detect-clause-drift.test.ts +++ b/tools/alignment/detect-clause-drift.test.ts @@ -1,32 +1,62 @@ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; -import { findClauseReferences } from './detect-clause-drift'; -import * as fs from 'fs'; -import * as path from 'path'; +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { findClauseReferences } from "./detect-clause-drift.ts"; -describe('findClauseReferences', () => { - const testDir = './test-dir'; +describe("findClauseReferences", () => { + // Use a unique OS temp directory rather than a fixed relative path so a + // changed CWD can never make the afterEach rmSync delete an unexpected dir. + let testDir: string; beforeEach(() => { - // Create test directory - fs.mkdirSync(testDir, { recursive: true }); - - // Create dummy files - fs.writeFileSync(path.join(testDir, 'file1.md'), 'This file references HC-1 and SD-2.'); - fs.writeFileSync(path.join(testDir, 'file2.ts'), 'This file references DIR-3.'); - fs.writeFileSync(path.join(testDir, 'file3.txt'), 'This file has no references.'); + testDir = mkdtempSync(join(tmpdir(), "clause-ref-")); + writeFileSync(join(testDir, "file1.md"), "This file references HC-1 and SD-2."); + writeFileSync(join(testDir, "file2.ts"), "This file references DIR-3."); + writeFileSync(join(testDir, "file3.txt"), "This file has no references."); }); afterEach(() => { - // Clean up test directory - fs.rmSync(testDir, { recursive: true, force: true }); + rmSync(testDir, { recursive: true, force: true }); }); - it('should find all references to alignment clauses in the specified directory', async () => { - const references = await findClauseReferences(testDir); - + it("finds all references to valid alignment clauses in the directory", () => { + const references = findClauseReferences(testDir); expect(references.size).toBe(3); - expect(references.get('HC-1')).toEqual([path.join(testDir, 'file1.md')]); - expect(references.get('SD-2')).toEqual([path.join(testDir, 'file1.md')]); - expect(references.get('DIR-3')).toEqual([path.join(testDir, 'file2.ts')]); + expect(references.get("HC-1")).toEqual([join(testDir, "file1.md")]); + expect(references.get("SD-2")).toEqual([join(testDir, "file1.md")]); + expect(references.get("DIR-3")).toEqual([join(testDir, "file2.ts")]); + }); + + it("ignores out-of-range clause IDs (word boundaries + bounded ranges)", () => { + writeFileSync( + join(testDir, "bad.md"), + "HC-0 and SD-99 and DIR-8 and XHC-1 are not valid clause refs.", + ); + const references = findClauseReferences(testDir); + expect(references.has("HC-0")).toBe(false); + expect(references.has("SD-99")).toBe(false); + expect(references.has("DIR-8")).toBe(false); + }); + + it("matches multiple clauses on one line without skipping (no shared lastIndex)", () => { + const dir = mkdtempSync(join(tmpdir(), "clause-multi-")); + try { + writeFileSync(join(dir, "multi.md"), "HC-1 HC-2 HC-3 on one line"); + const references = findClauseReferences(dir); + expect(references.get("HC-1")).toEqual([join(dir, "multi.md")]); + expect(references.get("HC-2")).toEqual([join(dir, "multi.md")]); + expect(references.get("HC-3")).toEqual([join(dir, "multi.md")]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("skips ignored directories (e.g. node_modules)", () => { + const nested = join(testDir, "node_modules"); + mkdirSync(nested, { recursive: true }); + writeFileSync(join(nested, "dep.md"), "Should be ignored: HC-7."); + const references = findClauseReferences(testDir); + expect(references.has("HC-7")).toBe(false); }); }); diff --git a/tools/alignment/detect-clause-drift.ts b/tools/alignment/detect-clause-drift.ts index 97722d7629..66385e433c 100644 --- a/tools/alignment/detect-clause-drift.ts +++ b/tools/alignment/detect-clause-drift.ts @@ -1,119 +1,168 @@ -import fs from 'fs'; -import path from 'path'; - -const CLAUSE_REGEX = /(HC-[0-9]+|SD-[0-9]+|DIR-[0-9]+)/g; -const IGNORE_DIRS = ['node_modules', '.git', '.vscode', '.idea', 'dist', 'build']; -const IGNORE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.pdf', '.zip', '.gz', '.tar', '.DS_Store']; - -interface Match { - file: string; - line: number; - clause: string; - text: string; +#!/usr/bin/env bun +// detect-clause-drift.ts — alignment-clause cross-reference (blast-radius) scan. +// +// B-0058.4 (decomposed from B-0058): pre-renegotiation impact survey. +// Scans the repository working tree for references to alignment clauses +// (HC-1..HC-7, SD-1..SD-9, DIR-1..DIR-5) from docs/ALIGNMENT.md and +// reports which files reference each clause. Answers "who depends on +// this clause, and what breaks if it moves?" BEFORE an ALIGNMENT.md +// renegotiation is accepted. +// +// Distinct from audit_clause_drift.ts (B-0058 slice 2), which diffs +// docs/ALIGNMENT.md between two git refs to detect WHAT changed. This +// tool detects WHO references the clauses (the blast radius). The two +// compose: audit_clause_drift.ts names the changed clauses; this tool +// surveys their cross-references across the working tree. +// +// Usage: +// bun tools/alignment/detect-clause-drift.ts # all clauses +// bun tools/alignment/detect-clause-drift.ts HC-1 # one clause +// bun tools/alignment/detect-clause-drift.ts --json +// +// Exit codes: +// 0 Clean run (references emitted, or none found) +// 2 Script error / bad args + +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +type ExitCode = 0 | 2; + +// Canonical valid clause set + matcher, aligned with +// tools/alignment/audit_clause_coverage.ts extractClauses(). Word +// boundaries + bounded numeric ranges prevent false positives on +// out-of-range IDs (HC-0, SD-99, etc.). A fresh RegExp per line keeps +// the matcher stateless (no shared /g lastIndex skipping matches). +const CLAUSE_PATTERN = "\\b(HC-[1-7]|SD-[1-9]|DIR-[1-5])\\b"; + +// Heavy / non-source trees that would make a full walk take minutes and +// drown the signal. references/ (and references/upstreams/) is the big +// one — gigabytes of mirrored OTHER-repo source per the repo convention +// (.claude/rules/references-upstreams-not-our-code-search-excludes.md). +const IGNORE_DIRS: readonly string[] = [ + "node_modules", ".git", ".vscode", ".idea", "dist", "build", + "bin", "obj", "target", "references", +]; +const IGNORE_EXTS: readonly string[] = [ + ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".pdf", + ".zip", ".gz", ".tar", ".DS_Store", +]; + +export interface ClauseMatch { + readonly file: string; + readonly line: number; + readonly clause: string; + readonly text: string; } -function searchInFile(filePath: string): Match[] { - const matches: Match[] = []; - if (IGNORE_EXTS.some(ext => filePath.endsWith(ext))) { - return matches; +/** Resolve the git repository root so the scan covers the whole repo + * regardless of the caller's CWD. Falls back to the given dir. */ +function repoRoot(fallback: string): string { + const res = spawnSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf-8", + }); + if (res.status === 0 && typeof res.stdout === "string") { + const root = res.stdout.trim(); + if (root.length > 0) return root; } + return fallback; +} +function searchInFile(filePath: string): ClauseMatch[] { + if (IGNORE_EXTS.some((ext) => filePath.endsWith(ext))) return []; + + const matches: ClauseMatch[] = []; + let content: string; try { - const content = fs.readFileSync(filePath, 'utf-8'); - const lines = content.split('\n'); - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (line === undefined) continue; - let match; - while ((match = CLAUSE_REGEX.exec(line)) !== null) { - matches.push({ - file: filePath, - line: i + 1, - clause: match[0], - text: line.trim(), - }); - } - } - } catch (error) { - // Ignore errors from reading binary files etc. + content = readFileSync(filePath, "utf-8"); + } catch { + // Unreadable / binary file — skip silently. + return matches; } + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line === undefined) continue; + // Fresh RegExp per line: matchAll over a non-shared instance avoids + // the cross-line lastIndex skipping that a module-level /g regex has. + for (const m of line.matchAll(new RegExp(CLAUSE_PATTERN, "g"))) { + const clause = m[0]; + matches.push({ file: filePath, line: i + 1, clause, text: line.trim() }); + } + } return matches; } -function searchInDirectory(dirPath: string): Match[] { - let allMatches: Match[] = []; - const entries = fs.readdirSync(dirPath, { withFileTypes: true }); - +function searchInDirectory(dirPath: string): ClauseMatch[] { + let allMatches: ClauseMatch[] = []; + const entries = readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - if (IGNORE_DIRS.includes(entry.name)) { - continue; - } - + if (IGNORE_DIRS.includes(entry.name)) continue; + const fullPath = join(dirPath, entry.name); if (entry.isDirectory()) { allMatches = allMatches.concat(searchInDirectory(fullPath)); } else if (entry.isFile()) { allMatches = allMatches.concat(searchInFile(fullPath)); } } - return allMatches; } -export async function findClauseReferences(dirPath: string): Promise> { - const allMatches = searchInDirectory(dirPath); - const references = new Map(); - - for (const match of allMatches) { - if (!references.has(match.clause)) { - references.set(match.clause, []); - } - const files = references.get(match.clause); - if (files && !files.includes(match.file)) { - files.push(match.file); - } - } - - return references; +/** Build a clause → referencing-files map for the given root. */ +export function findClauseReferences(dirPath: string): Map { + const references = new Map(); + for (const match of searchInDirectory(dirPath)) { + const files = references.get(match.clause) ?? []; + if (!files.includes(match.file)) files.push(match.file); + references.set(match.clause, files); + } + return references; } -function main() { - const searchDir = process.cwd(); - console.log(`Searching for alignment clause references in ${searchDir}... -`); +export function main(argv: readonly string[]): ExitCode { + const json = argv.includes("--json"); + const targetClause = argv.find((a) => !a.startsWith("--")); - const allMatches = searchInDirectory(searchDir); + const root = repoRoot(process.cwd()); + const allMatches = searchInDirectory(root); - const targetClause = process.argv[2]; - - const filteredMatches = targetClause - ? allMatches.filter(m => m.clause.toUpperCase() === targetClause.toUpperCase()) + const filtered = targetClause + ? allMatches.filter( + (m) => m.clause.toUpperCase() === targetClause.toUpperCase(), + ) : allMatches; - if (filteredMatches.length === 0) { - console.log('No alignment clause references found.'); - return; + if (json) { + const grouped: Record = {}; + for (const m of filtered) (grouped[m.clause] ??= []).push(m); + process.stdout.write( + JSON.stringify({ root, references: grouped }, null, 2) + "\n", + ); + return 0; } - const groupedByClause: { [key: string]: Match[] } = {}; - for (const match of filteredMatches) { - (groupedByClause[match.clause] ??= []).push(match); + process.stdout.write(`Alignment-clause references under ${root}\n`); + if (filtered.length === 0) { + process.stdout.write("No alignment clause references found.\n"); + return 0; } - for (const clause in groupedByClause) { + const groupedByClause: Record = {}; + for (const m of filtered) (groupedByClause[m.clause] ??= []).push(m); + + for (const clause of Object.keys(groupedByClause).sort()) { const group = groupedByClause[clause]; if (group === undefined) continue; - console.log(` ---- Found ${group.length} references to ${clause} --- -`); + process.stdout.write(`\n--- ${group.length} references to ${clause} ---\n`); for (const match of group) { - console.log(`${match.file}:${match.line} - ${match.text}`); + process.stdout.write(`${match.file}:${match.line} - ${match.text}\n`); } } + return 0; } -if (require.main === module) { - main(); +if (import.meta.main) { + process.exit(main(process.argv.slice(2))); }