specguard-mcp 0.1.0

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 (38) hide show
  1. package/README.md +366 -0
  2. package/dist/bin/specguard-mcp.d.ts +2 -0
  3. package/dist/bin/specguard-mcp.js +35 -0
  4. package/dist/bin/specguard-mcp.js.map +1 -0
  5. package/dist/src/config.d.ts +108 -0
  6. package/dist/src/config.js +172 -0
  7. package/dist/src/config.js.map +1 -0
  8. package/dist/src/errors.d.ts +60 -0
  9. package/dist/src/errors.js +64 -0
  10. package/dist/src/errors.js.map +1 -0
  11. package/dist/src/index.d.ts +5 -0
  12. package/dist/src/index.js +5 -0
  13. package/dist/src/index.js.map +1 -0
  14. package/dist/src/server.d.ts +28 -0
  15. package/dist/src/server.js +113 -0
  16. package/dist/src/server.js.map +1 -0
  17. package/dist/src/support/run-command.d.ts +86 -0
  18. package/dist/src/support/run-command.js +322 -0
  19. package/dist/src/support/run-command.js.map +1 -0
  20. package/dist/src/support/specguard-api.d.ts +11 -0
  21. package/dist/src/support/specguard-api.js +157 -0
  22. package/dist/src/support/specguard-api.js.map +1 -0
  23. package/dist/src/tools/args.d.ts +48 -0
  24. package/dist/src/tools/args.js +66 -0
  25. package/dist/src/tools/args.js.map +1 -0
  26. package/dist/src/tools/index.d.ts +33 -0
  27. package/dist/src/tools/index.js +34 -0
  28. package/dist/src/tools/index.js.map +1 -0
  29. package/dist/src/tools/lint-intent-annotations.d.ts +45 -0
  30. package/dist/src/tools/lint-intent-annotations.js +342 -0
  31. package/dist/src/tools/lint-intent-annotations.js.map +1 -0
  32. package/dist/src/tools/repository-overview.d.ts +424 -0
  33. package/dist/src/tools/repository-overview.js +797 -0
  34. package/dist/src/tools/repository-overview.js.map +1 -0
  35. package/dist/src/tools/types.d.ts +111 -0
  36. package/dist/src/tools/types.js +2 -0
  37. package/dist/src/tools/types.js.map +1 -0
  38. package/package.json +44 -0
@@ -0,0 +1,322 @@
1
+ import { spawn } from "node:child_process";
2
+ import { statSync } from "node:fs";
3
+ import { CommandError } from "../errors.js";
4
+ /** Beyond this, a wrapped command's output is truncated rather than buffered. */
5
+ export const MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
6
+ /** Default ceiling on how long a wrapped command may run. */
7
+ export const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
8
+ /**
9
+ * How long `close` may lag `exit` before the result is produced without it.
10
+ *
11
+ * `exit` and `close` are two events, not one: `close` additionally waits for
12
+ * every writer of the child's stdout/stderr pipes to let go, and a process that
13
+ * is not the child can be holding them. Long enough that the ordinary
14
+ * milliseconds-apart drain is never cut short; short enough that a leaked
15
+ * descriptor costs a truncated tail instead of a call that never returns.
16
+ */
17
+ export const EXIT_CLOSE_GRACE_MS = 1_000;
18
+ /**
19
+ * Runs a program with an argument LIST, never through a shell.
20
+ *
21
+ * `spawn` without `shell: true` is the load-bearing detail: tool arguments
22
+ * arrive from a model, so a file path containing `; rm -rf …` has to be a
23
+ * path that does not exist rather than a command. There is no escaping to get
24
+ * right because nothing is ever parsed as syntax.
25
+ *
26
+ * A non-zero exit is NOT an error here. `specguard-lint` uses its exit code as
27
+ * a three-valued verdict — 0 clean, 1 malformed annotations, 2 the tool could
28
+ * not do its job — so deciding what a code means is the caller's job and this
29
+ * function reports it. What IS an error is the process never running (missing
30
+ * binary) or never finishing (timeout): both leave the caller with no verdict
31
+ * at all, which is the one outcome that must not be mistaken for a clean run.
32
+ */
33
+ export const runCommand = (argv, options = {}) => {
34
+ const [program, ...args] = argv;
35
+ if (program === undefined) {
36
+ throw new CommandError("No command was configured to run.");
37
+ }
38
+ const timeoutMs = options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
39
+ return new Promise((resolve, reject) => {
40
+ // Checked before spawning, not diagnosed afterwards. `spawn` reports a
41
+ // working directory it cannot use by TWO different routes depending on the
42
+ // errno: an async `error` event (ENOENT, for a directory that is not there)
43
+ // and a synchronous throw (ENOTDIR, for one that is a file). The synchronous
44
+ // one is not a `CommandError`, so it would escape this whole file and be
45
+ // reported to the agent as a bug in the bridge. Asking first collapses both
46
+ // into one sentence that names the directory.
47
+ if (options.cwd !== undefined && !isDirectory(options.cwd)) {
48
+ reject(new CommandError(unusableCwd(program, options.cwd)));
49
+ return;
50
+ }
51
+ let child;
52
+ try {
53
+ child = spawnChild(program, args, options);
54
+ }
55
+ catch (error) {
56
+ // Belt and braces for the synchronous route: whatever `spawn` decides to
57
+ // throw, the agent gets a sentence about a command, never a raw errno that
58
+ // the error boundary would classify as a defect in this server.
59
+ reject(new CommandError(describeSpawnFailure(program, error, options)));
60
+ return;
61
+ }
62
+ const stdout = new OutputBuffer();
63
+ const stderr = new OutputBuffer();
64
+ let settled = false;
65
+ let timedOut = false;
66
+ let exited = false;
67
+ let graceTimer;
68
+ const timer = setTimeout(() => {
69
+ // Whether the RUN is still live — asked of the process, not of the kill.
70
+ // `exit` and `close` are separate events, so there is a window in which
71
+ // the process is already gone and a complete result is already on its way.
72
+ // A deadline landing in there has nothing to time out, and claiming one
73
+ // would throw the finished run away, exit code and document and all, to
74
+ // report that it never finished.
75
+ //
76
+ // `killRun`'s answer cannot be that test on its own, which is the trap
77
+ // this guard exists for. It reports whether anything in the process GROUP
78
+ // was signalled, and the group outlives the child by exactly the
79
+ // grandchild holding the pipes open — the same grandchild that made the
80
+ // window wide enough for a deadline to land in it at all. So on the
81
+ // topology this file is about, the kill SUCCEEDS after the child is gone,
82
+ // and a `timedOut` read off that answer calls a completed `code: 0` run a
83
+ // timeout. Only the child's own exit can contradict it.
84
+ //
85
+ // Returning rather than killing and discarding the answer: once the child
86
+ // has been reaped its pid is no longer ours to signal — see `killRun`.
87
+ if (exited)
88
+ return;
89
+ timedOut = killRun(child);
90
+ }, timeoutMs);
91
+ // `unref` so a hung command's timer cannot by itself hold the process open.
92
+ timer.unref?.();
93
+ const finish = (fn) => {
94
+ if (settled)
95
+ return;
96
+ settled = true;
97
+ clearTimeout(timer);
98
+ if (graceTimer !== undefined)
99
+ clearTimeout(graceTimer);
100
+ fn();
101
+ };
102
+ /**
103
+ * The one place a result is produced, reached from `close` and from the
104
+ * grace backstop alike — so the two cannot drift into disagreeing about
105
+ * what a timeout is. `timedOut` still decides reject-vs-resolve on BOTH:
106
+ * our own deadline is this server's decision and has no verdict to hand
107
+ * back, while a signal from outside is a fact about the child the caller
108
+ * has to be able to act on.
109
+ */
110
+ const settleWith = (code, signal, drained) => {
111
+ finish(() => {
112
+ if (timedOut) {
113
+ reject(new CommandError(`\`${program}\` did not finish within ${timeoutMs}ms and was killed, ` +
114
+ "so it produced no verdict."));
115
+ return;
116
+ }
117
+ resolve({
118
+ code,
119
+ signal,
120
+ stdout: stdout.text(),
121
+ stderr: stderr.text(),
122
+ stdoutTruncated: stdout.truncated,
123
+ stderrTruncated: stderr.truncated,
124
+ outputDrained: drained,
125
+ });
126
+ });
127
+ };
128
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
129
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
130
+ child.on("error", (error) => {
131
+ finish(() => reject(new CommandError(describeSpawnFailure(program, error, options))));
132
+ });
133
+ /**
134
+ * The backstop, and the reason this promise no longer depends on `close`.
135
+ *
136
+ * `close` fires only once the process has ended AND every writer of its
137
+ * stdout/stderr pipes has let go. Those descriptors are inherited, so a
138
+ * grandchild holds them: `bundle exec specguard-lint` — the configuration
139
+ * this project's README recommends — makes `bundle` the child and the linter
140
+ * a grandchild, and killing the group can still miss a great-grandchild that
141
+ * escaped it by double-forking. When that happens `close` never arrives, and
142
+ * with it as the only settle the promise never settles: in an MCP server
143
+ * that is a tool call that never returns, and therefore a calling agent that
144
+ * never returns.
145
+ *
146
+ * So `exit` — which needs nothing but the process — starts a short clock.
147
+ * The normal drain finishes in milliseconds and `close` wins the race; only
148
+ * a genuinely leaked descriptor reaches the timer, and it pays a truncated
149
+ * tail rather than the whole session.
150
+ */
151
+ child.on("exit", (code, signal) => {
152
+ // Recorded before anything else, and read by the deadline above: from here
153
+ // on we hold a real `code`/`signal`, so whatever the timer may still find
154
+ // alive in the process group, this is not a run that produced no verdict.
155
+ exited = true;
156
+ if (settled)
157
+ return;
158
+ graceTimer = setTimeout(() => {
159
+ // Released here rather than left to the garbage collector: the pipe is
160
+ // still open by definition on this path, so the two buffers behind it
161
+ // (up to MAX_OUTPUT_BYTES each) would be retained for the life of
162
+ // whatever is holding the descriptor.
163
+ child.stdout.destroy();
164
+ child.stderr.destroy();
165
+ settleWith(code, signal, false);
166
+ }, EXIT_CLOSE_GRACE_MS);
167
+ // Deliberately NOT `unref`'d, unlike the deadline above. That one is a
168
+ // ceiling on someone else's work and must never be the reason this process
169
+ // stays up; this one IS the settle — an unref'd timer only fires while
170
+ // something else holds the loop open, and the thing holding it here is the
171
+ // very pipe we are giving up on. It is bounded at EXIT_CLOSE_GRACE_MS and
172
+ // cleared by `finish`, so the most it can cost is one second of shutdown.
173
+ });
174
+ child.on("close", (code, signal) => settleWith(code, signal, true));
175
+ });
176
+ };
177
+ /**
178
+ * Kills the whole run, and answers whether there was anything to kill.
179
+ *
180
+ * `child.kill()` signals the CHILD. With `bundle exec specguard-lint` the child
181
+ * is `bundle` and the thing doing the work is its grandchild, which survives —
182
+ * still running, and still holding the pipes that `close` waits on. `detached`
183
+ * in `spawnChild` makes the child a process-group leader precisely so this
184
+ * function can signal the negated pid and reach the whole tree.
185
+ *
186
+ * The return value is the answer to "was a live process signalled", which is
187
+ * half of what the caller needs to decide whether a timeout actually happened —
188
+ * the other half is whether the CHILD has exited, because this group can outlive
189
+ * it. ESRCH — the group is already gone — is the normal way to learn "no", so it
190
+ * falls back to `child.kill`, whose `false` says the same thing about the child
191
+ * alone and whose own liveness check is what makes this safe on a platform where
192
+ * the group kill is not available.
193
+ *
194
+ * ONLY CALLED WHILE THE CHILD IS STILL LIVE, and that is a precondition rather
195
+ * than a convenience. `child.kill()` is safe by construction — Node reaped the
196
+ * child, so it knows the handle is spent and no-ops — but `process.kill(-pid)`
197
+ * is a raw signal at a number with no such check. After the reap that number is
198
+ * free, and on the one path where the group is ALSO empty (so ESRCH would have
199
+ * been the honest answer) a recycled pid that now leads some unrelated group
200
+ * would take a SIGKILL meant for a linter. It needs pid wraparound to land on a
201
+ * group leader, so it is vanishingly unlikely — but it is unrecoverable and
202
+ * aimed at a stranger, so the caller checks `exited` first rather than paying
203
+ * it. The cost of that choice is that stragglers outliving an already-exited
204
+ * child are left to leave on their own; the grace backstop settles regardless,
205
+ * and this is what `main` did too, where a kill after the reap was a no-op.
206
+ */
207
+ function killRun(child) {
208
+ const pid = child.pid;
209
+ // Unset when the spawn itself failed; there is no process and no group.
210
+ if (pid === undefined)
211
+ return false;
212
+ try {
213
+ process.kill(-pid, "SIGKILL");
214
+ return true;
215
+ }
216
+ catch {
217
+ return child.kill("SIGKILL");
218
+ }
219
+ }
220
+ /**
221
+ * The one place `spawn` is called.
222
+ *
223
+ * Extracted so the call can be wrapped in a `try` without the surrounding
224
+ * promise losing the child's type.
225
+ */
226
+ function spawnChild(program, args, options) {
227
+ return spawn(program, [...args], {
228
+ cwd: options.cwd,
229
+ stdio: ["ignore", "pipe", "pipe"],
230
+ // Explicitly off. Stated rather than defaulted, because this is the line
231
+ // that keeps a model-supplied argument from reaching a shell.
232
+ shell: false,
233
+ // A process group of its own, so the timeout can signal the whole run
234
+ // rather than only the process this server happens to have spawned — see
235
+ // `killRun`.
236
+ //
237
+ // Deliberately NOT paired with `child.unref()`, so the parent still waits on
238
+ // this handle rather than handing a linter permission to outlive the server.
239
+ // That is not the whole lifetime story though, and the honest version is
240
+ // that detaching buys the reach of the kill and PAYS for it here: a new
241
+ // group is also a new session, outside this server's controlling terminal,
242
+ // so a signal aimed at OUR group — an interactive Ctrl-C, a supervisor's
243
+ // `kill -- -PGID` — no longer reaches a lint run in flight. Nothing in
244
+ // `bin/specguard-mcp.ts` installs a SIGINT/SIGTERM handler to kill
245
+ // outstanding children at teardown, so such a run is now orphaned where it
246
+ // would previously have died alongside us. Worth it, because the failure
247
+ // being traded away is an agent that never returns rather than a stray
248
+ // process — but it is a trade, and a teardown handler is what would close
249
+ // it.
250
+ detached: true,
251
+ });
252
+ }
253
+ /**
254
+ * Which thing failed to run — asked rather than assumed.
255
+ *
256
+ * Node reports a non-existent `cwd` as `ENOENT` on the spawn: the same code and
257
+ * the same shape as a program that is not on PATH. Answering both with "it is not
258
+ * on this server's PATH" tells an operator whose only mistake was the directory
259
+ * to go and change the one thing that was right, and the caller of this function
260
+ * knows the difference is checkable. So on the error path — where an extra `stat`
261
+ * costs nothing — we ask which of the two is actually missing before naming a
262
+ * cause.
263
+ *
264
+ * This is the same reasoning as the non-zero-exit rule above: an outcome that
265
+ * leaves the caller with no verdict must not be described as some other outcome.
266
+ */
267
+ function describeSpawnFailure(program, error, options) {
268
+ const cwd = options.cwd;
269
+ // Consulted before the errno, because more than one code means this same thing.
270
+ if (cwd !== undefined && !isDirectory(cwd))
271
+ return unusableCwd(program, cwd);
272
+ if (error.code === "ENOENT") {
273
+ const hint = options.notFoundHint === undefined ? "" : ` ${options.notFoundHint}`;
274
+ return `Could not run \`${program}\`: it is not on this server's PATH.${hint}`;
275
+ }
276
+ return `Could not run \`${program}\`: ${error.message}`;
277
+ }
278
+ function unusableCwd(program, cwd) {
279
+ return (`Could not run \`${program}\`: its working directory ${JSON.stringify(cwd)} does not exist, ` +
280
+ "or is not a directory. The command itself was not the problem.");
281
+ }
282
+ function isDirectory(path) {
283
+ try {
284
+ return statSync(path).isDirectory();
285
+ }
286
+ catch {
287
+ return false;
288
+ }
289
+ }
290
+ /**
291
+ * Bounded accumulation of a child's output.
292
+ *
293
+ * An unbounded buffer here would let a linter run over a very large suite —
294
+ * exactly the suites SpecGuard exists for — decide this server's memory
295
+ * ceiling. Truncation is announced in the returned text rather than silent,
296
+ * because a JSON document cut in half must not look like a document that ended.
297
+ */
298
+ class OutputBuffer {
299
+ #chunks = [];
300
+ #bytes = 0;
301
+ #truncated = false;
302
+ get truncated() {
303
+ return this.#truncated;
304
+ }
305
+ push(chunk) {
306
+ if (this.#truncated)
307
+ return;
308
+ if (this.#bytes + chunk.byteLength > MAX_OUTPUT_BYTES) {
309
+ this.#chunks.push(chunk.subarray(0, MAX_OUTPUT_BYTES - this.#bytes));
310
+ this.#truncated = true;
311
+ this.#bytes = MAX_OUTPUT_BYTES;
312
+ return;
313
+ }
314
+ this.#chunks.push(chunk);
315
+ this.#bytes += chunk.byteLength;
316
+ }
317
+ text() {
318
+ const body = Buffer.concat(this.#chunks).toString("utf8");
319
+ return this.#truncated ? `${body}\n[truncated at ${MAX_OUTPUT_BYTES} bytes]` : body;
320
+ }
321
+ }
322
+ //# sourceMappingURL=run-command.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run-command.js","sourceRoot":"","sources":["../../../src/support/run-command.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ import { requireApiConfig, type ApiConfig } from "../config.js";
2
+ /**
3
+ * The SpecGuard HTTP client — a Bearer key and a path, and nothing else.
4
+ *
5
+ * Authorization is enforced by the deployment (`Api::BaseController`), never
6
+ * here: this carries the operator's key and reports what came back. The bridge
7
+ * adds no credentials of its own and makes no access decisions, so there is no
8
+ * second place for the permission model to be got wrong.
9
+ */
10
+ export declare function getJson(api: ApiConfig, path: string, query: Record<string, string | undefined>, fetchImpl: typeof globalThis.fetch): Promise<unknown>;
11
+ export { requireApiConfig };
@@ -0,0 +1,157 @@
1
+ import { requireApiConfig } from "../config.js";
2
+ import { ApiError } from "../errors.js";
3
+ /**
4
+ * The SpecGuard HTTP client — a Bearer key and a path, and nothing else.
5
+ *
6
+ * Authorization is enforced by the deployment (`Api::BaseController`), never
7
+ * here: this carries the operator's key and reports what came back. The bridge
8
+ * adds no credentials of its own and makes no access decisions, so there is no
9
+ * second place for the permission model to be got wrong.
10
+ */
11
+ export async function getJson(api, path, query, fetchImpl) {
12
+ const url = new URL(`${api.endpoint}${path}`);
13
+ for (const [key, value] of Object.entries(query)) {
14
+ if (value !== undefined)
15
+ url.searchParams.set(key, value);
16
+ }
17
+ const { response, body } = await fetchWithTimeout(url, api, fetchImpl);
18
+ if (!response.ok)
19
+ throw describeFailure(response.status, body, api);
20
+ try {
21
+ return JSON.parse(body);
22
+ }
23
+ catch {
24
+ throw new ApiError(`${api.endpoint} answered ${response.status} but the body was not JSON. ` +
25
+ `Check that ${api.endpointVariable} points at a SpecGuard deployment and not, say, a proxy ` +
26
+ "or login page.", response.status);
27
+ }
28
+ }
29
+ /**
30
+ * Tells "the deadline won the race" apart from any value a phase could produce.
31
+ *
32
+ * A resolved sentinel rather than a rejecting deadline promise: a promise that
33
+ * rejects has to be raced against every phase or its rejection is unhandled, and
34
+ * an unhandled rejection on stdio takes the whole transport down — the failure
35
+ * mode `errors.ts` exists to avoid.
36
+ */
37
+ const TIMED_OUT = Symbol("specguard-api deadline");
38
+ /**
39
+ * Headers AND body under ONE deadline.
40
+ *
41
+ * `SPECGUARD_TIMEOUT_MS` is documented in `config.ts` as how long an HTTP call
42
+ * to SpecGuard may take, and a call is not over when its headers arrive. An
43
+ * `AbortController` armed only around `fetchImpl` is disarmed the moment the
44
+ * response object resolves, so a deployment that answers `200 OK` and then
45
+ * dribbles — or freezes — the body leaves the body read awaiting with no
46
+ * deadline and no live signal. In an MCP server that is not a slow answer: it is
47
+ * a tool call, and therefore the agent that called it, which never returns.
48
+ *
49
+ * ONE TOTAL BUDGET, not one per phase. `requestTimeoutMs` bounds the whole call:
50
+ * headers and body share it, so a response whose headers took 29s of a 30s
51
+ * budget has 1s left in which to deliver its body. The sibling transport in
52
+ * `specguard-rspec` (`lib/specguard/rspec/transport.rb`) gives each phase its own
53
+ * full `@timeout` because `Net::HTTP` exposes exactly that knob and no other;
54
+ * here the deadline is ours to place, and a single total is both stricter and
55
+ * the thing an operator who set one number actually meant.
56
+ *
57
+ * The race is explicit rather than left to the abort signal. Aborting is still
58
+ * done — it tears a real connection down instead of leaking it — but WHETHER an
59
+ * aborted signal also errors an already-delivered body stream is a property of
60
+ * the fetch implementation, and this function takes `fetchImpl` from its caller.
61
+ * Racing the deadline here is what makes the bound hold for any implementation
62
+ * rather than for one in particular.
63
+ *
64
+ * The body is read HERE, inside the deadline, rather than by the caller one
65
+ * frame later, so there is no window in which the read is awaiting somewhere the
66
+ * timer does not reach.
67
+ */
68
+ async function fetchWithTimeout(url, api, fetchImpl) {
69
+ const controller = new AbortController();
70
+ let timer;
71
+ const deadline = new Promise((resolve) => {
72
+ timer = setTimeout(() => {
73
+ controller.abort();
74
+ resolve(TIMED_OUT);
75
+ }, api.requestTimeoutMs);
76
+ // `unref` so a stalled body's timer cannot by itself hold the process open —
77
+ // the same reason `run-command.ts` unrefs the timer that kills a hung child.
78
+ timer.unref?.();
79
+ });
80
+ try {
81
+ const response = await Promise.race([
82
+ fetchImpl(url, {
83
+ method: "GET",
84
+ headers: {
85
+ Authorization: `Bearer ${api.apiKey}`,
86
+ Accept: "application/json",
87
+ "User-Agent": "specguard-mcp",
88
+ },
89
+ signal: controller.signal,
90
+ }),
91
+ deadline,
92
+ ]);
93
+ if (response === TIMED_OUT)
94
+ throw timedOut(api);
95
+ const body = await Promise.race([response.text(), deadline]);
96
+ if (body === TIMED_OUT)
97
+ throw timedOut(api);
98
+ return { response, body };
99
+ }
100
+ catch (error) {
101
+ // Already diagnosed above — a timeout, converted while the phase that hit it
102
+ // was still known. Rethrown before the transport branch so a deadline can
103
+ // never be re-described as "could not reach", which would name a cause that
104
+ // is the opposite of what happened: the deployment was reached, and stopped.
105
+ if (error instanceof ApiError)
106
+ throw error;
107
+ // A transport failure and a refusal are different problems with different
108
+ // fixes, and "fetch failed" names neither. The endpoint is echoed because
109
+ // the commonest cause by far is that it is wrong — and the variable is named
110
+ // from the config rather than spelled out here, so an operator who set
111
+ // SPECGUARD_URL is not sent to fix a variable they never set.
112
+ if (controller.signal.aborted)
113
+ throw timedOut(api);
114
+ throw new ApiError(`Could not reach ${api.endpoint}: ${error instanceof Error ? error.message : String(error)}. ` +
115
+ `Check ${api.endpointVariable} and that the deployment is reachable from this machine.`);
116
+ }
117
+ finally {
118
+ // Cleared on every exit — success, HTTP failure, transport failure and
119
+ // timeout alike — because the timer now outlives the fetch call itself.
120
+ clearTimeout(timer);
121
+ }
122
+ }
123
+ /**
124
+ * One sentence for both phases, because the operator's move is the same either
125
+ * way: raise `SPECGUARD_TIMEOUT_MS` or find out why the deployment is slow.
126
+ *
127
+ * Deliberately an `ApiError` and deliberately WITHOUT a status. Letting an
128
+ * `AbortError` escape instead would reach `describeError` in `server.ts` as a
129
+ * non-`SpecGuardMcpError` and be reported to the agent as "a bug in the bridge,
130
+ * not in your project or configuration" — exactly inverting the diagnosis for a
131
+ * peer that stalled. And there was no response, so there is no status to carry.
132
+ */
133
+ function timedOut(api) {
134
+ return new ApiError(`${api.endpoint} did not respond within ${api.requestTimeoutMs}ms.`);
135
+ }
136
+ /**
137
+ * The status turned into something the agent can act on.
138
+ *
139
+ * 401 is called out by name because it is the one an operator will actually
140
+ * hit, and because SpecGuard answers it deliberately flat — "a valid Bearer API
141
+ * key is required", with no detail about why — so the useful half of the
142
+ * diagnosis has to be supplied from this side.
143
+ */
144
+ function describeFailure(status, body, api) {
145
+ if (status === 401) {
146
+ return new ApiError("SpecGuard rejected the API key (401). SPECGUARD_API_KEY must be an sgk_… key issued by " +
147
+ `${api.endpoint} for the repository you are asking about — keys are per-repository, and a ` +
148
+ "revoked key reads the same as a wrong one.", status);
149
+ }
150
+ if (status === 404) {
151
+ return new ApiError(`${api.endpoint} has no such endpoint (404). Check that ${api.endpointVariable} is the ` +
152
+ "deployment's root URL, without a path.", status);
153
+ }
154
+ return new ApiError(`SpecGuard answered ${status}${body.trim() === "" ? "" : `: ${body.trim().slice(0, 500)}`}`, status);
155
+ }
156
+ export { requireApiConfig };
157
+ //# sourceMappingURL=specguard-api.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"specguard-api.js","sourceRoot":"","sources":["../../../src/support/specguard-api.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The tool-agnostic argument coercions — the shape checks every tool needs and
3
+ * no tool should re-derive.
4
+ *
5
+ * == Why this file exists at all
6
+ *
7
+ * `optionalString` was written twice, once per tool file, in the same commit
8
+ * that created both. The copies then diverged on the one thing a copy is most
9
+ * likely to get wrong: which of the four error classes to throw. One picked
10
+ * `ApiError`, the other `CommandError`, with byte-identical message strings —
11
+ * so the same malformed argument was reported as two different kinds of failure
12
+ * depending on which tool received it. That was repaired by re-pointing both
13
+ * throws at `ArgumentError`, but repairing two copies is not the same as having
14
+ * one, and the next tool author still has to choose the class again from
15
+ * scratch.
16
+ *
17
+ * `types.ts` promised, until SPGD-558 narrowed the claim to *wiring* edits,
18
+ * that adding a tool was "two mechanical edits with no third". Hand-copying
19
+ * these coercions and re-picking an error class was the unlisted third edit.
20
+ * This file removes it.
21
+ *
22
+ * == What belongs here, and what does not
23
+ *
24
+ * Only checks about the SHAPE of a value — is it a string, is it a boolean —
25
+ * whose failure is always `ArgumentError`, the class meaning "the call was
26
+ * malformed and the agent can fix it unaided on the next call".
27
+ *
28
+ * A check that reasons about what an argument MEANS to one particular wrapped
29
+ * tool stays with that tool. `optionalStringArray` in
30
+ * `lint-intent-annotations.ts` is the standing example: its refusals of an
31
+ * empty list and of a leading dash are about the linter's own argument grammar
32
+ * — what `paths: []` would make `specguard-lint` audit, what its OptionParser
33
+ * would read a `-` as — and they throw `CommandError` for that reason. Moving
34
+ * it here would drag one tool's semantics into a file the next tool imports.
35
+ *
36
+ * == Placement
37
+ *
38
+ * `src/tools/` rather than `src/support/`. `src/support/` holds injected
39
+ * outside-world capabilities — `run-command.ts`, `specguard-api.ts` — which
40
+ * reach tools through `ToolContext` and are substituted in tests. These are
41
+ * pure functions with no context and nothing to inject: they are part of the
42
+ * tool-authoring contract, which lives here beside `types.ts`.
43
+ */
44
+ /**
45
+ * A non-blank string, or nothing.
46
+ */
47
+ export declare function optionalString(value: unknown, field: string): string | undefined;
48
+ export declare function optionalBoolean(value: unknown, field: string): boolean | undefined;
@@ -0,0 +1,66 @@
1
+ import { ArgumentError } from "../errors.js";
2
+ /**
3
+ * The tool-agnostic argument coercions — the shape checks every tool needs and
4
+ * no tool should re-derive.
5
+ *
6
+ * == Why this file exists at all
7
+ *
8
+ * `optionalString` was written twice, once per tool file, in the same commit
9
+ * that created both. The copies then diverged on the one thing a copy is most
10
+ * likely to get wrong: which of the four error classes to throw. One picked
11
+ * `ApiError`, the other `CommandError`, with byte-identical message strings —
12
+ * so the same malformed argument was reported as two different kinds of failure
13
+ * depending on which tool received it. That was repaired by re-pointing both
14
+ * throws at `ArgumentError`, but repairing two copies is not the same as having
15
+ * one, and the next tool author still has to choose the class again from
16
+ * scratch.
17
+ *
18
+ * `types.ts` promised, until SPGD-558 narrowed the claim to *wiring* edits,
19
+ * that adding a tool was "two mechanical edits with no third". Hand-copying
20
+ * these coercions and re-picking an error class was the unlisted third edit.
21
+ * This file removes it.
22
+ *
23
+ * == What belongs here, and what does not
24
+ *
25
+ * Only checks about the SHAPE of a value — is it a string, is it a boolean —
26
+ * whose failure is always `ArgumentError`, the class meaning "the call was
27
+ * malformed and the agent can fix it unaided on the next call".
28
+ *
29
+ * A check that reasons about what an argument MEANS to one particular wrapped
30
+ * tool stays with that tool. `optionalStringArray` in
31
+ * `lint-intent-annotations.ts` is the standing example: its refusals of an
32
+ * empty list and of a leading dash are about the linter's own argument grammar
33
+ * — what `paths: []` would make `specguard-lint` audit, what its OptionParser
34
+ * would read a `-` as — and they throw `CommandError` for that reason. Moving
35
+ * it here would drag one tool's semantics into a file the next tool imports.
36
+ *
37
+ * == Placement
38
+ *
39
+ * `src/tools/` rather than `src/support/`. `src/support/` holds injected
40
+ * outside-world capabilities — `run-command.ts`, `specguard-api.ts` — which
41
+ * reach tools through `ToolContext` and are substituted in tests. These are
42
+ * pure functions with no context and nothing to inject: they are part of the
43
+ * tool-authoring contract, which lives here beside `types.ts`.
44
+ */
45
+ /**
46
+ * A non-blank string, or nothing.
47
+ */
48
+ export function optionalString(value, field) {
49
+ if (value === undefined || value === null)
50
+ return undefined;
51
+ if (typeof value !== "string")
52
+ throw new ArgumentError(`\`${field}\` must be a string.`);
53
+ // Trimmed, not merely tested for blankness. `project_dir` becomes a `cwd`, so
54
+ // " /srv/app" — a path an agent produces by concatenating one — would otherwise
55
+ // be a directory that cannot exist, reported as a directory that does not.
56
+ const trimmed = value.trim();
57
+ return trimmed === "" ? undefined : trimmed;
58
+ }
59
+ export function optionalBoolean(value, field) {
60
+ if (value === undefined || value === null)
61
+ return undefined;
62
+ if (typeof value !== "boolean")
63
+ throw new ArgumentError(`\`${field}\` must be a boolean.`);
64
+ return value;
65
+ }
66
+ //# sourceMappingURL=args.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"args.js","sourceRoot":"","sources":["../../../src/tools/args.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE7C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAEH;;GAEG;AACH,MAAM,UAAU,cAAc,CAAC,KAAc,EAAE,KAAa;IAC1D,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,SAAS,CAAC;IAC5D,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,MAAM,IAAI,aAAa,CAAC,KAAK,KAAK,sBAAsB,CAAC,CAAC;IACzF,8EAA8E;IAC9E,gFAAgF;IAChF,2EAA2E;IAC3E,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;IAC7B,OAAO,OAAO,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC;AAC9C,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,KAAc,EAAE,KAAa;IAC3D,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,SAAS,CAAC;IAC5D,IAAI,OAAO,KAAK,KAAK,SAAS;QAAE,MAAM,IAAI,aAAa,CAAC,KAAK,KAAK,uBAAuB,CAAC,CAAC;IAC3F,OAAO,KAAK,CAAC;AACf,CAAC"}