@warpgogol/forge 4.2.1 → 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/README.md +2 -0
- package/README.uk.md +2 -0
- 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
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
# Canonical Compass Header Format
|
|
1
|
+
# Canonical Compass Header Format (v2, RFC-1094)
|
|
2
2
|
|
|
3
3
|
This is the canonical format for Compass source headers. Check the project's invariants file for the canonical Compass markup rule.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Three-block contract
|
|
6
6
|
|
|
7
|
-
Every non-trivial source file in `apps/`, `packages/`, or `services/` must carry:
|
|
7
|
+
Every non-trivial source file in `apps/`, `packages/`, or `services/` must carry, in this canonical order:
|
|
8
8
|
|
|
9
9
|
1. `MODULE_CONTRACT` — describes the file's purpose and boundaries.
|
|
10
|
-
2. `
|
|
10
|
+
2. `KEY_DECISIONS` — current-state design truths (required on medium/high riskClass files, optional on low).
|
|
11
|
+
3. `CHANGE_SUMMARY` — compressed change history: the 5 newest governance-ID items plus a `<history>` ID tail.
|
|
12
|
+
|
|
13
|
+
Blocks out of this order are a `COMPASS-ORDER-01` violation.
|
|
11
14
|
|
|
12
15
|
## MODULE_CONTRACT
|
|
13
16
|
|
|
@@ -22,24 +25,42 @@ Every non-trivial source file in `apps/`, `packages/`, or `services/` must carry
|
|
|
22
25
|
|
|
23
26
|
### Rules
|
|
24
27
|
|
|
25
|
-
- `<purpose>` must be specific to this file, under 100 characters.
|
|
28
|
+
- `<purpose>` must be specific to this file, under 100 characters, and contain at least one file-derived token (filename stem segment, parent-directory segment for generic stems, or an exported symbol name). Boilerplate openers ("This file…", "Utility…", "TODO…") are `COMPASS-PURPOSE-01`; a purpose with no file-derived token is `COMPASS-PURPOSE-02`.
|
|
26
29
|
- `<non-goals>` must have at least one `<item>`.
|
|
27
30
|
- Do not include `@ai-invariant` lines in `MODULE_CONTRACT` — they are inline comments in the file body.
|
|
28
31
|
|
|
32
|
+
## KEY_DECISIONS
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
<KEY_DECISIONS>
|
|
36
|
+
<item>Validation is deterministic regex parsing — no LLM in the check path.</item>
|
|
37
|
+
<item>Warnings never fail the build during the migration window.</item>
|
|
38
|
+
</KEY_DECISIONS>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Rules
|
|
42
|
+
|
|
43
|
+
- Content: current-state design truths of the file — invariants, chosen mechanisms, forbidden approaches. Rewritten in place when behavior changes; never appended chronologically.
|
|
44
|
+
- Required on files whose `riskClass` is `medium` or `high` (`COMPASS-KD-01`); optional on `low`. When present on a low-risk file, all item rules still apply.
|
|
45
|
+
- Limits: 1–7 `<item>` entries (`COMPASS-KD-04`), each ≤ 20 words (`COMPASS-KD-03`).
|
|
46
|
+
- An item MUST NOT begin with a governance-ID prefix (`RFC-`, `ADR-`, ticket pattern) — history belongs in `CHANGE_SUMMARY` (`COMPASS-KD-05`).
|
|
47
|
+
- An empty block or `TODO` placeholder items are a violation (`COMPASS-KD-02`).
|
|
48
|
+
|
|
29
49
|
## CHANGE_SUMMARY
|
|
30
50
|
|
|
31
51
|
```
|
|
32
52
|
<CHANGE_SUMMARY>
|
|
33
|
-
<item>RFC-
|
|
53
|
+
<item>RFC-1094: adopted v2 three-block contract.</item>
|
|
54
|
+
<history>RFC-0348, RFC-0349, RFC-0538</history>
|
|
34
55
|
</CHANGE_SUMMARY>
|
|
35
56
|
```
|
|
36
57
|
|
|
37
58
|
### Rules
|
|
38
59
|
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
-
|
|
60
|
+
- At most 5 `<item>` entries, each `ID: one-line description` (`COMPASS-CS-05`). The retained items are the 5 newest — the chronological tail.
|
|
61
|
+
- Every `<item>` MUST carry a governance ID (`RFC-XXXX`, `ADR-XXXX`, ticket pattern); items without an ID are violations (`COMPASS-CS-06`) and will be deleted by `compass.migrate` (sibling RFC, not yet implemented).
|
|
62
|
+
- Older IDs collapse into a single `<history>` element: comma-separated, deduplicated, per-namespace ascending numeric order (`COMPASS-CS-07`). `<history>` carries IDs only — no descriptions — and has no length cap.
|
|
63
|
+
- An empty `CHANGE_SUMMARY` (no items, no history) is legal for files never touched by a governance-referencing commit.
|
|
43
64
|
|
|
44
65
|
## Comment syntax by extension
|
|
45
66
|
|
|
@@ -47,8 +68,9 @@ See `reference/comment-styles.md` for the correct comment syntax per file extens
|
|
|
47
68
|
|
|
48
69
|
## Forbidden blocks
|
|
49
70
|
|
|
50
|
-
The following legacy Compass blocks are forbidden and must be removed:
|
|
71
|
+
The following legacy Compass blocks are forbidden and must be removed (`COMPASS-FORBIDDEN-01`, always an error regardless of `--mode`):
|
|
51
72
|
|
|
73
|
+
- `MODULE_MAP`, `keywords`, `responsibilities`, `COMPASS_BLOCK` — removed by RFC-0348.
|
|
52
74
|
- `GRACE_MODULE_CONTRACT` — use `MODULE_CONTRACT` instead.
|
|
53
75
|
- `GRACE_CHANGE_SUMMARY` — use `CHANGE_SUMMARY` instead.
|
|
54
76
|
- `AI_INVARIANTS` — use inline `// @ai-invariant` comments instead.
|
|
@@ -19,9 +19,17 @@ Generate a `MODULE_CONTRACT` block with:
|
|
|
19
19
|
- The purpose must be specific to this file, not generic.
|
|
20
20
|
- Non-goals must describe real boundaries the file respects.
|
|
21
21
|
- Do not use vague phrases like "utility functions" or "helper code".
|
|
22
|
-
- Do not
|
|
22
|
+
- The purpose MUST contain at least one file-derived token — a filename stem segment, a parent-directory segment for generic stems, or an exported symbol name (COMPASS-PURPOSE-02). Do not open with boilerplate like "This file" or "Utility" (COMPASS-PURPOSE-01).
|
|
23
23
|
- Keep the purpose under 100 characters.
|
|
24
24
|
|
|
25
|
+
## KEY_DECISIONS (v2, RFC-1094)
|
|
26
|
+
|
|
27
|
+
When the file's `riskClass` is `medium` or `high`, also generate a `KEY_DECISIONS` block between `MODULE_CONTRACT` and `CHANGE_SUMMARY`:
|
|
28
|
+
|
|
29
|
+
- 1–7 `<item>` entries, each ≤ 20 words.
|
|
30
|
+
- Content is current-state design truth: invariants, chosen mechanisms, forbidden approaches — "current truth, not history".
|
|
31
|
+
- Items MUST NOT start with a governance-ID prefix (`RFC-`, `ADR-`, ticket) — history belongs in `CHANGE_SUMMARY`.
|
|
32
|
+
|
|
25
33
|
## Example
|
|
26
34
|
|
|
27
35
|
```
|
|
@@ -32,4 +40,7 @@ Generate a `MODULE_CONTRACT` block with:
|
|
|
32
40
|
<item>Do not delete protected (RFC/code-referencing) items under any circumstance.</item>
|
|
33
41
|
</non-goals>
|
|
34
42
|
</MODULE_CONTRACT>
|
|
43
|
+
<KEY_DECISIONS>
|
|
44
|
+
<item>Classification is deterministic regex — no LLM in the validate path.</item>
|
|
45
|
+
</KEY_DECISIONS>
|
|
35
46
|
```
|
package/src/tests/create.test.ts
CHANGED
|
@@ -64,7 +64,7 @@ test("forge create --in-place scaffolds in cwd with forge.yaml and docs dirs", a
|
|
|
64
64
|
expect(existsSync(join(tempDir, "package.json"))).toBe(true);
|
|
65
65
|
expect(existsSync(join(tempDir, "scripts", "clean.mjs"))).toBe(true);
|
|
66
66
|
expect(existsSync(join(tempDir, "hooks", "pre-user-prompt-wrapper.mjs"))).toBe(true);
|
|
67
|
-
},
|
|
67
|
+
}, 120000);
|
|
68
68
|
|
|
69
69
|
test("forge create --in-place refuses when forge artifacts already exist", async () => {
|
|
70
70
|
await writeFile(join(tempDir, "forge.yaml"), "existing: true", "utf8");
|
|
@@ -102,7 +102,7 @@ test("forge create --in-place tolerates only .git directory in target directory"
|
|
|
102
102
|
expect(result.data?.status).toBe("pass");
|
|
103
103
|
expect(existsSync(join(tempDir, "forge.yaml"))).toBe(true);
|
|
104
104
|
expect(existsSync(join(tempDir, ".git"))).toBe(true);
|
|
105
|
-
},
|
|
105
|
+
}, 120000);
|
|
106
106
|
|
|
107
107
|
test("forge create fails on non-kebab-case name", async () => {
|
|
108
108
|
const result = await runCreate(
|
|
@@ -141,7 +141,7 @@ test("forge create --in-place --profile forge-shell uses forge-shell profile", a
|
|
|
141
141
|
);
|
|
142
142
|
expect(result.exitCode).toBe(0);
|
|
143
143
|
expect(result.data?.profile).toBe("forge-shell");
|
|
144
|
-
},
|
|
144
|
+
}, 120000);
|
|
145
145
|
|
|
146
146
|
test("forge create --package-manager npm writes npm into forge.yaml with npx bindings", async () => {
|
|
147
147
|
const result = await runCreate(
|
|
@@ -162,7 +162,7 @@ test("forge create --package-manager npm writes npm into forge.yaml with npx bin
|
|
|
162
162
|
expect(config.project.packageManager).toBe("npm");
|
|
163
163
|
expect(config.bindings?.commands.validateRfc).toContain("npx");
|
|
164
164
|
expect(config.bindings?.commands.validateRfc).toContain("forge rfc.validate");
|
|
165
|
-
},
|
|
165
|
+
}, 120000);
|
|
166
166
|
|
|
167
167
|
test("forge create --package-manager pnpm writes pnpm into forge.yaml", async () => {
|
|
168
168
|
const result = await runCreate(
|
|
@@ -182,7 +182,7 @@ test("forge create --package-manager pnpm writes pnpm into forge.yaml", async ()
|
|
|
182
182
|
const config = loadForgeConfig(tempDir);
|
|
183
183
|
expect(config.project.packageManager).toBe("pnpm");
|
|
184
184
|
expect(config.bindings?.commands.validateRfc).toContain("pnpm exec");
|
|
185
|
-
},
|
|
185
|
+
}, 120000);
|
|
186
186
|
|
|
187
187
|
test("forge.yaml has non-null forge-CLI bindings and null stack bindings", async () => {
|
|
188
188
|
await runCreate(
|
|
@@ -199,7 +199,7 @@ test("forge.yaml has non-null forge-CLI bindings and null stack bindings", async
|
|
|
199
199
|
expect(config.bindings?.commands.typecheck).toBeNull();
|
|
200
200
|
expect(config.bindings?.commands.test).toBeNull();
|
|
201
201
|
expect(config.bindings?.commands.scopedBuild).toBeNull();
|
|
202
|
-
},
|
|
202
|
+
}, 120000);
|
|
203
203
|
|
|
204
204
|
test("forge.yaml has forge.syncedVersion set", async () => {
|
|
205
205
|
await runCreate(
|
|
@@ -209,7 +209,7 @@ test("forge.yaml has forge.syncedVersion set", async () => {
|
|
|
209
209
|
|
|
210
210
|
const config = loadForgeConfig(tempDir);
|
|
211
211
|
expect(config.forge?.syncedVersion).toBeTruthy();
|
|
212
|
-
},
|
|
212
|
+
}, 120000);
|
|
213
213
|
|
|
214
214
|
test("result includes nextSteps with forge-bootstrap", async () => {
|
|
215
215
|
const result = await runCreate(
|
|
@@ -220,7 +220,7 @@ test("result includes nextSteps with forge-bootstrap", async () => {
|
|
|
220
220
|
expect(result.nextSteps).toBeDefined();
|
|
221
221
|
expect(result.nextSteps?.some((s) => s.action.includes("forge-bootstrap"))).toBe(true);
|
|
222
222
|
expect(result.nextSteps?.some((s) => s.kind === "required")).toBe(true);
|
|
223
|
-
},
|
|
223
|
+
}, 120000);
|
|
224
224
|
|
|
225
225
|
test("forge create generates AGENTS.md with behavioral layer (RFC-0548)", async () => {
|
|
226
226
|
const result = await runCreate(
|
|
@@ -237,7 +237,7 @@ test("forge create generates AGENTS.md with behavioral layer (RFC-0548)", async
|
|
|
237
237
|
expect(agentsMd).toContain("<!-- forge:begin behavioral-layer -->");
|
|
238
238
|
expect(agentsMd).toContain("<!-- forge:end behavioral-layer -->");
|
|
239
239
|
expect(agentsMd).toContain("### Intent-to-skill routing");
|
|
240
|
-
},
|
|
240
|
+
}, 120000);
|
|
241
241
|
|
|
242
242
|
test("forge create writes NEXT_STEPS.md with greenfield and transplant guidance (RFC-0550)", async () => {
|
|
243
243
|
const result = await runCreate(
|
|
@@ -255,7 +255,7 @@ test("forge create writes NEXT_STEPS.md with greenfield and transplant guidance
|
|
|
255
255
|
expect(nextSteps).toContain("forge-bootstrap");
|
|
256
256
|
expect(nextSteps).toContain("language");
|
|
257
257
|
expect(result.data?.filesCreated).toContain("NEXT_STEPS.md");
|
|
258
|
-
},
|
|
258
|
+
}, 120000);
|
|
259
259
|
|
|
260
260
|
test("forge create root package.json has scripts and replaced project name", async () => {
|
|
261
261
|
const result = await runCreate(
|
|
@@ -272,7 +272,7 @@ test("forge create root package.json has scripts and replaced project name", asy
|
|
|
272
272
|
expect(pkgJson.scripts["format:check"]).toBe("prettier --check .");
|
|
273
273
|
expect(pkgJson.scripts.test).toBe("vitest run --passWithNoTests");
|
|
274
274
|
expect(pkgJson.scripts["upgrade-packages"]).toBe("pnpm up");
|
|
275
|
-
},
|
|
275
|
+
}, 120000);
|
|
276
276
|
|
|
277
277
|
test("forge create --in-place derives name from folder name when --name omitted", async () => {
|
|
278
278
|
const namedDir = join(tempDir, "my-derived-project");
|
|
@@ -288,7 +288,7 @@ test("forge create --in-place derives name from folder name when --name omitted"
|
|
|
288
288
|
const { readFile: readFileAsync } = await import("node:fs/promises");
|
|
289
289
|
const pkgJson = JSON.parse(await readFileAsync(join(namedDir, "package.json"), "utf8"));
|
|
290
290
|
expect(pkgJson.name).toBe("my-derived-project");
|
|
291
|
-
},
|
|
291
|
+
}, 120000);
|
|
292
292
|
|
|
293
293
|
test("forge create --profile godot-csharp writes compass.fileExtensions into forge.yaml", async () => {
|
|
294
294
|
const result = await runCreate(
|
|
@@ -305,7 +305,7 @@ test("forge create --profile godot-csharp writes compass.fileExtensions into for
|
|
|
305
305
|
expect(forgeYaml).toContain(".tscn");
|
|
306
306
|
expect(forgeYaml).toContain(".tres");
|
|
307
307
|
expect(forgeYaml).toContain(".gd");
|
|
308
|
-
},
|
|
308
|
+
}, 120000);
|
|
309
309
|
|
|
310
310
|
test("forge create --in-place setup-git-guards.sh contains shell auto-detection (RFC-1019)", async () => {
|
|
311
311
|
const result = await runCreate(
|
|
@@ -319,7 +319,7 @@ test("forge create --in-place setup-git-guards.sh contains shell auto-detection
|
|
|
319
319
|
expect(setupScript).toContain(".zshenv");
|
|
320
320
|
expect(setupScript).toContain(".bashrc");
|
|
321
321
|
expect(setupScript).toContain("profile_file");
|
|
322
|
-
},
|
|
322
|
+
}, 120000);
|
|
323
323
|
|
|
324
324
|
test("forge create --in-place .windsurf/hooks.json calls wrapper not bash directly (RFC-1019)", async () => {
|
|
325
325
|
const result = await runCreate(
|
|
@@ -332,7 +332,7 @@ test("forge create --in-place .windsurf/hooks.json calls wrapper not bash direct
|
|
|
332
332
|
const hooksJson = await readFileAsync(join(tempDir, ".windsurf", "hooks.json"), "utf8");
|
|
333
333
|
expect(hooksJson).toContain("pre-user-prompt-wrapper.mjs");
|
|
334
334
|
expect(hooksJson).not.toContain("2>/dev/null || true");
|
|
335
|
-
},
|
|
335
|
+
}, 120000);
|
|
336
336
|
|
|
337
337
|
test("forge create --in-place pins pre-user-prompt-wrapper.mjs in pinned.yaml (RFC-1019)", async () => {
|
|
338
338
|
const result = await runCreate(
|
|
@@ -344,7 +344,7 @@ test("forge create --in-place pins pre-user-prompt-wrapper.mjs in pinned.yaml (R
|
|
|
344
344
|
const { readFile: readFileAsync } = await import("node:fs/promises");
|
|
345
345
|
const pinnedYaml = await readFileAsync(join(tempDir, ".forge", "pinned.yaml"), "utf8");
|
|
346
346
|
expect(pinnedYaml).toContain("pre-user-prompt-wrapper.mjs");
|
|
347
|
-
},
|
|
347
|
+
}, 120000);
|
|
348
348
|
|
|
349
349
|
test("forge create --profile phaser-turborepo writes compass.fileExtensions into forge.yaml", async () => {
|
|
350
350
|
const result = await runCreate(
|
|
@@ -357,4 +357,4 @@ test("forge create --profile phaser-turborepo writes compass.fileExtensions into
|
|
|
357
357
|
const forgeYaml = await readFileAsync(join(tempDir, "forge.yaml"), "utf8");
|
|
358
358
|
expect(forgeYaml).toContain("compass:");
|
|
359
359
|
expect(forgeYaml).toContain("fileExtensions:");
|
|
360
|
-
},
|
|
360
|
+
}, 120000);
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Unit tests for forge.file-size.lint — SIZE-01 rule from RFC-1088.</purpose>
|
|
4
|
+
</MODULE_CONTRACT>
|
|
5
|
+
<CHANGE_SUMMARY>
|
|
6
|
+
<item>RFC-1088: initial tests for portable file-size lint handler.</item>
|
|
7
|
+
</CHANGE_SUMMARY>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import { mkdtemp, mkdir, writeFile, rm, readFile } from "node:fs/promises";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { runFileSizeLint, countLines } from "../../os/core/handlers/file-size-lint.ts";
|
|
15
|
+
import type { ForgeRuntimeContext, ForgeLogger } from "../types.ts";
|
|
16
|
+
|
|
17
|
+
const logger: ForgeLogger = {
|
|
18
|
+
section() {},
|
|
19
|
+
info() {},
|
|
20
|
+
warn() {},
|
|
21
|
+
error() {},
|
|
22
|
+
success() {},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function mockContext(workspaceRoot: string): ForgeRuntimeContext {
|
|
26
|
+
return {
|
|
27
|
+
workspaceRoot,
|
|
28
|
+
logger,
|
|
29
|
+
dryRun: false,
|
|
30
|
+
outputFormat: "json",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function input(flags: Record<string, unknown> = {}): { argv: string[]; flags: Record<string, unknown> } {
|
|
35
|
+
return { argv: [], flags };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("countLines (RFC-1088)", () => {
|
|
39
|
+
it("counts physical lines including a trailing newline", () => {
|
|
40
|
+
expect(countLines("a\nb\nc\n")).toBe(4);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns 0 for an empty source", () => {
|
|
44
|
+
expect(countLines("")).toBe(0);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("runFileSizeLint (RFC-1088, command-level)", () => {
|
|
49
|
+
let tempDir: string;
|
|
50
|
+
|
|
51
|
+
beforeEach(async () => {
|
|
52
|
+
tempDir = await mkdtemp(join(tmpdir(), "forge-file-size-lint-"));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(async () => {
|
|
56
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("SIZE-01 (warning): flags a 700-line file as warning (601-1200 tier)", async () => {
|
|
60
|
+
await mkdir(join(tempDir, "packages", "some-pkg", "src"), { recursive: true });
|
|
61
|
+
const big = Array.from({ length: 700 }, (_, i) => `const x${i} = ${i};`).join("\n");
|
|
62
|
+
await writeFile(join(tempDir, "packages", "some-pkg", "src", "big.ts"), big, "utf8");
|
|
63
|
+
|
|
64
|
+
const result = await runFileSizeLint(input(), mockContext(tempDir));
|
|
65
|
+
expect(result.exitCode ?? 0).toBe(0);
|
|
66
|
+
|
|
67
|
+
const diagnostics = (
|
|
68
|
+
result.data as { diagnostics: Array<{ ruleId: string; severity: string; file: string }> }
|
|
69
|
+
).diagnostics;
|
|
70
|
+
const hit = diagnostics.find((d) => d.ruleId === "SIZE-01" && d.file.endsWith("big.ts"));
|
|
71
|
+
expect(hit).toBeDefined();
|
|
72
|
+
expect(hit!.severity).toBe("warning");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("SIZE-01 (error): flags a 1300-line file as error (1200+ tier)", async () => {
|
|
76
|
+
await mkdir(join(tempDir, "packages", "some-pkg", "src"), { recursive: true });
|
|
77
|
+
const huge = Array.from({ length: 1300 }, (_, i) => `const x${i} = ${i};`).join("\n");
|
|
78
|
+
await writeFile(join(tempDir, "packages", "some-pkg", "src", "huge.ts"), huge, "utf8");
|
|
79
|
+
|
|
80
|
+
const result = await runFileSizeLint(input(), mockContext(tempDir));
|
|
81
|
+
expect(result.exitCode).toBe(1);
|
|
82
|
+
|
|
83
|
+
const diagnostics = (
|
|
84
|
+
result.data as { diagnostics: Array<{ ruleId: string; severity: string; file: string }> }
|
|
85
|
+
).diagnostics;
|
|
86
|
+
const hit = diagnostics.find((d) => d.ruleId === "SIZE-01" && d.file.endsWith("huge.ts"));
|
|
87
|
+
expect(hit).toBeDefined();
|
|
88
|
+
expect(hit!.severity).toBe("error");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("passes without diagnostics when every file is under the threshold", async () => {
|
|
92
|
+
await mkdir(join(tempDir, "packages", "some-pkg", "src"), { recursive: true });
|
|
93
|
+
await writeFile(
|
|
94
|
+
join(tempDir, "packages", "some-pkg", "src", "small.ts"),
|
|
95
|
+
"export const x = 1;\n",
|
|
96
|
+
"utf8",
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const result = await runFileSizeLint(input(), mockContext(tempDir));
|
|
100
|
+
expect(result.exitCode ?? 0).toBe(0);
|
|
101
|
+
|
|
102
|
+
const diagnostics = (result.data as { diagnostics: Array<{ ruleId: string }> }).diagnostics;
|
|
103
|
+
expect(diagnostics.length).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("--write-baseline writes a YAML baseline to workspace root", async () => {
|
|
107
|
+
await mkdir(join(tempDir, "packages", "some-pkg", "src"), { recursive: true });
|
|
108
|
+
const big = Array.from({ length: 700 }, (_, i) => `const x${i} = ${i};`).join("\n");
|
|
109
|
+
await writeFile(join(tempDir, "packages", "some-pkg", "src", "big.ts"), big, "utf8");
|
|
110
|
+
|
|
111
|
+
const result = await runFileSizeLint(
|
|
112
|
+
input({ "write-baseline": true }),
|
|
113
|
+
mockContext(tempDir),
|
|
114
|
+
);
|
|
115
|
+
expect(result.exitCode ?? 0).toBe(0);
|
|
116
|
+
|
|
117
|
+
const baselineRaw = await readFile(
|
|
118
|
+
join(tempDir, "file-size-lint.baseline.yaml"),
|
|
119
|
+
"utf8",
|
|
120
|
+
);
|
|
121
|
+
expect(baselineRaw).toContain("schemaVersion");
|
|
122
|
+
expect(baselineRaw).toContain("threshold: 600");
|
|
123
|
+
expect(baselineRaw).toContain("big.ts");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("baseline suppresses files within their ceiling", async () => {
|
|
127
|
+
await mkdir(join(tempDir, "packages", "some-pkg", "src"), { recursive: true });
|
|
128
|
+
const big = Array.from({ length: 700 }, (_, i) => `const x${i} = ${i};`).join("\n");
|
|
129
|
+
await writeFile(join(tempDir, "packages", "some-pkg", "src", "big.ts"), big, "utf8");
|
|
130
|
+
|
|
131
|
+
await writeFile(
|
|
132
|
+
join(tempDir, "file-size-lint.baseline.yaml"),
|
|
133
|
+
`meta:\n schemaVersion: 1\n threshold: 600\nceilings:\n packages/some-pkg/src/big.ts: 700\n`,
|
|
134
|
+
"utf8",
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const result = await runFileSizeLint(input(), mockContext(tempDir));
|
|
138
|
+
expect(result.exitCode ?? 0).toBe(0);
|
|
139
|
+
|
|
140
|
+
const diagnostics = (result.data as { diagnostics: Array<{ ruleId: string }> }).diagnostics;
|
|
141
|
+
expect(diagnostics.length).toBe(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("baseline flags files that grew beyond their ceiling", async () => {
|
|
145
|
+
await mkdir(join(tempDir, "packages", "some-pkg", "src"), { recursive: true });
|
|
146
|
+
const big = Array.from({ length: 800 }, (_, i) => `const x${i} = ${i};`).join("\n");
|
|
147
|
+
await writeFile(join(tempDir, "packages", "some-pkg", "src", "big.ts"), big, "utf8");
|
|
148
|
+
|
|
149
|
+
await writeFile(
|
|
150
|
+
join(tempDir, "file-size-lint.baseline.yaml"),
|
|
151
|
+
`meta:\n schemaVersion: 1\n threshold: 600\nceilings:\n packages/some-pkg/src/big.ts: 700\n`,
|
|
152
|
+
"utf8",
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const result = await runFileSizeLint(input(), mockContext(tempDir));
|
|
156
|
+
expect(result.exitCode ?? 0).toBe(0);
|
|
157
|
+
|
|
158
|
+
const diagnostics = (
|
|
159
|
+
result.data as { diagnostics: Array<{ ruleId: string; message: string }> }
|
|
160
|
+
).diagnostics;
|
|
161
|
+
const hit = diagnostics.find((d) => d.ruleId === "SIZE-01");
|
|
162
|
+
expect(hit).toBeDefined();
|
|
163
|
+
expect(hit!.message).toContain("grew from the baselined 700 to 800");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("--baseline-path overrides the default baseline location", async () => {
|
|
167
|
+
await mkdir(join(tempDir, "packages", "some-pkg", "src"), { recursive: true });
|
|
168
|
+
const big = Array.from({ length: 700 }, (_, i) => `const x${i} = ${i};`).join("\n");
|
|
169
|
+
await writeFile(join(tempDir, "packages", "some-pkg", "src", "big.ts"), big, "utf8");
|
|
170
|
+
|
|
171
|
+
const customPath = "custom-baseline.yaml";
|
|
172
|
+
const result = await runFileSizeLint(
|
|
173
|
+
input({ "write-baseline": true, "baseline-path": customPath }),
|
|
174
|
+
mockContext(tempDir),
|
|
175
|
+
);
|
|
176
|
+
expect(result.exitCode ?? 0).toBe(0);
|
|
177
|
+
|
|
178
|
+
const baselineRaw = await readFile(join(tempDir, customPath), "utf8");
|
|
179
|
+
expect(baselineRaw).toContain("big.ts");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
</MODULE_CONTRACT>
|
|
5
5
|
<CHANGE_SUMMARY>
|
|
6
6
|
<item>RFC-1080: initial public-surface validator tests covering all 5 rules, exit codes, and monorepo resolution.</item>
|
|
7
|
+
<item>RFC-1088: raise SURFACE-01 threshold from 300 to 600 lines.</item>
|
|
7
8
|
</CHANGE_SUMMARY>
|
|
8
9
|
*/
|
|
9
10
|
|
|
@@ -101,7 +102,7 @@ async function setupMonorepo(
|
|
|
101
102
|
await setupStandalone(join(dir, "packages", "forge"), opts);
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
test("SURFACE-01: README under
|
|
105
|
+
test("SURFACE-01: README under 600 lines passes", async () => {
|
|
105
106
|
await setupStandalone(tempDir, { readmeLines: 100 });
|
|
106
107
|
const result = await runPublicSurfaceValidate({} as never, mockContext(tempDir));
|
|
107
108
|
const checks = result.data?.checks ?? [];
|
|
@@ -109,12 +110,20 @@ test("SURFACE-01: README under 300 lines passes", async () => {
|
|
|
109
110
|
expect(surface01?.status, "README with 100 lines should pass SURFACE-01").toBe("pass");
|
|
110
111
|
});
|
|
111
112
|
|
|
112
|
-
test("SURFACE-01: README
|
|
113
|
-
await setupStandalone(tempDir, { readmeLines:
|
|
113
|
+
test("SURFACE-01: README at exactly 600 lines passes", async () => {
|
|
114
|
+
await setupStandalone(tempDir, { readmeLines: 600 });
|
|
114
115
|
const result = await runPublicSurfaceValidate({} as never, mockContext(tempDir));
|
|
115
116
|
const checks = result.data?.checks ?? [];
|
|
116
117
|
const surface01 = checks.find((c) => c.rule === "SURFACE-01");
|
|
117
|
-
expect(surface01?.status, "README with
|
|
118
|
+
expect(surface01?.status, "README with 600 lines should pass SURFACE-01").toBe("pass");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("SURFACE-01: README over 600 lines warns", async () => {
|
|
122
|
+
await setupStandalone(tempDir, { readmeLines: 601 });
|
|
123
|
+
const result = await runPublicSurfaceValidate({} as never, mockContext(tempDir));
|
|
124
|
+
const checks = result.data?.checks ?? [];
|
|
125
|
+
const surface01 = checks.find((c) => c.rule === "SURFACE-01");
|
|
126
|
+
expect(surface01?.status, "README with 601 lines should warn on SURFACE-01").toBe("warn");
|
|
118
127
|
});
|
|
119
128
|
|
|
120
129
|
test("SURFACE-02: Node version match passes", async () => {
|
|
@@ -60,7 +60,7 @@ test("forge.scaffold tolerates non-empty directory (RFC-0877 in-place mode)", as
|
|
|
60
60
|
expect(result.data?.status).toBe("pass");
|
|
61
61
|
expect(existsSync(join(tempDir, "package.json"))).toBe(true);
|
|
62
62
|
expect(existsSync(join(tempDir, "some-file.txt"))).toBe(true);
|
|
63
|
-
},
|
|
63
|
+
}, 120000);
|
|
64
64
|
|
|
65
65
|
test("forge.scaffold fails on missing --profile", async () => {
|
|
66
66
|
const result = await runScaffoldProject(
|