Files
msd-core/src/fallow-runner.cts
Tom Boucher e4dfa6b9ea fix(#1012): invoke fallow with its real CLI and wire the report normalizer (#1044)
* fix(#1012): invoke fallow with its real CLI and wire the report normalizer

The /gsd-code-review structural pre-pass invoked fallow with flags no published
fallow version accepts (--json, --profile, --stdin-files), so it failed on every
run and degraded silently per REQ-FALLOW-02 — the feature never delivered on any
fallow version. Three compounding defects:

1. Invalid flags. Real fallow audit uses --format json (not --json), -q/--quiet,
   --changed-since/--base for changed-files scoping (no file-list input), and
   --max-crap for thresholds. There is no --profile or --stdin-files.
2. Exit-code handling. fallow audit exits 1 when it FINDS issues (verdict=fail),
   0 when clean. The pre-pass treated any non-zero exit as a crash and discarded
   the output — i.e. it threw away exactly the findings it exists to surface.
   Success is now decided by whether a valid fallow JSON report was produced,
   not by the exit code.
3. Schema mismatch. normalizeFallowReport parsed a fictional top-level schema
   (unusedExports/duplicates/circularDependencies) fallow never shipped, and was
   dead code (the workflow embedded raw JSON; its tests asserted the fictional
   schema, one even calling a non-existent runFallowAudit and passing vacuously).

Fixes: align the invocation to fallow's documented agent-facing pattern; map the
profile preset (minimal/standard/strict) to --max-crap (50/30/15); scope phase
runs via --changed-since with a repo-scope fallback; rewrite the normalizer to
fallow's real schema (dead_code.unused_exports/unused_files/circular_dependencies
+ duplication.clone_groups) and wire it into the workflow so the reviewer
receives normalized findings; replace the fictional-schema fixtures and tests
with real-schema ones and delete the vacuous runFallowAudit test.

Closes #1012

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#1012): backfill changeset PR number to 1044

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:36:50 -04:00

245 lines
7.0 KiB
TypeScript

/**
* Fallow binary resolution and report normalisation.
*
* ADR-457 build-at-publish: the hand-written bin/lib/fallow-runner.cjs
* collapsed to a TypeScript source of truth. Behaviour is preserved
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
*
* Parses the real fallow `audit --format json` schema (schema_version 3
* envelope, nested dead_code/duplication sections). See fallow 2.70.0+.
*/
import fs from 'node:fs';
import path from 'node:path';
function candidateNames(): string[] {
return process.platform === 'win32'
? ['fallow.exe', 'fallow.cmd', 'fallow.bat', 'fallow']
: ['fallow'];
}
function isExecutableFile(filePath: string): boolean {
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) return false;
if (process.platform === 'win32') return true;
fs.accessSync(filePath, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
function findInPath(envPath: string | undefined): string | null {
if (!envPath) return null;
const names = candidateNames();
const segments = envPath.split(path.delimiter).filter(Boolean);
for (const segment of segments) {
for (const name of names) {
const candidate = path.join(segment, name);
if (isExecutableFile(candidate)) return candidate;
}
}
return null;
}
function findInNodeModules(cwd: string): string | null {
const names = candidateNames();
const binDir = path.join(cwd, 'node_modules', '.bin');
for (const name of names) {
const candidate = path.join(binDir, name);
if (isExecutableFile(candidate)) return candidate;
}
return null;
}
export interface ResolveFallowOpts {
cwd: string;
envPath?: string;
}
export function resolveFallowBinary({ cwd, envPath = process.env['PATH'] ?? '' }: ResolveFallowOpts): string | null {
return findInNodeModules(cwd) || findInPath(envPath) || null;
}
export function requireFallowBinary({ cwd, envPath = process.env['PATH'] ?? '' }: ResolveFallowOpts): string {
const binary = resolveFallowBinary({ cwd, envPath });
if (binary) return binary;
throw new Error(
'Fallow is enabled but no binary was found. Please install fallow via `npm install -D fallow` or `cargo install fallow`.',
);
}
// --- Real fallow audit --format json schema (schema_version 3) interfaces ---
interface FallowUnusedExport {
path?: string;
export_name?: string;
is_type_only?: boolean;
line?: number | null;
col?: number | null;
span_start?: number | null;
is_re_export?: boolean;
actions?: unknown[];
introduced?: boolean;
}
interface FallowUnusedFile {
path?: string;
actions?: unknown[];
introduced?: boolean;
}
interface FallowCircularDependency {
files?: string[];
length?: number;
line?: number | null;
col?: number | null;
actions?: unknown[];
introduced?: boolean;
}
interface FallowCloneInstance {
file?: string;
start_line?: number | null;
end_line?: number | null;
start_col?: number | null;
end_col?: number | null;
fragment?: string;
}
interface FallowCloneGroup {
instances?: FallowCloneInstance[];
}
interface FallowDeadCode {
unused_exports?: FallowUnusedExport[];
unused_files?: FallowUnusedFile[];
circular_dependencies?: FallowCircularDependency[];
summary?: unknown;
schema_version?: number;
}
interface FallowDuplication {
clone_groups?: FallowCloneGroup[];
stats?: unknown;
}
interface FallowReport {
schema_version?: number;
version?: string;
command?: string;
verdict?: string;
changed_files_count?: number;
base_ref?: string;
head_sha?: string;
elapsed_ms?: number;
summary?: unknown;
attribution?: unknown;
dead_code?: FallowDeadCode;
duplication?: FallowDuplication;
complexity?: unknown;
}
export interface FallowFinding {
type: 'unused_export' | 'unused_file' | 'duplicate_block' | 'circular_dependency';
message: string;
file: string;
line: number | null;
related_file?: string;
}
export interface NormalizedFallowReport {
summary: {
unused_exports: number;
unused_files: number;
duplicates: number;
circular_dependencies: number;
total: number;
};
findings: FallowFinding[];
}
export function normalizeFallowReport(report: FallowReport | null | undefined): NormalizedFallowReport {
const deadCodeRaw = report?.dead_code;
const duplicationRaw = report?.duplication;
const unusedExports: FallowUnusedExport[] = (Array.isArray(deadCodeRaw?.unused_exports)
? (deadCodeRaw?.unused_exports ?? [])
: []).filter((x): x is FallowUnusedExport => x !== null && typeof x === 'object');
const unusedFiles: FallowUnusedFile[] = (Array.isArray(deadCodeRaw?.unused_files)
? (deadCodeRaw?.unused_files ?? [])
: []).filter((x): x is FallowUnusedFile => x !== null && typeof x === 'object');
const circularDeps: FallowCircularDependency[] = (Array.isArray(deadCodeRaw?.circular_dependencies)
? (deadCodeRaw?.circular_dependencies ?? [])
: []).filter((x): x is FallowCircularDependency => x !== null && typeof x === 'object');
const cloneGroups: FallowCloneGroup[] = (Array.isArray(duplicationRaw?.clone_groups)
? (duplicationRaw?.clone_groups ?? [])
: []).filter((x): x is FallowCloneGroup => x !== null && typeof x === 'object');
const findings: FallowFinding[] = [];
for (const item of unusedExports) {
if (!item || typeof item !== 'object') continue;
findings.push({
type: 'unused_export',
message: `Unused export ${item.export_name ?? '<unknown>'}`,
file: item.path ?? '',
line: item.line ?? null,
});
}
for (const item of unusedFiles) {
if (!item || typeof item !== 'object') continue;
findings.push({
type: 'unused_file',
message: `Unused file ${item.path ?? '<unknown>'}`,
file: item.path ?? '',
line: null,
});
}
for (const item of circularDeps) {
if (!item || typeof item !== 'object') continue;
const files = Array.isArray(item.files) ? item.files : [];
findings.push({
type: 'circular_dependency',
message: `Circular dependency: ${files.join(' -> ')}`,
file: files.length > 0 ? files[0] : '',
line: item.line ?? null,
});
}
for (const group of cloneGroups) {
if (!group || typeof group !== 'object') continue;
const instances = Array.isArray(group.instances) ? group.instances : [];
findings.push({
type: 'duplicate_block',
message: `Duplicate block (${instances.length} instances)`,
file: instances[0]?.file ?? '',
line: instances[0]?.start_line ?? null,
related_file: instances[1]?.file ?? '',
});
}
return {
summary: {
unused_exports: unusedExports.length,
unused_files: unusedFiles.length,
duplicates: cloneGroups.length,
circular_dependencies: circularDeps.length,
total: findings.length,
},
findings,
};
}
export function normalizeFallowReportFile(filePath: string): NormalizedFallowReport {
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw) as FallowReport;
return normalizeFallowReport(parsed);
} catch {
return normalizeFallowReport(null);
}
}