@nextcommerce/campaigns-os 1.37.3 → 1.43.1
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 +114 -10
- package/CHANGELOG.md +708 -0
- package/README.md +44 -31
- package/agents/claude/CLAUDE.md +5 -1
- package/campaign-spec/dist/types.d.ts +2 -0
- package/contracts/agent-relevant-change-policy.v1.json +11 -1
- package/contracts/effects.v1.json +4887 -0
- package/contracts/migration-sidecar-bundle.v0.json +9 -0
- package/contracts/release-ledger.json +1541 -0
- package/contracts/supported-surface.json +33 -12
- package/docs/build-packet.md +83 -22
- package/docs/campaigns-os-build-flow.md +2 -2
- package/docs/demo-preview.md +1 -1
- package/docs/diagnostics.md +7 -4
- package/docs/effects.md +350 -0
- package/docs/gateway-login.md +113 -0
- package/docs/local-setup.md +51 -0
- package/docs/migration-sidecar-bundle.md +6 -1
- package/docs/orientation-contract-reference.md +4 -1
- package/docs/progress-snapshots.md +9 -3
- package/docs/qa-and-test-orders.md +29 -13
- package/docs/readback.md +523 -0
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +364 -0
- package/docs/supported-surface.md +11 -3
- package/docs/versioning.md +8 -4
- package/package.json +10 -4
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +6 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +11 -1
- package/schemas/campaign-spec.v4.schema.json +4 -0
- package/schemas/campaigns-os-effects.v1.schema.json +211 -0
- package/schemas/campaigns-os-progress-snapshot.v0.schema.json +1 -0
- package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +1 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +1 -0
- package/schemas/campaigns-os-readback.v2.schema.json +267 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +179 -0
- package/skills/campaign-readback-classification/SKILL.md +230 -0
- package/skills/campaign-run-evidence/SKILL.md +142 -0
- package/skills/contribution-intake/SKILL.md +85 -0
- package/skills/next-campaigns-build/SKILL.md +33 -12
- package/skills/next-campaigns-os/SKILL.md +59 -22
- package/skills/next-campaigns-os/references/session-intake.md +4 -4
- package/skills/next-campaigns-os-setup/SKILL.md +35 -14
- package/skills/next-campaigns-polish/SKILL.md +43 -17
- package/skills/next-campaigns-qa/SKILL.md +53 -28
- package/skills.json +40 -7
- package/src/admin-transport.mjs +123 -0
- package/src/cli.mjs +1178 -270
- package/src/credential-store.mjs +183 -0
- package/src/deviation.mjs +3 -2
- package/src/diagnostic.mjs +4 -1
- package/src/finding-cause.mjs +14 -10
- package/src/gate-actions.mjs +2 -2
- package/src/install-mode.mjs +17 -9
- package/src/lifecycle.mjs +96 -0
- package/src/login.mjs +152 -0
- package/src/package-install-fixture.mjs +3 -2
- package/src/polish-node.mjs +5 -2
- package/src/progress-node.mjs +3 -2
- package/src/progress.mjs +5 -3
- package/src/qa-node.mjs +105 -36
- package/src/qa-publish.mjs +112 -2
- package/src/qa-sidecar.mjs +2 -0
- package/src/qa-verdict-discovery.mjs +11 -0
- package/src/qa-verdict-publish.mjs +1 -0
- package/src/qa-verdict.mjs +8 -1
- package/src/readback.mjs +1937 -0
- package/src/remit.mjs +17 -3
- package/src/run-record-closeout.mjs +3 -4
- package/src/run-record.mjs +4 -0
- package/src/sidecar-bundle.mjs +21 -0
- package/src/spec-source-identity.mjs +44 -0
- package/src/stage-ledger.mjs +4 -1
- package/src/tooling-setup.mjs +160 -0
package/src/qa-publish.mjs
CHANGED
|
@@ -20,10 +20,12 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
20
20
|
import { basename, dirname, join, resolve } from "node:path";
|
|
21
21
|
|
|
22
22
|
import { campaignSidecarPaths, targetRepoFor } from "./campaign-workspace.mjs";
|
|
23
|
+
import { refused } from "./lifecycle.mjs";
|
|
23
24
|
import { SIDECAR_RELATIVE_PATH } from "./qa-sidecar.mjs";
|
|
24
25
|
import { qaVerdictIdentityMatch } from "./qa-verdict-discovery.mjs";
|
|
25
|
-
import { publishQaVerdict, qaPortalUrl, qaVerdictPublishBlock, QA_VERDICT_PUBLISHERS } from "./qa-verdict-publish.mjs";
|
|
26
|
+
import { publishQaVerdict, qaPortalUrl, qaVerdictPublishBlock, QA_VERDICT_PUBLISH_ENDPOINT, QA_VERDICT_PUBLISHERS } from "./qa-verdict-publish.mjs";
|
|
26
27
|
import { validateVerdict } from "./qa-verdict.mjs";
|
|
28
|
+
import { assertFetchAvailable, assertSecureProxyBase, classifyRemitOutcome, describeRemitBaseKind } from "./remit.mjs";
|
|
27
29
|
import { readRunRecordsForTarget, writeRunRecord } from "./run-record.mjs";
|
|
28
30
|
import { identityMatches } from "./run-record-closeout.mjs";
|
|
29
31
|
import { DEFAULT_PROXY_BASE } from "./spec-fetch.mjs";
|
|
@@ -32,6 +34,9 @@ import { singleLineFragment } from "./text-safety.mjs";
|
|
|
32
34
|
|
|
33
35
|
export const QA_PUBLISH_STATUSES = Object.freeze({
|
|
34
36
|
published: "published",
|
|
37
|
+
// --dry-run: every refusal check ran and none fired, so a real run would
|
|
38
|
+
// have posted. Nothing was sent, so this is neither published nor failed.
|
|
39
|
+
dry_run: "dry_run",
|
|
35
40
|
refused: "refused",
|
|
36
41
|
publish_failed: "publish_failed",
|
|
37
42
|
});
|
|
@@ -41,6 +46,7 @@ export const QA_PUBLISH_STATUSES = Object.freeze({
|
|
|
41
46
|
// thrown error, so --json readers get a code to branch on.
|
|
42
47
|
export const QA_PUBLISH_REFUSALS = Object.freeze({
|
|
43
48
|
packet_required: "packet_required",
|
|
49
|
+
local_spec: "local_spec",
|
|
44
50
|
order_flags_refused: "order_flags_refused",
|
|
45
51
|
verdict_missing: "verdict_missing",
|
|
46
52
|
verdict_unreadable: "verdict_unreadable",
|
|
@@ -55,6 +61,7 @@ export const QA_PUBLISH_REFUSALS = Object.freeze({
|
|
|
55
61
|
|
|
56
62
|
export const QA_PUBLISH_EXIT_CODES = Object.freeze({
|
|
57
63
|
[QA_PUBLISH_STATUSES.published]: 0,
|
|
64
|
+
[QA_PUBLISH_STATUSES.dry_run]: 0,
|
|
58
65
|
[QA_PUBLISH_STATUSES.refused]: 2,
|
|
59
66
|
[QA_PUBLISH_STATUSES.publish_failed]: 1,
|
|
60
67
|
});
|
|
@@ -165,6 +172,25 @@ export function findRunRecordForVerdict({ records, packet, verdictRunId, verdict
|
|
|
165
172
|
* the record stamping are assertable without a receiver or a filesystem.
|
|
166
173
|
*/
|
|
167
174
|
export async function publishStoredVerdict(args, operations = {}) {
|
|
175
|
+
// `--dry-run` is a bare flag; `--dry-run true` must fail rather than quietly
|
|
176
|
+
// become a real POST.
|
|
177
|
+
// An up-front flag refusal, tagged like every other so the lifecycle
|
|
178
|
+
// journal records nothing for it (a plain Error here would be journaled as
|
|
179
|
+
// a handler failure).
|
|
180
|
+
if (Object.hasOwn(args, "dry-run") && args["dry-run"] !== true) {
|
|
181
|
+
throw refused(`--dry-run takes no value (got ${JSON.stringify(args["dry-run"])}); write \`--dry-run\` on its own, after the other flags.`);
|
|
182
|
+
}
|
|
183
|
+
const dryRun = args["dry-run"] === true;
|
|
184
|
+
const result = await attemptPublish(args, operations, dryRun);
|
|
185
|
+
if (!dryRun) return result;
|
|
186
|
+
// The envelope says what the real command would have done. A refusal is a
|
|
187
|
+
// refusal either way — same code, same exit — and so is a destination the
|
|
188
|
+
// transport gate turns down before any request; `would_publish` is true only
|
|
189
|
+
// when every check passed and the post is all that is left.
|
|
190
|
+
return { ...result, dry_run: true, would_publish: result.status === QA_PUBLISH_STATUSES.dry_run };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function attemptPublish(args, operations, dryRun) {
|
|
168
194
|
const ops = {
|
|
169
195
|
readJsonFile: readJson,
|
|
170
196
|
exists: existsSync,
|
|
@@ -193,6 +219,9 @@ export async function publishStoredVerdict(args, operations = {}) {
|
|
|
193
219
|
return refusal(QA_PUBLISH_REFUSALS.packet_required, `Build Packet ${packetPath} is not readable (${error.code || error.message}).`);
|
|
194
220
|
}
|
|
195
221
|
|
|
222
|
+
if (packet?.spec?.local_spec_id != null) {
|
|
223
|
+
return refusal(QA_PUBLISH_REFUSALS.local_spec, "Local-spec QA has no saved Map destination; keep its verdict in the repository. Portal publication requires a saved Map and fresh evidence.");
|
|
224
|
+
}
|
|
196
225
|
const source = resolveStoredVerdictSource({ args, packetPath, packet, readJsonFile: ops.readJsonFile, exists: ops.exists });
|
|
197
226
|
if (source.error) return source.error;
|
|
198
227
|
let verdict;
|
|
@@ -268,6 +297,70 @@ export async function publishStoredVerdict(args, operations = {}) {
|
|
|
268
297
|
);
|
|
269
298
|
}
|
|
270
299
|
|
|
300
|
+
// Every refusal check above has run and none fired, so a real run would post
|
|
301
|
+
// now. Under --dry-run this is where it stops: nothing is sent, and the Run
|
|
302
|
+
// Record below is left exactly as it stands.
|
|
303
|
+
if (dryRun) {
|
|
304
|
+
// First, the checks the transport itself makes before it opens a socket.
|
|
305
|
+
// `publishQaVerdict` -> `remit` demands a fetch to send with
|
|
306
|
+
// (`assertFetchAvailable`) and then puts every destination through
|
|
307
|
+
// `assertSecureProxyBase`; a runtime with no global fetch, or a base that
|
|
308
|
+
// is not https (nor a loopback host for local testing), fails there,
|
|
309
|
+
// locally, with nothing sent: the real command reports publish_failed and
|
|
310
|
+
// exits 1. A preview may not approve a send the transport refuses, so both
|
|
311
|
+
// gates run here, in the transport's own order — the same functions, with
|
|
312
|
+
// the label and the (absent) credential the publish rail passes them — and
|
|
313
|
+
// the refusal is classified by the same classifier the real outcome goes
|
|
314
|
+
// through, so the message and the exit code are the real command's.
|
|
315
|
+
// `attempted: false` is the one difference: nothing was sent.
|
|
316
|
+
let destinationRefusal = null;
|
|
317
|
+
try {
|
|
318
|
+
assertFetchAvailable(globalThis.fetch);
|
|
319
|
+
assertSecureProxyBase(proxyBase, { label: "QA verdict publish", credential: null });
|
|
320
|
+
} catch (error) {
|
|
321
|
+
destinationRefusal = classifyRemitOutcome(error);
|
|
322
|
+
}
|
|
323
|
+
if (destinationRefusal) {
|
|
324
|
+
return {
|
|
325
|
+
ok: false,
|
|
326
|
+
action: "qa-publish",
|
|
327
|
+
status: QA_PUBLISH_STATUSES.publish_failed,
|
|
328
|
+
...identity,
|
|
329
|
+
republished: republish && priorBlock?.state === "ok",
|
|
330
|
+
publish: {
|
|
331
|
+
attempted: false,
|
|
332
|
+
ok: destinationRefusal.ok,
|
|
333
|
+
error: destinationRefusal.error,
|
|
334
|
+
endpoint: QA_VERDICT_PUBLISH_ENDPOINT,
|
|
335
|
+
result: destinationRefusal.result,
|
|
336
|
+
http_status: destinationRefusal.http_status,
|
|
337
|
+
base_kind: describeRemitBaseKind(proxyBase),
|
|
338
|
+
published_at: null,
|
|
339
|
+
},
|
|
340
|
+
dashboard_url: null,
|
|
341
|
+
run_record: recordEntry ? { ...recordSummary(recordEntry, priorBlock), written: false, preserved: false } : null,
|
|
342
|
+
orders_placed: 0,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
ok: true,
|
|
347
|
+
action: "qa-publish",
|
|
348
|
+
status: QA_PUBLISH_STATUSES.dry_run,
|
|
349
|
+
...identity,
|
|
350
|
+
republished: republish && priorBlock?.state === "ok",
|
|
351
|
+
would_post: {
|
|
352
|
+
endpoint: QA_VERDICT_PUBLISH_ENDPOINT,
|
|
353
|
+
base_kind: describeRemitBaseKind(proxyBase),
|
|
354
|
+
verdict_run_id: verdictRunId,
|
|
355
|
+
payload_bytes: Buffer.byteLength(JSON.stringify(verdict), "utf8"),
|
|
356
|
+
},
|
|
357
|
+
publish: { attempted: false, ok: null, error: null, endpoint: QA_VERDICT_PUBLISH_ENDPOINT, result: null, http_status: null, base_kind: describeRemitBaseKind(proxyBase), published_at: null },
|
|
358
|
+
dashboard_url: qaPortalUrl(proxyBase, mapId, verdictRunId),
|
|
359
|
+
run_record: recordEntry ? { ...recordSummary(recordEntry, priorBlock), written: false, preserved: false } : null,
|
|
360
|
+
orders_placed: 0,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
271
364
|
const outcome = await ops.post(verdict, proxyBase);
|
|
272
365
|
const publishedAt = ops.now();
|
|
273
366
|
const block = qaVerdictPublishBlock(outcome, { verdictRunId, publisher: QA_VERDICT_PUBLISHERS.publish, publishedAt });
|
|
@@ -332,6 +425,21 @@ export function qaPublishTextLines(result, { cmd = (verb) => `campaigns-os ${ver
|
|
|
332
425
|
if (result.verdict_path) lines.push(`Verdict: ${result.verdict_path}${result.source_kind ? ` (${result.source_kind})` : ""}`);
|
|
333
426
|
if (result.dashboard_url) lines.push(`QA portal: ${result.dashboard_url}`);
|
|
334
427
|
lines.push("No order was placed and nothing was sent.");
|
|
428
|
+
if (result.dry_run) lines.push("Dry run (--dry-run): the real command refuses this the same way.");
|
|
429
|
+
return lines;
|
|
430
|
+
}
|
|
431
|
+
if (result.status === QA_PUBLISH_STATUSES.dry_run) {
|
|
432
|
+
const post = result.would_post || {};
|
|
433
|
+
lines.push("QA publish dry run (--dry-run): every refusal check passed and nothing was sent.");
|
|
434
|
+
lines.push(`Map ID: ${result.map_id}`);
|
|
435
|
+
lines.push(`Run ID: ${result.run_id}`);
|
|
436
|
+
lines.push(`Disposition: ${result.disposition}`);
|
|
437
|
+
lines.push(`Verdict: ${result.verdict_path} (${result.source_kind})`);
|
|
438
|
+
lines.push(`Would POST: ${post.endpoint} at ${result.proxy_base} [base: ${post.base_kind}] — verdict ${post.verdict_run_id}, ${post.payload_bytes} bytes`);
|
|
439
|
+
lines.push(result.run_record
|
|
440
|
+
? `Run Record: ${result.run_record.run_id} would take the publish outcome (${result.run_record.path}); it was not touched.`
|
|
441
|
+
: "Run Record: none references this verdict under the packet's campaign, so a real publish would not be recorded on one.");
|
|
442
|
+
lines.push("Orders placed: 0 (qa publish never places orders).");
|
|
335
443
|
return lines;
|
|
336
444
|
}
|
|
337
445
|
lines.push(result.status === QA_PUBLISH_STATUSES.published
|
|
@@ -356,7 +464,9 @@ export function qaPublishTextLines(result, { cmd = (verb) => `campaigns-os ${ver
|
|
|
356
464
|
}
|
|
357
465
|
lines.push("Orders placed: 0 (qa publish never places orders).");
|
|
358
466
|
if (result.status === QA_PUBLISH_STATUSES.publish_failed) {
|
|
359
|
-
lines.push(
|
|
467
|
+
lines.push(result.dry_run
|
|
468
|
+
? `Dry run (--dry-run): nothing was sent, and the real command fails here the same way — the destination is refused before any request. ${result.publish?.error || ""}`.trimEnd()
|
|
469
|
+
: `Re-run ${cmd("qa")} publish with network access; the local verdict is untouched.`);
|
|
360
470
|
}
|
|
361
471
|
return lines;
|
|
362
472
|
}
|
package/src/qa-sidecar.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { localSpecIdentityFields } from "./spec-source-identity.mjs";
|
|
1
2
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -77,6 +78,7 @@ export function projectVerdictForSidecar(verdict, { generatedAt }) {
|
|
|
77
78
|
schema_version: verdict.schema_version,
|
|
78
79
|
run_id: verdict.run_id,
|
|
79
80
|
campaign_slug: verdict.campaign_slug,
|
|
81
|
+
...localSpecIdentityFields(verdict),
|
|
80
82
|
...(verdict.public_route_slug != null ? { public_route_slug: verdict.public_route_slug } : {}),
|
|
81
83
|
...(verdict.campaign_ref_id != null ? { campaign_ref_id: verdict.campaign_ref_id } : {}),
|
|
82
84
|
spec_version: verdict.spec_version,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveCampaignIdentity, localQaIdentifier } from "./spec-source-identity.mjs";
|
|
1
2
|
// QA verdict discovery: the one walk over the local verdicts a campaign has
|
|
2
3
|
// written, with the projections each reader needs.
|
|
3
4
|
//
|
|
@@ -28,11 +29,20 @@ function qaVerdictSlug(packet) {
|
|
|
28
29
|
// the slug as identity. A verdict is this campaign's when its campaign_slug
|
|
29
30
|
// is one of them.
|
|
30
31
|
export function qaVerdictIdentifiers(packet) {
|
|
32
|
+
if (packet?.spec?.local_spec_id != null) {
|
|
33
|
+
const identity = resolveCampaignIdentity(packet.spec);
|
|
34
|
+
return identity?.kind === "local_spec" ? [localQaIdentifier(identity.id)] : [];
|
|
35
|
+
}
|
|
31
36
|
return [...new Set([optionalString(packet?.spec?.map_id), qaVerdictSlug(packet)].filter(Boolean))];
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
export function qaVerdictIdentityMatch(verdict, packet) {
|
|
35
40
|
const slug = optionalString(verdict?.campaign_slug);
|
|
41
|
+
if (packet?.spec?.local_spec_id != null || verdict?.local_spec_id != null) {
|
|
42
|
+
const identity = resolveCampaignIdentity(packet?.spec);
|
|
43
|
+
return identity?.kind === "local_spec" && verdict?.local_spec_id === identity.id
|
|
44
|
+
&& slug === localQaIdentifier(identity.id);
|
|
45
|
+
}
|
|
36
46
|
return Boolean(slug) && qaVerdictIdentifiers(packet).includes(slug);
|
|
37
47
|
}
|
|
38
48
|
|
|
@@ -174,6 +184,7 @@ export function qaVerdictCandidateScore(candidate, packet) {
|
|
|
174
184
|
const mapId = optionalString(packet?.spec?.map_id);
|
|
175
185
|
const slug = qaVerdictSlug(packet);
|
|
176
186
|
let score = 0;
|
|
187
|
+
if (packet?.spec?.local_spec_id && qaVerdictIdentityMatch(verdict, packet)) score += 100;
|
|
177
188
|
if (mapId && verdict.campaign_slug === mapId) score += 100;
|
|
178
189
|
if (slug && verdict.campaign_slug === slug) score += 80;
|
|
179
190
|
if (verdict.schema_version === "1.0" || verdict.schema_version === "campaigns-os-qa-verdict/v0") score += 10;
|
|
@@ -42,6 +42,7 @@ export const QA_VERDICT_PUBLISH_STATES = Object.freeze(["skipped", "ok", "failed
|
|
|
42
42
|
* must not claim a credential is travelling in clear.
|
|
43
43
|
*/
|
|
44
44
|
export async function publishQaVerdict(verdict, proxyBase, { fetchImpl = globalThis.fetch, remitImpl = remit } = {}) {
|
|
45
|
+
if (verdict?.local_spec_id != null) return { ...skippedQaVerdictPublish(), reason: "local_spec" };
|
|
45
46
|
let httpStatus = null;
|
|
46
47
|
let response = null;
|
|
47
48
|
let failure = null;
|
package/src/qa-verdict.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveCampaignIdentity, localQaIdentifier } from "./spec-source-identity.mjs";
|
|
1
2
|
export const QA_SCHEMA_VERSION = "1.0";
|
|
2
3
|
|
|
3
4
|
export const STATUS = Object.freeze({
|
|
@@ -95,6 +96,7 @@ function dispositionWithCommercial(assertions, commercial) {
|
|
|
95
96
|
export function createVerdict({
|
|
96
97
|
runId,
|
|
97
98
|
mapId,
|
|
99
|
+
localSpecId = null,
|
|
98
100
|
publicRouteSlug = null,
|
|
99
101
|
campaignRefId = null,
|
|
100
102
|
specVersion,
|
|
@@ -126,7 +128,8 @@ export function createVerdict({
|
|
|
126
128
|
run_id: runId,
|
|
127
129
|
// campaign_slug carries the Map ID for schema back-compat; the true public
|
|
128
130
|
// route slug rides alongside so consumers stop conflating the two.
|
|
129
|
-
campaign_slug: mapId,
|
|
131
|
+
campaign_slug: localSpecId ? localQaIdentifier(localSpecId) : mapId,
|
|
132
|
+
...(localSpecId ? { local_spec_id: localSpecId } : {}),
|
|
130
133
|
public_route_slug: optionalString(publicRouteSlug),
|
|
131
134
|
campaign_ref_id: campaignRefId,
|
|
132
135
|
spec_version: specVersion,
|
|
@@ -208,6 +211,10 @@ export function deriveExceptions(assertions = []) {
|
|
|
208
211
|
|
|
209
212
|
export function validateVerdict(verdict) {
|
|
210
213
|
const errors = [];
|
|
214
|
+
if (verdict?.local_spec_id != null && (!resolveCampaignIdentity({ local_spec_id: verdict.local_spec_id })
|
|
215
|
+
|| verdict.campaign_slug !== localQaIdentifier(verdict.local_spec_id))) {
|
|
216
|
+
errors.push("Local-spec verdict requires a valid local_spec_id and its local QA identifier.");
|
|
217
|
+
}
|
|
211
218
|
if (!verdict || typeof verdict !== "object" || Array.isArray(verdict)) {
|
|
212
219
|
return ["verdict: must be an object"];
|
|
213
220
|
}
|