@loucompanion/forge-bridge 0.1.1-dev.65ea6889412c → 0.1.1-dev.718bdb0018b9
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/dist/bridge/bridge.service.js +29 -1
- package/dist/bridge/bridge.types.d.ts +9 -0
- package/dist/cli/local-config.js +18 -1
- package/dist/session/phase-completion.js +80 -2
- package/dist/session/provider-cli/provider-cli.service.js +168 -19
- package/dist/session/qa-sidecar-validation.d.ts +3 -0
- package/dist/session/qa-sidecar-validation.js +44 -0
- package/dist/session/session.types.d.ts +16 -0
- package/dist/session/session.types.js +50 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -123,6 +123,7 @@ export class ForgeBridgeService {
|
|
|
123
123
|
const mapping = findRepoMapping(dispatch, this.config.repoMappings);
|
|
124
124
|
const localPath = mapping?.localPath?.trim();
|
|
125
125
|
const repoRoot = localPath ? path.dirname(path.resolve(localPath)) : this.config.defaultRepoRoot;
|
|
126
|
+
const qaLaunchTarget = normalizeQaAppLaunchTarget(mapping?.qaAppLaunchTarget) ?? dispatch.qaBrowser?.launchTarget ?? null;
|
|
126
127
|
return {
|
|
127
128
|
...dispatch,
|
|
128
129
|
repo: {
|
|
@@ -133,7 +134,13 @@ export class ForgeBridgeService {
|
|
|
133
134
|
serverLocal: {
|
|
134
135
|
repoRoot,
|
|
135
136
|
cleanupWorktrees: this.config.cleanupWorktrees
|
|
136
|
-
}
|
|
137
|
+
},
|
|
138
|
+
qaBrowser: dispatch.qaBrowser
|
|
139
|
+
? {
|
|
140
|
+
...dispatch.qaBrowser,
|
|
141
|
+
launchTarget: qaLaunchTarget
|
|
142
|
+
}
|
|
143
|
+
: dispatch.qaBrowser
|
|
137
144
|
};
|
|
138
145
|
}
|
|
139
146
|
async sendResult(leaseId, fencingToken, result) {
|
|
@@ -323,6 +330,27 @@ function findRepoMapping(dispatch, mappings) {
|
|
|
323
330
|
(!!mapping.remoteUrl?.trim() && equalsIgnoreCase(mapping.remoteUrl, dispatch.repo.remoteUrl)));
|
|
324
331
|
});
|
|
325
332
|
}
|
|
333
|
+
function normalizeQaAppLaunchTarget(target) {
|
|
334
|
+
if (!target) {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
const routes = [...new Set((target.routes ?? []).map((route) => route.trim()).filter(Boolean))];
|
|
338
|
+
const normalized = {
|
|
339
|
+
url: cleanString(target.url),
|
|
340
|
+
command: cleanString(target.command),
|
|
341
|
+
working_directory: cleanString(target.working_directory),
|
|
342
|
+
ready_url: cleanString(target.ready_url),
|
|
343
|
+
routes,
|
|
344
|
+
notes: cleanString(target.notes)
|
|
345
|
+
};
|
|
346
|
+
return normalized.url || normalized.command || normalized.ready_url || routes.length > 0
|
|
347
|
+
? normalized
|
|
348
|
+
: null;
|
|
349
|
+
}
|
|
350
|
+
function cleanString(value) {
|
|
351
|
+
const clean = value?.trim();
|
|
352
|
+
return clean ? clean : null;
|
|
353
|
+
}
|
|
326
354
|
function equalsIgnoreCase(left, right) {
|
|
327
355
|
return !!left?.trim() && !!right?.trim() && left.trim().toLowerCase() === right.trim().toLowerCase();
|
|
328
356
|
}
|
|
@@ -27,6 +27,15 @@ export interface BridgeRepoMapping {
|
|
|
27
27
|
readonly name?: string | null;
|
|
28
28
|
readonly remoteUrl?: string | null;
|
|
29
29
|
readonly localPath?: string | null;
|
|
30
|
+
readonly qaAppLaunchTarget?: QaAppLaunchTarget | null;
|
|
31
|
+
}
|
|
32
|
+
export interface QaAppLaunchTarget {
|
|
33
|
+
readonly url?: string | null;
|
|
34
|
+
readonly command?: string | null;
|
|
35
|
+
readonly working_directory?: string | null;
|
|
36
|
+
readonly ready_url?: string | null;
|
|
37
|
+
readonly routes?: readonly string[] | null;
|
|
38
|
+
readonly notes?: string | null;
|
|
30
39
|
}
|
|
31
40
|
export interface BridgeProviderStatus {
|
|
32
41
|
readonly signedIn?: boolean | null;
|
package/dist/cli/local-config.js
CHANGED
|
@@ -159,7 +159,8 @@ function normalizeRepoMappings(values) {
|
|
|
159
159
|
repoId: cleanString(mapping.repoId),
|
|
160
160
|
name: cleanString(mapping.name) ?? label,
|
|
161
161
|
remoteUrl: cleanString(mapping.remoteUrl),
|
|
162
|
-
localPath: path.resolve(mapping.localPath)
|
|
162
|
+
localPath: path.resolve(mapping.localPath),
|
|
163
|
+
qaAppLaunchTarget: normalizeQaAppLaunchTarget(mapping.qaAppLaunchTarget)
|
|
163
164
|
};
|
|
164
165
|
})
|
|
165
166
|
.sort((left, right) => left.label.localeCompare(right.label));
|
|
@@ -171,6 +172,22 @@ function normalizeLabel(value) {
|
|
|
171
172
|
}
|
|
172
173
|
return label;
|
|
173
174
|
}
|
|
175
|
+
function normalizeQaAppLaunchTarget(value) {
|
|
176
|
+
if (!value)
|
|
177
|
+
return undefined;
|
|
178
|
+
const routes = cleanList(value.routes);
|
|
179
|
+
const normalized = {
|
|
180
|
+
url: cleanString(value.url),
|
|
181
|
+
command: cleanString(value.command),
|
|
182
|
+
working_directory: cleanString(value.working_directory),
|
|
183
|
+
ready_url: cleanString(value.ready_url),
|
|
184
|
+
routes,
|
|
185
|
+
notes: cleanString(value.notes)
|
|
186
|
+
};
|
|
187
|
+
return normalized.url || normalized.command || normalized.ready_url || routes?.length
|
|
188
|
+
? normalized
|
|
189
|
+
: undefined;
|
|
190
|
+
}
|
|
174
191
|
function cleanString(value) {
|
|
175
192
|
const clean = value?.trim();
|
|
176
193
|
return clean ? clean : undefined;
|
|
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { redactSecrets } from "../shared/redaction/redaction.js";
|
|
6
|
+
import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "./qa-sidecar-validation.js";
|
|
6
7
|
const toolServerName = "forge";
|
|
7
8
|
const completionRedactionSecretsEnv = "FORGE_COMPLETION_REDACTION_SECRETS";
|
|
8
9
|
const completionPhaseOrder = ["implementation", "self_review", "validation"];
|
|
@@ -304,6 +305,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
304
305
|
const evidenceSource = qaEvidenceSource(payload);
|
|
305
306
|
const commands = qaArray(payload, "commands", evidenceSource);
|
|
306
307
|
const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
|
|
308
|
+
const sidecars = await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(payload, "sidecars", evidenceSource));
|
|
307
309
|
const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
|
|
308
310
|
const findings = qaObjectArray(payload, "findings");
|
|
309
311
|
const candidateBugs = qaObjectArray(payload, "candidate_bugs");
|
|
@@ -323,6 +325,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
323
325
|
phase: "qa",
|
|
324
326
|
commands,
|
|
325
327
|
browser_checks: browserChecks,
|
|
328
|
+
sidecars,
|
|
326
329
|
notes
|
|
327
330
|
});
|
|
328
331
|
const findingsJson = jsonText({
|
|
@@ -386,6 +389,57 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
386
389
|
producedBy
|
|
387
390
|
};
|
|
388
391
|
}
|
|
392
|
+
async function validateQaSidecarReferences(worktreePath, workItemId, sidecars) {
|
|
393
|
+
if (sidecars.length === 0) {
|
|
394
|
+
return [];
|
|
395
|
+
}
|
|
396
|
+
const artifactRoot = artifactRootFor(worktreePath, workItemId);
|
|
397
|
+
const qaSidecarRoot = path.resolve(artifactRoot, "qa");
|
|
398
|
+
const validated = [];
|
|
399
|
+
for (const [index, sidecar] of sidecars.entries()) {
|
|
400
|
+
if (!sidecar || typeof sidecar !== "object" || Array.isArray(sidecar)) {
|
|
401
|
+
throw new Error(`sidecars[${index}] must be an object.`);
|
|
402
|
+
}
|
|
403
|
+
const record = sidecar;
|
|
404
|
+
const sidecarPath = optionalString(record, "path");
|
|
405
|
+
const contentHash = optionalString(record, "content_hash");
|
|
406
|
+
const bytes = record.bytes;
|
|
407
|
+
if (!sidecarPath || !contentHash || typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) {
|
|
408
|
+
throw new Error(`sidecars[${index}] must include path, content_hash, and positive bytes.`);
|
|
409
|
+
}
|
|
410
|
+
if (path.isAbsolute(sidecarPath)) {
|
|
411
|
+
throw new Error(`sidecars[${index}].path must be relative to the worktree.`);
|
|
412
|
+
}
|
|
413
|
+
const fullPath = path.resolve(worktreePath, sidecarPath);
|
|
414
|
+
if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
|
|
415
|
+
throw new Error(`sidecars[${index}].path must be under .forge/work-items/${workItemId}/artifacts/qa/.`);
|
|
416
|
+
}
|
|
417
|
+
const stat = await fs.lstat(fullPath).catch((error) => {
|
|
418
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
419
|
+
throw new Error(`sidecars[${index}].path does not exist: ${sidecarPath}`);
|
|
420
|
+
}
|
|
421
|
+
throw error;
|
|
422
|
+
});
|
|
423
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
424
|
+
throw new Error(`sidecars[${index}].path must reference a regular file.`);
|
|
425
|
+
}
|
|
426
|
+
await ensureNoSymbolicLinkPathSegments(qaSidecarRoot, path.dirname(fullPath), `sidecars[${index}].path must not traverse symbolic links.`);
|
|
427
|
+
if (stat.size !== bytes) {
|
|
428
|
+
throw new Error(`sidecars[${index}].bytes does not match the referenced file.`);
|
|
429
|
+
}
|
|
430
|
+
const actualHash = await sha256File(fullPath);
|
|
431
|
+
if (actualHash !== contentHash.trim().toLowerCase()) {
|
|
432
|
+
throw new Error(`sidecars[${index}].content_hash does not match the referenced file.`);
|
|
433
|
+
}
|
|
434
|
+
validated.push({
|
|
435
|
+
...record,
|
|
436
|
+
path: path.relative(worktreePath, fullPath).split(path.sep).join("/"),
|
|
437
|
+
content_hash: actualHash,
|
|
438
|
+
bytes: stat.size
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
return validated;
|
|
442
|
+
}
|
|
389
443
|
export async function readCompletionArtifact(worktreePath, workItemId, phase) {
|
|
390
444
|
const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase);
|
|
391
445
|
const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase);
|
|
@@ -753,15 +807,25 @@ function toolInputSchema(phase) {
|
|
|
753
807
|
},
|
|
754
808
|
browser_checks: {
|
|
755
809
|
type: "array",
|
|
756
|
-
description: "Browser/manual UI checks with URL, action, observed result, screenshot/trace references, console/network notes, and status
|
|
757
|
-
items: evidenceObjectSchema(["summary"], {
|
|
810
|
+
description: "Browser/manual UI checks with URL or route, action, observed result, screenshot/trace references, console/network notes, and status.",
|
|
811
|
+
items: evidenceObjectSchema(["summary", "status"], {
|
|
758
812
|
summary: { type: "string" },
|
|
759
813
|
url: { type: "string" },
|
|
814
|
+
route: { type: "string" },
|
|
760
815
|
status: { type: "string" },
|
|
761
816
|
screenshot: { type: "string" },
|
|
762
817
|
trace: { type: "string" }
|
|
763
818
|
})
|
|
764
819
|
},
|
|
820
|
+
sidecars: {
|
|
821
|
+
type: "array",
|
|
822
|
+
description: "Optional QA sidecar files already written under .forge/work-items/<id>/artifacts/qa/ with path/content_hash/bytes metadata.",
|
|
823
|
+
items: evidenceObjectSchema(["path", "content_hash", "bytes"], {
|
|
824
|
+
path: { type: "string" },
|
|
825
|
+
content_hash: { type: "string" },
|
|
826
|
+
bytes: { type: "number" }
|
|
827
|
+
})
|
|
828
|
+
},
|
|
765
829
|
notes: { type: "string", description: "Additional QA evidence notes." },
|
|
766
830
|
evidence: {
|
|
767
831
|
type: "object",
|
|
@@ -954,6 +1018,7 @@ function validateQaPayloadShape(payload, status) {
|
|
|
954
1018
|
const evidenceSource = qaEvidenceSource(payload);
|
|
955
1019
|
const commands = qaArray(payload, "commands", evidenceSource);
|
|
956
1020
|
const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
|
|
1021
|
+
const sidecars = qaArray(payload, "sidecars", evidenceSource);
|
|
957
1022
|
const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
|
|
958
1023
|
const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
|
|
959
1024
|
const findings = qaObjectArray(payload, "findings");
|
|
@@ -991,10 +1056,23 @@ function validateQaPayloadShape(payload, status) {
|
|
|
991
1056
|
requireEvidenceRecord(browserCheck, `browser_checks[${index}]`);
|
|
992
1057
|
const record = browserCheck;
|
|
993
1058
|
requireNonEmptyStringRecord(record, "summary", `browser_checks[${index}].`);
|
|
1059
|
+
requireNonEmptyStringRecord(record, "status", `browser_checks[${index}].`);
|
|
994
1060
|
const checkStatus = optionalString(record, "status");
|
|
995
1061
|
if (status === "pass" && checkStatus && normalizedString(checkStatus) !== "pass") {
|
|
996
1062
|
throw new Error(`browser_checks[${index}].status must be pass when QA status is pass.`);
|
|
997
1063
|
}
|
|
1064
|
+
if (!optionalString(record, "url") && !optionalString(record, "route")) {
|
|
1065
|
+
throw new Error(`browser_checks[${index}] must include url or route.`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
for (const [index, sidecar] of sidecars.entries()) {
|
|
1069
|
+
requireEvidenceRecord(sidecar, `sidecars[${index}]`);
|
|
1070
|
+
const record = sidecar;
|
|
1071
|
+
requireNonEmptyStringRecord(record, "path", `sidecars[${index}].`);
|
|
1072
|
+
requireNonEmptyStringRecord(record, "content_hash", `sidecars[${index}].`);
|
|
1073
|
+
if (typeof record.bytes !== "number" || !Number.isFinite(record.bytes) || record.bytes <= 0) {
|
|
1074
|
+
throw new Error(`sidecars[${index}].bytes must be greater than 0.`);
|
|
1075
|
+
}
|
|
998
1076
|
}
|
|
999
1077
|
for (const [index, finding] of findings.entries()) {
|
|
1000
1078
|
requireNonEmptyStringRecord(finding, "id", `findings[${index}].`);
|
|
@@ -6,6 +6,7 @@ import readline from "node:readline";
|
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
7
|
import { GitClient } from "../../shared/git/git.js";
|
|
8
8
|
import { resolveCursorAgentDirectLaunch } from "../../shared/cursor-agent/cursor-agent.launch.js";
|
|
9
|
+
import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "../qa-sidecar-validation.js";
|
|
9
10
|
import { buildSpecBody, selectLaneCommand } from "../../worktree/artifact-injector.js";
|
|
10
11
|
import { codexMcpConfigArgs, completionPhaseFromDispatch, completionPromptForDevelopCycle, completionPromptForPhase, completionRedactionEnv, expectedCompletionToolName, prepareCompletionToolRuntime, readCompletionArtifact, usesDevelopCycleCompletion, writeCompletionArtifact, artifactRootFor } from "../phase-completion.js";
|
|
11
12
|
import { addUsage, buildProviderUsageEvent, emptyUsage, hasUsage, readUsageNumber } from "./provider-usage.js";
|
|
@@ -171,7 +172,7 @@ export class ProcessBackedProviderCliService {
|
|
|
171
172
|
eventType: "completion_tools_configured"
|
|
172
173
|
});
|
|
173
174
|
const outputState = createProviderOutputState(dispatch.execution.provider);
|
|
174
|
-
const liveQaEvents = new LiveQaArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, context.emit);
|
|
175
|
+
const liveQaEvents = new LiveQaArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, dispatch, context.emit);
|
|
175
176
|
let result;
|
|
176
177
|
try {
|
|
177
178
|
liveQaEvents.start();
|
|
@@ -211,6 +212,7 @@ export class ProcessBackedProviderCliService {
|
|
|
211
212
|
? "Provider CLI QA completed but did not produce the complete durable QA artifact bundle."
|
|
212
213
|
: "Provider CLI QA completed but did not call the Forge QA completion tool.");
|
|
213
214
|
}
|
|
215
|
+
enforceQaBrowserLaunchTargetGate(dispatch, qaArtifact);
|
|
214
216
|
if (!liveQaEvents.hasEmitted()) {
|
|
215
217
|
await emitQaArtifactRecorded(context.emit, worktree.worktreePath, qaArtifact);
|
|
216
218
|
await emitQaStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, qaArtifact);
|
|
@@ -356,7 +358,18 @@ export class DeterministicProviderCliService {
|
|
|
356
358
|
content: `Planning ${dispatch.execution.provider} QA checks through forge-bridge.`,
|
|
357
359
|
eventType: "qa_plan"
|
|
358
360
|
});
|
|
359
|
-
const
|
|
361
|
+
const qaBrowser = dispatch.qaBrowser;
|
|
362
|
+
const launchTarget = qaBrowser?.launchTarget ?? null;
|
|
363
|
+
const missingLaunchTarget = qaBrowser?.required === true && !hasLaunchTarget(launchTarget);
|
|
364
|
+
const browserSidecar = !missingLaunchTarget && qaBrowser?.required === true && launchTarget
|
|
365
|
+
? deterministicBrowserSidecar(dispatch.workItem.id, launchTarget)
|
|
366
|
+
: null;
|
|
367
|
+
const browserChecks = browserSidecar && launchTarget
|
|
368
|
+
? [deterministicBrowserCheck(launchTarget, browserSidecar.relativePath)]
|
|
369
|
+
: [];
|
|
370
|
+
const summary = missingLaunchTarget
|
|
371
|
+
? "QA blocked because browser testing is required but no app launch target was provided."
|
|
372
|
+
: "All checks passed. forge-bridge QA completed.";
|
|
360
373
|
const qaArtifact = await writeQaArtifacts({
|
|
361
374
|
worktreePath: worktree.worktreePath,
|
|
362
375
|
workItemId: dispatch.workItem.id,
|
|
@@ -365,13 +378,17 @@ export class DeterministicProviderCliService {
|
|
|
365
378
|
provider: dispatch.execution.provider,
|
|
366
379
|
model: dispatch.execution.model,
|
|
367
380
|
command: "forge-bridge deterministic QA",
|
|
368
|
-
summary
|
|
381
|
+
summary,
|
|
382
|
+
status: missingLaunchTarget ? "blocked" : "pass",
|
|
383
|
+
blockerReason: missingLaunchTarget ? "missing_app_launch_target" : null,
|
|
384
|
+
browserChecks,
|
|
385
|
+
sidecarFiles: browserSidecar ? [browserSidecar] : []
|
|
369
386
|
});
|
|
370
387
|
await emitQaArtifactRecorded(context.emit, worktree.worktreePath, qaArtifact);
|
|
371
388
|
await emitQaStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, qaArtifact);
|
|
372
389
|
await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
|
|
373
390
|
return {
|
|
374
|
-
summary: "forge-bridge QA passed.",
|
|
391
|
+
summary: missingLaunchTarget ? "forge-bridge QA blocked." : "forge-bridge QA passed.",
|
|
375
392
|
artifact: qaArtifact,
|
|
376
393
|
artifacts: [qaArtifact]
|
|
377
394
|
};
|
|
@@ -483,9 +500,62 @@ const qaArtifactFileNames = {
|
|
|
483
500
|
verdict: "qa.verdict.json",
|
|
484
501
|
report: "qa.report.md"
|
|
485
502
|
};
|
|
503
|
+
function hasLaunchTarget(target) {
|
|
504
|
+
return Boolean(target?.url?.trim()
|
|
505
|
+
|| target?.ready_url?.trim()
|
|
506
|
+
|| target?.command?.trim()
|
|
507
|
+
|| (target?.routes ?? []).some((route) => route.trim().length > 0));
|
|
508
|
+
}
|
|
509
|
+
function deterministicBrowserSidecar(workItemId, target) {
|
|
510
|
+
const route = primaryLaunchRoute(target);
|
|
511
|
+
return {
|
|
512
|
+
relativePath: qaArtifactRelativePath(workItemId, "qa/screenshots/deterministic-browser-check.txt"),
|
|
513
|
+
content: `Deterministic Forge QA browser evidence\nRoute: ${route}\nConsole errors: []\nNetwork errors: []\nResponsive checks: desktop, mobile\n`
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
function deterministicBrowserCheck(target, screenshotPath) {
|
|
517
|
+
const route = primaryLaunchRoute(target);
|
|
518
|
+
return {
|
|
519
|
+
route,
|
|
520
|
+
url: route,
|
|
521
|
+
status: "pass",
|
|
522
|
+
summary: "Deterministic browser QA exercised the configured launch target.",
|
|
523
|
+
actions: ["open launch target", "walk acceptance criteria", "check console/network summaries", "check responsive viewport"],
|
|
524
|
+
screenshots: [screenshotPath],
|
|
525
|
+
console_errors: [],
|
|
526
|
+
network_errors: [],
|
|
527
|
+
responsive_checks: ["desktop", "mobile"]
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
function primaryLaunchRoute(target) {
|
|
531
|
+
return target.ready_url?.trim()
|
|
532
|
+
|| target.url?.trim()
|
|
533
|
+
|| target.routes.find((route) => route.trim().length > 0)?.trim()
|
|
534
|
+
|| "app launch command";
|
|
535
|
+
}
|
|
536
|
+
async function writeQaSidecarFiles(worktreePath, files) {
|
|
537
|
+
const sidecars = [];
|
|
538
|
+
for (const file of files) {
|
|
539
|
+
const fullPath = path.resolve(worktreePath, file.relativePath);
|
|
540
|
+
const relativePath = path.relative(worktreePath, fullPath).split(path.sep).join("/");
|
|
541
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
542
|
+
throw new Error(`QA sidecar path must stay under the worktree: ${file.relativePath}`);
|
|
543
|
+
}
|
|
544
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
545
|
+
await fs.writeFile(fullPath, file.content, "utf8");
|
|
546
|
+
sidecars.push({
|
|
547
|
+
path: relativePath,
|
|
548
|
+
content_hash: sha256(file.content),
|
|
549
|
+
bytes: Buffer.byteLength(file.content, "utf8")
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
return sidecars;
|
|
553
|
+
}
|
|
486
554
|
async function writeQaArtifacts(request) {
|
|
487
555
|
const artifactRoot = artifactRootFor(request.worktreePath, request.workItemId);
|
|
488
556
|
await fs.mkdir(artifactRoot, { recursive: true });
|
|
557
|
+
const sidecars = await writeQaSidecarFiles(request.worktreePath, request.sidecarFiles ?? []);
|
|
558
|
+
const status = request.status ?? "pass";
|
|
489
559
|
const criterion = request.acceptanceCriteria.trim() || "Forge QA evidence exists";
|
|
490
560
|
const relativeEvidencePath = qaArtifactRelativePath(request.workItemId, qaArtifactFileNames.evidence);
|
|
491
561
|
const relativeFindingsPath = qaArtifactRelativePath(request.workItemId, qaArtifactFileNames.findings);
|
|
@@ -503,15 +573,18 @@ async function writeQaArtifacts(request) {
|
|
|
503
573
|
const evidenceJson = jsonText({
|
|
504
574
|
schema_version: 1,
|
|
505
575
|
phase: "qa",
|
|
506
|
-
commands:
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
576
|
+
commands: status === "blocked"
|
|
577
|
+
? []
|
|
578
|
+
: [
|
|
579
|
+
{
|
|
580
|
+
command: request.command,
|
|
581
|
+
exit_code: 0,
|
|
582
|
+
status: "pass",
|
|
583
|
+
summary: `${request.provider} QA completed successfully.`
|
|
584
|
+
}
|
|
585
|
+
],
|
|
586
|
+
browser_checks: request.browserChecks ?? [],
|
|
587
|
+
sidecars,
|
|
515
588
|
notes: request.summary
|
|
516
589
|
});
|
|
517
590
|
const findingsJson = jsonText({
|
|
@@ -523,12 +596,12 @@ async function writeQaArtifacts(request) {
|
|
|
523
596
|
const verdictJson = jsonText({
|
|
524
597
|
schema_version: 1,
|
|
525
598
|
phase: "qa",
|
|
526
|
-
status
|
|
599
|
+
status,
|
|
527
600
|
summary: request.summary,
|
|
528
|
-
tested_acceptance_criteria: [criterion],
|
|
601
|
+
tested_acceptance_criteria: status === "blocked" ? [] : [criterion],
|
|
529
602
|
finding_ids: [],
|
|
530
603
|
candidate_bug_ids: [],
|
|
531
|
-
blocker_reason: null,
|
|
604
|
+
blocker_reason: status === "blocked" ? request.blockerReason ?? "inconclusive" : null,
|
|
532
605
|
evidence_path: relativeEvidencePath,
|
|
533
606
|
findings_path: relativeFindingsPath,
|
|
534
607
|
report_path: relativeReportPath
|
|
@@ -546,7 +619,7 @@ async function writeQaArtifacts(request) {
|
|
|
546
619
|
verdictJson,
|
|
547
620
|
report,
|
|
548
621
|
producedBy: request.producedBy,
|
|
549
|
-
status
|
|
622
|
+
status,
|
|
550
623
|
phase: "qa"
|
|
551
624
|
};
|
|
552
625
|
}
|
|
@@ -570,6 +643,7 @@ async function readQaCompletionArtifactBundle(worktreePath, workItemId) {
|
|
|
570
643
|
if (!artifact || !await hasCompleteQaArtifactBundle(worktreePath, workItemId)) {
|
|
571
644
|
return null;
|
|
572
645
|
}
|
|
646
|
+
await validateQaBundleSidecarReferences(worktreePath, workItemId);
|
|
573
647
|
return artifact;
|
|
574
648
|
}
|
|
575
649
|
async function hasCompleteQaArtifactBundle(worktreePath, workItemId) {
|
|
@@ -590,6 +664,59 @@ async function hasCompleteQaArtifactBundle(worktreePath, workItemId) {
|
|
|
590
664
|
}
|
|
591
665
|
return true;
|
|
592
666
|
}
|
|
667
|
+
function enforceQaBrowserLaunchTargetGate(dispatch, artifact) {
|
|
668
|
+
const qaBrowser = dispatch.qaBrowser;
|
|
669
|
+
if (!qaBrowser?.required || hasLaunchTarget(qaBrowser.launchTarget ?? null)) {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const verdict = parseJsonLine(artifact.verdictJson) ?? {};
|
|
673
|
+
const status = normalizedString(stringField(verdict, "status") ?? artifact.status ?? "");
|
|
674
|
+
const blockerReason = normalizedString(stringField(verdict, "blocker_reason") ?? "");
|
|
675
|
+
if (status !== "blocked" || blockerReason !== "missing_app_launch_target") {
|
|
676
|
+
throw new Error("QA browser testing is required but no app launch target was provided; provider must return blocked with blocker_reason missing_app_launch_target.");
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
async function validateQaBundleSidecarReferences(worktreePath, workItemId) {
|
|
680
|
+
const artifactRoot = artifactRootFor(worktreePath, workItemId);
|
|
681
|
+
const evidence = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.evidence));
|
|
682
|
+
const sidecars = arrayField(evidence, "sidecars");
|
|
683
|
+
if (sidecars.length === 0) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const qaSidecarRoot = path.resolve(artifactRoot, "qa");
|
|
687
|
+
for (const [index, sidecar] of sidecars.entries()) {
|
|
688
|
+
const sidecarPath = stringField(sidecar, "path");
|
|
689
|
+
const expectedHash = stringField(sidecar, "content_hash");
|
|
690
|
+
const expectedBytes = sidecar.bytes;
|
|
691
|
+
if (!sidecarPath || !expectedHash || typeof expectedBytes !== "number" || !Number.isFinite(expectedBytes) || expectedBytes <= 0) {
|
|
692
|
+
throw new Error(`QA sidecar ${index} must include path, content_hash, and positive bytes.`);
|
|
693
|
+
}
|
|
694
|
+
if (path.isAbsolute(sidecarPath)) {
|
|
695
|
+
throw new Error(`QA sidecar ${index} path must be relative to the worktree.`);
|
|
696
|
+
}
|
|
697
|
+
const fullPath = path.resolve(worktreePath, sidecarPath);
|
|
698
|
+
if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
|
|
699
|
+
throw new Error(`QA sidecar ${index} path must be under .forge/work-items/${workItemId}/artifacts/qa/.`);
|
|
700
|
+
}
|
|
701
|
+
const stat = await fs.lstat(fullPath).catch((error) => {
|
|
702
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
703
|
+
throw new Error(`QA sidecar ${index} file does not exist: ${sidecarPath}`);
|
|
704
|
+
}
|
|
705
|
+
throw error;
|
|
706
|
+
});
|
|
707
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
708
|
+
throw new Error(`QA sidecar ${index} must reference a regular file.`);
|
|
709
|
+
}
|
|
710
|
+
await ensureNoSymbolicLinkPathSegments(qaSidecarRoot, path.dirname(fullPath), `QA sidecar ${index} path must not traverse symbolic links.`);
|
|
711
|
+
if (stat.size !== expectedBytes) {
|
|
712
|
+
throw new Error(`QA sidecar ${index} bytes do not match the referenced file.`);
|
|
713
|
+
}
|
|
714
|
+
const actualHash = await sha256File(fullPath);
|
|
715
|
+
if (actualHash !== expectedHash.trim().toLowerCase()) {
|
|
716
|
+
throw new Error(`QA sidecar ${index} content_hash does not match the referenced file.`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
593
720
|
async function emitQaStructuredEvents(emit, worktreePath, workItemId, artifact) {
|
|
594
721
|
const artifactRoot = artifactRootFor(worktreePath, workItemId);
|
|
595
722
|
const plan = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.plan));
|
|
@@ -634,13 +761,15 @@ async function emitQaStructuredEvents(emit, worktreePath, workItemId, artifact)
|
|
|
634
761
|
class LiveQaArtifactEmitter {
|
|
635
762
|
worktreePath;
|
|
636
763
|
workItemId;
|
|
764
|
+
dispatch;
|
|
637
765
|
emit;
|
|
638
766
|
emitted = false;
|
|
639
767
|
timer = null;
|
|
640
768
|
pending = Promise.resolve();
|
|
641
|
-
constructor(worktreePath, workItemId, emit) {
|
|
769
|
+
constructor(worktreePath, workItemId, dispatch, emit) {
|
|
642
770
|
this.worktreePath = worktreePath;
|
|
643
771
|
this.workItemId = workItemId;
|
|
772
|
+
this.dispatch = dispatch;
|
|
644
773
|
this.emit = emit;
|
|
645
774
|
}
|
|
646
775
|
start() {
|
|
@@ -673,6 +802,7 @@ class LiveQaArtifactEmitter {
|
|
|
673
802
|
if (!artifact) {
|
|
674
803
|
return;
|
|
675
804
|
}
|
|
805
|
+
enforceQaBrowserLaunchTargetGate(this.dispatch, artifact);
|
|
676
806
|
this.emitted = true;
|
|
677
807
|
await emitQaArtifactRecorded(this.emit, this.worktreePath, artifact);
|
|
678
808
|
await emitQaStructuredEvents(this.emit, this.worktreePath, this.workItemId, artifact);
|
|
@@ -1125,6 +1255,21 @@ function phaseWorkInstruction(phase) {
|
|
|
1125
1255
|
}
|
|
1126
1256
|
return "Implement the work item and keep the change focused.";
|
|
1127
1257
|
}
|
|
1258
|
+
function qaBrowserInstruction(dispatch) {
|
|
1259
|
+
const qaBrowser = dispatch.qaBrowser;
|
|
1260
|
+
if (!qaBrowser) {
|
|
1261
|
+
return "Browser/app launch context: not provided by Forge.";
|
|
1262
|
+
}
|
|
1263
|
+
const launchTarget = qaBrowser.launchTarget;
|
|
1264
|
+
if (qaBrowser.required && !hasLaunchTarget(launchTarget ?? null)) {
|
|
1265
|
+
return `Browser/app launch context:\n- UI/browser QA is required for this item.\n- No app launch target was provided.\n- Return blocked with blocker_reason: missing_app_launch_target instead of pretending browser testing occurred.\n- Artifact sidecars path when available: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
|
|
1266
|
+
}
|
|
1267
|
+
if (!launchTarget) {
|
|
1268
|
+
return `Browser/app launch context:\n- UI/browser QA is not required by Forge heuristics for this item.\n- If you determine browser testing is needed, return blocked with blocker_reason: missing_app_launch_target unless you can safely identify a launch target from the repo.\n- Browser sidecars path: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
|
|
1269
|
+
}
|
|
1270
|
+
const routes = launchTarget.routes.length > 0 ? launchTarget.routes.join(", ") : "not specified";
|
|
1271
|
+
return `Browser/app launch context:\n- Browser QA required: ${qaBrowser.required ? "yes" : "no, unless the diff/AC requires it"}\n- URL: ${launchTarget.url || "not specified"}\n- Ready URL: ${launchTarget.ready_url || "not specified"}\n- Launch command: ${launchTarget.command || "not specified"}\n- Working directory: ${launchTarget.working_directory || "repo root"}\n- Routes to cover: ${routes}\n- Notes: ${launchTarget.notes || "none"}\n- Browser sidecars path: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
|
|
1272
|
+
}
|
|
1128
1273
|
function buildQaPrompt(dispatch) {
|
|
1129
1274
|
return `You are Forge's QA lane running locally in a prepared git worktree.
|
|
1130
1275
|
|
|
@@ -1143,9 +1288,10 @@ System info / environment notes:
|
|
|
1143
1288
|
${dispatch.workItem.systemInfo || "Not provided."}
|
|
1144
1289
|
|
|
1145
1290
|
Inspect the validated implementation and Forge artifacts under .forge/work-items/${dispatch.workItem.id}/artifacts/ when present, especially implementation, self-review, and validation evidence.
|
|
1291
|
+
${qaBrowserInstruction(dispatch)}
|
|
1146
1292
|
${qaReworkRetestInstruction(dispatch)}
|
|
1147
1293
|
QA is read-mostly: inspect code, run commands, and exercise browser/app flows when available, but do not edit product source or tests.
|
|
1148
|
-
Record concrete evidence. For UI/browser work, include browser_checks with URLs, actions, observed results, screenshots/traces when available,
|
|
1294
|
+
Record concrete evidence. For UI/browser work, include browser_checks with URLs/routes, actions, observed results, screenshots/traces when available, console errors, failed network requests, and responsive checks. Store browser sidecars under .forge/work-items/${dispatch.workItem.id}/artifacts/qa/.
|
|
1149
1295
|
Report in-scope defects as actionable findings with repro steps, expected/actual behavior, evidence, likely area, required fix, and required regression.
|
|
1150
1296
|
Out-of-scope or follow-up issues belong in candidate_bugs instead of blocking the current item.
|
|
1151
1297
|
|
|
@@ -1508,6 +1654,9 @@ function stringField(source, key) {
|
|
|
1508
1654
|
const value = source[key];
|
|
1509
1655
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
1510
1656
|
}
|
|
1657
|
+
function normalizedString(value) {
|
|
1658
|
+
return value.trim().toLowerCase().replace(/-/g, "_");
|
|
1659
|
+
}
|
|
1511
1660
|
function isNodeError(error) {
|
|
1512
1661
|
return error instanceof Error && "code" in error;
|
|
1513
1662
|
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function isPathUnderRoot(root: string, target: string): boolean;
|
|
2
|
+
export declare function ensureNoSymbolicLinkPathSegments(root: string, targetDirectory: string, errorMessage: string): Promise<void>;
|
|
3
|
+
export declare function sha256File(filePath: string): Promise<string>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
export function isPathUnderRoot(root, target) {
|
|
6
|
+
const relative = path.relative(path.resolve(root), path.resolve(target));
|
|
7
|
+
return Boolean(relative)
|
|
8
|
+
&& relative !== ".."
|
|
9
|
+
&& !relative.startsWith(`..${path.sep}`)
|
|
10
|
+
&& !path.isAbsolute(relative);
|
|
11
|
+
}
|
|
12
|
+
export async function ensureNoSymbolicLinkPathSegments(root, targetDirectory, errorMessage) {
|
|
13
|
+
const resolvedRoot = path.resolve(root);
|
|
14
|
+
const resolvedTargetDirectory = path.resolve(targetDirectory);
|
|
15
|
+
const relative = path.relative(resolvedRoot, resolvedTargetDirectory);
|
|
16
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
17
|
+
throw new Error(errorMessage);
|
|
18
|
+
}
|
|
19
|
+
let current = resolvedRoot;
|
|
20
|
+
const rootStat = await fs.lstat(current);
|
|
21
|
+
if (rootStat.isSymbolicLink()) {
|
|
22
|
+
throw new Error(errorMessage);
|
|
23
|
+
}
|
|
24
|
+
if (!relative) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
for (const segment of relative.split(path.sep)) {
|
|
28
|
+
if (!segment) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
current = path.join(current, segment);
|
|
32
|
+
const stat = await fs.lstat(current);
|
|
33
|
+
if (stat.isSymbolicLink()) {
|
|
34
|
+
throw new Error(errorMessage);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function sha256File(filePath) {
|
|
39
|
+
const hash = crypto.createHash("sha256");
|
|
40
|
+
for await (const chunk of createReadStream(filePath)) {
|
|
41
|
+
hash.update(chunk);
|
|
42
|
+
}
|
|
43
|
+
return hash.digest("hex");
|
|
44
|
+
}
|
|
@@ -11,8 +11,23 @@ export interface RunOnceDispatchPacket {
|
|
|
11
11
|
readonly apiKeySecretGrant?: ApiKeySecretGrant | null;
|
|
12
12
|
readonly artifacts: readonly DispatchResolvedArtifact[];
|
|
13
13
|
readonly warmPathOverrides?: readonly string[];
|
|
14
|
+
readonly qaBrowser?: DispatchQaBrowserContext | null;
|
|
14
15
|
readonly qaRework?: DispatchQaReworkContext | null;
|
|
15
16
|
}
|
|
17
|
+
export interface DispatchQaBrowserContext {
|
|
18
|
+
readonly required: boolean;
|
|
19
|
+
readonly launchTarget?: DispatchQaAppLaunchTarget | null;
|
|
20
|
+
readonly artifactsPath: string;
|
|
21
|
+
readonly missingTargetReason?: string | null;
|
|
22
|
+
}
|
|
23
|
+
export interface DispatchQaAppLaunchTarget {
|
|
24
|
+
readonly url?: string | null;
|
|
25
|
+
readonly command?: string | null;
|
|
26
|
+
readonly working_directory?: string | null;
|
|
27
|
+
readonly ready_url?: string | null;
|
|
28
|
+
readonly routes: readonly string[];
|
|
29
|
+
readonly notes?: string | null;
|
|
30
|
+
}
|
|
16
31
|
export interface DispatchQaReworkContext {
|
|
17
32
|
readonly attempt: number;
|
|
18
33
|
readonly maxAttempts: number;
|
|
@@ -80,6 +95,7 @@ export interface DispatchExecution {
|
|
|
80
95
|
readonly role: string;
|
|
81
96
|
readonly lane: string;
|
|
82
97
|
readonly requiresApiKeySecretGrant: boolean;
|
|
98
|
+
readonly runnerRepoLocalPath?: string | null;
|
|
83
99
|
}
|
|
84
100
|
export interface ApiKeySecretGrant {
|
|
85
101
|
readonly grantId: string;
|
|
@@ -18,6 +18,7 @@ export function parseRunOnceDispatchPacket(value) {
|
|
|
18
18
|
apiKeySecretGrant: parseApiKeySecretGrant(packet.apiKeySecretGrant),
|
|
19
19
|
artifacts: parseArtifacts(packet.artifacts),
|
|
20
20
|
warmPathOverrides: parseWarmPathOverrides(packet.warmPathOverrides),
|
|
21
|
+
qaBrowser: parseQaBrowser(packet.qaBrowser),
|
|
21
22
|
qaRework: parseQaRework(packet.qaRework)
|
|
22
23
|
};
|
|
23
24
|
}
|
|
@@ -94,7 +95,8 @@ function parseExecution(value) {
|
|
|
94
95
|
repoId: requiredString(execution, "repoId"),
|
|
95
96
|
role: requiredString(execution, "role"),
|
|
96
97
|
lane: requiredString(execution, "lane"),
|
|
97
|
-
requiresApiKeySecretGrant: requiredBoolean(execution, "requiresApiKeySecretGrant")
|
|
98
|
+
requiresApiKeySecretGrant: requiredBoolean(execution, "requiresApiKeySecretGrant"),
|
|
99
|
+
runnerRepoLocalPath: optionalString(execution, "runnerRepoLocalPath")
|
|
98
100
|
};
|
|
99
101
|
}
|
|
100
102
|
function parseApiKeySecretGrant(value) {
|
|
@@ -139,6 +141,43 @@ function parseWarmPathOverride(entry, index) {
|
|
|
139
141
|
}
|
|
140
142
|
return segments.join("/");
|
|
141
143
|
}
|
|
144
|
+
function parseQaBrowser(value) {
|
|
145
|
+
if (value === undefined || value === null) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const qaBrowser = asObject(value, "qaBrowser");
|
|
149
|
+
return {
|
|
150
|
+
required: optionalBoolean(qaBrowser, "required") ?? false,
|
|
151
|
+
launchTarget: parseQaAppLaunchTarget(qaBrowser.launchTarget ?? qaBrowser.launch_target),
|
|
152
|
+
artifactsPath: optionalString(qaBrowser, "artifactsPath") ?? optionalString(qaBrowser, "artifacts_path") ?? "",
|
|
153
|
+
missingTargetReason: optionalString(qaBrowser, "missingTargetReason") ?? optionalString(qaBrowser, "missing_target_reason")
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function parseQaAppLaunchTarget(value) {
|
|
157
|
+
if (value === undefined || value === null) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const launchTarget = asObject(value, "qaBrowser.launchTarget");
|
|
161
|
+
const routes = launchTarget.routes;
|
|
162
|
+
if (routes !== undefined && routes !== null && !Array.isArray(routes)) {
|
|
163
|
+
throw new Error("qaBrowser.launchTarget.routes must be an array when provided.");
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
url: optionalString(launchTarget, "url"),
|
|
167
|
+
command: optionalString(launchTarget, "command"),
|
|
168
|
+
working_directory: optionalString(launchTarget, "working_directory"),
|
|
169
|
+
ready_url: optionalString(launchTarget, "ready_url"),
|
|
170
|
+
routes: Array.isArray(routes)
|
|
171
|
+
? routes.map((entry, index) => {
|
|
172
|
+
if (typeof entry !== "string") {
|
|
173
|
+
throw new Error(`qaBrowser.launchTarget.routes[${index}] must be a string.`);
|
|
174
|
+
}
|
|
175
|
+
return entry;
|
|
176
|
+
}).filter((entry) => entry.trim().length > 0)
|
|
177
|
+
: [],
|
|
178
|
+
notes: optionalString(launchTarget, "notes")
|
|
179
|
+
};
|
|
180
|
+
}
|
|
142
181
|
function parseQaRework(value) {
|
|
143
182
|
if (value === undefined || value === null) {
|
|
144
183
|
return null;
|
|
@@ -217,6 +256,16 @@ function requiredBoolean(source, key) {
|
|
|
217
256
|
}
|
|
218
257
|
return value;
|
|
219
258
|
}
|
|
259
|
+
function optionalBoolean(source, key) {
|
|
260
|
+
const value = source[key];
|
|
261
|
+
if (value === undefined || value === null) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
if (typeof value !== "boolean") {
|
|
265
|
+
throw new Error(`${key} must be a boolean when provided.`);
|
|
266
|
+
}
|
|
267
|
+
return value;
|
|
268
|
+
}
|
|
220
269
|
function optionalPositiveInteger(source, key) {
|
|
221
270
|
const value = source[key];
|
|
222
271
|
if (value === undefined || value === null) {
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const forgeBridgeVersion = "0.1.1-dev.
|
|
1
|
+
export declare const forgeBridgeVersion = "0.1.1-dev.718bdb0018b9";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const forgeBridgeVersion = "0.1.1-dev.
|
|
1
|
+
export const forgeBridgeVersion = "0.1.1-dev.718bdb0018b9";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loucompanion/forge-bridge",
|
|
3
|
-
"version": "0.1.1-dev.
|
|
3
|
+
"version": "0.1.1-dev.718bdb0018b9",
|
|
4
4
|
"description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|