forgeos 0.1.0-alpha.20 → 0.1.0-alpha.21

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 CHANGED
@@ -1,4 +1,4 @@
1
- // @forge-generated generator=0.1.0-alpha.20 input=a0a760df40f02ebbcf3138bab4133a51f5a69548cba44b7ee4ec711ce57af5c6 content=0d493cf0e41b71cb652d5e0e1b0c1f83d2a1281b748321f0b00f0773ba93074e
1
+ // @forge-generated generator=0.1.0-alpha.21 input=e2f2a360a9fd4118a2d21dd1353365628fe3927c40f9f9ce870f448f3e221f90 content=0d493cf0e41b71cb652d5e0e1b0c1f83d2a1281b748321f0b00f0773ba93074e
2
2
  # AGENTS.md
3
3
 
4
4
  <!-- forge-generated:start -->
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # forgeos
2
2
 
3
+ ## 0.1.0-alpha.21
4
+
5
+ ### Patch Changes
6
+
7
+ - Harden Codex hook queue privacy and brownfield import classification.
8
+
9
+ - Queue new Codex hook events as redacted payloads instead of storing raw prompts, tool inputs, tool responses, or transcripts in `.forge/agent/events.ndjson`.
10
+ - Compact consumed hook queue history into redacted `.history` lines so old raw queue entries are not copied forward during drain retention.
11
+ - Scope brownfield route classification to the detected route handler, so read-only GET handlers are not marked command-like because a sibling route in the same file writes state.
12
+ - Mark read-shaped `POST /search`, `/query`, `/filter`, `/lookup`, and `/graphql` routes as `command-candidate` with `ambiguous-post-query` risk instead of treating them as normal writes.
13
+ - Sync the public docs changelog/CLI reference and clarify the alpha/latest npm dist-tag policy.
14
+
3
15
  ## 0.1.0-alpha.20
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -413,7 +413,7 @@ Configure npm Trusted Publisher for package `forgeos`:
413
413
  | Environment | blank |
414
414
  | Allowed action | `npm publish` |
415
415
 
416
- Do not add `NPM_TOKEN` for normal releases. Alpha releases publish with the `alpha` dist-tag so prerelease builds do not become `latest` accidentally. Use `release:publish-local-alpha -- --dry-run` only to validate the staged tarball locally; real npm publishing should go through `release:publish-alpha`, which dispatches `publish.yml` and uses npm OIDC Trusted Publisher. The workflow checks whether the package version already exists before installing dependencies or running tests, then uses `id-token: write`, Node 24/npm 11+, and provenance for the actual publish. `npm run release:smoke` runs `npm pack`, creates a fresh app with the packed tarball, installs dependencies, runs `forge dev --once --json`, and verifies the app smoke path.
416
+ Do not add `NPM_TOKEN` for normal alpha publishes. Alpha releases publish with the `alpha` dist-tag through npm OIDC Trusted Publisher so prerelease builds do not become `latest` accidentally. Configure `NPM_TOKEN` only when maintainers intentionally want the workflow to promote `latest` with `npm dist-tag add forgeos@<version> latest`; otherwise that step is skipped and `latest` may lag behind `alpha` during hardening. Use `release:publish-local-alpha -- --dry-run` only to validate the staged tarball locally; real npm publishing should go through `release:publish-alpha`, which dispatches `publish.yml` and uses npm OIDC Trusted Publisher. The workflow checks whether the package version already exists before installing dependencies or running tests, then uses `id-token: write`, Node 24/npm 11+, and provenance for the actual publish. `npm run release:smoke` runs `npm pack`, creates a fresh app with the packed tarball, installs dependencies, runs `forge dev --once --json`, and verifies the app smoke path.
417
417
 
418
418
  ## Milestone History
419
419
 
package/docs/changelog.md CHANGED
@@ -6,6 +6,36 @@ The canonical source file in the repository is `CHANGELOG.md`.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.1.0-alpha.21
10
+
11
+ Alpha.21 hardens external-agent privacy and brownfield import polish:
12
+
13
+ - Codex hook runner queue entries now store redacted payloads instead of raw
14
+ prompts, tool inputs, tool responses, or transcripts.
15
+ - Consumed hook queue history is compacted as redacted `.history` entries, so
16
+ old raw queue lines are not copied forward during retention.
17
+ - Brownfield import now scopes write/side-effect heuristics to the detected
18
+ route handler when possible, preventing sibling mutating routes from making a
19
+ read-only GET route look command-like.
20
+ - Read-shaped `POST /search`, `/query`, `/filter`, `/lookup`, and `/graphql`
21
+ routes are emitted as `command-candidate` with `ambiguous-post-query` risk
22
+ until a human review decides whether they should become Forge queries or
23
+ commands.
24
+ - CLI/reference docs now include the CAIR agent protocol and clarify the
25
+ `alpha`/`latest` npm dist-tag policy.
26
+
27
+ ## 0.1.0-alpha.20
28
+
29
+ Generated-change and hook queue fixes:
30
+
31
+ - Fixed generated-change diagnostics for `AGENTS.md` generated blocks and
32
+ `.forge/agent/context.json`.
33
+ - Skipped probe, invalid, and out-of-workspace queued hook events during Agent
34
+ Memory drain, and bounded large hook queue inspection.
35
+ - Preserved empty stdio command arguments, diagnosed malformed command strings,
36
+ and supported structured `service.commandArgs` in external manifests.
37
+ - Included the basic example client demo in typecheck coverage.
38
+
9
39
  ## 0.1.0-alpha.19
10
40
 
11
41
  Alpha hardening:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgeos",
3
- "version": "0.1.0-alpha.20",
3
+ "version": "0.1.0-alpha.21",
4
4
  "description": "Agent-native application framework and compiler for building Forge apps without a mandatory dashboard.",
5
5
  "type": "module",
6
6
  "files": [
@@ -1 +1 @@
1
- {"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.20","releaseId":"forgeos@0.1.0-alpha.20+unknown","schemaVersion":"0.1.0"}
1
+ {"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.21","releaseId":"forgeos@0.1.0-alpha.21+unknown","schemaVersion":"0.1.0"}
@@ -1,4 +1,4 @@
1
- // @forge-generated generator=0.1.0-alpha.20 input=a0a760df40f02ebbcf3138bab4133a51f5a69548cba44b7ee4ec711ce57af5c6 content=3c1af03eecebf9f9b009d8a4a2e08079c9853fca2c742eed3ea3d5b8b68a57b7
1
+ // @forge-generated generator=0.1.0-alpha.21 input=e2f2a360a9fd4118a2d21dd1353365628fe3927c40f9f9ce870f448f3e221f90 content=dcf914c56ed71bee812d0015d689e1dbf4e952b17ad03cd835f396f5fc8fee1a
2
2
  export const releaseManifest = {
3
3
  "defaultProvider": "local",
4
4
  "diagnostics": [],
@@ -19,7 +19,7 @@ export const releaseManifest = {
19
19
  "custom"
20
20
  ],
21
21
  "packageName": "forgeos",
22
- "packageVersion": "0.1.0-alpha.20",
23
- "releaseId": "forgeos@0.1.0-alpha.20+unknown",
22
+ "packageVersion": "0.1.0-alpha.21",
23
+ "releaseId": "forgeos@0.1.0-alpha.21+unknown",
24
24
  "schemaVersion": "0.1.0"
25
25
  } as const;
@@ -4,6 +4,7 @@ import { createDiagnostic } from "../compiler/diagnostics/create.ts";
4
4
  import { createDeltaId } from "../delta/ids.ts";
5
5
  import { DeltaStore, DeltaStoreBusyError, describeDeltaStoreBusy, summarizeDeltaStoreBusy } from "../delta/store.ts";
6
6
  import { extractAgentEventBindings, normalizeAgentEvent, summarizeAgentEvent } from "./normalize.ts";
7
+ import { redactAgentPayload } from "./redaction.ts";
7
8
  import { buildAgentMemoryContext } from "./context-pack.ts";
8
9
  import { claudeCodeInstallFiles, claudeCodeInstallResult } from "./sources/claude-code.ts";
9
10
  import { codexInstallFiles, codexInstallResult, privacyDefaults } from "./sources/codex.ts";
@@ -497,15 +498,62 @@ function compactAgentMemoryQueueFile(options: {
497
498
  }
498
499
  mkdirSync(dirname(historyFile), { recursive: true });
499
500
  const existingHistory = existsSync(historyFile) ? readFileSync(historyFile) : Buffer.alloc(0);
501
+ const redactedConsumedHistory = redactedQueueHistoryBuffer(originalConsumed);
500
502
  writeFileSync(
501
503
  historyFile,
502
- trimBufferStart(Buffer.concat([existingHistory, originalConsumed]), options.historyMaxBytes),
504
+ trimBufferStart(Buffer.concat([existingHistory, redactedConsumedHistory]), options.historyMaxBytes),
503
505
  );
504
506
  writeFileSync(options.watchFile, currentBuffer.subarray(options.consumedOffset));
505
507
  writeQueueCheckpoint(options.watchFile, 0);
506
508
  return { compacted: true, historyFile };
507
509
  }
508
510
 
511
+ function redactedQueueHistoryBuffer(consumedBuffer: Buffer): Buffer {
512
+ const { complete } = splitCompleteJsonLines(consumedBuffer);
513
+ const lines: string[] = [];
514
+ for (const line of complete) {
515
+ if (!line.raw.trim()) {
516
+ continue;
517
+ }
518
+ const parsed = normalizeRawInput(line.raw);
519
+ if (!parsed) {
520
+ lines.push(JSON.stringify({
521
+ forgeHookQueueV1: true,
522
+ historyRedacted: true,
523
+ rawStored: false,
524
+ payloadRedacted: true,
525
+ payload: { _parseError: true },
526
+ }));
527
+ continue;
528
+ }
529
+ lines.push(JSON.stringify(redactedQueueHistoryEntry(parsed)));
530
+ }
531
+ return Buffer.from(lines.length > 0 ? `${lines.join("\n")}\n` : "", "utf8");
532
+ }
533
+
534
+ function redactedQueueHistoryEntry(parsed: Record<string, unknown>): Record<string, unknown> {
535
+ if (parsed.forgeHookQueueV1 !== true) {
536
+ return {
537
+ historyRedacted: true,
538
+ rawStored: false,
539
+ payloadRedacted: true,
540
+ payload: redactAgentPayload(parsed).value,
541
+ };
542
+ }
543
+ const queuedPayload = objectField(parsed, "payload") ?? objectField(parsed, "raw") ?? {};
544
+ return {
545
+ forgeHookQueueV1: true,
546
+ source: typeof parsed.source === "string" ? parsed.source : "codex",
547
+ eventName: typeof parsed.eventName === "string" ? parsed.eventName : undefined,
548
+ workspaceRoot: typeof parsed.workspaceRoot === "string" ? parsed.workspaceRoot : undefined,
549
+ enqueuedAt: typeof parsed.enqueuedAt === "string" ? parsed.enqueuedAt : undefined,
550
+ historyRedacted: true,
551
+ rawStored: false,
552
+ payloadRedacted: true,
553
+ payload: parsed.payloadRedacted === true ? queuedPayload : redactAgentPayload(queuedPayload).value,
554
+ };
555
+ }
556
+
509
557
  function splitCompleteJsonLines(buffer: Buffer): {
510
558
  complete: Array<{ raw: string; endOffset: number }>;
511
559
  completeBytes: number;
@@ -1104,6 +1152,11 @@ function normalizeRawInput(input: unknown): Record<string, unknown> | null {
1104
1152
  return null;
1105
1153
  }
1106
1154
 
1155
+ function objectField(value: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
1156
+ const child = value[key];
1157
+ return child && typeof child === "object" && !Array.isArray(child) ? child as Record<string, unknown> : undefined;
1158
+ }
1159
+
1107
1160
  export async function readStdinJson(options?: { timeoutMs?: number }): Promise<unknown> {
1108
1161
  if (process.stdin.isTTY) {
1109
1162
  return undefined;
@@ -1150,15 +1203,15 @@ function parseQueuedHookLine(raw: Record<string, unknown>): {
1150
1203
  if (raw.forgeHookQueueV1 !== true) {
1151
1204
  return null;
1152
1205
  }
1153
- const payload = raw.raw;
1154
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
1206
+ const payload = objectField(raw, "payload") ?? objectField(raw, "raw");
1207
+ if (!payload) {
1155
1208
  return null;
1156
1209
  }
1157
1210
  return {
1158
1211
  source: typeof raw.source === "string" ? raw.source : "codex",
1159
1212
  eventName: typeof raw.eventName === "string" ? raw.eventName : undefined,
1160
1213
  workspaceRoot: typeof raw.workspaceRoot === "string" ? raw.workspaceRoot : undefined,
1161
- payload: payload as Record<string, unknown>,
1214
+ payload,
1162
1215
  };
1163
1216
  }
1164
1217
 
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Lightweight Codex hook runner — no Forge CLI, no DeltaDB.
4
- * Reads stdin with a short timeout, enqueues to .forge/agent/events.ndjson, exits.
4
+ * Reads stdin with a short timeout, enqueues a redacted event to .forge/agent/events.ndjson, exits.
5
5
  */
6
+ import { createHash } from "node:crypto";
6
7
  import { appendFileSync, mkdirSync } from "node:fs";
7
8
  import { dirname, join, resolve } from "node:path";
8
9
 
@@ -17,6 +18,24 @@ if (!eventName) {
17
18
  const workspaceRoot = resolve(process.cwd());
18
19
  const eventsFile = join(workspaceRoot, ".forge", "agent", "events.ndjson");
19
20
 
21
+ const RAW_TEXT_KEYS = new Set([
22
+ "prompt",
23
+ "userPrompt",
24
+ "last_assistant_message",
25
+ "lastAssistantMessage",
26
+ "completion",
27
+ "message",
28
+ "transcript",
29
+ "transcript_path",
30
+ "transcriptPath",
31
+ "output",
32
+ "stdout",
33
+ "stderr",
34
+ "result",
35
+ ]);
36
+
37
+ const RAW_ARGS_KEYS = new Set(["args", "arguments", "tool_input", "toolInput", "tool_response", "toolResponse", "input"]);
38
+
20
39
  function readStdin(timeoutMs) {
21
40
  return new Promise((resolveRead) => {
22
41
  if (process.stdin.isTTY) {
@@ -72,13 +91,183 @@ async function main() {
72
91
  eventName,
73
92
  workspaceRoot,
74
93
  enqueuedAt: new Date().toISOString(),
75
- raw,
94
+ rawStored: false,
95
+ payloadRedacted: true,
96
+ payload: sanitizePayload(raw, eventName),
76
97
  };
77
98
 
78
99
  mkdirSync(dirname(eventsFile), { recursive: true });
79
100
  appendFileSync(eventsFile, `${JSON.stringify(entry)}\n`, "utf8");
80
101
  }
81
102
 
103
+ function sanitizePayload(raw, hookEventName) {
104
+ const payload = stripRawPayload(raw);
105
+ if (!payload.hook_event_name) {
106
+ payload.hook_event_name = hookEventName;
107
+ }
108
+ if (!payload.cwd) {
109
+ payload.cwd = workspaceRoot;
110
+ }
111
+
112
+ const toolInput = objectField(raw, "tool_input") ?? objectField(raw, "toolInput");
113
+ const toolResponse = objectField(raw, "tool_response") ?? objectField(raw, "toolResponse");
114
+ const command = stringField(toolInput, "command") ?? stringField(raw, "command");
115
+ if (command) {
116
+ payload.commandHash = hashStable(command);
117
+ payload.commandStored = false;
118
+ payload.commandSummary = summarizeCommand(command);
119
+ payload.commandKind = classifyCommand(stringField(raw, "tool_name") ?? stringField(raw, "toolName"), command);
120
+ }
121
+
122
+ const description = stringField(toolInput, "description");
123
+ if (description) {
124
+ payload.approvalDescriptionSummary = safeSummary(description, 180);
125
+ }
126
+
127
+ const exitCode = numberField(toolResponse, "exitCode") ?? numberField(toolResponse, "exit_code") ??
128
+ numberField(raw, "exitCode") ?? numberField(raw, "exit_code");
129
+ if (exitCode !== undefined) {
130
+ payload.exitCode = exitCode;
131
+ payload.resultStatus = exitCode === 0 ? "success" : "failed";
132
+ } else {
133
+ const status = stringField(toolResponse, "status") ?? stringField(raw, "status");
134
+ if (status) {
135
+ payload.resultStatus = status;
136
+ }
137
+ }
138
+
139
+ const responseSummary = summarizeToolResponse(toolResponse);
140
+ if (responseSummary) {
141
+ payload.responseSummary = responseSummary;
142
+ }
143
+ if (toolResponse) {
144
+ payload.responseHash = hashStable(JSON.stringify(toolResponse));
145
+ payload.responseStored = false;
146
+ }
147
+
148
+ return payload;
149
+ }
150
+
151
+ function stripRawPayload(value) {
152
+ if (Array.isArray(value)) {
153
+ return value.slice(0, 50).map((item) => stripRawPayload(item));
154
+ }
155
+ if (!value || typeof value !== "object") {
156
+ return value;
157
+ }
158
+ const output = {};
159
+ for (const [key, child] of Object.entries(value)) {
160
+ if (RAW_TEXT_KEYS.has(key)) {
161
+ output[`${key}Hash`] = hashStable(typeof child === "string" ? child : JSON.stringify(child ?? null));
162
+ output[`${key}Stored`] = false;
163
+ if (typeof child === "string" && !isPromptLikeKey(key)) {
164
+ const summary = safeSummary(child, 160);
165
+ if (summary) {
166
+ output[`${key}Summary`] = summary;
167
+ }
168
+ }
169
+ continue;
170
+ }
171
+ if (RAW_ARGS_KEYS.has(key)) {
172
+ output[`${key}Hash`] = hashStable(JSON.stringify(child ?? null));
173
+ output[`${key}Stored`] = false;
174
+ output[`${key}Shape`] = describeShape(child);
175
+ continue;
176
+ }
177
+ output[key] = stripRawPayload(child);
178
+ }
179
+ return output;
180
+ }
181
+
182
+ function objectField(value, key) {
183
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
184
+ return undefined;
185
+ }
186
+ const child = value[key];
187
+ return child && typeof child === "object" && !Array.isArray(child) ? child : undefined;
188
+ }
189
+
190
+ function stringField(value, key) {
191
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
192
+ return undefined;
193
+ }
194
+ const child = value[key];
195
+ return typeof child === "string" && child.length > 0 ? child : undefined;
196
+ }
197
+
198
+ function numberField(value, key) {
199
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
200
+ return undefined;
201
+ }
202
+ const child = value[key];
203
+ return typeof child === "number" && Number.isFinite(child) ? child : undefined;
204
+ }
205
+
206
+ function describeShape(value) {
207
+ if (Array.isArray(value)) {
208
+ return { kind: "array", length: value.length };
209
+ }
210
+ if (value && typeof value === "object") {
211
+ return {
212
+ kind: "object",
213
+ keys: Object.keys(value).slice(0, 20).sort(),
214
+ };
215
+ }
216
+ return { kind: typeof value };
217
+ }
218
+
219
+ function summarizeCommand(command) {
220
+ return safeSummary(
221
+ command
222
+ .replace(/--(token|api-key|apikey|password|secret)\s+[^\s]+/giu, "--$1 [REDACTED]")
223
+ .replace(/(["']?)(token|apiKey|api_key|password|secret)(["']?)\s*:\s*(["'])(.*?)\4/giu, "$1$2$3: \"[REDACTED]\""),
224
+ 220,
225
+ ) ?? "[command redacted]";
226
+ }
227
+
228
+ function summarizeToolResponse(response) {
229
+ if (!response || typeof response !== "object" || Array.isArray(response)) {
230
+ return undefined;
231
+ }
232
+ const text = stringField(response, "stdout") ?? stringField(response, "stderr") ??
233
+ stringField(response, "output") ?? stringField(response, "result");
234
+ return text ? safeSummary(text, 180) : undefined;
235
+ }
236
+
237
+ function safeSummary(value, maxLength) {
238
+ const normalized = scrubSecretTokens(value).replace(/\s+/gu, " ").trim();
239
+ if (!normalized) {
240
+ return undefined;
241
+ }
242
+ return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 3)}...` : normalized;
243
+ }
244
+
245
+ function classifyCommand(toolName, command) {
246
+ if (toolName === "apply_patch" || command.includes("*** Begin Patch")) {
247
+ return "patch";
248
+ }
249
+ if (/^\s*(?:node|npm|bun|pnpm|yarn|forge|git)\b/u.test(command)) {
250
+ return "shell";
251
+ }
252
+ return "unknown";
253
+ }
254
+
255
+ function isPromptLikeKey(key) {
256
+ return key.toLowerCase().includes("prompt") || key.toLowerCase().includes("completion") || key.toLowerCase().includes("message");
257
+ }
258
+
259
+ function scrubSecretTokens(value) {
260
+ return value
261
+ .replace(/\bsk[-_][A-Za-z0-9_\-.]{8,}\b/gu, "[REDACTED]")
262
+ .replace(/\bnpm_[A-Za-z0-9]{16,}\b/gu, "[REDACTED]")
263
+ .replace(/\bgh[pousr]_[A-Za-z0-9_]{16,}\b/gu, "[REDACTED]")
264
+ .replace(/\b(?:xox[baprs]-)[A-Za-z0-9-]{16,}\b/gu, "[REDACTED]");
265
+ }
266
+
267
+ function hashStable(value) {
268
+ return createHash("sha256").update(value).digest("hex");
269
+ }
270
+
82
271
  main()
83
272
  .then(() => process.exit(0))
84
273
  .catch(() => process.exit(1));
@@ -8,6 +8,7 @@ import type {
8
8
  ImportedDependencyInventory,
9
9
  ImportedFrontendCall,
10
10
  ImportedInventory,
11
+ ImportedEntryKind,
11
12
  ImportedRiskFinding,
12
13
  ImportedRiskReport,
13
14
  ImportedRoute,
@@ -365,7 +366,53 @@ function collectEnv(workspaceRoot: string, files: SourceFile[]): ImportedInvento
365
366
  }
366
367
 
367
368
  function sourceTextForRoute(route: ImportedRoute, files: SourceFile[]): string {
368
- return files.find((file) => file.relativePath === route.file)?.text ?? "";
369
+ const text = files.find((file) => file.relativePath === route.file)?.text ?? "";
370
+ if (!text) {
371
+ return "";
372
+ }
373
+ return scopedSourceTextForRoute(route, text) ?? text;
374
+ }
375
+
376
+ function scopedSourceTextForRoute(route: ImportedRoute, text: string): string | null {
377
+ if (route.source === "next-app-router" && route.handler) {
378
+ return sliceUntilNextMatch(
379
+ text,
380
+ new RegExp(`export\\s+(?:async\\s+)?function\\s+${escapeRegExp(route.handler)}\\b`, "u"),
381
+ /export\s+(?:async\s+)?function\s+(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/gu,
382
+ );
383
+ }
384
+ if (route.source === "express") {
385
+ const method = route.method.toLowerCase();
386
+ const matcher = new RegExp(`\\b(?:app|router)\\s*\\.\\s*${escapeRegExp(method)}\\s*\\(\\s*["'\`]${escapeRegExp(route.path)}["'\`]`, "u");
387
+ return sliceUntilNextMatch(
388
+ text,
389
+ matcher,
390
+ /\b(?:app|router)\s*\.\s*(?:get|post|put|patch|delete|all)\s*\(\s*["'`][^"'`]+["'`]/giu,
391
+ );
392
+ }
393
+ if (route.source === "nest") {
394
+ const method = route.method.charAt(0).toUpperCase() + route.method.slice(1).toLowerCase();
395
+ return sliceUntilNextMatch(
396
+ text,
397
+ new RegExp(`@${escapeRegExp(method)}\\s*\\(`, "u"),
398
+ /@(Get|Post|Put|Patch|Delete|All)\s*\(/gu,
399
+ );
400
+ }
401
+ return null;
402
+ }
403
+
404
+ function sliceUntilNextMatch(text: string, startPattern: RegExp, nextPattern: RegExp): string | null {
405
+ const start = text.search(startPattern);
406
+ if (start < 0) {
407
+ return null;
408
+ }
409
+ nextPattern.lastIndex = start + 1;
410
+ const next = nextPattern.exec(text);
411
+ return text.slice(start, next?.index ?? text.length);
412
+ }
413
+
414
+ function escapeRegExp(value: string): string {
415
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
369
416
  }
370
417
 
371
418
  function classifyCandidate(route: ImportedRoute, text: string): Pick<ImportedCandidateEntry, "kind" | "confidence" | "risks" | "evidence" | "needsApproval"> {
@@ -381,9 +428,17 @@ function classifyCandidate(route: ImportedRoute, text: string): Pick<ImportedCan
381
428
  const auth = /(auth|session|currentuser|getserversession|clerk|nextauth|requireuser|requireauth)/u.test(lowerText);
382
429
  const tenant = /(tenantid|tenant_id|organizationid|orgid|accountid)/u.test(lowerText);
383
430
  const methodUnknown = method === "ANY" || method === "ALL";
384
- if (!isQuery || writes) {
431
+ const ambiguousPostQuery = method === "POST" &&
432
+ /(?:^|\/)(search|query|filter|lookup|graphql)(?:$|\/)/u.test(lowerPath) &&
433
+ !writes &&
434
+ !isDestructive &&
435
+ !external;
436
+ if ((!isQuery && !ambiguousPostQuery) || writes) {
385
437
  risks.add("writes-state");
386
438
  }
439
+ if (ambiguousPostQuery) {
440
+ risks.add("ambiguous-post-query");
441
+ }
387
442
  if (isDestructive) {
388
443
  risks.add("destructive");
389
444
  }
@@ -403,6 +458,15 @@ function classifyCandidate(route: ImportedRoute, text: string): Pick<ImportedCan
403
458
  risks.add("method-unknown");
404
459
  }
405
460
  const commandLike = !isQuery || writes || isDestructive || external;
461
+ if (ambiguousPostQuery) {
462
+ return {
463
+ kind: "command-candidate",
464
+ confidence: 0.55,
465
+ risks: Array.from(risks).sort(),
466
+ evidence,
467
+ needsApproval: true,
468
+ };
469
+ }
406
470
  return {
407
471
  kind: commandLike ? "command" : "query",
408
472
  confidence: commandLike ? (isDestructive ? 0.9 : 0.78) : 0.86,
@@ -412,7 +476,7 @@ function classifyCandidate(route: ImportedRoute, text: string): Pick<ImportedCan
412
476
  };
413
477
  }
414
478
 
415
- function nameForCandidate(route: ImportedRoute, kind: "command" | "query" | "unknown"): string {
479
+ function nameForCandidate(route: ImportedRoute, kind: ImportedEntryKind): string {
416
480
  const nouns = route.path
417
481
  .replace(/^\/api\//u, "")
418
482
  .replace(/:\w+\*?/gu, "byId")
@@ -424,6 +488,7 @@ function nameForCandidate(route: ImportedRoute, kind: "command" | "query" | "unk
424
488
  const method = route.method.toUpperCase();
425
489
  const action =
426
490
  kind === "query" ? "read" :
491
+ kind === "command-candidate" ? "candidate" :
427
492
  method === "POST" ? "create" :
428
493
  method === "PUT" || method === "PATCH" ? "update" :
429
494
  method === "DELETE" ? "delete" :
@@ -11,7 +11,7 @@ export interface BrownfieldImportCommandOptions {
11
11
 
12
12
  export type ImportedAssurance = "static-scan";
13
13
  export type ImportedReviewStatus = "needs-review" | "approved" | "rejected";
14
- export type ImportedEntryKind = "command" | "query" | "unknown";
14
+ export type ImportedEntryKind = "command" | "command-candidate" | "query" | "unknown";
15
15
  export type ImportedRouteSource = "next-app-router" | "next-pages-api" | "express" | "nest" | "unknown";
16
16
 
17
17
  export interface ImportedDependencyInventory {
@@ -1,3 +1,3 @@
1
- export const FORGEOS_VERSION = "0.1.0-alpha.20";
1
+ export const FORGEOS_VERSION = "0.1.0-alpha.21";
2
2
  export const GENERATOR_VERSION = FORGEOS_VERSION;
3
3
  export const CLI_VERSION = FORGEOS_VERSION;