@warpgogol/forge 4.2.2 → 4.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 +5 -3
- package/bin/cli.ts +8 -0
- package/dist/bin/cli.js +8 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/os/compass/compass.module.d.ts.map +1 -1
- package/dist/os/compass/compass.module.js +9 -4
- package/dist/os/compass/compass.module.js.map +1 -1
- package/dist/os/compass/handlers/compass-audit-handler.d.ts +1 -0
- package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-audit-handler.js +3 -0
- package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
- package/dist/os/compass/handlers/compass-change-summary-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-change-summary-handler.js +36 -6
- package/dist/os/compass/handlers/compass-change-summary-handler.js.map +1 -1
- package/dist/os/compass/handlers/compass-inventory-handler.d.ts +2 -1
- package/dist/os/compass/handlers/compass-inventory-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-inventory-handler.js +54 -7
- package/dist/os/compass/handlers/compass-inventory-handler.js.map +1 -1
- package/dist/os/compass/handlers/compass-inventory.d.ts +17 -0
- package/dist/os/compass/handlers/compass-inventory.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-inventory.js +277 -12
- package/dist/os/compass/handlers/compass-inventory.js.map +1 -1
- package/dist/os/core/core.module.d.ts.map +1 -1
- package/dist/os/core/core.module.js +26 -0
- package/dist/os/core/core.module.js.map +1 -1
- package/dist/os/core/handlers/file-size-lint.d.ts +8 -0
- package/dist/os/core/handlers/file-size-lint.d.ts.map +1 -0
- package/dist/os/core/handlers/file-size-lint.js +166 -0
- package/dist/os/core/handlers/file-size-lint.js.map +1 -0
- package/dist/os/core/handlers/public-surface.d.ts.map +1 -1
- package/dist/os/core/handlers/public-surface.js +4 -3
- package/dist/os/core/handlers/public-surface.js.map +1 -1
- package/os/compass/compass.module.ts +12 -7
- package/os/compass/handlers/compass-audit-handler.ts +4 -0
- package/os/compass/handlers/compass-change-summary-handler.ts +45 -6
- package/os/compass/handlers/compass-inventory-handler.ts +78 -9
- package/os/compass/handlers/compass-inventory.ts +339 -13
- package/os/compass/handlers/tests/compass-v2-contract.test.ts +239 -0
- package/os/core/core.module.ts +28 -0
- package/os/core/handlers/file-size-lint.ts +200 -0
- package/os/core/handlers/public-surface.ts +4 -3
- package/package.json +12 -12
- package/skills/fo/fo-compass-annotate/SKILL.md +13 -11
- package/skills/fo/fo-compass-annotate/templates/audit-prompt.md +7 -1
- package/skills/fo/fo-compass-annotate/templates/header-format.md +33 -11
- package/skills/fo/fo-compass-annotate/templates/header-prompt.md +12 -1
- package/src/tests/create.test.ts +17 -17
- package/src/tests/file-size-lint.test.ts +181 -0
- package/src/tests/public-surface.test.ts +13 -4
- package/src/tests/scaffold-project.test.ts +1 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { runCompassValidation, runCompassInventory } from "../compass-inventory-handler.ts";
|
|
6
|
+
import { runCompassChangeSummaryValidate } from "../compass-change-summary-handler.ts";
|
|
7
|
+
import type {
|
|
8
|
+
ForgeCommandInput,
|
|
9
|
+
ForgeCommandResult,
|
|
10
|
+
ForgeRuntimeContext,
|
|
11
|
+
} from "../../../../src/types.ts";
|
|
12
|
+
|
|
13
|
+
function dataOf<T>(result: ForgeCommandResult<T>): T {
|
|
14
|
+
if (!result.data) throw new Error("command returned no data payload");
|
|
15
|
+
return result.data;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeContext(workspaceRoot: string, dryRun = false): ForgeRuntimeContext {
|
|
19
|
+
return {
|
|
20
|
+
workspaceRoot,
|
|
21
|
+
site: undefined,
|
|
22
|
+
siteExplicit: false,
|
|
23
|
+
dryRun,
|
|
24
|
+
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
25
|
+
} as unknown as ForgeRuntimeContext;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeInput(flags: Record<string, unknown> = {}): ForgeCommandInput {
|
|
29
|
+
return { flags } as ForgeCommandInput;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const MC = (purpose: string) =>
|
|
33
|
+
`<MODULE_CONTRACT>\n<purpose>${purpose}</purpose>\n<non-goals>\n <item>Do not perform real validation — fixtures only.</item>\n</non-goals>\n</MODULE_CONTRACT>`;
|
|
34
|
+
const KD = (items: string[]) =>
|
|
35
|
+
`<KEY_DECISIONS>\n${items.map((i) => ` <item>${i}</item>`).join("\n")}\n</KEY_DECISIONS>`;
|
|
36
|
+
const KD_EMPTY = `<KEY_DECISIONS>\n</KEY_DECISIONS>`;
|
|
37
|
+
const CS = (items: string[], history?: string) =>
|
|
38
|
+
`<CHANGE_SUMMARY>\n${items.map((i) => ` <item>${i}</item>`).join("\n")}${history ? `\n <history>${history}</history>` : ""}\n</CHANGE_SUMMARY>`;
|
|
39
|
+
const wrap = (...blocks: string[]) => `/*\n${blocks.join("\n")}\n*/`;
|
|
40
|
+
|
|
41
|
+
const PURPOSE_WITH_TOKEN =
|
|
42
|
+
"Validates mission widget fixtures for the compass v2 contract test suite.";
|
|
43
|
+
const KD_ITEM = "Fixtures live in a temp workspace so tests never touch the real repository.";
|
|
44
|
+
const CS_ITEM = "RFC-1094: initial v2 fixture.";
|
|
45
|
+
|
|
46
|
+
// Medium-risk path: src/components/** maps to layer "component" → riskClass medium.
|
|
47
|
+
const MEDIUM_FILE = "packages/fixture-pkg/src/components/mission-widget.ts";
|
|
48
|
+
// Low-risk path: bare src/** maps to layer "source" → riskClass low.
|
|
49
|
+
const LOW_FILE = "packages/fixture-pkg/src/mission-notes.ts";
|
|
50
|
+
|
|
51
|
+
describe("compass v2 contract (RFC-1094)", () => {
|
|
52
|
+
let tempDir: string;
|
|
53
|
+
|
|
54
|
+
function writeFixture(relPath: string, header: string): void {
|
|
55
|
+
const abs = join(tempDir, relPath);
|
|
56
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
57
|
+
const body = Array.from({ length: 25 }, (_, i) => `export const fixtureLine${i} = ${i};`).join(
|
|
58
|
+
"\n",
|
|
59
|
+
);
|
|
60
|
+
writeFileSync(abs, `${header}\n\n${body}\n`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
tempDir = mkdtempSync(join(tmpdir(), "compass-v2-test-"));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("AC-1/AC-9: missing KEY_DECISIONS on medium-risk file — warning in default mode, error in --mode error", async () => {
|
|
72
|
+
writeFixture(MEDIUM_FILE, wrap(MC(PURPOSE_WITH_TOKEN), CS([CS_ITEM])));
|
|
73
|
+
|
|
74
|
+
const warnResult = await runCompassValidation(makeInput(), makeContext(tempDir));
|
|
75
|
+
const warnDiag = dataOf(warnResult).diagnostics.find((d) => d.ruleId === "COMPASS-KD-01");
|
|
76
|
+
expect(warnDiag, "KD-01 must fire for medium-risk file without KEY_DECISIONS").toBeDefined();
|
|
77
|
+
expect(warnDiag!.severity).toBe("warning");
|
|
78
|
+
expect(warnResult.exitCode).toBe(0);
|
|
79
|
+
|
|
80
|
+
const errorResult = await runCompassValidation(
|
|
81
|
+
makeInput({ mode: "error" }),
|
|
82
|
+
makeContext(tempDir),
|
|
83
|
+
);
|
|
84
|
+
const errorDiag = dataOf(errorResult).diagnostics.find((d) => d.ruleId === "COMPASS-KD-01");
|
|
85
|
+
expect(errorDiag!.severity).toBe("error");
|
|
86
|
+
expect(errorResult.exitCode).toBe(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("low-risk file without KEY_DECISIONS produces no KD-01", async () => {
|
|
90
|
+
writeFixture(
|
|
91
|
+
LOW_FILE,
|
|
92
|
+
wrap(MC("Tracks mission notes for the compass v2 contract test suite."), CS([CS_ITEM])),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const result = await runCompassValidation(makeInput(), makeContext(tempDir));
|
|
96
|
+
expect(dataOf(result).diagnostics.find((d) => d.ruleId === "COMPASS-KD-01")).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("AC-2: CHANGE_SUMMARY with more than 5 items reports COMPASS-CS-05", async () => {
|
|
100
|
+
const items = [1, 2, 3, 4, 5, 6].map((n) => `RFC-10${n}0: change number ${n}.`);
|
|
101
|
+
writeFixture(MEDIUM_FILE, wrap(MC(PURPOSE_WITH_TOKEN), KD([KD_ITEM]), CS(items)));
|
|
102
|
+
|
|
103
|
+
const result = await runCompassChangeSummaryValidate(makeInput(), makeContext(tempDir));
|
|
104
|
+
const diag = dataOf(result).diagnostics.find((d) => d.ruleId === "COMPASS-CS-05");
|
|
105
|
+
expect(diag, "CS-05 must fire when CHANGE_SUMMARY exceeds 5 items").toBeDefined();
|
|
106
|
+
expect(diag!.severity).toBe("warning");
|
|
107
|
+
expect(result.exitCode).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("AC-3: CHANGE_SUMMARY item without governance ID reports COMPASS-CS-06", async () => {
|
|
111
|
+
writeFixture(
|
|
112
|
+
MEDIUM_FILE,
|
|
113
|
+
wrap(MC(PURPOSE_WITH_TOKEN), KD([KD_ITEM]), CS(["fixed a bug in the widget"])),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const result = await runCompassChangeSummaryValidate(makeInput(), makeContext(tempDir));
|
|
117
|
+
const diag = dataOf(result).diagnostics.find((d) => d.ruleId === "COMPASS-CS-06");
|
|
118
|
+
expect(diag, "CS-06 must fire for items lacking a governance ID").toBeDefined();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("AC-4: KEY_DECISIONS item with governance-ID prefix reports COMPASS-KD-05", async () => {
|
|
122
|
+
writeFixture(
|
|
123
|
+
MEDIUM_FILE,
|
|
124
|
+
wrap(MC(PURPOSE_WITH_TOKEN), KD(["RFC-1094: decided to compress history"]), CS([CS_ITEM])),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const result = await runCompassValidation(makeInput(), makeContext(tempDir));
|
|
128
|
+
const diag = dataOf(result).diagnostics.find((d) => d.ruleId === "COMPASS-KD-05");
|
|
129
|
+
expect(diag, "KD-05 must fire when a KEY_DECISIONS item starts with an ID").toBeDefined();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("AC-5: compass.inventory emits the new v2 fields per entry", async () => {
|
|
133
|
+
writeFixture(
|
|
134
|
+
MEDIUM_FILE,
|
|
135
|
+
wrap(MC(PURPOSE_WITH_TOKEN), KD([KD_ITEM]), CS([CS_ITEM], "RFC-0348, RFC-0349")),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const result = await runCompassInventory(makeInput(), makeContext(tempDir, true));
|
|
139
|
+
const entry = dataOf(result).entries.find((e) => e.path === MEDIUM_FILE);
|
|
140
|
+
expect(entry, "inventory must include the fixture entry").toBeDefined();
|
|
141
|
+
expect(entry!.hasKeyDecisions).toBe(true);
|
|
142
|
+
expect(entry!.keyDecisionsItemCount).toBe(1);
|
|
143
|
+
expect(entry!.keyDecisionsRequired).toBe(true);
|
|
144
|
+
expect(entry!.changeSummaryItemCount).toBe(1);
|
|
145
|
+
expect(entry!.historyIds).toEqual(["RFC-0348", "RFC-0349"]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("AC-6: boilerplate purpose reports COMPASS-PURPOSE-01; tokenless purpose reports COMPASS-PURPOSE-02", async () => {
|
|
149
|
+
writeFixture(
|
|
150
|
+
MEDIUM_FILE,
|
|
151
|
+
wrap(
|
|
152
|
+
MC("This file provides various reusable capabilities for the wider workspace ecosystem."),
|
|
153
|
+
KD([KD_ITEM]),
|
|
154
|
+
CS([CS_ITEM]),
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const result = await runCompassValidation(makeInput(), makeContext(tempDir));
|
|
159
|
+
expect(
|
|
160
|
+
dataOf(result).diagnostics.find((d) => d.ruleId === "COMPASS-PURPOSE-01"),
|
|
161
|
+
"PURPOSE-01 must fire on a boilerplate purpose opener",
|
|
162
|
+
).toBeDefined();
|
|
163
|
+
expect(
|
|
164
|
+
dataOf(result).diagnostics.find((d) => d.ruleId === "COMPASS-PURPOSE-02"),
|
|
165
|
+
"PURPOSE-02 must fire when no file-derived token appears in purpose",
|
|
166
|
+
).toBeDefined();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("AC-10: KEY_DECISIONS after CHANGE_SUMMARY reports COMPASS-ORDER-01", async () => {
|
|
170
|
+
writeFixture(MEDIUM_FILE, wrap(MC(PURPOSE_WITH_TOKEN), CS([CS_ITEM]), KD([KD_ITEM])));
|
|
171
|
+
|
|
172
|
+
const result = await runCompassValidation(makeInput(), makeContext(tempDir));
|
|
173
|
+
const diag = dataOf(result).diagnostics.find((d) => d.ruleId === "COMPASS-ORDER-01");
|
|
174
|
+
expect(diag, "ORDER-01 must fire when blocks violate canonical order").toBeDefined();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("KD-02: empty or TODO-only KEY_DECISIONS block", async () => {
|
|
178
|
+
writeFixture(MEDIUM_FILE, wrap(MC(PURPOSE_WITH_TOKEN), KD_EMPTY, CS([CS_ITEM])));
|
|
179
|
+
|
|
180
|
+
const result = await runCompassValidation(makeInput(), makeContext(tempDir));
|
|
181
|
+
expect(dataOf(result).diagnostics.find((d) => d.ruleId === "COMPASS-KD-02")).toBeDefined();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("KD-03: KEY_DECISIONS item over 20 words", async () => {
|
|
185
|
+
const longItem = Array.from({ length: 21 }, (_, i) => `word${i}`).join(" ");
|
|
186
|
+
writeFixture(MEDIUM_FILE, wrap(MC(PURPOSE_WITH_TOKEN), KD([longItem]), CS([CS_ITEM])));
|
|
187
|
+
|
|
188
|
+
const result = await runCompassValidation(makeInput(), makeContext(tempDir));
|
|
189
|
+
expect(dataOf(result).diagnostics.find((d) => d.ruleId === "COMPASS-KD-03")).toBeDefined();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("KD-04: more than 7 KEY_DECISIONS items", async () => {
|
|
193
|
+
const items = Array.from({ length: 8 }, (_, i) => `Decision number ${i} stays deterministic.`);
|
|
194
|
+
writeFixture(MEDIUM_FILE, wrap(MC(PURPOSE_WITH_TOKEN), KD(items), CS([CS_ITEM])));
|
|
195
|
+
|
|
196
|
+
const result = await runCompassValidation(makeInput(), makeContext(tempDir));
|
|
197
|
+
expect(dataOf(result).diagnostics.find((d) => d.ruleId === "COMPASS-KD-04")).toBeDefined();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("CS-07: malformed history — non-ID token, duplicates, and descending order", async () => {
|
|
201
|
+
writeFixture(
|
|
202
|
+
MEDIUM_FILE,
|
|
203
|
+
wrap(MC(PURPOSE_WITH_TOKEN), KD([KD_ITEM]), CS([CS_ITEM], "RFC-0350, RFC-0348")),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const result = await runCompassChangeSummaryValidate(makeInput(), makeContext(tempDir));
|
|
207
|
+
expect(
|
|
208
|
+
dataOf(result).diagnostics.find((d) => d.ruleId === "COMPASS-CS-07"),
|
|
209
|
+
"CS-07 must fire on descending per-namespace order",
|
|
210
|
+
).toBeDefined();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("empty CHANGE_SUMMARY (no items, no history) is legal", async () => {
|
|
214
|
+
writeFixture(MEDIUM_FILE, wrap(MC(PURPOSE_WITH_TOKEN), KD([KD_ITEM]), CS([])));
|
|
215
|
+
|
|
216
|
+
const warnResult = await runCompassValidation(makeInput(), makeContext(tempDir));
|
|
217
|
+
const csResult = await runCompassChangeSummaryValidate(makeInput(), makeContext(tempDir));
|
|
218
|
+
expect(
|
|
219
|
+
dataOf(warnResult).diagnostics.filter((d) => d.ruleId.startsWith("COMPASS-CS-")),
|
|
220
|
+
).toHaveLength(0);
|
|
221
|
+
expect(
|
|
222
|
+
dataOf(csResult).diagnostics.filter((d) => d.ruleId.startsWith("COMPASS-CS-")),
|
|
223
|
+
).toHaveLength(0);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("fully compliant v2 file produces zero v2 diagnostics", async () => {
|
|
227
|
+
writeFixture(
|
|
228
|
+
MEDIUM_FILE,
|
|
229
|
+
wrap(MC(PURPOSE_WITH_TOKEN), KD([KD_ITEM]), CS([CS_ITEM], "RFC-0348, RFC-0349")),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const result = await runCompassValidation(makeInput(), makeContext(tempDir));
|
|
233
|
+
const v2 = dataOf(result).diagnostics.filter((d) =>
|
|
234
|
+
["COMPASS-KD-", "COMPASS-ORDER-", "COMPASS-PURPOSE-"].some((p) => d.ruleId.startsWith(p)),
|
|
235
|
+
);
|
|
236
|
+
expect(v2, `unexpected v2 diagnostics: ${JSON.stringify(v2)}`).toHaveLength(0);
|
|
237
|
+
expect(result.exitCode).toBe(0);
|
|
238
|
+
});
|
|
239
|
+
});
|
package/os/core/core.module.ts
CHANGED
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
<item>RFC-0877: forge.create --in-place required, --profile required, --name optional (derived from folder), strict empty-directory check (only .git/ tolerated).</item>
|
|
28
28
|
<item>RFC-0940: register forge.autonomy.validate command enforcing FORGE-AUTONOMY-01 (no @warpgogol/werkstatt-engine imports outside os/werkstatt/).</item>
|
|
29
29
|
<item>RFC-1080: register forge.public-surface.validate command for README/package.json consistency checks (SURFACE-01..05).</item>
|
|
30
|
+
<item>RFC-1088: register forge.file-size.lint command for portable SIZE-01 line-count validation.</item>
|
|
31
|
+
<item>RFC-1088 fix: add writes field to forge.file-size.lint registration per fo-review.</item>
|
|
32
|
+
<item>RFC-1089: rename forge.file-size.lint to file.size.lint (domain validator, no forge. prefix).</item>
|
|
30
33
|
</CHANGE_SUMMARY>
|
|
31
34
|
*/
|
|
32
35
|
|
|
@@ -69,6 +72,7 @@ export async function createForgeCoreModule(): Promise<ForgeModule> {
|
|
|
69
72
|
const { runPackageHealth } = await import("./handlers/package-health.ts");
|
|
70
73
|
const { runForgeAutonomyValidate } = await import("./handlers/forge-autonomy-validate.ts");
|
|
71
74
|
const { runPublicSurfaceValidate } = await import("./handlers/public-surface.ts");
|
|
75
|
+
const { runFileSizeLint } = await import("./handlers/file-size-lint.ts");
|
|
72
76
|
|
|
73
77
|
const scaffoldWrapper = async (
|
|
74
78
|
input: ForgeCommandInput,
|
|
@@ -746,6 +750,30 @@ export async function createForgeCoreModule(): Promise<ForgeModule> {
|
|
|
746
750
|
flags: {},
|
|
747
751
|
execute: runPublicSurfaceValidate,
|
|
748
752
|
},
|
|
753
|
+
{
|
|
754
|
+
name: "file.size.lint",
|
|
755
|
+
contract: "file",
|
|
756
|
+
rules: ["SIZE-01"],
|
|
757
|
+
description:
|
|
758
|
+
"SIZE-01: flags a packages/** .ts/.tsx source file exceeding a 600-line threshold (warning 601-1200, error 1200+), against a shrink-only ratchet baseline. Pass --write-baseline to regenerate the baseline. Pass --baseline-path to override the default baseline location (RFC-1088).",
|
|
759
|
+
scope: "workspace",
|
|
760
|
+
supportsAllSites: false,
|
|
761
|
+
reads: ["packages/*/src/**/*.ts", "packages/*/src/**/*.tsx"],
|
|
762
|
+
writes: ["file-size-lint.baseline.yaml"],
|
|
763
|
+
cacheable: false,
|
|
764
|
+
flags: {
|
|
765
|
+
"write-baseline": {
|
|
766
|
+
kind: "boolean",
|
|
767
|
+
description: "Regenerate the oversized-file baseline instead of validating against it.",
|
|
768
|
+
},
|
|
769
|
+
"baseline-path": {
|
|
770
|
+
kind: "string",
|
|
771
|
+
description:
|
|
772
|
+
"Override the baseline file path (default: file-size-lint.baseline.yaml at workspace root).",
|
|
773
|
+
},
|
|
774
|
+
},
|
|
775
|
+
execute: runFileSizeLint,
|
|
776
|
+
},
|
|
749
777
|
{
|
|
750
778
|
name: "docs.archive",
|
|
751
779
|
description:
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>
|
|
4
|
+
forge.file-size.lint — RFC-1088: portable two-tier severity for .ts/.tsx source files
|
|
5
|
+
under packages/** exceeding a line-count threshold. 601-1200 lines → warning (SIZE-01);
|
|
6
|
+
above 1200 lines → error (SIZE-01). Autonomous: no @warpgogol/* imports. Inlined
|
|
7
|
+
collectFiles and diagnosticsResult. Ratcheted baseline at workspace root.
|
|
8
|
+
</purpose>
|
|
9
|
+
<non-goals>
|
|
10
|
+
<item>Do not import from @warpgogol/* — this module is portable (FORGE-AUTONOMY-01).</item>
|
|
11
|
+
<item>Do not parse a real AST — a physical line count is sufficient.</item>
|
|
12
|
+
<item>Do not split files — that is a manual action, one file per commit.</item>
|
|
13
|
+
</non-goals>
|
|
14
|
+
</MODULE_CONTRACT>
|
|
15
|
+
<CHANGE_SUMMARY>
|
|
16
|
+
<item>RFC-1088: initial implementation — ported from packages/werkstatt-site/src/checks/file-size-lint.ts to forge for autonomy.</item>
|
|
17
|
+
</CHANGE_SUMMARY>
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
21
|
+
import { join, relative } from "node:path";
|
|
22
|
+
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
|
|
23
|
+
import type {
|
|
24
|
+
CheckResult,
|
|
25
|
+
Diagnostic,
|
|
26
|
+
ForgeCommandInput,
|
|
27
|
+
ForgeCommandResult,
|
|
28
|
+
ForgeRuntimeContext,
|
|
29
|
+
} from "../../../src/types.ts";
|
|
30
|
+
|
|
31
|
+
const SCAN_ROOT = "packages";
|
|
32
|
+
const DEFAULT_BASELINE_PATH = "file-size-lint.baseline.yaml";
|
|
33
|
+
const WARNING_THRESHOLD = 600;
|
|
34
|
+
const ERROR_THRESHOLD = 1200;
|
|
35
|
+
|
|
36
|
+
interface FileSizeLintBaseline {
|
|
37
|
+
meta: { schemaVersion: 1; threshold: 600 };
|
|
38
|
+
/** Workspace-relative file path → accepted line-count ceiling (shrink-only). */
|
|
39
|
+
ceilings: Record<string, number>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Pure: physical line count of a source string. */
|
|
43
|
+
export function countLines(source: string): number {
|
|
44
|
+
if (source.length === 0) return 0;
|
|
45
|
+
return source.split("\n").length;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Inlined recursive file collector — no @warpgogol/werkstatt-shared dependency. */
|
|
49
|
+
async function collectSourceFiles(root: string): Promise<string[]> {
|
|
50
|
+
const results: string[] = [];
|
|
51
|
+
|
|
52
|
+
async function walk(dir: string): Promise<void> {
|
|
53
|
+
let entries;
|
|
54
|
+
try {
|
|
55
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
56
|
+
} catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (entry.name.startsWith("-") || entry.name.startsWith("old-")) continue;
|
|
62
|
+
if (entry.name === "tests" || entry.name === "node_modules" || entry.name === "dist") continue;
|
|
63
|
+
if (entry.name === ".astro" || entry.name.endsWith(".d.ts")) continue;
|
|
64
|
+
if (entry.name.endsWith(".generated.yaml")) continue;
|
|
65
|
+
|
|
66
|
+
const full = join(dir, entry.name);
|
|
67
|
+
|
|
68
|
+
if (entry.isDirectory()) {
|
|
69
|
+
await walk(full);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!entry.isFile()) continue;
|
|
74
|
+
if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".tsx")) continue;
|
|
75
|
+
|
|
76
|
+
results.push(full);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await walk(root);
|
|
81
|
+
return results;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function readBaseline(
|
|
85
|
+
workspaceRoot: string,
|
|
86
|
+
baselinePath: string,
|
|
87
|
+
): Promise<FileSizeLintBaseline | undefined> {
|
|
88
|
+
try {
|
|
89
|
+
const raw = await readFile(join(workspaceRoot, baselinePath), "utf8");
|
|
90
|
+
return yamlParse(raw) as FileSizeLintBaseline;
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function renderBaseline(ceilings: Record<string, number>): string {
|
|
97
|
+
const baseline: FileSizeLintBaseline = {
|
|
98
|
+
meta: { schemaVersion: 1, threshold: WARNING_THRESHOLD },
|
|
99
|
+
ceilings,
|
|
100
|
+
};
|
|
101
|
+
return yamlStringify(baseline);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function findOversizedFiles(workspaceRoot: string): Promise<Map<string, number>> {
|
|
105
|
+
const files = await collectSourceFiles(join(workspaceRoot, SCAN_ROOT));
|
|
106
|
+
const oversized = new Map<string, number>();
|
|
107
|
+
for (const filePath of files) {
|
|
108
|
+
let source: string;
|
|
109
|
+
try {
|
|
110
|
+
source = await readFile(filePath, "utf8");
|
|
111
|
+
} catch {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const lines = countLines(source);
|
|
115
|
+
if (lines > WARNING_THRESHOLD) {
|
|
116
|
+
const relFile = relative(workspaceRoot, filePath).replace(/\\/g, "/");
|
|
117
|
+
oversized.set(relFile, lines);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return oversized;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Inlined diagnosticsResult — constructs a CheckResult from Diagnostic[]. */
|
|
124
|
+
function diagnosticsResult(
|
|
125
|
+
command: string,
|
|
126
|
+
diagnostics: Diagnostic[],
|
|
127
|
+
): ForgeCommandResult<CheckResult> {
|
|
128
|
+
const summary = {
|
|
129
|
+
error: diagnostics.filter((d) => d.severity === "error").length,
|
|
130
|
+
warning: diagnostics.filter((d) => d.severity === "warning").length,
|
|
131
|
+
info: diagnostics.filter((d) => d.severity === "info").length,
|
|
132
|
+
};
|
|
133
|
+
const status: CheckResult["status"] =
|
|
134
|
+
summary.error > 0 ? "fail" : summary.warning > 0 ? "warn" : "pass";
|
|
135
|
+
const counts: string[] = [];
|
|
136
|
+
if (summary.error > 0) counts.push(`${summary.error} error${summary.error === 1 ? "" : "s"}`);
|
|
137
|
+
if (summary.warning > 0) counts.push(`${summary.warning} warning${summary.warning === 1 ? "" : "s"}`);
|
|
138
|
+
const summaryStr = counts.length > 0 ? `[${command}] ${counts.join(", ")}` : `[${command}]`;
|
|
139
|
+
return {
|
|
140
|
+
data: { command, status, diagnostics, summary },
|
|
141
|
+
exitCode: summary.error > 0 ? 1 : 0,
|
|
142
|
+
summary: summaryStr,
|
|
143
|
+
nextSteps:
|
|
144
|
+
summary.error > 0
|
|
145
|
+
? [
|
|
146
|
+
{
|
|
147
|
+
action: `Fix the error diagnostics reported by ${command} above, then re-run the pipeline`,
|
|
148
|
+
kind: "required" as const,
|
|
149
|
+
},
|
|
150
|
+
]
|
|
151
|
+
: undefined,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function runFileSizeLint(
|
|
156
|
+
input: ForgeCommandInput,
|
|
157
|
+
context: ForgeRuntimeContext,
|
|
158
|
+
): Promise<ForgeCommandResult<CheckResult | { file: string; files: number }>> {
|
|
159
|
+
const { workspaceRoot } = context;
|
|
160
|
+
const baselinePath =
|
|
161
|
+
(input.flags["baseline-path"] as string | undefined) ?? DEFAULT_BASELINE_PATH;
|
|
162
|
+
const oversized = await findOversizedFiles(workspaceRoot);
|
|
163
|
+
|
|
164
|
+
if (input.flags["write-baseline"] === true) {
|
|
165
|
+
const ceilings: Record<string, number> = {};
|
|
166
|
+
for (const [file, lines] of oversized) ceilings[file] = lines;
|
|
167
|
+
await writeFile(join(workspaceRoot, baselinePath), renderBaseline(ceilings), "utf8");
|
|
168
|
+
return {
|
|
169
|
+
data: { file: baselinePath, files: oversized.size },
|
|
170
|
+
exitCode: 0,
|
|
171
|
+
summary: `forge.file-size.lint: wrote ${oversized.size} oversized file(s) to ${baselinePath}`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const baseline = await readBaseline(workspaceRoot, baselinePath);
|
|
176
|
+
const ceilings = baseline?.ceilings ?? {};
|
|
177
|
+
const diagnostics: Diagnostic[] = [];
|
|
178
|
+
|
|
179
|
+
for (const [file, lines] of oversized) {
|
|
180
|
+
const ceiling = ceilings[file];
|
|
181
|
+
if (ceiling !== undefined && lines <= ceiling) continue;
|
|
182
|
+
|
|
183
|
+
const severity: "warning" | "error" = lines > ERROR_THRESHOLD ? "error" : "warning";
|
|
184
|
+
|
|
185
|
+
diagnostics.push({
|
|
186
|
+
ruleId: "SIZE-01",
|
|
187
|
+
severity,
|
|
188
|
+
file,
|
|
189
|
+
message:
|
|
190
|
+
ceiling !== undefined
|
|
191
|
+
? `File grew from the baselined ${ceiling} to ${lines} lines, exceeding the ${WARNING_THRESHOLD}-line threshold.`
|
|
192
|
+
: `New file has ${lines} lines, exceeding the ${WARNING_THRESHOLD}-line threshold.`,
|
|
193
|
+
fixHint:
|
|
194
|
+
"Split the file into a sibling folder-of-files with a thin re-export shim, or shrink the baseline ceiling after review.",
|
|
195
|
+
data: { lines, threshold: WARNING_THRESHOLD, ceiling: ceiling ?? null },
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return diagnosticsResult("forge.file-size.lint", diagnostics);
|
|
200
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
<CHANGE_SUMMARY>
|
|
10
10
|
<item>RFC-1080: initial public-surface consistency validator — README length, Node version match, .tgz absence, docs/ structure, required root files.</item>
|
|
11
11
|
<item>RFC-1080: add monorepo auto-detection — resolve packages/forge/ when run from monorepo root.</item>
|
|
12
|
+
<item>RFC-1088: raise SURFACE-01 README threshold from 300 to 600 lines.</item>
|
|
12
13
|
</CHANGE_SUMMARY>
|
|
13
14
|
*/
|
|
14
15
|
|
|
@@ -76,11 +77,11 @@ export async function runPublicSurfaceValidate(
|
|
|
76
77
|
|
|
77
78
|
const pkg = readJsonFile(packageJsonPath);
|
|
78
79
|
|
|
79
|
-
// SURFACE-01: README length (warning)
|
|
80
|
+
// SURFACE-01: README length (warning, RFC-1088: raised from 300 to 600)
|
|
80
81
|
checks.push({
|
|
81
82
|
rule: "SURFACE-01",
|
|
82
|
-
message: `README.md is ${readmeLines} lines (${readmeLines <=
|
|
83
|
-
status: readmeLines <=
|
|
83
|
+
message: `README.md is ${readmeLines} lines (${readmeLines <= 600 ? "under 600" : "over 600"})`,
|
|
84
|
+
status: readmeLines <= 600 ? "pass" : "warn",
|
|
84
85
|
});
|
|
85
86
|
|
|
86
87
|
// SURFACE-02: Node version consistency (error)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@warpgogol/forge",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -177,22 +177,22 @@
|
|
|
177
177
|
"test:tarball:node24": "node test-fixtures/node24-consumer/smoke.mjs"
|
|
178
178
|
},
|
|
179
179
|
"dependencies": {
|
|
180
|
-
"@aws-sdk/client-s3": "^3.
|
|
180
|
+
"@aws-sdk/client-s3": "^3.1131.0",
|
|
181
181
|
"ajv": "^8.20.0",
|
|
182
|
-
"picomatch": "^4.0.
|
|
182
|
+
"picomatch": "^4.0.7",
|
|
183
183
|
"trash": "^10.1.1",
|
|
184
|
-
"yaml": "^2.9.
|
|
185
|
-
"zod": "^4.
|
|
184
|
+
"yaml": "^2.9.1",
|
|
185
|
+
"zod": "^4.6.5"
|
|
186
186
|
},
|
|
187
187
|
"devDependencies": {
|
|
188
|
-
"@types/node": "^26.
|
|
188
|
+
"@types/node": "^26.5.1",
|
|
189
189
|
"@types/picomatch": "^4.0.3",
|
|
190
|
-
"
|
|
191
|
-
"
|
|
192
|
-
"
|
|
193
|
-
"typescript
|
|
194
|
-
"
|
|
195
|
-
"vitest": "^
|
|
190
|
+
"@vitest/coverage-v8": "^5.0.0",
|
|
191
|
+
"eslint": "^10.10.0",
|
|
192
|
+
"fast-check": "^4.10.0",
|
|
193
|
+
"typescript": "~5.9.3",
|
|
194
|
+
"typescript-eslint": "8.70.0",
|
|
195
|
+
"vitest": "^5.0.0"
|
|
196
196
|
},
|
|
197
197
|
"engines": {
|
|
198
198
|
"node": ">=24 <25"
|
|
@@ -56,10 +56,11 @@ For each discovered file, determine the required action:
|
|
|
56
56
|
If the file has no `MODULE_CONTRACT` and no `CHANGE_SUMMARY` block:
|
|
57
57
|
|
|
58
58
|
1. Read the file content and understand its purpose.
|
|
59
|
-
2. Use `templates/header-prompt.md` to generate a `MODULE_CONTRACT` block with `<purpose>` and `<non-goals>`.
|
|
60
|
-
3.
|
|
61
|
-
4.
|
|
62
|
-
5.
|
|
59
|
+
2. Use `templates/header-prompt.md` to generate a `MODULE_CONTRACT` block with `<purpose>` and `<non-goals>`. The `<purpose>` must contain a file-derived token (stem segment, parent-directory segment for generic stems, or exported symbol).
|
|
60
|
+
3. If the file's `riskClass` is `medium` or `high`, generate a `KEY_DECISIONS` block with 1–7 current-state design items (no governance-ID prefixes — current truth, not history).
|
|
61
|
+
4. Create a `CHANGE_SUMMARY` block with a single `<item>` carrying the governing RFC/ADR/ticket ID.
|
|
62
|
+
5. Insert the blocks at the top of the file in canonical order `MODULE_CONTRACT` → `KEY_DECISIONS` → `CHANGE_SUMMARY`, after any existing license/comment header but before imports.
|
|
63
|
+
6. Use `reference/comment-styles.md` to determine the correct comment syntax for the file extension.
|
|
63
64
|
|
|
64
65
|
#### 2b. Update (file has headers but content has changed)
|
|
65
66
|
|
|
@@ -67,17 +68,18 @@ If the file has Compass headers but the content has significantly changed:
|
|
|
67
68
|
|
|
68
69
|
1. Read the current `MODULE_CONTRACT` and compare with the file's actual purpose.
|
|
69
70
|
2. Use `templates/header-prompt.md` to regenerate the `MODULE_CONTRACT` if the purpose has shifted.
|
|
70
|
-
3.
|
|
71
|
-
4.
|
|
71
|
+
3. Rewrite `KEY_DECISIONS` items in place when the change alters a recorded decision — never append chronologically.
|
|
72
|
+
4. Add a new `<item>` to `CHANGE_SUMMARY` referencing the current RFC/ADR/ticket ID. When the block exceeds 5 items, collapse the oldest IDs into the `<history>` element (comma-separated, deduplicated, per-namespace ascending).
|
|
72
73
|
|
|
73
74
|
#### 2c. Audit (semantic check)
|
|
74
75
|
|
|
75
76
|
For each file with Compass headers:
|
|
76
77
|
|
|
77
|
-
1. Use `templates/audit-prompt.md` to audit the `MODULE_CONTRACT` against the file's actual content.
|
|
78
|
+
1. Use `templates/audit-prompt.md` to audit the `MODULE_CONTRACT` (and `KEY_DECISIONS` when present) against the file's actual content.
|
|
78
79
|
2. Check that `<purpose>` accurately describes what the file does.
|
|
79
80
|
3. Check that `<non-goals>` lists at least one boundary the file does not cross.
|
|
80
|
-
4.
|
|
81
|
+
4. Check that `KEY_DECISIONS` items still describe current design truth.
|
|
82
|
+
5. Flag files where the header is stale, misleading, or empty.
|
|
81
83
|
|
|
82
84
|
#### 2d. Risk flag
|
|
83
85
|
|
|
@@ -91,8 +93,8 @@ For each file with Compass headers:
|
|
|
91
93
|
|
|
92
94
|
After processing all files:
|
|
93
95
|
|
|
94
|
-
1. Run `compass.validate` to verify all headers are well-formed.
|
|
95
|
-
2. If any violations are found, attempt to fix them automatically (missing `<non-goals>`, empty `<purpose>`, etc.).
|
|
96
|
+
1. Run `compass.validate` to verify all headers are well-formed. v2 rules emit warnings by default (`--mode warning`); use `--mode error` to check post-migration compliance.
|
|
97
|
+
2. If any violations are found, attempt to fix them automatically (missing `<non-goals>`, empty `<purpose>`, missing `KEY_DECISIONS` on medium/high-risk files, etc.).
|
|
96
98
|
3. Re-run `compass.validate` to confirm fixes.
|
|
97
99
|
4. If violations persist after 3 retry attempts, report them to the operator.
|
|
98
100
|
|
|
@@ -122,7 +124,7 @@ Output a summary:
|
|
|
122
124
|
|
|
123
125
|
## Completion criteria
|
|
124
126
|
|
|
125
|
-
- All discovered files have `MODULE_CONTRACT` and `CHANGE_SUMMARY` blocks
|
|
127
|
+
- All discovered files have `MODULE_CONTRACT` and `CHANGE_SUMMARY` blocks in canonical order; medium/high-risk files also carry `KEY_DECISIONS`.
|
|
126
128
|
- Pack-declared Compass contract blocks (RFC-0943, if any are declared in `forge.plugin.yaml` `extensionPoints.compass.contract`) are present in matching files.
|
|
127
129
|
- `compass.validate` passes with zero errors.
|
|
128
130
|
- No `TODO(compass)` sentinels remain (if `--cleanup` was used).
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# Semantic Audit Prompt
|
|
2
2
|
|
|
3
|
-
You are auditing a Compass `MODULE_CONTRACT` block against the file's actual content. Determine whether the header is accurate, complete, and not misleading.
|
|
3
|
+
You are auditing a Compass `MODULE_CONTRACT` block (and `KEY_DECISIONS` when present) against the file's actual content. Determine whether the header is accurate, complete, and not misleading.
|
|
4
4
|
|
|
5
5
|
## Input
|
|
6
6
|
|
|
7
7
|
- File path: `{path}`
|
|
8
8
|
- Current `MODULE_CONTRACT` block: `{header}`
|
|
9
|
+
- Current `KEY_DECISIONS` block (may be empty): `{key_decisions}`
|
|
9
10
|
- File content (full): `{content}`
|
|
10
11
|
|
|
11
12
|
## Audit axes
|
|
@@ -25,6 +26,11 @@ You are auditing a Compass `MODULE_CONTRACT` block against the file's actual con
|
|
|
25
26
|
- Removed functionality is still mentioned in the purpose.
|
|
26
27
|
- The file's role has shifted (e.g., from validator to generator).
|
|
27
28
|
|
|
29
|
+
4. **KEY_DECISIONS truth** (when the block is present) — do the items still describe current design truth? Flag if:
|
|
30
|
+
- An item records a decision the code no longer follows.
|
|
31
|
+
- An item is really history (references what changed rather than what is true now).
|
|
32
|
+
- A load-bearing invariant visible in the code is missing from the block.
|
|
33
|
+
|
|
28
34
|
## Output
|
|
29
35
|
|
|
30
36
|
```
|