/*
RFC-0332: Change impact classifier — derives impact class and advisory
check profiles from changed paths. Pure, data-driven, no CI gating.
- No CI gating — advisory only, DNA-35 remains the readiness signal.
- No hand-authored risk field — risk is derived from changed paths.
- No dependency-graph analysis — path-pattern classification only in v1.
- Impact class derives from changed paths, not diff content — classification stays cheap.
- Advisory check profiles are suggestions, not gates — the caller decides what runs.
- RFC-0332: initial implementation.
- RFC-1097: step 6 — compass.migrate codemod run
Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
- RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared
*/
import type {
KernelCommandInput,
KernelCommandResult,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-shared/kernel";
import type { WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
// ─── Types ───────────────────────────────────────────────────────────────────
export type ImpactClass = "none" | "low" | "medium" | "high";
export interface ImpactRule {
pattern: string;
class: ImpactClass;
ruleId: string;
}
export interface PathClassification {
path: string;
class: ImpactClass;
ruleId: string;
}
export interface ImpactClassification {
overall: ImpactClass;
perPath: PathClassification[];
}
export interface ProfileRecommendation {
commands: string[];
note: string;
}
export interface ChangeImpactResult {
command: "change.impact.derive";
status: "ok";
mode: "paths" | "git-base" | "working-tree";
overall: ImpactClass;
impactedApps: string[];
perPath: PathClassification[];
recommendation: ProfileRecommendation;
}
// ─── Rules table ─────────────────────────────────────────────────────────────
const CLASS_ORDER: Record = { none: 0, low: 1, medium: 2, high: 3 };
export const IMPACT_RULES: ImpactRule[] = [
{ pattern: "AGENTS.md", class: "high", ruleId: "IMP-AGENT-POLICY" },
{ pattern: "apps/**/AGENTS.md", class: "high", ruleId: "IMP-AGENT-POLICY" },
{ pattern: "services/**/AGENTS.md", class: "high", ruleId: "IMP-AGENT-POLICY" },
{ pattern: "packages/**/AGENTS.md", class: "high", ruleId: "IMP-AGENT-POLICY" },
{ pattern: "packages/os/**", class: "high", ruleId: "IMP-OS" },
{ pattern: "packages/share/**", class: "high", ruleId: "IMP-SHARE" },
{ pattern: "turbo.json", class: "high", ruleId: "IMP-ROOT-CONFIG" },
{ pattern: "pnpm-workspace.yaml", class: "high", ruleId: "IMP-ROOT-CONFIG" },
{ pattern: "pnpm-lock.yaml", class: "high", ruleId: "IMP-ROOT-CONFIG" },
{ pattern: "package.json", class: "high", ruleId: "IMP-ROOT-CONFIG" },
{ pattern: "docs/requirements.xml", class: "high", ruleId: "IMP-COMPASS" },
{ pattern: "docs/technology.xml", class: "high", ruleId: "IMP-COMPASS" },
{ pattern: "docs/development-plan.xml", class: "high", ruleId: "IMP-COMPASS" },
{ pattern: "docs/knowledge-graph.xml", class: "high", ruleId: "IMP-COMPASS" },
{ pattern: "docs/verification-plan.xml", class: "high", ruleId: "IMP-COMPASS" },
{ pattern: "docs/source-markup.xml", class: "high", ruleId: "IMP-COMPASS" },
{ pattern: "docs/styling.xml", class: "high", ruleId: "IMP-COMPASS" },
{ pattern: "packages/ui/**", class: "medium", ruleId: "IMP-UI-ONTOLOGY" },
{ pattern: "packages/ontology/**", class: "medium", ruleId: "IMP-UI-ONTOLOGY" },
{ pattern: "packages/**", class: "medium", ruleId: "IMP-PKG" },
{ pattern: "apps/*/astro.config.*", class: "medium", ruleId: "IMP-APP-CONFIG" },
{ pattern: "apps/*/system.md", class: "medium", ruleId: "IMP-APP-CONFIG" },
{ pattern: "apps/*/package.json", class: "medium", ruleId: "IMP-APP-CONFIG" },
{ pattern: "apps/*/src/content/**", class: "low", ruleId: "IMP-CONTENT" },
{ pattern: "apps/**", class: "medium", ruleId: "IMP-APP-CODE" },
{ pattern: "docs/rfcs/**", class: "low", ruleId: "IMP-RFC" },
{ pattern: "docs/rfcs-audit/**", class: "none", ruleId: "IMP-DOCS" },
{ pattern: "docs/**", class: "none", ruleId: "IMP-DOCS" },
{ pattern: "*.md", class: "none", ruleId: "IMP-DOCS" },
{ pattern: ".agents/**", class: "none", ruleId: "IMP-DOCS" },
{ pattern: "**", class: "medium", ruleId: "IMP-UNKNOWN" },
];
// ─── Glob matching ───────────────────────────────────────────────────────────
function globMatch(pattern: string, path: string): boolean {
// Simple glob matcher supporting **, *, and exact paths
// Convert glob to regex
let re = pattern
.replace(/\./g, "\\.")
.replace(/\*\*/g, "::GLOBSTAR::")
.replace(/\*/g, "[^/]*")
.replace(/::GLOBSTAR::/g, ".*");
// Anchor to start
re = `^${re}$`;
return new RegExp(re).test(path);
}
// ─── Pure classifier ─────────────────────────────────────────────────────────
export function classifyPaths(paths: string[]): ImpactClassification {
const perPath: PathClassification[] = [];
let overall: ImpactClass = "none";
for (const p of paths) {
const normalized = p.replace(/\\/g, "/").replace(/^\.\//, "");
let matched = false;
for (const rule of IMPACT_RULES) {
if (globMatch(rule.pattern, normalized)) {
perPath.push({ path: normalized, class: rule.class, ruleId: rule.ruleId });
if (CLASS_ORDER[rule.class] > CLASS_ORDER[overall]) {
overall = rule.class;
}
matched = true;
break;
}
}
if (!matched) {
// Should never reach here due to IMP-UNKNOWN catch-all
perPath.push({ path: normalized, class: "medium", ruleId: "IMP-UNKNOWN" });
if (CLASS_ORDER.medium > CLASS_ORDER[overall]) overall = "medium";
}
}
return { overall, perPath };
}
export function deriveImpactedApps(paths: string[], appNames: string[]): string[] {
const impacted = new Set();
const hasWorkspaceBlast = paths.some((p) => {
const normalized = p.replace(/\\/g, "/");
return (
normalized.startsWith("packages/") ||
normalized === "AGENTS.md" ||
normalized.startsWith("turbo.json") ||
normalized.startsWith("pnpm-workspace.yaml") ||
normalized.startsWith("pnpm-lock.yaml") ||
normalized === "package.json" ||
normalized.startsWith("docs/requirements.xml") ||
normalized.startsWith("docs/technology.xml") ||
normalized.startsWith("docs/development-plan.xml") ||
normalized.startsWith("docs/knowledge-graph.xml") ||
normalized.startsWith("docs/verification-plan.xml") ||
normalized.startsWith("docs/source-markup.xml") ||
normalized.startsWith("docs/styling.xml")
);
});
if (hasWorkspaceBlast) return [...appNames].sort();
for (const p of paths) {
const normalized = p.replace(/\\/g, "/");
const appMatch = normalized.match(/^apps\/([^/]+)\//);
if (appMatch) {
const appName = appMatch[1]!;
if (appNames.includes(appName)) impacted.add(appName);
}
}
return [...impacted].sort();
}
export function recommendProfile(
classification: ImpactClassification,
impactedApps: string[],
): ProfileRecommendation {
const note = "Advisory only. app.contract.full remains the readiness signal (DNA-35).";
const commands: string[] = [];
// IMP-RFC always adds rfc.validate
const hasRfcPaths = classification.perPath.some((p) => p.ruleId === "IMP-RFC");
if (hasRfcPaths) commands.push("rfc.validate");
switch (classification.overall) {
case "none":
return { commands, note };
case "low":
for (const app of impactedApps) commands.push(`sites-check.author --site ${app}`);
return { commands, note };
case "medium":
for (const app of impactedApps) commands.push(`sites-check.run --site ${app}`);
commands.push("packages-check.run");
return { commands, note };
case "high":
for (const app of impactedApps) commands.push(`app.contract.full --site ${app}`);
return { commands, note };
}
}
// ─── Command handler ─────────────────────────────────────────────────────────
async function gitDiffNames(
workspaceRoot: string,
args: string[],
io: WorkspaceIO,
): Promise {
const result = await io.exec("git", ["diff", "--name-only", ...args], {
cwd: workspaceRoot,
});
if (result.exitCode !== 0) {
throw new Error(`git diff failed (exit ${result.exitCode}): ${result.stderr.trim()}`);
}
return result.stdout.trim().split("\n").filter(Boolean);
}
async function gitStatusPorcelain(workspaceRoot: string, io: WorkspaceIO): Promise {
const result = await io.exec("git", ["status", "--porcelain"], {
cwd: workspaceRoot,
});
if (result.exitCode !== 0) {
throw new Error(`git status failed (exit ${result.exitCode}): ${result.stderr.trim()}`);
}
return result.stdout
.trim()
.split("\n")
.filter(Boolean)
.map((line) => line.slice(3).trim());
}
export async function runChangeImpactDerive(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot, logger, outputFormat } = context;
const pathsFlag = input.flags["paths"] as string | undefined;
const gitBase = input.flags["git-base"] as string | undefined;
let mode: ChangeImpactResult["mode"];
let paths: string[];
if (pathsFlag) {
mode = "paths";
paths = pathsFlag
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (gitBase) {
logger.warn("--paths takes precedence over --git-base; ignoring --git-base.");
}
} else if (gitBase) {
mode = "git-base";
try {
paths = await gitDiffNames(workspaceRoot, [`${gitBase}...HEAD`], context.io);
} catch (err) {
throw new Error(`git diff failed: ${String(err)}`);
}
} else {
mode = "working-tree";
try {
const unstaged = await gitDiffNames(workspaceRoot, [], context.io);
const staged = await gitDiffNames(workspaceRoot, ["--cached"], context.io);
const untracked = (await gitStatusPorcelain(workspaceRoot, context.io)).filter(
(p) => !unstaged.includes(p) && !staged.includes(p),
);
paths = [...new Set([...unstaged, ...staged, ...untracked])];
} catch (err) {
throw new Error(`git command failed: ${String(err)}`);
}
}
const classification = classifyPaths(paths);
// Discover app names from workspace
const appNames: string[] = [];
try {
const { discoverWorkspacePackages } = await import("./workspace-discovery.ts");
const discovery = await discoverWorkspacePackages(workspaceRoot);
for (const pkg of discovery.packages) {
if (pkg.directory.startsWith("apps/")) {
appNames.push(pkg.directory.replace("apps/", ""));
}
}
} catch {
// graceful — no apps discovered
}
const impactedApps = deriveImpactedApps(paths, appNames);
const recommendation = recommendProfile(classification, impactedApps);
const result: ChangeImpactResult = {
command: "change.impact.derive",
status: "ok",
mode,
overall: classification.overall,
impactedApps,
perPath: classification.perPath,
recommendation,
};
if (outputFormat === "pretty") {
logger.section(`Change impact: ${classification.overall}`);
const byClass: Record = {};
for (const p of classification.perPath) {
byClass[p.class] = (byClass[p.class] ?? 0) + 1;
}
for (const cls of ["high", "medium", "low", "none"]) {
if (byClass[cls]) logger.info(` ${cls}: ${byClass[cls]} path(s)`);
}
if (impactedApps.length > 0) {
logger.info(` Impacted apps: ${impactedApps.join(", ")}`);
}
if (recommendation.commands.length > 0) {
logger.info(" Recommended:");
for (const cmd of recommendation.commands) logger.info(` ${cmd}`);
}
logger.info(` ${recommendation.note}`);
}
return {
data: result,
exitCode: 0,
summary: `[change.impact.derive] overall=${classification.overall}, ${paths.length} path(s), ${impactedApps.length} app(s)`,
};
}