185 lines
7.6 KiB
TypeScript
185 lines
7.6 KiB
TypeScript
// aidlc-workspace-doctor.ts - workspace-manifest health rows for `/aidlc --doctor`.
|
|
//
|
|
// A multi-repo workspace holds the AI-DLC records under aidlc/ at the root with
|
|
// child code repos cloned in as gitignored siblings. When a team declares those
|
|
// repos in an OPTIONAL repos.json manifest and reconciles them with
|
|
// aidlc-workspace-sync, these checks give the workspace structure a
|
|
// deterministic voice in --doctor that it otherwise lacks.
|
|
//
|
|
// Every row is ADVISORY (pass:true) with the detail in the LABEL - a workspace
|
|
// with uncommitted records or a not-yet-synced manifest is normal user state,
|
|
// not framework breakage, so these rows never change doctor's exit code (the
|
|
// --doctor render loop only prints `fix` on a FAILED row). W2/W3 stay absent
|
|
// without a manifest, so they do not add manifest-specific noise to single-repo
|
|
// installs.
|
|
//
|
|
// Factored into its own module (rather than inlined in aidlc-utility.ts, whose
|
|
// handleDoctor is already large) so the workspace-manifest checks read as one
|
|
// unit; handleDoctor calls workspaceManifestChecks() and spreads the rows in.
|
|
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { discoverSiblingRepos, errorMessage } from "./aidlc-lib.ts";
|
|
import { aidlcToolInvocation } from "./aidlc-runtime-paths.ts";
|
|
import {
|
|
parseWorkspaceManifest,
|
|
WORKSPACE_GITIGNORE_GATE_BEGIN as GATE_BEGIN,
|
|
WORKSPACE_GITIGNORE_GATE_END as GATE_END,
|
|
WORKSPACE_RECOVERY_GITIGNORE,
|
|
} from "./aidlc-workspace-manifest.ts";
|
|
|
|
/** One --doctor report row. Structurally matches handleDoctor's results[]. */
|
|
export interface DoctorCheck {
|
|
pass: boolean;
|
|
severity?: "warn";
|
|
label: string;
|
|
fix?: string;
|
|
}
|
|
|
|
/**
|
|
* Workspace-manifest health rows for `/aidlc --doctor` (all advisory - pass:true).
|
|
*
|
|
* W1 runs in any git workspace; W2/W3 run only when a repos.json manifest is
|
|
* present at the workspace root (the declared multi-repo signal). A single-repo
|
|
* install or a bare test fixture has no repos.json, so W2/W3 skip silently and
|
|
* no manifest-specific rows are added.
|
|
*/
|
|
export function workspaceManifestChecks(projectDir: string): DoctorCheck[] {
|
|
const results: DoctorCheck[] = [];
|
|
const syncCmd = aidlcToolInvocation("workspace-sync");
|
|
|
|
// W1 - Uncommitted records. Any git workspace: surface uncommitted changes
|
|
// under aidlc/ so the shared records get committed and pushed to teammates.
|
|
// Skips silently outside a git repo (smoke / fresh fixtures), mirroring the
|
|
// stale-branches guard. git status --porcelain omits gitignored per-user
|
|
// cursors, so ignored runtime state (active-intent, runtime-graph.json, ...)
|
|
// never false-positives.
|
|
try {
|
|
const proc = Bun.spawnSync({
|
|
cmd: [
|
|
"git",
|
|
"-C",
|
|
projectDir,
|
|
"-c",
|
|
"status.showUntrackedFiles=all",
|
|
"status",
|
|
"--porcelain=v1",
|
|
"--untracked-files=all",
|
|
"--",
|
|
"aidlc",
|
|
],
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
if (proc.exitCode !== 0) {
|
|
results.push({ pass: true, label: "Workspace records: not a git repo - nothing to commit" });
|
|
} else {
|
|
const dirty = new TextDecoder()
|
|
.decode(proc.stdout)
|
|
.split("\n")
|
|
.filter((l) => l.trim().length > 0);
|
|
if (dirty.length === 0) {
|
|
results.push({ pass: true, label: "Workspace records: no uncommitted changes under aidlc/" });
|
|
} else {
|
|
results.push({
|
|
pass: false,
|
|
severity: "warn",
|
|
label:
|
|
`Workspace records: ${dirty.length} uncommitted change(s) under aidlc/ (advisory - the shared records travel by git; ` +
|
|
"commit & push so teammates get them: `git add aidlc/ && git commit && git push`)",
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
results.push({ pass: true, label: `Workspace records: check skipped (advisory) - ${errorMessage(e)}` });
|
|
}
|
|
|
|
// W2/W3 - only when a repos.json manifest is present (the declared multi-repo signal).
|
|
const reposManifestPath = join(projectDir, "repos.json");
|
|
if (existsSync(reposManifestPath)) {
|
|
// Parse through the exact schema used by sync so doctor never reports a
|
|
// manifest as synchronized when sync would reject it.
|
|
let manifestNames: string[] | null = null;
|
|
try {
|
|
manifestNames = parseWorkspaceManifest(
|
|
readFileSync(reposManifestPath, "utf-8"),
|
|
).repos.map((repo) => repo.name);
|
|
} catch (e) {
|
|
results.push({
|
|
pass: true,
|
|
label: `Workspace repos: repos.json present but unparseable or invalid (advisory) - ${errorMessage(e)}`,
|
|
});
|
|
}
|
|
|
|
if (manifestNames) {
|
|
// W2 - Manifest vs disk drift. discoverSiblingRepos scans the disk for
|
|
// .git siblings - the SAME set the runtime uses at intent creation - so a
|
|
// mismatch is exactly what the agent would (in)visibly see. Disk wins at
|
|
// runtime; the manifest only drives sync, so this row nudges, never fails.
|
|
const declared = new Set(manifestNames);
|
|
const onDisk = new Set(discoverSiblingRepos(projectDir));
|
|
const notCloned = [...declared].filter((n) => !onDisk.has(n)).sort();
|
|
const notDeclared = [...onDisk].filter((n) => !declared.has(n)).sort();
|
|
if (notCloned.length === 0 && notDeclared.length === 0) {
|
|
results.push({
|
|
pass: true,
|
|
label: `Workspace repos: repos.json ⇄ on-disk siblings in sync (${declared.size} repo(s))`,
|
|
});
|
|
} else {
|
|
const parts: string[] = [];
|
|
if (notCloned.length > 0) {
|
|
parts.push(`declared but not cloned [${notCloned.join(", ")}] - run \`${syncCmd}\``);
|
|
}
|
|
if (notDeclared.length > 0) {
|
|
parts.push(
|
|
`on disk but not in repos.json [${notDeclared.join(", ")}] - add them to repos.json ` +
|
|
"so the declared set matches what the runtime discovers (they already work at runtime; this only keeps clone/sync accurate)",
|
|
);
|
|
}
|
|
results.push({
|
|
pass: true,
|
|
label: `Workspace repos: ${notCloned.length + notDeclared.length} manifest/disk drift (advisory - ${parts.join("; ")})`,
|
|
});
|
|
}
|
|
|
|
// W3 - Stale .gitignore managed block. The block between the frozen
|
|
// markers is regenerated from repos.json by aidlc-workspace-sync; if it
|
|
// doesn't match the manifest, sync hasn't been run since the last edit.
|
|
const gitignorePath = join(projectDir, ".gitignore");
|
|
const gi = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
|
|
const b = gi.indexOf(GATE_BEGIN);
|
|
const e = gi.indexOf(GATE_END);
|
|
const expected = [
|
|
WORKSPACE_RECOVERY_GITIGNORE,
|
|
...[...manifestNames].sort().map((n) => `/${n}/`),
|
|
].sort();
|
|
if (b === -1 || e === -1 || e < b) {
|
|
results.push({
|
|
pass: true,
|
|
label:
|
|
manifestNames.length === 0
|
|
? `Workspace .gitignore: no managed block yet (advisory - run \`${syncCmd}\` once you declare repos)`
|
|
: `Workspace .gitignore: managed block missing (advisory - run \`${syncCmd}\` to generate it)`,
|
|
});
|
|
} else {
|
|
const actual = gi
|
|
.slice(b + GATE_BEGIN.length, e)
|
|
.split("\n")
|
|
.map((l) => l.trim())
|
|
.filter((l) => l.length > 0)
|
|
.sort();
|
|
const inSync =
|
|
actual.length === expected.length && actual.every((v, i) => v === expected[i]);
|
|
results.push({
|
|
pass: true,
|
|
label: inSync
|
|
? `Workspace .gitignore: managed block matches repos.json (${manifestNames.length} repo dir(s))`
|
|
: `Workspace .gitignore: managed block stale vs repos.json (advisory - run \`${syncCmd}\` to regenerate it)`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|