@warpgogol/forge 5.3.0 → 5.3.2

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.
Files changed (45) hide show
  1. package/bin/cli.ts +24 -33
  2. package/dist/bin/cli.js +20 -32
  3. package/dist/bin/cli.js.map +1 -1
  4. package/dist/os/adr/handlers/implement-stamp.d.ts.map +1 -1
  5. package/dist/os/adr/handlers/implement-stamp.js +0 -1
  6. package/dist/os/adr/handlers/implement-stamp.js.map +1 -1
  7. package/dist/os/compass/compass.module.d.ts.map +1 -1
  8. package/dist/os/compass/compass.module.js +6 -2
  9. package/dist/os/compass/compass.module.js.map +1 -1
  10. package/dist/os/mission/handlers/archive.d.ts.map +1 -1
  11. package/dist/os/mission/handlers/archive.js +22 -4
  12. package/dist/os/mission/handlers/archive.js.map +1 -1
  13. package/dist/os/rfc/handlers/validate.d.ts.map +1 -1
  14. package/dist/os/rfc/handlers/validate.js +6 -1
  15. package/dist/os/rfc/handlers/validate.js.map +1 -1
  16. package/dist/os/werkstatt/handlers/workspace-deps.d.ts.map +1 -1
  17. package/dist/os/werkstatt/handlers/workspace-deps.js +0 -1
  18. package/dist/os/werkstatt/handlers/workspace-deps.js.map +1 -1
  19. package/dist/src/cli-flags.d.ts +28 -0
  20. package/dist/src/cli-flags.d.ts.map +1 -0
  21. package/dist/src/cli-flags.js +236 -0
  22. package/dist/src/cli-flags.js.map +1 -0
  23. package/dist/src/onboarding/agents-generate.d.ts.map +1 -1
  24. package/dist/src/onboarding/agents-generate.js +18 -5
  25. package/dist/src/onboarding/agents-generate.js.map +1 -1
  26. package/os/adr/handlers/implement-stamp.ts +0 -1
  27. package/os/compass/compass.module.ts +7 -2
  28. package/os/mission/handlers/archive.test.ts +38 -1
  29. package/os/mission/handlers/archive.ts +27 -4
  30. package/os/rfc/handlers/validate.test.ts +71 -0
  31. package/os/rfc/handlers/validate.ts +6 -1
  32. package/os/werkstatt/handlers/workspace-deps.ts +0 -1
  33. package/package.json +1 -1
  34. package/profiles/forge-shell.yaml +1 -1
  35. package/profiles/godot-game.yaml +1 -1
  36. package/profiles/knowledge.yaml +1 -1
  37. package/profiles/phaser-game.yaml +1 -1
  38. package/profiles/site-workshop.yaml +1 -1
  39. package/profiles/typescript.yaml +1 -1
  40. package/src/cli-flags.ts +284 -0
  41. package/src/onboarding/agents-generate.ts +20 -5
  42. package/src/tests/agents-generate.test.ts +22 -1
  43. package/src/tests/cli-flags.test.ts +175 -0
  44. package/src/tests/fixtures/agents-generate-business-before.txt +31 -2
  45. package/AGENTS.md +0 -532
@@ -13,6 +13,7 @@ Mechanical v1 to v2 header migration across the workspace: 942 files rewritten
13
13
  <item>RFC-1097: sweep — werkstatt-engine clean
14
14
 
15
15
  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>
16
+ <item>Guard --id against non-string values (boolean/array from unvalidated flag input) — throws a clear flag error instead of TypeError in rfcFileMatchesId.</item>
16
17
  </CHANGE_SUMMARY>
17
18
  */
18
19
 
@@ -48,7 +49,11 @@ export async function runRfcValidate(
48
49
  const { workspaceRoot, logger, outputFormat } = context;
49
50
  const rfcDirPath = path.join(workspaceRoot, RFC_DIR);
50
51
 
51
- const targetId = input.flags["id"] as string | undefined;
52
+ const rawId = input.flags["id"];
53
+ if (rawId !== undefined && typeof rawId !== "string") {
54
+ throw new Error(`Flag "--id" for command "rfc.validate" requires a single string value.`);
55
+ }
56
+ const targetId = rawId as string | undefined;
52
57
 
53
58
  const allFiles = await listRfcFiles(rfcDirPath);
54
59
  const filesToValidate = targetId
@@ -9,7 +9,6 @@
9
9
  <CHANGE_SUMMARY>
10
10
  <item>RFC-0682: extract dynamic-import helper so forge publishes without workspace:* deps.</item>
11
11
  <item>RFC-0940: moved from os/core/handlers/ to os/werkstatt/handlers/ — this module imports @warpgogol/werkstatt-engine and belongs in the adapter directory.</item>
12
- <item>2026-09-22: fingerprint import switched from @warpgogol/werkstatt-engine/fingerprint to @warpgogol/werkstatt-shared/fingerprint (canonical home since RFC-1104) — breaks the forge↔engine package cycle reported by turbo.</item>
13
12
  </CHANGE_SUMMARY>
14
13
  */
15
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warpgogol/forge",
3
- "version": "5.3.0",
3
+ "version": "5.3.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -525,7 +525,7 @@ workspace:
525
525
  "hooks": {
526
526
  "pre_user_prompt": [
527
527
  {
528
- "command": "node $ROOT_WORKSPACE_PATH/hooks/pre-user-prompt-wrapper.mjs",
528
+ "command": "node \"${ROOT_WORKSPACE_PATH:-$PWD}/hooks/pre-user-prompt-wrapper.mjs\"",
529
529
  "show_output": true
530
530
  }
531
531
  ]
@@ -1002,7 +1002,7 @@ workspace:
1002
1002
  "hooks": {
1003
1003
  "pre_user_prompt": [
1004
1004
  {
1005
- "command": "node $ROOT_WORKSPACE_PATH/hooks/pre-user-prompt-wrapper.mjs",
1005
+ "command": "node \"${ROOT_WORKSPACE_PATH:-$PWD}/hooks/pre-user-prompt-wrapper.mjs\"",
1006
1006
  "show_output": true
1007
1007
  }
1008
1008
  ]
@@ -378,7 +378,7 @@ workspace:
378
378
  "hooks": {
379
379
  "pre_user_prompt": [
380
380
  {
381
- "command": "node $ROOT_WORKSPACE_PATH/hooks/pre-user-prompt-wrapper.mjs",
381
+ "command": "node \"${ROOT_WORKSPACE_PATH:-$PWD}/hooks/pre-user-prompt-wrapper.mjs\"",
382
382
  "show_output": true
383
383
  }
384
384
  ]
@@ -605,7 +605,7 @@ workspace:
605
605
  "hooks": {
606
606
  "pre_user_prompt": [
607
607
  {
608
- "command": "node $ROOT_WORKSPACE_PATH/hooks/pre-user-prompt-wrapper.mjs",
608
+ "command": "node \"${ROOT_WORKSPACE_PATH:-$PWD}/hooks/pre-user-prompt-wrapper.mjs\"",
609
609
  "show_output": true
610
610
  }
611
611
  ]
@@ -516,7 +516,7 @@ workspace:
516
516
  "hooks": {
517
517
  "pre_user_prompt": [
518
518
  {
519
- "command": "node $ROOT_WORKSPACE_PATH/hooks/pre-user-prompt-wrapper.mjs",
519
+ "command": "node \"${ROOT_WORKSPACE_PATH:-$PWD}/hooks/pre-user-prompt-wrapper.mjs\"",
520
520
  "show_output": true
521
521
  }
522
522
  ]
@@ -453,7 +453,7 @@ workspace:
453
453
  "hooks": {
454
454
  "pre_user_prompt": [
455
455
  {
456
- "command": "node $ROOT_WORKSPACE_PATH/hooks/pre-user-prompt-wrapper.mjs",
456
+ "command": "node \"${ROOT_WORKSPACE_PATH:-$PWD}/hooks/pre-user-prompt-wrapper.mjs\"",
457
457
  "show_output": true
458
458
  }
459
459
  ]
@@ -0,0 +1,284 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Argv-to-flags resolution for the standalone forge CLI — a standalone
4
+ port of the kernel's RFC-0260 semantics (resolveCommandFlags for schema-carrying
5
+ commands, the heuristic parser for schema-less ones). Ensures a declared string
6
+ flag never reaches a handler as `true`, which crashed `forge rfc.validate --id`
7
+ with `TypeError: targetId.toLowerCase is not a function`.</purpose>
8
+ <non-goals>
9
+ <item>Do not import from @warpgogol/* — forge is autonomous (FORGE-AUTONOMY-01); keep this a self-contained port.</item>
10
+ <item>Do not call console.* or process.exit — bin/cli.ts renders diagnostics and decides the exit code.</item>
11
+ </non-goals>
12
+ </MODULE_CONTRACT>
13
+ <CHANGE_SUMMARY>
14
+ <item>Standalone port of kernel argv resolution for the forge CLI (RFC-0260 lineage): strict KERNEL-FLAG-01/02/03 + KERNEL-ARG-01 diagnostics for schema-carrying commands, legacy heuristic parse (with KERNEL-ARG-01) for schema-less commands; repeated single-value string flags diagnose instead of silently promoting to an array that crashes handlers; indexOf-based inline-value split preserves values containing "=" that split("=", 2) silently truncated.</item>
15
+ </CHANGE_SUMMARY>
16
+ */
17
+
18
+ import type { Diagnostic, ForgeCommandDefinition, ForgeFlagSpec, ForgeFlagValue } from "./types.ts";
19
+
20
+ /**
21
+ * Flags every CLI command accepts regardless of its own declared schema —
22
+ * mirrors the kernel's KERNEL_UNIVERSAL_FLAGS so both entry points accept the
23
+ * same universal switches.
24
+ */
25
+ export const CLI_UNIVERSAL_FLAGS: Record<string, ForgeFlagSpec> = {
26
+ site: { kind: "string", description: "Target a specific site by name." },
27
+ all: { kind: "boolean", description: "Run against every discovered app." },
28
+ json: { kind: "boolean", description: "Emit machine-readable JSON instead of pretty output." },
29
+ quiet: { kind: "boolean", description: "Suppress non-essential log output." },
30
+ verbose: { kind: "boolean", description: "Emit additional diagnostic log output." },
31
+ root: { kind: "string", description: "Override the resolved workspace root." },
32
+ "dry-run": { kind: "boolean", description: "Report intended mutations without writing them." },
33
+ force: { kind: "boolean", description: "Bypass a normally-blocking safety check." },
34
+ help: { kind: "boolean", description: "Print command help instead of executing." },
35
+ };
36
+
37
+ /**
38
+ * Boolean switches for the legacy heuristic path (schema-less commands).
39
+ * Mirrors the kernel's KERNEL_BOOLEAN_FLAGS: a listed flag never consumes the
40
+ * following token as its value.
41
+ */
42
+ const CLI_BOOLEAN_FLAGS = new Set<string>([
43
+ "all",
44
+ "all-apps",
45
+ "approve-all",
46
+ "dry-run",
47
+ "force",
48
+ "help",
49
+ "inplace",
50
+ "json",
51
+ "mini",
52
+ "packages",
53
+ "page",
54
+ "prod",
55
+ "quiet",
56
+ "regen",
57
+ "regenerate",
58
+ "report-only",
59
+ "show-versions",
60
+ "strict",
61
+ "verbose",
62
+ "version",
63
+ "write",
64
+ ]);
65
+
66
+ function addFlagValue(
67
+ target: Record<string, ForgeFlagValue>,
68
+ key: string,
69
+ value: string | boolean,
70
+ ): void {
71
+ const existing = target[key];
72
+
73
+ if (existing === undefined) {
74
+ target[key] = value;
75
+ return;
76
+ }
77
+
78
+ if (Array.isArray(existing)) {
79
+ if (typeof value === "string") {
80
+ target[key] = [...existing, value];
81
+ }
82
+ return;
83
+ }
84
+
85
+ if (typeof existing === "string") {
86
+ if (typeof value === "string") {
87
+ target[key] = [existing, value];
88
+ return;
89
+ }
90
+
91
+ target[key] = existing;
92
+ return;
93
+ }
94
+
95
+ target[key] = value;
96
+ }
97
+
98
+ function positionalDiagnostic(entry: string, commandName?: string): Diagnostic {
99
+ const scope = commandName ? ` for command "${commandName}"` : "";
100
+ return {
101
+ ruleId: "KERNEL-ARG-01",
102
+ severity: "error",
103
+ message: `Unexpected positional argument "${entry}"${scope}. All arguments must be passed as flags.`,
104
+ fixHint: `Convert "${entry}" to a flag, e.g. --id ${entry}.`,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Legacy heuristic parse for commands without a `flags` schema — mirrors the
110
+ * kernel's parseKernelArgv: unknown flags are accepted, boolean-ness comes
111
+ * from CLI_BOOLEAN_FLAGS, and positional tokens produce KERNEL-ARG-01.
112
+ */
113
+ export function parseCliArgs(argv: string[]): {
114
+ flags: Record<string, ForgeFlagValue>;
115
+ diagnostics: Diagnostic[];
116
+ } {
117
+ const flags: Record<string, ForgeFlagValue> = {};
118
+ const diagnostics: Diagnostic[] = [];
119
+ let passthrough = false;
120
+
121
+ for (let index = 0; index < argv.length; index += 1) {
122
+ const entry = argv[index]!;
123
+
124
+ if (passthrough) {
125
+ diagnostics.push(positionalDiagnostic(entry));
126
+ continue;
127
+ }
128
+
129
+ if (entry === "--") {
130
+ passthrough = true;
131
+ continue;
132
+ }
133
+
134
+ if (!entry.startsWith("--")) {
135
+ diagnostics.push(positionalDiagnostic(entry));
136
+ continue;
137
+ }
138
+
139
+ const withoutPrefix = entry.slice(2);
140
+ const eqIndex = withoutPrefix.indexOf("=");
141
+ const flagName = eqIndex === -1 ? withoutPrefix : withoutPrefix.slice(0, eqIndex);
142
+ const inlineValue = eqIndex === -1 ? undefined : withoutPrefix.slice(eqIndex + 1);
143
+ if (!flagName) continue;
144
+
145
+ if (inlineValue !== undefined) {
146
+ addFlagValue(flags, flagName, inlineValue);
147
+ continue;
148
+ }
149
+
150
+ if (CLI_BOOLEAN_FLAGS.has(flagName)) {
151
+ addFlagValue(flags, flagName, true);
152
+ continue;
153
+ }
154
+
155
+ const next = argv[index + 1];
156
+ if (typeof next === "string" && !next.startsWith("--")) {
157
+ addFlagValue(flags, flagName, next);
158
+ index += 1;
159
+ continue;
160
+ }
161
+
162
+ addFlagValue(flags, flagName, true);
163
+ }
164
+
165
+ return { flags, diagnostics };
166
+ }
167
+
168
+ function resolveStrict(
169
+ rawArgv: string[],
170
+ command: ForgeCommandDefinition,
171
+ ): { flags: Record<string, ForgeFlagValue>; diagnostics: Diagnostic[] } {
172
+ const schema: Record<string, ForgeFlagSpec> = {
173
+ ...CLI_UNIVERSAL_FLAGS,
174
+ ...(command.flags ?? {}),
175
+ };
176
+ const validFlagNames = Object.keys(schema).sort();
177
+ const flags: Record<string, ForgeFlagValue> = {};
178
+ const diagnostics: Diagnostic[] = [];
179
+ let passthrough = false;
180
+
181
+ for (let index = 0; index < rawArgv.length; index += 1) {
182
+ const entry = rawArgv[index]!;
183
+
184
+ if (passthrough) {
185
+ diagnostics.push(positionalDiagnostic(entry, command.name));
186
+ continue;
187
+ }
188
+
189
+ if (entry === "--") {
190
+ passthrough = true;
191
+ continue;
192
+ }
193
+
194
+ if (!entry.startsWith("--")) {
195
+ diagnostics.push(positionalDiagnostic(entry, command.name));
196
+ continue;
197
+ }
198
+
199
+ const withoutPrefix = entry.slice(2);
200
+ const eqIndex = withoutPrefix.indexOf("=");
201
+ const flagName = eqIndex === -1 ? withoutPrefix : withoutPrefix.slice(0, eqIndex);
202
+ const inlineValue = eqIndex === -1 ? undefined : withoutPrefix.slice(eqIndex + 1);
203
+ if (!flagName) continue;
204
+
205
+ const spec = schema[flagName];
206
+ if (!spec) {
207
+ diagnostics.push({
208
+ ruleId: "KERNEL-FLAG-01",
209
+ severity: "error",
210
+ message: `Unknown flag "--${flagName}" for command "${command.name}". Valid flags: ${validFlagNames.join(", ")}`,
211
+ fixHint: `Remove --${flagName}, fix the typo, or add it to the flags schema for ${command.name}.`,
212
+ });
213
+ continue;
214
+ }
215
+
216
+ if (inlineValue !== undefined) {
217
+ addFlagValue(
218
+ flags,
219
+ flagName,
220
+ spec.kind === "boolean" ? inlineValue !== "false" : inlineValue,
221
+ );
222
+ continue;
223
+ }
224
+
225
+ if (spec.kind === "boolean") {
226
+ addFlagValue(flags, flagName, true);
227
+ continue;
228
+ }
229
+
230
+ const next = rawArgv[index + 1];
231
+ if (typeof next === "string" && !next.startsWith("--")) {
232
+ addFlagValue(flags, flagName, next);
233
+ index += 1;
234
+ continue;
235
+ }
236
+
237
+ diagnostics.push({
238
+ ruleId: "KERNEL-FLAG-02",
239
+ severity: "error",
240
+ message: `Flag "--${flagName}" for command "${command.name}" requires a value but none was given.`,
241
+ fixHint: `Pass --${flagName} <value> or --${flagName}=<value>.`,
242
+ });
243
+ }
244
+
245
+ for (const [flagName, spec] of Object.entries(schema)) {
246
+ const value = flags[flagName];
247
+ if (spec.required && value === undefined) {
248
+ diagnostics.push({
249
+ ruleId: "KERNEL-FLAG-03",
250
+ severity: "error",
251
+ message: `Required flag "--${flagName}" is missing for command "${command.name}".`,
252
+ fixHint: `Pass --${flagName} <value>.`,
253
+ });
254
+ } else if (value === undefined && spec.default !== undefined) {
255
+ flags[flagName] = spec.default;
256
+ } else if (spec.kind === "string" && Array.isArray(value)) {
257
+ diagnostics.push({
258
+ ruleId: "KERNEL-FLAG-02",
259
+ severity: "error",
260
+ message: `Flag "--${flagName}" for command "${command.name}" accepts a single value but was given multiple.`,
261
+ fixHint: `Pass --${flagName} once, or declare the flag as kind "string[]" if multiple values are intended.`,
262
+ });
263
+ }
264
+ }
265
+
266
+ return { flags, diagnostics };
267
+ }
268
+
269
+ /**
270
+ * Resolve raw argv against a command's declared flag schema (merged with
271
+ * CLI_UNIVERSAL_FLAGS) — the standalone-CLI counterpart of the kernel's
272
+ * resolveCommandFlags. Schema-carrying commands (`command.flags` set,
273
+ * including `{}`) resolve strictly; schema-less commands keep the legacy
274
+ * heuristic parse. Either way, positional tokens produce KERNEL-ARG-01.
275
+ */
276
+ export function resolveCliFlags(
277
+ argv: string[],
278
+ command: ForgeCommandDefinition,
279
+ ): { flags: Record<string, ForgeFlagValue>; diagnostics: Diagnostic[] } {
280
+ if (command.flags) {
281
+ return resolveStrict(argv, command);
282
+ }
283
+ return parseCliArgs(argv);
284
+ }
@@ -141,10 +141,22 @@ function readRegister(workspaceRoot: string): BehavioralRegister {
141
141
  return "business";
142
142
  }
143
143
 
144
+ /** resolveForgeRoot throws when neither layout exists — routing-table
145
+ * extraction is best-effort, so an unresolvable root yields an empty table. */
146
+ function safeResolveForgeRoot(workspaceRoot: string): string | undefined {
147
+ try {
148
+ return resolveForgeRoot(workspaceRoot);
149
+ } catch {
150
+ return undefined;
151
+ }
152
+ }
153
+
144
154
  function extractTriggerPhrases(
145
- workspaceRoot: string,
155
+ forgeRoot: string | undefined,
146
156
  ): Array<{ name: string; triggerPhrases: string[] }> {
147
- const forgeRoot = path.join(workspaceRoot, "packages", "forge");
157
+ // Consumer packages have no packages/forge — the root must come from the
158
+ // runtime context (or the installed package), never a hardcoded monorepo path.
159
+ if (!forgeRoot) return [];
148
160
  const result: Array<{ name: string; triggerPhrases: string[] }> = [];
149
161
  for (const skill of FORGE_SKILLS) {
150
162
  if (skill.category !== "fo") continue;
@@ -187,10 +199,10 @@ function generateTriggersTable(
187
199
  }
188
200
 
189
201
  function generateBehavioralLayer(
190
- workspaceRoot: string,
191
202
  register: BehavioralRegister,
203
+ forgeRoot: string | undefined,
192
204
  ): string {
193
- const triggerPhrases = extractTriggerPhrases(workspaceRoot);
205
+ const triggerPhrases = extractTriggerPhrases(forgeRoot);
194
206
 
195
207
  let template: string;
196
208
  try {
@@ -355,7 +367,10 @@ export async function runAgentsGenerate(
355
367
  }
356
368
 
357
369
  // Behavioral layer section (RFC-0548)
358
- const behavioralLayer = generateBehavioralLayer(workspaceRoot, register);
370
+ const behavioralLayer = generateBehavioralLayer(
371
+ register,
372
+ context.forgeRoot ?? safeResolveForgeRoot(workspaceRoot),
373
+ );
359
374
  dynamicLines.push(behavioralLayer);
360
375
  dynamicLines.push("");
361
376
 
@@ -10,7 +10,7 @@ routing table, conditional extended layer, markers, and idempotency (RFC-0548, R
10
10
  */
11
11
 
12
12
  import { test, expect, beforeEach, afterEach } from "vitest";
13
- import { mkdtemp, rm, writeFile, mkdir, readFile } from "node:fs/promises";
13
+ import { mkdtemp, rm, writeFile, mkdir, readFile, symlink } from "node:fs/promises";
14
14
  import { existsSync } from "node:fs";
15
15
  import { tmpdir } from "node:os";
16
16
  import { join } from "node:path";
@@ -108,6 +108,27 @@ test("agents-generate includes intent-to-skill routing table", async () => {
108
108
  expect(agentsMd).toContain("fo-");
109
109
  });
110
110
 
111
+ test("agents-generate populates routing table rows in consumer layout (no packages/forge)", async () => {
112
+ await makeForgeYaml(tempDir);
113
+ // Consumer install: the package lives under node_modules — skills ship inside
114
+ // it. A symlinked node_modules/@warpgogol/forge mimics the npm layout.
115
+ const consumerPkg = join(tempDir, "node_modules", "@warpgogol", "forge");
116
+ await mkdir(join(consumerPkg, ".."), { recursive: true });
117
+ await symlink(FORGE_ROOT, consumerPkg, "junction");
118
+
119
+ // forgeRoot undefined → extraction must resolve via the consumer layout.
120
+ const result = await runAgentsGenerate(
121
+ { argv: [], flags: {} },
122
+ { ...makeContext(tempDir), forgeRoot: undefined },
123
+ );
124
+ expect(result.exitCode).toBe(0);
125
+
126
+ const agentsMd = await readFile(join(tempDir, "AGENTS.md"), "utf8");
127
+ // A quoted trigger phrase appears only in routing-table rows — the Skills
128
+ // table contains bare skill names, so this probes extraction end-to-end.
129
+ expect(agentsMd).toContain('"show RFC and ADR status"');
130
+ });
131
+
111
132
  test("agents-generate includes fixed policy text sections", async () => {
112
133
  await makeForgeYaml(tempDir);
113
134
  const result = await runAgentsGenerate({ argv: [], flags: {} }, makeContext(tempDir));
@@ -0,0 +1,175 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Unit tests for src/cli-flags.ts — schema-driven argv-to-flags
4
+ resolution for the standalone forge CLI (strict KERNEL-FLAG-01/02/03 and
5
+ KERNEL-ARG-01 diagnostics, legacy heuristic path for schema-less commands).</purpose>
6
+ <non-goals>
7
+ <item>Do not test bin/cli.ts dispatch — these tests cover pure functions only.</item>
8
+ </non-goals>
9
+ </MODULE_CONTRACT>
10
+ <CHANGE_SUMMARY>
11
+ <item>Initial cli-flags resolution tests — covers the `forge rfc.validate --id` crash regression (value-less declared string flag must produce KERNEL-FLAG-02, not a boolean).</item>
12
+ </CHANGE_SUMMARY>
13
+ */
14
+
15
+ import { describe, expect, test } from "vitest";
16
+ import { parseCliArgs, resolveCliFlags } from "../cli-flags.ts";
17
+ import type { ForgeCommandDefinition, ForgeFlagSpec } from "../types.ts";
18
+
19
+ function command(flags?: Record<string, ForgeFlagSpec>): ForgeCommandDefinition {
20
+ return {
21
+ name: "test.command",
22
+ description: "test command",
23
+ scope: "workspace",
24
+ flags,
25
+ execute: () => {},
26
+ };
27
+ }
28
+
29
+ const errorRuleIds = (diagnostics: { ruleId: string; severity: string }[]) =>
30
+ diagnostics.filter((d) => d.severity === "error").map((d) => d.ruleId);
31
+
32
+ describe("resolveCliFlags — strict (schema-carrying) path", () => {
33
+ const rfcValidate = command({
34
+ id: { kind: "string", description: "Target a single RFC by id." },
35
+ });
36
+
37
+ test("value-less declared string flag errors with KERNEL-FLAG-02 instead of becoming `true`", () => {
38
+ const { flags, diagnostics } = resolveCliFlags(["--id"], rfcValidate);
39
+ expect(flags["id"]).toBeUndefined();
40
+ expect(errorRuleIds(diagnostics)).toEqual(["KERNEL-FLAG-02"]);
41
+ expect(diagnostics[0]!.message).toContain('Flag "--id"');
42
+ expect(diagnostics[0]!.message).toContain('"test.command"');
43
+ });
44
+
45
+ test("value-less declared string flag before another flag still errors", () => {
46
+ const { flags, diagnostics } = resolveCliFlags(["--id", "--json"], rfcValidate);
47
+ expect(flags["id"]).toBeUndefined();
48
+ expect(flags["json"]).toBe(true);
49
+ expect(errorRuleIds(diagnostics)).toEqual(["KERNEL-FLAG-02"]);
50
+ });
51
+
52
+ test("--id <value> and --id=<value> resolve to the string", () => {
53
+ for (const argv of [["--id", "RFC-0609"], ["--id=RFC-0609"]]) {
54
+ const { flags, diagnostics } = resolveCliFlags(argv, rfcValidate);
55
+ expect(flags["id"]).toBe("RFC-0609");
56
+ expect(errorRuleIds(diagnostics)).toEqual([]);
57
+ }
58
+ });
59
+
60
+ test("inline value containing = is preserved verbatim", () => {
61
+ const { flags, diagnostics } = resolveCliFlags(["--id=RFC=0609"], rfcValidate);
62
+ expect(flags["id"]).toBe("RFC=0609");
63
+ expect(errorRuleIds(diagnostics)).toEqual([]);
64
+ });
65
+
66
+ test("repeated single-value string flag errors instead of promoting to an array", () => {
67
+ const { flags, diagnostics } = resolveCliFlags(["--id", "RFC-1", "--id", "RFC-2"], rfcValidate);
68
+ expect(errorRuleIds(diagnostics)).toEqual(["KERNEL-FLAG-02"]);
69
+ expect(diagnostics[0]!.message).toContain("single value");
70
+ expect(flags["id"]).toEqual(["RFC-1", "RFC-2"]);
71
+ });
72
+
73
+ test("unknown flag errors with KERNEL-FLAG-01 listing valid flags", () => {
74
+ const { diagnostics } = resolveCliFlags(["--idd"], rfcValidate);
75
+ expect(errorRuleIds(diagnostics)).toEqual(["KERNEL-FLAG-01"]);
76
+ expect(diagnostics[0]!.message).toContain("id");
77
+ });
78
+
79
+ test("unknown flag followed by a bare token also flags the token as positional", () => {
80
+ const { diagnostics } = resolveCliFlags(["--idd", "RFC-1"], rfcValidate);
81
+ expect(errorRuleIds(diagnostics)).toEqual(["KERNEL-FLAG-01", "KERNEL-ARG-01"]);
82
+ });
83
+
84
+ test("required flag missing errors with KERNEL-FLAG-03", () => {
85
+ const cmd = command({
86
+ name: { kind: "string", required: true, description: "Required name." },
87
+ });
88
+ const { diagnostics } = resolveCliFlags([], cmd);
89
+ expect(errorRuleIds(diagnostics)).toEqual(["KERNEL-FLAG-03"]);
90
+ });
91
+
92
+ test("declared default applies when flag absent", () => {
93
+ const cmd = command({
94
+ mode: { kind: "string", default: "all", description: "Mode." },
95
+ });
96
+ const { flags, diagnostics } = resolveCliFlags([], cmd);
97
+ expect(flags["mode"]).toBe("all");
98
+ expect(errorRuleIds(diagnostics)).toEqual([]);
99
+ });
100
+
101
+ test("positional token errors with KERNEL-ARG-01", () => {
102
+ const { diagnostics } = resolveCliFlags(["RFC-0609"], rfcValidate);
103
+ expect(errorRuleIds(diagnostics)).toEqual(["KERNEL-ARG-01"]);
104
+ });
105
+
106
+ test("tokens after -- are positional errors", () => {
107
+ const { diagnostics } = resolveCliFlags(["--id", "RFC-1", "--", "extra"], rfcValidate);
108
+ expect(errorRuleIds(diagnostics)).toEqual(["KERNEL-ARG-01"]);
109
+ });
110
+
111
+ test("universal flags accepted on commands with empty schema", () => {
112
+ const { flags, diagnostics } = resolveCliFlags(
113
+ ["--json", "--dry-run", "--help", "--site", "x"],
114
+ command({}),
115
+ );
116
+ expect(flags["json"]).toBe(true);
117
+ expect(flags["dry-run"]).toBe(true);
118
+ expect(flags["help"]).toBe(true);
119
+ expect(flags["site"]).toBe("x");
120
+ expect(errorRuleIds(diagnostics)).toEqual([]);
121
+ });
122
+
123
+ test("universal boolean does not swallow the next flag's token", () => {
124
+ const cmd = command({ fix: { kind: "boolean", description: "Fix." } });
125
+ const { flags, diagnostics } = resolveCliFlags(["--json", "--fix"], cmd);
126
+ expect(flags["json"]).toBe(true);
127
+ expect(flags["fix"]).toBe(true);
128
+ expect(errorRuleIds(diagnostics)).toEqual([]);
129
+ });
130
+
131
+ test("--flag=false on a declared boolean resolves to false", () => {
132
+ const cmd = command({ fix: { kind: "boolean", description: "Fix." } });
133
+ const { flags, diagnostics } = resolveCliFlags(["--fix=false"], cmd);
134
+ expect(flags["fix"]).toBe(false);
135
+ expect(errorRuleIds(diagnostics)).toEqual([]);
136
+ });
137
+
138
+ test("string[] flag collects repeated values", () => {
139
+ const cmd = command({
140
+ files: { kind: "string[]", description: "Files." },
141
+ });
142
+ const { flags, diagnostics } = resolveCliFlags(["--files", "a", "--files", "b"], cmd);
143
+ expect(flags["files"]).toEqual(["a", "b"]);
144
+ expect(errorRuleIds(diagnostics)).toEqual([]);
145
+ });
146
+ });
147
+
148
+ describe("resolveCliFlags — legacy (schema-less) path", () => {
149
+ const schemaLess = command(undefined);
150
+
151
+ test("unknown flags still parse heuristically", () => {
152
+ const { flags, diagnostics } = resolveCliFlags(["--anything", "v"], schemaLess);
153
+ expect(flags["anything"]).toBe("v");
154
+ expect(errorRuleIds(diagnostics)).toEqual([]);
155
+ });
156
+
157
+ test("known boolean switches do not consume the next token", () => {
158
+ const { flags } = resolveCliFlags(["--json", "v"], schemaLess);
159
+ expect(flags["json"]).toBe(true);
160
+ });
161
+
162
+ test("positional token errors with KERNEL-ARG-01", () => {
163
+ const { diagnostics } = resolveCliFlags(["positional"], schemaLess);
164
+ expect(errorRuleIds(diagnostics)).toEqual(["KERNEL-ARG-01"]);
165
+ });
166
+ });
167
+
168
+ describe("parseCliArgs", () => {
169
+ test("--key=value and --key value forms both resolve", () => {
170
+ const { flags } = parseCliArgs(["--a=1", "--b", "2", "--json"]);
171
+ expect(flags["a"]).toBe("1");
172
+ expect(flags["b"]).toBe("2");
173
+ expect(flags["json"]).toBe(true);
174
+ });
175
+ });