@warpgogol/forge 5.2.0 → 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 +2 -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/queue.module.js +1 -1
- package/dist/os/queue/queue.module.js.map +1 -1
- 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/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/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/queue.module.ts +1 -1
- package/os/rfc/handlers/list-create.ts +83 -35
- 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 +1 -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/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
|
/**
|
package/os/queue/queue.module.ts
CHANGED
|
@@ -31,7 +31,7 @@ export async function createForgeQueueModule(): Promise<ForgeModule> {
|
|
|
31
31
|
description:
|
|
32
32
|
"Validate a queue manifest (docs/queues/*.yaml) and report derived " +
|
|
33
33
|
"per-item pipeline status plus the next actionable item. Read-only — " +
|
|
34
|
-
"the pipeline orchestrator runs this before starting a queued batch (RFC-1140).",
|
|
34
|
+
"the pipeline orchestrator runs this before starting a queued batch (RFC-1140). Required flags: `--file`.",
|
|
35
35
|
scope: "workspace",
|
|
36
36
|
contract: "queue",
|
|
37
37
|
rules: ["QUEUE-01", "QUEUE-02", "QUEUE-03", "QUEUE-04", "QUEUE-05", "QUEUE-06"],
|
|
@@ -6,13 +6,16 @@
|
|
|
6
6
|
</non-goals>
|
|
7
7
|
</MODULE_CONTRACT>
|
|
8
8
|
<CHANGE_SUMMARY>
|
|
9
|
-
<item>RFC-0303: extracted list and create handlers from handlers.ts into handlers/list-create.ts.</item>
|
|
10
9
|
<item>RFC-1097: step 6 — compass.migrate codemod run
|
|
11
10
|
|
|
12
11
|
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.</item>
|
|
13
12
|
<item>RFC-1097: sweep — werkstatt-engine clean
|
|
14
13
|
|
|
15
14
|
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.</item>
|
|
15
|
+
<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>
|
|
16
|
+
<item>RFC-1139: CLI hint accuracy and agent-safety hygiene — rfc.create hint, EC-14-PARTIAL, amend delegation, ledger scope, sync footer, mission.open remnants</item>
|
|
17
|
+
<item>RFC-1139: second hint fix (rfc.list empty-state) + read-only test fix</item>
|
|
18
|
+
<history>RFC-0303</history>
|
|
16
19
|
</CHANGE_SUMMARY>
|
|
17
20
|
*/
|
|
18
21
|
|
|
@@ -140,7 +143,7 @@ export async function runRfcList(
|
|
|
140
143
|
entries.length === 0
|
|
141
144
|
? [
|
|
142
145
|
{
|
|
143
|
-
action: `Create a new RFC: pnpm exec
|
|
146
|
+
action: `Create a new RFC: pnpm exec werkstatt run rfc.create --title "..."`,
|
|
144
147
|
kind: "optional",
|
|
145
148
|
},
|
|
146
149
|
]
|
|
@@ -184,21 +187,6 @@ export async function runRfcCreate(
|
|
|
184
187
|
}
|
|
185
188
|
await assertSatisfiesIdsExist(workspaceRoot, satisfies);
|
|
186
189
|
|
|
187
|
-
const files = await listRfcFiles(rfcDirPath);
|
|
188
|
-
let maxId = 0;
|
|
189
|
-
for (const f of files) {
|
|
190
|
-
const basename = path.basename(f);
|
|
191
|
-
const match = basename.match(/^rfc-(\d{4})/);
|
|
192
|
-
if (match) {
|
|
193
|
-
const num = parseInt(match[1]!, 10);
|
|
194
|
-
if (num > maxId) maxId = num;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
const nextNum = maxId + 1;
|
|
198
|
-
const paddedNum = String(nextNum).padStart(4, "0");
|
|
199
|
-
const nextSlug = `rfc-${paddedNum}`;
|
|
200
|
-
const nextId = `RFC-${paddedNum}`;
|
|
201
|
-
|
|
202
190
|
const templatePath = resolveRfcTemplate(workspaceRoot);
|
|
203
191
|
let templateContent: string;
|
|
204
192
|
try {
|
|
@@ -208,7 +196,6 @@ export async function runRfcCreate(
|
|
|
208
196
|
}
|
|
209
197
|
|
|
210
198
|
const kebabTitle = toKebabCase(title);
|
|
211
|
-
const fileName = `${nextSlug}-${kebabTitle}.md`;
|
|
212
199
|
|
|
213
200
|
// RFC-0329: consult decision log before scaffolding
|
|
214
201
|
let consultedDecisions: ConsultedDecision[] = [];
|
|
@@ -230,25 +217,86 @@ export async function runRfcCreate(
|
|
|
230
217
|
}
|
|
231
218
|
}
|
|
232
219
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
220
|
+
// RFC-1138: atomic id allocation — scan → claim the id via wx on a
|
|
221
|
+
// title-independent `rfc-NNNN.claim` file → write the real file → release.
|
|
222
|
+
// The claim must be on the id, not the filename: two creates with different
|
|
223
|
+
// titles produce different filenames, so a wx write on the target path can
|
|
224
|
+
// never collide. `.claim` files are invisible to listRfcFiles (.md-only),
|
|
225
|
+
// so they don't pollute rfc.list or the maxId scan. Stale claims (crashed
|
|
226
|
+
// create) are reclaimed after CLAIM_STALE_MS.
|
|
227
|
+
const MAX_CREATE_ATTEMPTS = 10;
|
|
228
|
+
const CLAIM_STALE_MS = 30_000;
|
|
229
|
+
let nextId = "";
|
|
230
|
+
let relativeFile = "";
|
|
231
|
+
let allocated = false;
|
|
232
|
+
|
|
233
|
+
const files = await listRfcFiles(rfcDirPath);
|
|
234
|
+
let maxId = 0;
|
|
235
|
+
for (const f of files) {
|
|
236
|
+
const basename = path.basename(f);
|
|
237
|
+
const match = basename.match(/^rfc-(\d{4})/);
|
|
238
|
+
if (match) {
|
|
239
|
+
const num = parseInt(match[1]!, 10);
|
|
240
|
+
if (num > maxId) maxId = num;
|
|
241
|
+
}
|
|
245
242
|
}
|
|
246
|
-
|
|
243
|
+
let candidate = maxId + 1;
|
|
244
|
+
|
|
245
|
+
for (let attempt = 1; attempt <= MAX_CREATE_ATTEMPTS; attempt++) {
|
|
246
|
+
const paddedNum = String(candidate).padStart(4, "0");
|
|
247
|
+
const nextSlug = `rfc-${paddedNum}`;
|
|
248
|
+
nextId = `RFC-${paddedNum}`;
|
|
249
|
+
const fileName = `${nextSlug}-${kebabTitle}.md`;
|
|
250
|
+
const claimPath = path.join(rfcDirPath, `${nextSlug}.claim`);
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
await fs.writeFile(claimPath, `${process.pid}\n`, { flag: "wx" });
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
|
|
256
|
+
// Stale claim (crashed create) → reclaim and retry the same id.
|
|
257
|
+
// Live claim → another create holds this id — move to the next.
|
|
258
|
+
const st = await fs.stat(claimPath).catch(() => null);
|
|
259
|
+
if (st && Date.now() - st.mtimeMs > CLAIM_STALE_MS) {
|
|
260
|
+
await fs.rm(claimPath, { force: true });
|
|
261
|
+
} else {
|
|
262
|
+
candidate++;
|
|
263
|
+
}
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
247
268
|
|
|
248
|
-
|
|
249
|
-
|
|
269
|
+
try {
|
|
270
|
+
let content = templateContent;
|
|
271
|
+
content = content.replace(/^id: RFC-0000$/m, `id: ${nextId}`);
|
|
272
|
+
content = content.replace(/^title: ".*"$/m, `title: "${title}"`);
|
|
273
|
+
content = content.replace(/^kind: \w+$/m, `kind: ${kind}`);
|
|
274
|
+
content = content.replace(/^scope: \w+$/m, `scope: ${scope}`);
|
|
275
|
+
content = content.replace(/^createdAt: YYYY-MM-DD$/m, `createdAt: ${today}`);
|
|
276
|
+
content = content.replace(/^updatedAt: YYYY-MM-DD$/m, `updatedAt: ${today}`);
|
|
277
|
+
if (satisfies.length > 0) {
|
|
278
|
+
content = content.replace(
|
|
279
|
+
/^satisfies: \[\]$/m,
|
|
280
|
+
`satisfies:\n${satisfies.map((id) => ` - ${id}`).join("\n")}`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
content = content.replace(/^# RFC-0000: .+$/m, `# ${nextId}: ${title}`);
|
|
284
|
+
|
|
285
|
+
const targetPath = path.join(rfcDirPath, fileName);
|
|
286
|
+
await fs.writeFile(targetPath, content, { flag: "wx" });
|
|
287
|
+
relativeFile = path.join(RFC_DIR, fileName);
|
|
288
|
+
allocated = true;
|
|
289
|
+
} finally {
|
|
290
|
+
await fs.rm(claimPath, { force: true });
|
|
291
|
+
}
|
|
292
|
+
if (allocated) break;
|
|
293
|
+
}
|
|
250
294
|
|
|
251
|
-
|
|
295
|
+
if (!allocated) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`rfc.create: id allocation failed after ${MAX_CREATE_ATTEMPTS} attempts — concurrent creates keep claiming the next id`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
252
300
|
|
|
253
301
|
if (outputFormat === "pretty") {
|
|
254
302
|
logger.success(`Created ${nextId}: ${title}`);
|
|
@@ -269,7 +317,7 @@ export async function runRfcCreate(
|
|
|
269
317
|
summary: `Created ${nextId}: ${title}`,
|
|
270
318
|
nextSteps: [
|
|
271
319
|
{
|
|
272
|
-
action: `Validate the RFC: pnpm exec
|
|
320
|
+
action: `Validate the RFC: pnpm exec werkstatt run rfc.validate --id ${nextId}`,
|
|
273
321
|
kind: "optional",
|
|
274
322
|
},
|
|
275
323
|
],
|
|
@@ -385,6 +385,56 @@ describe("V-32: implementation commit drift detection", () => {
|
|
|
385
385
|
}
|
|
386
386
|
});
|
|
387
387
|
|
|
388
|
+
test("no V-32 or V-17 when implemented RFC is later superseded", async () => {
|
|
389
|
+
const dir = createGitRepoWithCommits([
|
|
390
|
+
{ message: "implement: RFC-9999 — step 1", date: "2026-01-02T10:00:00" },
|
|
391
|
+
]);
|
|
392
|
+
try {
|
|
393
|
+
const parsed = makeParsed("superseded", BASE_BODY, {
|
|
394
|
+
supersededBy: "RFC-0001",
|
|
395
|
+
});
|
|
396
|
+
const { add, violations } = makeViolationsCollector();
|
|
397
|
+
const allParsed = new Map([
|
|
398
|
+
[
|
|
399
|
+
"RFC-0001",
|
|
400
|
+
{
|
|
401
|
+
fileName: "rfc-0001-other.md",
|
|
402
|
+
parsed: makeParsed("accepted", BASE_BODY, { supersedes: ["RFC-9999"] }),
|
|
403
|
+
},
|
|
404
|
+
],
|
|
405
|
+
]);
|
|
406
|
+
await validateSingleRfc(
|
|
407
|
+
"rfc-9999-test.md",
|
|
408
|
+
parsed,
|
|
409
|
+
allParsed,
|
|
410
|
+
new Map(),
|
|
411
|
+
new Set(),
|
|
412
|
+
new Set(),
|
|
413
|
+
new Set(Object.keys(parsed.frontmatter)),
|
|
414
|
+
dir,
|
|
415
|
+
add,
|
|
416
|
+
);
|
|
417
|
+
expect(filterRule(violations, "V-32")).toHaveLength(0);
|
|
418
|
+
expect(filterRule(violations, "V-17")).toHaveLength(0);
|
|
419
|
+
} finally {
|
|
420
|
+
rmSync(dir, { recursive: true, force: true });
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("no V-32 when status is rejected", async () => {
|
|
425
|
+
const dir = createGitRepoWithCommits([
|
|
426
|
+
{ message: "implement: RFC-9999 — step 1", date: "2026-01-02T10:00:00" },
|
|
427
|
+
]);
|
|
428
|
+
try {
|
|
429
|
+
const parsed = makeParsed("rejected", BASE_BODY);
|
|
430
|
+
const violations = await runValidateInDir(parsed, dir);
|
|
431
|
+
const v32 = filterRule(violations, "V-32");
|
|
432
|
+
expect(v32).toHaveLength(0);
|
|
433
|
+
} finally {
|
|
434
|
+
rmSync(dir, { recursive: true, force: true });
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
388
438
|
test("no V-32 when no implement: commits exist", async () => {
|
|
389
439
|
const dir = createGitRepoWithCommits([
|
|
390
440
|
{ message: "feat: add some feature", date: "2026-01-02T10:00:00" },
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { runRfcCreate } from "./handlers/list-create.ts";
|
|
6
|
+
import type { ForgeCommandInput, ForgeRuntimeContext } from "../../src/types.ts";
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
<MODULE_CONTRACT>
|
|
10
|
+
<purpose>
|
|
11
|
+
RFC-1138 AC-5: concurrent rfc.create invocations must produce distinct
|
|
12
|
+
RFC ids — the wx (exclusive-create) write + bounded retry on EEXIST
|
|
13
|
+
replaces the old scan-then-write race.
|
|
14
|
+
</purpose>
|
|
15
|
+
</MODULE_CONTRACT>
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const RFC_TEMPLATE = `---
|
|
19
|
+
id: RFC-0000
|
|
20
|
+
title: "TEMPLATE"
|
|
21
|
+
kind: command
|
|
22
|
+
scope: workspace
|
|
23
|
+
createdAt: YYYY-MM-DD
|
|
24
|
+
updatedAt: YYYY-MM-DD
|
|
25
|
+
satisfies: []
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
# RFC-0000: TEMPLATE
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
function makeContext(workspaceRoot: string): ForgeRuntimeContext {
|
|
32
|
+
return {
|
|
33
|
+
workspaceRoot,
|
|
34
|
+
logger: {
|
|
35
|
+
info: () => {},
|
|
36
|
+
success: () => {},
|
|
37
|
+
warn: () => {},
|
|
38
|
+
error: () => {},
|
|
39
|
+
debug: () => {},
|
|
40
|
+
},
|
|
41
|
+
outputFormat: "json",
|
|
42
|
+
dryRun: false,
|
|
43
|
+
} as unknown as ForgeRuntimeContext;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeInput(title: string): ForgeCommandInput {
|
|
47
|
+
return {
|
|
48
|
+
commandName: "rfc.create",
|
|
49
|
+
// kind "command" avoids the post-cutoff --satisfies requirement
|
|
50
|
+
// (only architecture|contract require DNA ids).
|
|
51
|
+
flags: { title, kind: "command", scope: "workspace" },
|
|
52
|
+
} as unknown as ForgeCommandInput;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("rfc.create — RFC-1138 concurrent id allocation", () => {
|
|
56
|
+
let tmpDir: string;
|
|
57
|
+
let rfcDir: string;
|
|
58
|
+
|
|
59
|
+
beforeEach(async () => {
|
|
60
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rfc-create-"));
|
|
61
|
+
rfcDir = path.join(tmpDir, "docs", "rfcs");
|
|
62
|
+
await fs.mkdir(rfcDir, { recursive: true });
|
|
63
|
+
await fs.writeFile(path.join(rfcDir, "rfc-0000-template.md"), RFC_TEMPLATE, "utf-8");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(async () => {
|
|
67
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("AC-5: five concurrent creates produce five distinct ids", async () => {
|
|
71
|
+
const results = await Promise.all(
|
|
72
|
+
[1, 2, 3, 4, 5].map((n) =>
|
|
73
|
+
runRfcCreate(makeInput(`Concurrent RFC ${n}`), makeContext(tmpDir)),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const ids = results.map((r) => (r.data as { id: string }).id);
|
|
78
|
+
expect(new Set(ids).size).toBe(5);
|
|
79
|
+
|
|
80
|
+
const files = (await fs.readdir(rfcDir)).filter((f) => f.startsWith("rfc-") && f !== "rfc-0000-template.md");
|
|
81
|
+
expect(files).toHaveLength(5);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("AC-5: sequential creates still allocate monotonically", async () => {
|
|
85
|
+
const first = await runRfcCreate(makeInput("First"), makeContext(tmpDir));
|
|
86
|
+
const second = await runRfcCreate(makeInput("Second"), makeContext(tmpDir));
|
|
87
|
+
|
|
88
|
+
const firstId = (first.data as { id: string }).id;
|
|
89
|
+
const secondId = (second.data as { id: string }).id;
|
|
90
|
+
expect(firstId).not.toBe(secondId);
|
|
91
|
+
expect(secondId > firstId).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("AC-5: pre-existing highest id is respected after a collision", async () => {
|
|
95
|
+
// Seed rfc-0001 so the first create takes 0002; a file racing in at 0002
|
|
96
|
+
// forces the retry path to land on 0003.
|
|
97
|
+
await fs.writeFile(path.join(rfcDir, "rfc-0001-seeded.md"), "---\nid: RFC-0001\n---\n", "utf-8");
|
|
98
|
+
const winner = runRfcCreate(makeInput("Winner"), makeContext(tmpDir));
|
|
99
|
+
// Interleave a manual claim at the id the scan will compute.
|
|
100
|
+
const claim = (async () => {
|
|
101
|
+
// Small delay so the create's scan completes first, then we steal 0002.
|
|
102
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
103
|
+
await fs
|
|
104
|
+
.writeFile(path.join(rfcDir, "rfc-0002-interloper.md"), "---\nid: RFC-0002\n---\n", {
|
|
105
|
+
flag: "wx",
|
|
106
|
+
})
|
|
107
|
+
.catch(() => {});
|
|
108
|
+
})();
|
|
109
|
+
const [result] = await Promise.all([winner, claim]);
|
|
110
|
+
|
|
111
|
+
const id = (result.data as { id: string }).id;
|
|
112
|
+
// Either the create won 0002 (interloper lost the wx race) or it retried to 0003.
|
|
113
|
+
expect(["RFC-0002", "RFC-0003"]).toContain(id);
|
|
114
|
+
const files = (await fs.readdir(rfcDir)).filter((f) => f.startsWith("rfc-") && f !== "rfc-0000-template.md");
|
|
115
|
+
// Seeded + interloper (if it won) + create output — never a silent overwrite.
|
|
116
|
+
expect(files.length).toBeGreaterThanOrEqual(2);
|
|
117
|
+
});
|
|
118
|
+
});
|