@akanjs/devkit 3.0.0-alpha.76 → 3.0.0-alpha.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/executors.ts +20 -0
- package/fleetConfig.ts +101 -0
- package/fleetGuard.test.ts +121 -0
- package/fleetGuard.ts +157 -0
- package/fleetSpoke.test.ts +365 -0
- package/fleetSpoke.ts +663 -0
- package/libSource.test.ts +109 -0
- package/libSource.ts +126 -0
- package/package.json +2 -2
- package/semver.test.ts +26 -0
- package/semver.ts +31 -0
- package/slicePlanner.test.ts +151 -0
- package/slicePlanner.ts +157 -0
package/executors.ts
CHANGED
|
@@ -941,6 +941,26 @@ export class WorkspaceExecutor extends Executor {
|
|
|
941
941
|
// Argument vector, not a shell string: a message carrying a double quote breaks the interpolated form.
|
|
942
942
|
await this.spawn("git", ["commit", "--quiet", "-m", message]);
|
|
943
943
|
}
|
|
944
|
+
/** `git commit` exits non-zero on an empty index, so a re-runnable caller has to ask first. */
|
|
945
|
+
async hasChanges() {
|
|
946
|
+
return !!(await this.spawn("git", ["status", "--porcelain"])).trim();
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Workspace-relative paths git knows about, sorted. `untracked` adds files that exist but are not
|
|
950
|
+
* committed yet, still honoring `.gitignore` — which is what a freshly copied library looks like.
|
|
951
|
+
*
|
|
952
|
+
* Reading the file set from git is what keeps generated barrels, the `page/**` and `public/libs`
|
|
953
|
+
* symlinks, env values and the lockfile out of it without any caller restating that list.
|
|
954
|
+
*/
|
|
955
|
+
async listGitFiles(paths: string[], { untracked = false }: { untracked?: boolean } = {}) {
|
|
956
|
+
if (!paths.length) return [];
|
|
957
|
+
const mode = untracked ? ["--cached", "--others", "--exclude-standard"] : ["--cached"];
|
|
958
|
+
const stdout = await this.spawn("git", ["ls-files", "-z", ...mode, "--", ...paths]);
|
|
959
|
+
return stdout
|
|
960
|
+
.split("\0")
|
|
961
|
+
.filter((file) => !!file)
|
|
962
|
+
.sort();
|
|
963
|
+
}
|
|
944
964
|
async #getDirHasFile(basePath: string, targetFilename: string) {
|
|
945
965
|
const AVOID_DIRS = ["node_modules", "dist", "public", "webkit"];
|
|
946
966
|
const getDirs = async (dirname: string, maxDepth = 3, results: string[] = [], prefix = "") => {
|
package/fleetConfig.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { WorkspaceExecutor } from "./executors";
|
|
3
|
+
import { FileSys } from "./fileSys";
|
|
4
|
+
|
|
5
|
+
export interface FleetSpokeDeclaration {
|
|
6
|
+
/** Short name used on the command line and as the git remote suffix. */
|
|
7
|
+
name: string;
|
|
8
|
+
repo: string;
|
|
9
|
+
/** Apps this spoke serves. Libraries are never listed — they are derived from each app's closure. */
|
|
10
|
+
apps: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FleetGuardConfig {
|
|
14
|
+
/** CI system the guard job is written for. `"none"` leaves it out — wire `akan fleet check` yourself. */
|
|
15
|
+
ci: "github" | "none";
|
|
16
|
+
/** Add the `akan fleet check` line to the spoke's pre-commit hook. */
|
|
17
|
+
preCommit: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FleetConfigInput {
|
|
21
|
+
/**
|
|
22
|
+
* Branches a push may target. A hub feature branch is refused, because pushing it would copy the hub's
|
|
23
|
+
* branch namespace into every customer repo.
|
|
24
|
+
*/
|
|
25
|
+
pushableBranches?: string[];
|
|
26
|
+
/**
|
|
27
|
+
* Workspace-root entries to keep out of every spoke, on top of the ones that always are. Top-level
|
|
28
|
+
* names or `dir/` prefixes — a hub's own release and benchmark trees are the usual entries.
|
|
29
|
+
*/
|
|
30
|
+
exclude?: string[];
|
|
31
|
+
/** What a push installs in each spoke to keep hub-owned files from being edited there. */
|
|
32
|
+
guard?: Partial<FleetGuardConfig>;
|
|
33
|
+
spokes: FleetSpokeDeclaration[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `akan.fleet.ts` at the workspace root: which customer repo each app is mirrored to.
|
|
38
|
+
*
|
|
39
|
+
* There is no branch field. The branch is whichever one the hub is on, so the same declaration serves
|
|
40
|
+
* `develop` and `main` and every spoke on one branch holds the same akanjs version and library source.
|
|
41
|
+
*/
|
|
42
|
+
export class FleetConfig {
|
|
43
|
+
static readonly fileName = "akan.fleet.ts";
|
|
44
|
+
static readonly defaultPushableBranches = ["main", "develop", "debug"];
|
|
45
|
+
static readonly defaultGuard: FleetGuardConfig = { ci: "github", preCommit: true };
|
|
46
|
+
|
|
47
|
+
static async from(workspace: WorkspaceExecutor): Promise<FleetConfig | null> {
|
|
48
|
+
const configPath = path.join(workspace.workspaceRoot, FleetConfig.fileName);
|
|
49
|
+
if (!(await FileSys.fileExists(configPath))) return null;
|
|
50
|
+
const input = await import(configPath).then((mod: { default: FleetConfigInput }) => mod.default);
|
|
51
|
+
return new FleetConfig(input);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
readonly pushableBranches: string[];
|
|
55
|
+
readonly exclude: string[];
|
|
56
|
+
readonly guard: FleetGuardConfig;
|
|
57
|
+
readonly spokes: FleetSpokeDeclaration[];
|
|
58
|
+
|
|
59
|
+
constructor(input: FleetConfigInput) {
|
|
60
|
+
this.pushableBranches = input.pushableBranches ?? FleetConfig.defaultPushableBranches;
|
|
61
|
+
this.exclude = input.exclude ?? [];
|
|
62
|
+
this.guard = { ...FleetConfig.defaultGuard, ...input.guard };
|
|
63
|
+
this.spokes = input.spokes ?? [];
|
|
64
|
+
this.#assertValid();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#assertValid() {
|
|
68
|
+
const seenNames = new Set<string>();
|
|
69
|
+
const appOwners = new Map<string, string>();
|
|
70
|
+
for (const spoke of this.spokes) {
|
|
71
|
+
if (!spoke.name || !spoke.repo) throw new Error(`${FleetConfig.fileName}: every spoke needs a name and a repo`);
|
|
72
|
+
if (seenNames.has(spoke.name)) throw new Error(`${FleetConfig.fileName}: duplicate spoke "${spoke.name}"`);
|
|
73
|
+
seenNames.add(spoke.name);
|
|
74
|
+
if (!spoke.apps?.length) throw new Error(`${FleetConfig.fileName}: spoke "${spoke.name}" declares no apps`);
|
|
75
|
+
for (const app of spoke.apps) {
|
|
76
|
+
const owner = appOwners.get(app);
|
|
77
|
+
//* One app never goes to two spokes: the two would need per-spoke domains and ids, which this
|
|
78
|
+
//* declaration cannot express. Share code through a lib instead.
|
|
79
|
+
if (owner)
|
|
80
|
+
throw new Error(`${FleetConfig.fileName}: app "${app}" is claimed by both "${owner}" and "${spoke.name}"`);
|
|
81
|
+
appOwners.set(app, spoke.name);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
select(names: string[]) {
|
|
87
|
+
if (!names.length) return this.spokes;
|
|
88
|
+
return names.map((name) => {
|
|
89
|
+
const spoke = this.spokes.find((candidate) => candidate.name === name);
|
|
90
|
+
if (!spoke) throw new Error(`${FleetConfig.fileName}: unknown spoke "${name}"`);
|
|
91
|
+
return spoke;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
assertPushable(branch: string) {
|
|
96
|
+
if (this.pushableBranches.includes(branch)) return;
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Branch "${branch}" is not pushable. ${FleetConfig.fileName} allows: ${this.pushableBranches.join(", ")}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Executor, LibExecutor, WorkspaceExecutor } from "./executors";
|
|
6
|
+
import { FleetGuard, formatFleetGuardReport } from "./fleetGuard";
|
|
7
|
+
import { LibSource } from "./libSource";
|
|
8
|
+
|
|
9
|
+
const tempRoots: string[] = [];
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const write = async (filePath: string, content: string) => {
|
|
16
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
17
|
+
await writeFile(filePath, content);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const git = async (cwd: string, args: string[]) =>
|
|
21
|
+
await new Executor("fixture", cwd).spawn("git", ["-c", "user.email=t@t", "-c", "user.name=t", ...args]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A spoke as a push leaves it. Built by hand rather than by running a push: `LibExecutor.from` memoises by
|
|
25
|
+
* library name across the whole process, so a fixture that also builds a hub would hand the guard the
|
|
26
|
+
* hub's copy of the library. In a real spoke the hub is a different process entirely.
|
|
27
|
+
*/
|
|
28
|
+
const makeSpoke = async (libName: string, appName: string) => {
|
|
29
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-guard-"));
|
|
30
|
+
tempRoots.push(root);
|
|
31
|
+
await write(path.join(root, "package.json"), '{ "name": "spoke", "version": "0.0.1", "description": "spoke" }\n');
|
|
32
|
+
await write(path.join(root, "tsconfig.json"), "{}\n");
|
|
33
|
+
await write(path.join(root, ".gitignore"), "node_modules\n**/.akan\n");
|
|
34
|
+
await write(path.join(root, `apps/${appName}/akan.config.ts`), "export default {};\n");
|
|
35
|
+
await write(path.join(root, `apps/${appName}/env/env.server.local.ts`), "export const env = {};\n");
|
|
36
|
+
await write(path.join(root, `libs/${libName}/akan.config.ts`), "export default {};\n");
|
|
37
|
+
await write(path.join(root, `libs/${libName}/package.json`), `{ "name": "@${libName}", "version": "0.0.1" }\n`);
|
|
38
|
+
await write(path.join(root, `libs/${libName}/common/helper.ts`), "export const helper = 1;\n");
|
|
39
|
+
await mkdir(path.join(root, `libs/${libName}/lib`), { recursive: true });
|
|
40
|
+
|
|
41
|
+
await git(root, ["init", "--quiet", "--initial-branch=develop"]);
|
|
42
|
+
await git(root, ["add", "-A"]);
|
|
43
|
+
await git(root, ["commit", "--quiet", "-m", "initial"]);
|
|
44
|
+
|
|
45
|
+
const workspace = WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: `spoke-${libName}` });
|
|
46
|
+
await new LibSource(LibExecutor.from(workspace, libName)).write({ origin: "hub#develop", sha: "abc1234" });
|
|
47
|
+
await write(
|
|
48
|
+
path.join(root, FleetGuard.anchorFile),
|
|
49
|
+
`${JSON.stringify({ hub: "hub", hubSha: "abc1234", branch: "develop", apps: [appName], syncedAt: "now" }, null, 2)}\n`,
|
|
50
|
+
);
|
|
51
|
+
await git(root, ["add", "-A"]);
|
|
52
|
+
await git(root, ["commit", "--quiet", "-m", "chore(fleet): sync from hub@abc1234"]);
|
|
53
|
+
|
|
54
|
+
return { root, workspace, guard: new FleetGuard(workspace) };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
describe("FleetGuard", () => {
|
|
58
|
+
test("passes in a workspace that is not a spoke, so one hook line is safe in the hub too", async () => {
|
|
59
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-guard-"));
|
|
60
|
+
tempRoots.push(root);
|
|
61
|
+
await write(path.join(root, "package.json"), '{ "name": "hub", "version": "0.0.1", "description": "hub" }\n');
|
|
62
|
+
await git(root, ["init", "--quiet"]);
|
|
63
|
+
|
|
64
|
+
const report = await new FleetGuard(
|
|
65
|
+
WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: "bare-hub" }),
|
|
66
|
+
).check();
|
|
67
|
+
|
|
68
|
+
expect(report.spoke).toBeNull();
|
|
69
|
+
expect(report.violations).toEqual([]);
|
|
70
|
+
expect(formatFleetGuardReport(report)).toContain("not a fleet spoke");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("passes a spoke that has changed nothing hub-owned", async () => {
|
|
74
|
+
const { root, guard } = await makeSpoke("clean-kit", "clean-app");
|
|
75
|
+
await write(path.join(root, "apps/clean-app/lib/task.ts"), "export const task = 1;\n");
|
|
76
|
+
await git(root, ["add", "-A"]);
|
|
77
|
+
await git(root, ["commit", "--quiet", "-m", "customer app work"]);
|
|
78
|
+
|
|
79
|
+
const report = await guard.check();
|
|
80
|
+
|
|
81
|
+
expect(report.spoke?.branch).toBe("develop");
|
|
82
|
+
expect(report.anchor).toMatch(/^[0-9a-f]{40}$/);
|
|
83
|
+
expect(report.violations).toEqual([]);
|
|
84
|
+
expect(report.driftedLibs).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("catches a library edit both by path and by stamp", async () => {
|
|
88
|
+
const { root, guard } = await makeSpoke("drift-kit", "drift-app");
|
|
89
|
+
await write(path.join(root, "libs/drift-kit/common/helper.ts"), "export const helper = 99;\n");
|
|
90
|
+
await git(root, ["add", "-A"]);
|
|
91
|
+
await git(root, ["commit", "--quiet", "-m", "quick fix in the lib"]);
|
|
92
|
+
|
|
93
|
+
const report = await guard.check();
|
|
94
|
+
|
|
95
|
+
expect(report.violations.map((violation) => violation.path)).toEqual(["libs/drift-kit/common/helper.ts"]);
|
|
96
|
+
expect(report.violations[0]?.reason).toContain("owned by the hub");
|
|
97
|
+
expect(report.driftedLibs.map((drift) => drift.lib)).toEqual(["drift-kit"]);
|
|
98
|
+
expect(formatFleetGuardReport(report)).toContain("akansoft");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("catches a workspace manifest edit and leaves env alone", async () => {
|
|
102
|
+
const { root, guard } = await makeSpoke("shell-kit", "shell-app");
|
|
103
|
+
await write(path.join(root, "package.json"), '{ "name": "spoke", "version": "0.0.2", "description": "x" }\n');
|
|
104
|
+
await write(path.join(root, "apps/shell-app/env/env.server.local.ts"), "export const env = { key: 1 };\n");
|
|
105
|
+
|
|
106
|
+
const report = await guard.check();
|
|
107
|
+
|
|
108
|
+
expect(report.violations.map((violation) => violation.path)).toEqual(["package.json"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("reads only the index in staged mode", async () => {
|
|
112
|
+
const { root, guard } = await makeSpoke("staged-kit", "staged-app");
|
|
113
|
+
await write(path.join(root, "libs/staged-kit/common/helper.ts"), "export const helper = 5;\n");
|
|
114
|
+
|
|
115
|
+
expect((await guard.check({ staged: true })).violations).toEqual([]);
|
|
116
|
+
await git(root, ["add", "libs/staged-kit/common/helper.ts"]);
|
|
117
|
+
expect((await guard.check({ staged: true })).violations.map((violation) => violation.path)).toEqual([
|
|
118
|
+
"libs/staged-kit/common/helper.ts",
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
});
|
package/fleetGuard.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { LibExecutor, type WorkspaceExecutor } from "./executors";
|
|
3
|
+
import { FileSys } from "./fileSys";
|
|
4
|
+
import { LibSource } from "./libSource";
|
|
5
|
+
|
|
6
|
+
export interface FleetAnchorFile {
|
|
7
|
+
hub: string;
|
|
8
|
+
hubSha: string;
|
|
9
|
+
branch: string;
|
|
10
|
+
apps: string[];
|
|
11
|
+
syncedAt: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FleetViolation {
|
|
15
|
+
path: string;
|
|
16
|
+
reason: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FleetLibDrift {
|
|
20
|
+
lib: string;
|
|
21
|
+
expected: string;
|
|
22
|
+
actual: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FleetGuardReport {
|
|
26
|
+
/** Null when this workspace is not a spoke, which is a pass — the same hook line is safe in the hub. */
|
|
27
|
+
spoke: FleetAnchorFile | null;
|
|
28
|
+
anchor: string | null;
|
|
29
|
+
violations: FleetViolation[];
|
|
30
|
+
driftedLibs: FleetLibDrift[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The spoke-side half of the fleet contract, run *inside* a customer repo by its own CI and pre-commit
|
|
35
|
+
* hook. It needs no hub access and no credentials: the library stamps carry the hash to compare against,
|
|
36
|
+
* and the last push is found by the one file only a push writes.
|
|
37
|
+
*
|
|
38
|
+
* Guards `libs/**` and the workspace-shell manifests, which the hub owns. `env/` is deliberately not
|
|
39
|
+
* guarded — those values belong to the repo that deploys.
|
|
40
|
+
*/
|
|
41
|
+
export class FleetGuard {
|
|
42
|
+
static readonly anchorFile = "akan.fleet.json";
|
|
43
|
+
static readonly hubOwnedRootFiles = ["package.json", "tsconfig.json", "biome.json", "bunfig.toml"];
|
|
44
|
+
static readonly spokeOwnedDirs = ["env"];
|
|
45
|
+
static readonly contact = "Ask the akansoft team to make this change in the hub, then pull it back down.";
|
|
46
|
+
|
|
47
|
+
#workspace: WorkspaceExecutor;
|
|
48
|
+
constructor(workspace: WorkspaceExecutor) {
|
|
49
|
+
this.#workspace = workspace;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async #readAnchorFile() {
|
|
53
|
+
const anchorPath = path.join(this.#workspace.workspaceRoot, FleetGuard.anchorFile);
|
|
54
|
+
if (!(await FileSys.fileExists(anchorPath))) return null;
|
|
55
|
+
return (await FileSys.readJson(anchorPath)) as FleetAnchorFile;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The push commit: the newest one that touched the file only a push writes. */
|
|
59
|
+
async #anchorCommit() {
|
|
60
|
+
const commit = await this.#workspace.spawn("git", ["log", "-1", "--format=%H", "--", FleetGuard.anchorFile]);
|
|
61
|
+
return commit.trim() || null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#isSpokeOwned(file: string) {
|
|
65
|
+
const segments = file.split("/");
|
|
66
|
+
if (segments[0] !== "apps" && segments[0] !== "libs") return false;
|
|
67
|
+
return FleetGuard.spokeOwnedDirs.includes(segments[2] ?? "");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#violationOf(file: string): FleetViolation | null {
|
|
71
|
+
if (this.#isSpokeOwned(file)) return null;
|
|
72
|
+
if (file === FleetGuard.anchorFile) return { path: file, reason: "the fleet anchor is written by a push" };
|
|
73
|
+
if (FleetGuard.hubOwnedRootFiles.includes(file))
|
|
74
|
+
return { path: file, reason: "workspace manifests are owned by the hub" };
|
|
75
|
+
if (file.startsWith("libs/")) return { path: file, reason: "shared libraries are owned by the hub" };
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async #changedFiles({ staged }: { staged: boolean }) {
|
|
80
|
+
if (staged) {
|
|
81
|
+
const cached = await this.#workspace.spawn("git", ["diff", "--cached", "--name-only"]);
|
|
82
|
+
return cached.split("\n").filter((file) => !!file.trim());
|
|
83
|
+
}
|
|
84
|
+
const anchor = await this.#anchorCommit();
|
|
85
|
+
const sinceAnchor = anchor
|
|
86
|
+
? (await this.#workspace.spawn("git", ["diff", "--name-only", `${anchor}..HEAD`]))
|
|
87
|
+
.split("\n")
|
|
88
|
+
.filter((file) => !!file.trim())
|
|
89
|
+
: [];
|
|
90
|
+
const working = (await this.#workspace.spawn("git", ["status", "--porcelain"]))
|
|
91
|
+
.split("\n")
|
|
92
|
+
.filter((line) => !!line.trim())
|
|
93
|
+
//? Porcelain v1: two status columns, a space, then the path — and a rename carries `old -> new`.
|
|
94
|
+
.map((line) => line.slice(3).split(" -> ").at(-1) ?? "")
|
|
95
|
+
.filter((file) => !!file);
|
|
96
|
+
return [...new Set([...sinceAnchor, ...working])];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async #libDrift(): Promise<FleetLibDrift[]> {
|
|
100
|
+
const libNames = await this.#workspace.getLibs();
|
|
101
|
+
const statuses = await Promise.all(
|
|
102
|
+
libNames.map(async (libName) => await new LibSource(LibExecutor.from(this.#workspace, libName)).status()),
|
|
103
|
+
);
|
|
104
|
+
return statuses
|
|
105
|
+
.filter((status) => status.drift === "drifted")
|
|
106
|
+
.map((status) => ({ lib: status.lib, expected: status.stamp?.hash ?? "", actual: status.hash }));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async check({ staged = false }: { staged?: boolean } = {}): Promise<FleetGuardReport> {
|
|
110
|
+
const spoke = await this.#readAnchorFile();
|
|
111
|
+
//* Not a spoke: a pass, so one `akan fleet check` line in a shared pre-commit hook is safe in the hub
|
|
112
|
+
//* too — the hub is where hub-owned files are supposed to be edited.
|
|
113
|
+
if (!spoke) return { spoke: null, anchor: null, violations: [], driftedLibs: [] };
|
|
114
|
+
const [anchor, changed, driftedLibs] = await Promise.all([
|
|
115
|
+
this.#anchorCommit(),
|
|
116
|
+
this.#changedFiles({ staged }),
|
|
117
|
+
staged ? Promise.resolve<FleetLibDrift[]>([]) : this.#libDrift(),
|
|
118
|
+
]);
|
|
119
|
+
const violations = changed
|
|
120
|
+
.map((file) => this.#violationOf(file))
|
|
121
|
+
.filter((violation): violation is FleetViolation => !!violation);
|
|
122
|
+
return { spoke, anchor, violations, driftedLibs };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function formatFleetGuardReport(report: FleetGuardReport, { warn = false }: { warn?: boolean } = {}) {
|
|
127
|
+
if (!report.spoke) return "Akan Fleet Guard: not a fleet spoke (no akan.fleet.json) — nothing to check.";
|
|
128
|
+
const clean = !report.violations.length && !report.driftedLibs.length;
|
|
129
|
+
const sections = [
|
|
130
|
+
"Akan Fleet Guard",
|
|
131
|
+
`hub: ${report.spoke.hub}#${report.spoke.branch} apps: ${report.spoke.apps.join(", ")}`,
|
|
132
|
+
`last push: ${report.anchor?.slice(0, 12) ?? "(none)"}`,
|
|
133
|
+
"",
|
|
134
|
+
...(clean ? [" no hub-owned file was changed here."] : []),
|
|
135
|
+
...(report.violations.length
|
|
136
|
+
? [
|
|
137
|
+
`Hub-owned files changed in this repo (${report.violations.length}):`,
|
|
138
|
+
"",
|
|
139
|
+
...report.violations.map((violation) => ` ${violation.path} — ${violation.reason}`),
|
|
140
|
+
]
|
|
141
|
+
: []),
|
|
142
|
+
...(report.driftedLibs.length
|
|
143
|
+
? [
|
|
144
|
+
"",
|
|
145
|
+
`Libraries edited since the last push (${report.driftedLibs.length}):`,
|
|
146
|
+
"",
|
|
147
|
+
...report.driftedLibs.map(
|
|
148
|
+
(drift) => ` libs/${drift.lib} stamped ${drift.expected.slice(0, 12)}, now ${drift.actual.slice(0, 12)}`,
|
|
149
|
+
),
|
|
150
|
+
]
|
|
151
|
+
: []),
|
|
152
|
+
...(clean
|
|
153
|
+
? []
|
|
154
|
+
: ["", ` ${FleetGuard.contact}`, ...(warn ? [" (warning only — this commit is not blocked)"] : [])]),
|
|
155
|
+
];
|
|
156
|
+
return sections.join("\n");
|
|
157
|
+
}
|