@warpgogol/forge 5.1.3 → 5.2.3
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/AGENTS.md +3 -1
- package/bin/cli.ts +4 -0
- package/dist/bin/cli.js +4 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/os/adr/adr.module.js +1 -1
- package/dist/os/adr/adr.module.js.map +1 -1
- package/dist/os/compass/compass.module.js +2 -2
- package/dist/os/compass/compass.module.js.map +1 -1
- package/dist/os/compass/handlers/compass-audit-handler.d.ts +3 -0
- package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-audit-handler.js +87 -8
- package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
- package/dist/os/core/core.module.js +1 -1
- package/dist/os/core/core.module.js.map +1 -1
- package/dist/os/mission/handlers/archive.d.ts.map +1 -1
- package/dist/os/mission/handlers/archive.js +22 -6
- package/dist/os/mission/handlers/archive.js.map +1 -1
- package/dist/os/program/lease.js +1 -1
- package/dist/os/program/lease.js.map +1 -1
- package/dist/os/queue/handlers/queue-validate.d.ts +4 -0
- package/dist/os/queue/handlers/queue-validate.d.ts.map +1 -0
- package/dist/os/queue/handlers/queue-validate.js +62 -0
- package/dist/os/queue/handlers/queue-validate.js.map +1 -0
- package/dist/os/queue/index.d.ts +5 -0
- package/dist/os/queue/index.d.ts.map +1 -0
- package/dist/os/queue/index.js +18 -0
- package/dist/os/queue/index.js.map +1 -0
- package/dist/os/queue/manifest.d.ts +14 -0
- package/dist/os/queue/manifest.d.ts.map +1 -0
- package/dist/os/queue/manifest.js +110 -0
- package/dist/os/queue/manifest.js.map +1 -0
- package/dist/os/queue/queue.module.d.ts +3 -0
- package/dist/os/queue/queue.module.d.ts.map +1 -0
- package/dist/os/queue/queue.module.js +56 -0
- package/dist/os/queue/queue.module.js.map +1 -0
- package/dist/os/queue/types.d.ts +25 -0
- package/dist/os/queue/types.d.ts.map +1 -0
- package/dist/os/queue/types.js +28 -0
- package/dist/os/queue/types.js.map +1 -0
- package/dist/os/rfc/handlers/list-create.d.ts.map +1 -1
- package/dist/os/rfc/handlers/list-create.js +79 -32
- package/dist/os/rfc/handlers/list-create.js.map +1 -1
- package/dist/os/rfc/handlers/pipeline-status.d.ts +2 -7
- package/dist/os/rfc/handlers/pipeline-status.d.ts.map +1 -1
- package/dist/os/rfc/handlers/pipeline-status.js +15 -57
- package/dist/os/rfc/handlers/pipeline-status.js.map +1 -1
- package/dist/os/rfc/rfc.module.js +1 -1
- package/dist/os/rfc/rfc.module.js.map +1 -1
- package/dist/os/spec/spec.module.d.ts.map +1 -1
- package/dist/os/spec/spec.module.js +2 -0
- package/dist/os/spec/spec.module.js.map +1 -1
- package/dist/src/knowledge/index.d.ts +1 -0
- package/dist/src/knowledge/index.d.ts.map +1 -1
- package/dist/src/knowledge/index.js +1 -0
- package/dist/src/knowledge/index.js.map +1 -1
- package/dist/src/knowledge/sync.d.ts +24 -0
- package/dist/src/knowledge/sync.d.ts.map +1 -0
- package/dist/src/knowledge/sync.js +127 -0
- package/dist/src/knowledge/sync.js.map +1 -0
- package/dist/src/onboarding/doctor.d.ts.map +1 -1
- package/dist/src/onboarding/doctor.js +7 -6
- package/dist/src/onboarding/doctor.js.map +1 -1
- package/dist/src/onboarding/init.d.ts.map +1 -1
- package/dist/src/onboarding/init.js +15 -8
- package/dist/src/onboarding/init.js.map +1 -1
- package/dist/src/onboarding/upgrade.d.ts.map +1 -1
- package/dist/src/onboarding/upgrade.js +42 -13
- package/dist/src/onboarding/upgrade.js.map +1 -1
- package/dist/src/pipeline-status.d.ts +62 -0
- package/dist/src/pipeline-status.d.ts.map +1 -0
- package/dist/src/pipeline-status.js +220 -0
- package/dist/src/pipeline-status.js.map +1 -0
- package/dist/src/types.d.ts +1 -2
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +1 -0
- package/dist/src/types.js.map +1 -1
- package/dist/src/utils/fs-atomic.js +1 -1
- package/dist/src/utils/fs-atomic.js.map +1 -1
- package/os/adr/adr.module.ts +1 -1
- package/os/compass/compass.module.ts +2 -2
- package/os/compass/handlers/compass-audit-handler.ts +116 -8
- package/os/compass/handlers/tests/compass-audit-plan.test.ts +98 -0
- package/os/compass/handlers/tests/compass-audit-record.test.ts +105 -0
- package/os/compass/handlers/tests/compass-audit-validate.test.ts +133 -0
- package/os/compass/handlers/tests/compass-ledger-scope.test.ts +124 -0
- package/os/core/core.module.ts +1 -1
- package/os/mission/handlers/archive.test.ts +49 -0
- package/os/mission/handlers/archive.ts +21 -6
- package/os/program/lease.ts +1 -1
- package/os/queue/handlers/queue-validate.ts +83 -0
- package/os/queue/index.ts +19 -0
- package/os/queue/manifest.ts +183 -0
- package/os/queue/queue-validate.test.ts +177 -0
- package/os/queue/queue.module.ts +59 -0
- package/os/queue/types.ts +49 -0
- package/os/rfc/handlers/list-create.ts +83 -35
- package/os/rfc/handlers/pipeline-status.ts +24 -67
- package/os/rfc/handlers/validate-rules.test.ts +50 -0
- package/os/rfc/rfc-create-concurrent.test.ts +118 -0
- package/os/rfc/rfc-create-hint.test.ts +90 -0
- package/os/rfc/rfc-read-only-no-side-effects.test.ts +83 -0
- package/os/rfc/rfc.module.ts +1 -1
- package/os/spec/spec.module.ts +2 -0
- package/package.json +17 -1
- package/skills/fo/fo-idea-i-just-want-to-see-the-result/SKILL.md +46 -1
- package/src/knowledge/__tests__/sync.test.ts +227 -0
- package/src/knowledge/index.ts +7 -0
- package/src/knowledge/sync.ts +161 -0
- package/src/onboarding/doctor.ts +7 -6
- package/src/onboarding/init.ts +15 -8
- package/src/onboarding/upgrade.ts +47 -16
- package/src/pipeline-status.test.ts +192 -0
- package/src/pipeline-status.ts +319 -0
- package/src/tests/upgrade.test.ts +53 -0
- package/src/types.ts +2 -2
- package/src/utils/fs-atomic.ts +1 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>
|
|
4
|
+
RFC-1139 AC-5: compass.audit.baseline must not seed ledger entries for
|
|
5
|
+
paths under missions/ or gitignored roots — the tracked ledger only
|
|
6
|
+
accumulates entries for paths that can be audited long-term.
|
|
7
|
+
</purpose>
|
|
8
|
+
</MODULE_CONTRACT>
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
12
|
+
import { mkdtemp, mkdir, writeFile, rm, readFile } from "node:fs/promises";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
import { promisify } from "node:util";
|
|
17
|
+
import { parse as yamlParse } from "yaml";
|
|
18
|
+
import { runCompassAuditBaseline } from "../compass-audit-handler.ts";
|
|
19
|
+
import type { ForgeCommandInput, ForgeRuntimeContext } from "../../../../src/types.ts";
|
|
20
|
+
|
|
21
|
+
const execFileAsync = promisify(execFile);
|
|
22
|
+
|
|
23
|
+
const logger = {
|
|
24
|
+
section() {},
|
|
25
|
+
info() {},
|
|
26
|
+
warn() {},
|
|
27
|
+
error() {},
|
|
28
|
+
success() {},
|
|
29
|
+
getEvents() {
|
|
30
|
+
return [];
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function makeContext(workspaceRoot: string): ForgeRuntimeContext {
|
|
35
|
+
return {
|
|
36
|
+
workspaceRoot,
|
|
37
|
+
site: undefined,
|
|
38
|
+
siteExplicit: false,
|
|
39
|
+
logger: logger as never,
|
|
40
|
+
dryRun: false,
|
|
41
|
+
outputFormat: "json",
|
|
42
|
+
io: {} as never,
|
|
43
|
+
actualState: undefined as never,
|
|
44
|
+
fileIntents: [],
|
|
45
|
+
} as unknown as ForgeRuntimeContext;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeInput(flags: Record<string, unknown> = {}): ForgeCommandInput {
|
|
49
|
+
return { argv: [], flags } as unknown as ForgeCommandInput;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface LedgerEntry {
|
|
53
|
+
path: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("compass.audit.baseline — RFC-1139 ledger scope", () => {
|
|
57
|
+
let root: string;
|
|
58
|
+
|
|
59
|
+
beforeEach(async () => {
|
|
60
|
+
root = await mkdtemp(join(tmpdir(), "compass-ledger-"));
|
|
61
|
+
// Authored source file (plain .ts, no generated marker → authored entry)
|
|
62
|
+
await mkdir(join(root, "packages", "foo"), { recursive: true });
|
|
63
|
+
await writeFile(join(root, "packages", "foo", "index.ts"), "export const x = 1;\n", "utf8");
|
|
64
|
+
// Mission workpiece file — would be authored but must be excluded
|
|
65
|
+
await mkdir(join(root, "missions", "acme-m000001", "workpiece", "src"), {
|
|
66
|
+
recursive: true,
|
|
67
|
+
});
|
|
68
|
+
await writeFile(
|
|
69
|
+
join(root, "missions", "acme-m000001", "workpiece", "src", "local.ts"),
|
|
70
|
+
"export const y = 2;\n",
|
|
71
|
+
"utf8",
|
|
72
|
+
);
|
|
73
|
+
// Gitignored generated dir — excluded via check-ignore
|
|
74
|
+
await mkdir(join(root, "dist"), { recursive: true });
|
|
75
|
+
await writeFile(join(root, "dist", "bundle.ts"), "export const z = 3;\n", "utf8");
|
|
76
|
+
await writeFile(join(root, ".gitignore"), "dist/\nmissions/\n", "utf8");
|
|
77
|
+
// Git repo for getRevisionByPath + check-ignore
|
|
78
|
+
await execFileAsync("git", ["init"], { cwd: root });
|
|
79
|
+
await execFileAsync("git", ["config", "user.email", "test@test.com"], { cwd: root });
|
|
80
|
+
await execFileAsync("git", ["config", "user.name", "Test"], { cwd: root });
|
|
81
|
+
await execFileAsync("git", ["add", "."], { cwd: root });
|
|
82
|
+
await execFileAsync("git", ["commit", "-m", "init"], { cwd: root });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(async () => {
|
|
86
|
+
await rm(root, { recursive: true, force: true });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("AC-5: baseline seeds no missions/ or gitignored ledger entries", async () => {
|
|
90
|
+
const result = await runCompassAuditBaseline(makeInput(), makeContext(root));
|
|
91
|
+
expect(result.exitCode).toBe(0);
|
|
92
|
+
|
|
93
|
+
const ledgerRaw = await readFile(
|
|
94
|
+
join(root, "docs", "compass-audit-ledger.generated.yaml"),
|
|
95
|
+
"utf8",
|
|
96
|
+
);
|
|
97
|
+
const ledger = yamlParse(ledgerRaw) as { entries: LedgerEntry[] };
|
|
98
|
+
const paths = ledger.entries.map((e) => e.path);
|
|
99
|
+
|
|
100
|
+
expect(paths.some((p) => p.startsWith("missions/"))).toBe(false);
|
|
101
|
+
expect(paths.some((p) => p.startsWith("dist/"))).toBe(false);
|
|
102
|
+
expect(paths).toContain("packages/foo/index.ts");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("AC-5: pre-existing missions/ entries are pruned on next baseline", async () => {
|
|
106
|
+
// Seed a ledger with a stale missions/ entry
|
|
107
|
+
await mkdir(join(root, "docs"), { recursive: true });
|
|
108
|
+
await writeFile(
|
|
109
|
+
join(root, "docs", "compass-audit-ledger.generated.yaml"),
|
|
110
|
+
`entries:\n - path: missions/acme-m000001/workpiece/src/local.ts\n entityId: ""\n auditedRevision: abc\n auditedHash: def\n auditedAt: "2026-01-01T00:00:00Z"\n verdict: baseline\n agent: system:baseline\n`,
|
|
111
|
+
"utf8",
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const result = await runCompassAuditBaseline(makeInput(), makeContext(root));
|
|
115
|
+
expect(result.exitCode).toBe(0);
|
|
116
|
+
|
|
117
|
+
const ledgerRaw = await readFile(
|
|
118
|
+
join(root, "docs", "compass-audit-ledger.generated.yaml"),
|
|
119
|
+
"utf8",
|
|
120
|
+
);
|
|
121
|
+
const ledger = yamlParse(ledgerRaw) as { entries: LedgerEntry[] };
|
|
122
|
+
expect(ledger.entries.some((e) => e.path.startsWith("missions/"))).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
package/os/core/core.module.ts
CHANGED
|
@@ -305,7 +305,7 @@ export async function createForgeCoreModule(): Promise<ForgeModule> {
|
|
|
305
305
|
{
|
|
306
306
|
name: "forge.scaffold",
|
|
307
307
|
description:
|
|
308
|
-
"Create a working pnpm + Turborepo monorepo from a stack profile in an empty directory.",
|
|
308
|
+
"Create a working pnpm + Turborepo monorepo from a stack profile in an empty directory. Required flags: `--profile`, `--name`.",
|
|
309
309
|
scope: "workspace",
|
|
310
310
|
supportsAllSites: false,
|
|
311
311
|
flags: {
|
|
@@ -572,4 +572,53 @@ describe("mission.archive", () => {
|
|
|
572
572
|
expect(data.skipped).toHaveLength(1);
|
|
573
573
|
expect(data.skipped[0].reason).toContain("use --clean-orphans");
|
|
574
574
|
});
|
|
575
|
+
|
|
576
|
+
test("RFC-1138 AC-2: git add -A skipped when archive target is gitignored", async () => {
|
|
577
|
+
execSyncMock.mockReset();
|
|
578
|
+
execSyncMock.mockImplementation((cmd: string) => {
|
|
579
|
+
if (cmd.startsWith("pnpm install")) return "";
|
|
580
|
+
if (cmd.startsWith("git status --porcelain pnpm-lock.yaml")) return "M pnpm-lock.yaml";
|
|
581
|
+
// git check-ignore exits 0 → path is ignored (missions/ is gitignored)
|
|
582
|
+
if (cmd.startsWith("git check-ignore")) return "";
|
|
583
|
+
return "";
|
|
584
|
+
});
|
|
585
|
+
await writeMissionManifest(missionsDir, "test-r1138-01", "closed");
|
|
586
|
+
|
|
587
|
+
await runMissionArchive(makeInput(), makeContext(tmpDir));
|
|
588
|
+
|
|
589
|
+
const checkIgnoreCall = execSyncMock.mock.calls.find(
|
|
590
|
+
(c) => typeof c[0] === "string" && c[0].startsWith("git check-ignore"),
|
|
591
|
+
);
|
|
592
|
+
expect(checkIgnoreCall).toBeDefined();
|
|
593
|
+
|
|
594
|
+
const addArchiveCall = execSyncMock.mock.calls.find(
|
|
595
|
+
(c) => typeof c[0] === "string" && c[0].startsWith("git add -A"),
|
|
596
|
+
);
|
|
597
|
+
expect(addArchiveCall).toBeUndefined();
|
|
598
|
+
|
|
599
|
+
// Lockfile itself is still staged + committed
|
|
600
|
+
const lockfileAdd = execSyncMock.mock.calls.find(
|
|
601
|
+
(c) => typeof c[0] === "string" && c[0] === "git add pnpm-lock.yaml",
|
|
602
|
+
);
|
|
603
|
+
expect(lockfileAdd).toBeDefined();
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("RFC-1138 AC-2: git add -A still runs when archive target is NOT gitignored", async () => {
|
|
607
|
+
execSyncMock.mockReset();
|
|
608
|
+
execSyncMock.mockImplementation((cmd: string) => {
|
|
609
|
+
if (cmd.startsWith("pnpm install")) return "";
|
|
610
|
+
if (cmd.startsWith("git status --porcelain pnpm-lock.yaml")) return "M pnpm-lock.yaml";
|
|
611
|
+
// git check-ignore exits 1 → not ignored → execSync throws
|
|
612
|
+
if (cmd.startsWith("git check-ignore")) throw new Error("exit 1");
|
|
613
|
+
return "";
|
|
614
|
+
});
|
|
615
|
+
await writeMissionManifest(missionsDir, "test-r1138-02", "closed");
|
|
616
|
+
|
|
617
|
+
await runMissionArchive(makeInput(), makeContext(tmpDir));
|
|
618
|
+
|
|
619
|
+
const addArchiveCall = execSyncMock.mock.calls.find(
|
|
620
|
+
(c) => typeof c[0] === "string" && c[0].startsWith("git add -A"),
|
|
621
|
+
);
|
|
622
|
+
expect(addArchiveCall).toBeDefined();
|
|
623
|
+
});
|
|
575
624
|
});
|
|
@@ -13,7 +13,6 @@ archive subdirectories back to missions/.
|
|
|
13
13
|
</non-goals>
|
|
14
14
|
</MODULE_CONTRACT>
|
|
15
15
|
<CHANGE_SUMMARY>
|
|
16
|
-
<item>RFC-0733: add pinned-files pre-check — skip pinned mission directories with warning instead of moving them.</item>
|
|
17
16
|
<item>RFC-0804: auto-refresh pnpm-lock.yaml after directory moves.</item>
|
|
18
17
|
<item>RFC-0982: fallback state detection for orphaned workpiece dirs (no mission.yaml) via .closed marker and cache-only heuristic; --clean-orphans flag; improved skip reasons.</item>
|
|
19
18
|
<item>RFC-1097: step 6 — compass.migrate codemod run
|
|
@@ -22,7 +21,8 @@ Mechanical v1 to v2 header migration across the workspace: 942 files rewritten
|
|
|
22
21
|
<item>RFC-1097: sweep — packages/forge + services clean
|
|
23
22
|
|
|
24
23
|
Sweep batch 2: real KEY_DECISIONS on 10 files, expanded purposes (CONTRACT-02/PURPOSE-02), headers on mission/index + gen-upstreams, sanitizeItemText in summary.record (literal Compass tags corrupted history), excludedPaths for wrangler types, test-fixtures testPattern. forge+services now 0 diagnostics under --mode error.</item>
|
|
25
|
-
<
|
|
24
|
+
<item>RFC-1138: pipeline hygiene — module-scoped exempt entries, archive gitignore guard, dns upsert to deploy phases, promote auto-sync, rfc.create claim protocol, siteHasRuntime filter</item>
|
|
25
|
+
<history>RFC-0573, RFC-0733, RFC-0801</history>
|
|
26
26
|
</CHANGE_SUMMARY>
|
|
27
27
|
*/
|
|
28
28
|
|
|
@@ -520,10 +520,25 @@ export async function runMissionArchive(
|
|
|
520
520
|
stdio: ["pipe", "pipe", "pipe"],
|
|
521
521
|
});
|
|
522
522
|
for (const m of moved) {
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
523
|
+
// RFC-1138: missions/ is gitignored — `git add` on the archive path is
|
|
524
|
+
// structurally guaranteed to fail. Skip staging when the target is
|
|
525
|
+
// ignored (git check-ignore exits 0 = ignored, 1 = not ignored).
|
|
526
|
+
let targetIgnored = false;
|
|
527
|
+
try {
|
|
528
|
+
execSync(`git check-ignore -q ${JSON.stringify(m.to)}`, {
|
|
529
|
+
cwd: workspaceRoot,
|
|
530
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
531
|
+
});
|
|
532
|
+
targetIgnored = true;
|
|
533
|
+
} catch {
|
|
534
|
+
targetIgnored = false;
|
|
535
|
+
}
|
|
536
|
+
if (!targetIgnored) {
|
|
537
|
+
execSync(`git add -A ${JSON.stringify(m.to)}`, {
|
|
538
|
+
cwd: workspaceRoot,
|
|
539
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
540
|
+
});
|
|
541
|
+
}
|
|
527
542
|
if (existsSync(m.from)) continue;
|
|
528
543
|
execSync(`git rm -r --cached --ignore-unmatch ${JSON.stringify(m.from)}`, {
|
|
529
544
|
cwd: workspaceRoot,
|
package/os/program/lease.ts
CHANGED
|
@@ -38,7 +38,7 @@ export function leasePath(workspaceRoot: string, program: string, packetId: stri
|
|
|
38
38
|
* Generate a random opaque lease token (32 bytes hex).
|
|
39
39
|
*/
|
|
40
40
|
export function generateLeaseToken(): string {
|
|
41
|
-
return randomBytes(32).toString("
|
|
41
|
+
return Array.from(randomBytes(32), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>queue.validate command handler (RFC-1140) — validates a queue
|
|
4
|
+
manifest and reports derived per-item pipeline status plus the next
|
|
5
|
+
actionable item. Read-only; the orchestrator runs this before a queued batch.</purpose>
|
|
6
|
+
<non-goals>
|
|
7
|
+
<item>Do not execute the queue — this command validates and previews only.</item>
|
|
8
|
+
<item>Do not mutate any files.</item>
|
|
9
|
+
</non-goals>
|
|
10
|
+
</MODULE_CONTRACT>
|
|
11
|
+
<CHANGE_SUMMARY>
|
|
12
|
+
<item>RFC-1140: initial queue.validate handler.</item>
|
|
13
|
+
<item>RFC-1140: steps 1-4 — shared resolver, queue module, registration
|
|
14
|
+
|
|
15
|
+
Extract pipeline-status derivation into packages/forge/src/pipeline-status.ts, refactor rfc.pipeline.status onto it, add os/queue module with queue.validate command, register in WORKSHOP_MODULE_MAP.forge + bin/cli.ts + package.json exports.</item>
|
|
16
|
+
</CHANGE_SUMMARY>
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
ForgeCommandInput,
|
|
21
|
+
ForgeCommandResult,
|
|
22
|
+
ForgeRuntimeContext,
|
|
23
|
+
} from "../../../src/types.ts";
|
|
24
|
+
import { deriveQueueReport } from "../../../src/pipeline-status.ts";
|
|
25
|
+
import { loadQueueManifest } from "../manifest.ts";
|
|
26
|
+
import type { QueueValidateResult } from "../types.ts";
|
|
27
|
+
|
|
28
|
+
export async function runQueueValidate(
|
|
29
|
+
input: ForgeCommandInput,
|
|
30
|
+
context: ForgeRuntimeContext,
|
|
31
|
+
): Promise<ForgeCommandResult<QueueValidateResult>> {
|
|
32
|
+
const { workspaceRoot, logger, outputFormat } = context;
|
|
33
|
+
|
|
34
|
+
const file = input.flags["file"] as string | undefined;
|
|
35
|
+
if (!file) {
|
|
36
|
+
throw new Error("queue.validate requires --file <path-to-manifest.yaml>");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { manifest, errors, warnings } = await loadQueueManifest(workspaceRoot, file);
|
|
40
|
+
|
|
41
|
+
const report =
|
|
42
|
+
errors.length === 0
|
|
43
|
+
? await deriveQueueReport(
|
|
44
|
+
workspaceRoot,
|
|
45
|
+
manifest.items.map((i) => i.id),
|
|
46
|
+
)
|
|
47
|
+
: { items: [], next: null };
|
|
48
|
+
|
|
49
|
+
const status = errors.length === 0 ? "pass" : "fail";
|
|
50
|
+
|
|
51
|
+
if (outputFormat === "pretty") {
|
|
52
|
+
logger.section(`Queue: ${manifest.id || file}`);
|
|
53
|
+
for (const item of report.items) {
|
|
54
|
+
const step = item.pipelineStep ? ` (${item.pipelineStep})` : "";
|
|
55
|
+
logger.info(` ${item.id}: ${item.status}${step}`);
|
|
56
|
+
}
|
|
57
|
+
logger.info(`next: ${report.next ?? "—"}`);
|
|
58
|
+
for (const w of warnings) {
|
|
59
|
+
logger.warn(` ${w.ruleId}: ${w.message}`);
|
|
60
|
+
}
|
|
61
|
+
for (const e of errors) {
|
|
62
|
+
logger.error(` ${e.ruleId}: ${e.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
data: {
|
|
68
|
+
command: "queue.validate",
|
|
69
|
+
status,
|
|
70
|
+
queue: manifest.id,
|
|
71
|
+
file,
|
|
72
|
+
items: report.items,
|
|
73
|
+
next: report.next,
|
|
74
|
+
errors,
|
|
75
|
+
warnings,
|
|
76
|
+
},
|
|
77
|
+
summary:
|
|
78
|
+
status === "pass"
|
|
79
|
+
? `Queue "${manifest.id}": ${report.items.length} item(s), next: ${report.next ?? "none"}`
|
|
80
|
+
: `Queue manifest invalid: ${errors.length} error(s)`,
|
|
81
|
+
exitCode: status === "pass" ? 0 : 1,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Barrel export for the forge queue module (RFC-1140).</purpose>
|
|
4
|
+
<non-goals>
|
|
5
|
+
<item>Do not add logic here — re-exports only.</item>
|
|
6
|
+
</non-goals>
|
|
7
|
+
</MODULE_CONTRACT>
|
|
8
|
+
<CHANGE_SUMMARY>
|
|
9
|
+
<item>RFC-1140: initial barrel export.</item>
|
|
10
|
+
<item>RFC-1140: steps 1-4 — shared resolver, queue module, registration
|
|
11
|
+
|
|
12
|
+
Extract pipeline-status derivation into packages/forge/src/pipeline-status.ts, refactor rfc.pipeline.status onto it, add os/queue module with queue.validate command, register in WORKSHOP_MODULE_MAP.forge + bin/cli.ts + package.json exports.</item>
|
|
13
|
+
</CHANGE_SUMMARY>
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export { createForgeQueueModule } from "./queue.module.ts";
|
|
17
|
+
export { loadQueueManifest } from "./manifest.ts";
|
|
18
|
+
export { queueManifestSchema, QUEUE_ITEM_ID_PATTERN } from "./types.ts";
|
|
19
|
+
export type { QueueManifest, QueueValidateResult } from "./types.ts";
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Queue manifest loading and validation (RFC-1140) — YAML parse, zod
|
|
4
|
+
schema, id↔filename stem check, item id pattern, document resolution across
|
|
5
|
+
active and archive dirs, duplicate detection, and dependsOn order warnings.</purpose>
|
|
6
|
+
<non-goals>
|
|
7
|
+
<item>Do not derive item status — that is src/pipeline-status.ts.</item>
|
|
8
|
+
<item>Do not mutate the manifest file.</item>
|
|
9
|
+
</non-goals>
|
|
10
|
+
</MODULE_CONTRACT>
|
|
11
|
+
<CHANGE_SUMMARY>
|
|
12
|
+
<item>RFC-1140: initial manifest loader and validator.</item>
|
|
13
|
+
<item>RFC-1140: steps 1-4 — shared resolver, queue module, registration
|
|
14
|
+
|
|
15
|
+
Extract pipeline-status derivation into packages/forge/src/pipeline-status.ts, refactor rfc.pipeline.status onto it, add os/queue module with queue.validate command, register in WORKSHOP_MODULE_MAP.forge + bin/cli.ts + package.json exports.</item>
|
|
16
|
+
</CHANGE_SUMMARY>
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from "node:fs/promises";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import YAML from "yaml";
|
|
22
|
+
|
|
23
|
+
import type { Diagnostic } from "../../src/types.ts";
|
|
24
|
+
import { resolveDocument } from "../../src/pipeline-status.ts";
|
|
25
|
+
import { QUEUE_ITEM_ID_PATTERN, queueManifestSchema } from "./types.ts";
|
|
26
|
+
import type { QueueManifest } from "./types.ts";
|
|
27
|
+
|
|
28
|
+
export interface LoadedQueueManifest {
|
|
29
|
+
manifest: QueueManifest;
|
|
30
|
+
errors: Diagnostic[];
|
|
31
|
+
warnings: Diagnostic[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function diag(
|
|
35
|
+
ruleId: string,
|
|
36
|
+
severity: "error" | "warning",
|
|
37
|
+
message: string,
|
|
38
|
+
file?: string,
|
|
39
|
+
): Diagnostic {
|
|
40
|
+
return { ruleId, severity, message, file };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Load and validate a queue manifest. Blocking errors: unreadable/invalid
|
|
45
|
+
* YAML, schema violations, id↔filename mismatch, malformed or unresolvable
|
|
46
|
+
* item ids, duplicates. Non-blocking: dependsOn order violations.
|
|
47
|
+
*/
|
|
48
|
+
export async function loadQueueManifest(
|
|
49
|
+
workspaceRoot: string,
|
|
50
|
+
filePath: string,
|
|
51
|
+
): Promise<LoadedQueueManifest> {
|
|
52
|
+
const errors: Diagnostic[] = [];
|
|
53
|
+
const warnings: Diagnostic[] = [];
|
|
54
|
+
const absolutePath = path.isAbsolute(filePath)
|
|
55
|
+
? filePath
|
|
56
|
+
: path.join(workspaceRoot, filePath);
|
|
57
|
+
const displayPath = path.isAbsolute(filePath)
|
|
58
|
+
? path.relative(workspaceRoot, filePath)
|
|
59
|
+
: filePath;
|
|
60
|
+
|
|
61
|
+
let source: string;
|
|
62
|
+
try {
|
|
63
|
+
source = await fs.readFile(absolutePath, "utf-8");
|
|
64
|
+
} catch (e) {
|
|
65
|
+
errors.push(
|
|
66
|
+
diag(
|
|
67
|
+
"QUEUE-01",
|
|
68
|
+
"error",
|
|
69
|
+
`Cannot read queue manifest: ${e instanceof Error ? e.message : String(e)}`,
|
|
70
|
+
displayPath,
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
return { manifest: { id: "", createdAt: "", items: [] }, errors, warnings };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let raw: unknown;
|
|
77
|
+
try {
|
|
78
|
+
raw = YAML.parse(source);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
errors.push(
|
|
81
|
+
diag(
|
|
82
|
+
"QUEUE-01",
|
|
83
|
+
"error",
|
|
84
|
+
`YAML parse error: ${e instanceof Error ? e.message : String(e)}`,
|
|
85
|
+
displayPath,
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
return { manifest: { id: "", createdAt: "", items: [] }, errors, warnings };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const parsed = queueManifestSchema.safeParse(raw);
|
|
92
|
+
if (!parsed.success) {
|
|
93
|
+
for (const issue of parsed.error.issues) {
|
|
94
|
+
errors.push(
|
|
95
|
+
diag(
|
|
96
|
+
"QUEUE-01",
|
|
97
|
+
"error",
|
|
98
|
+
`Manifest schema violation at ${issue.path.join(".") || "<root>"}: ${issue.message}`,
|
|
99
|
+
displayPath,
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return { manifest: { id: "", createdAt: "", items: [] }, errors, warnings };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const manifest = parsed.data;
|
|
107
|
+
|
|
108
|
+
// QUEUE-02: manifest id must equal the filename stem.
|
|
109
|
+
const stem = path.basename(absolutePath).replace(/\.(ya?ml)$/i, "");
|
|
110
|
+
if (manifest.id !== stem) {
|
|
111
|
+
errors.push(
|
|
112
|
+
diag(
|
|
113
|
+
"QUEUE-02",
|
|
114
|
+
"error",
|
|
115
|
+
`Manifest id "${manifest.id}" does not match filename stem "${stem}"`,
|
|
116
|
+
displayPath,
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// QUEUE-03/04/05: item id pattern, resolution, duplicates.
|
|
122
|
+
const seen = new Set<string>();
|
|
123
|
+
const resolvedDependsOn = new Map<string, string[]>();
|
|
124
|
+
for (const item of manifest.items) {
|
|
125
|
+
const id = item.id.trim();
|
|
126
|
+
if (!QUEUE_ITEM_ID_PATTERN.test(id)) {
|
|
127
|
+
errors.push(
|
|
128
|
+
diag("QUEUE-03", "error", `Item id "${item.id}" does not match ^(RFC|ADR)-\\d{4}$`, displayPath),
|
|
129
|
+
);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const upper = id.toUpperCase();
|
|
133
|
+
if (seen.has(upper)) {
|
|
134
|
+
errors.push(diag("QUEUE-05", "error", `Duplicate item id "${upper}"`, displayPath));
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
seen.add(upper);
|
|
138
|
+
|
|
139
|
+
const doc = await resolveDocument(workspaceRoot, upper);
|
|
140
|
+
if (!doc) {
|
|
141
|
+
errors.push(
|
|
142
|
+
diag(
|
|
143
|
+
"QUEUE-04",
|
|
144
|
+
"error",
|
|
145
|
+
`Item "${upper}" does not resolve to a document in docs/rfcs/ or docs/adrs/ (incl. archive)`,
|
|
146
|
+
displayPath,
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const dependsOn = doc.frontmatter["dependsOn"];
|
|
153
|
+
if (Array.isArray(dependsOn)) {
|
|
154
|
+
resolvedDependsOn.set(
|
|
155
|
+
upper,
|
|
156
|
+
dependsOn.map((d) => String(d).toUpperCase()),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// QUEUE-06: dependsOn order — an item must not precede its in-queue dependency.
|
|
162
|
+
const order = new Map<string, number>();
|
|
163
|
+
manifest.items.forEach((item, index) => order.set(item.id.trim().toUpperCase(), index));
|
|
164
|
+
for (const [itemId, deps] of resolvedDependsOn) {
|
|
165
|
+
const itemIndex = order.get(itemId);
|
|
166
|
+
if (itemIndex === undefined) continue;
|
|
167
|
+
for (const dep of deps) {
|
|
168
|
+
const depIndex = order.get(dep);
|
|
169
|
+
if (depIndex !== undefined && depIndex > itemIndex) {
|
|
170
|
+
warnings.push(
|
|
171
|
+
diag(
|
|
172
|
+
"QUEUE-06",
|
|
173
|
+
"warning",
|
|
174
|
+
`Item "${itemId}" (position ${itemIndex + 1}) precedes its dependsOn dependency "${dep}" (position ${depIndex + 1})`,
|
|
175
|
+
displayPath,
|
|
176
|
+
),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { manifest, errors, warnings };
|
|
183
|
+
}
|