ccqa 1.32.0 → 1.33.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.
- package/dist/bin/ccqa.mjs +439 -70
- package/dist/hub-client/index.d.mts +60 -0
- package/dist/hub-client/index.mjs +20 -0
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -7518,6 +7518,46 @@ z.object({
|
|
|
7518
7518
|
specs: z.record(z.string(), AttestationSchema)
|
|
7519
7519
|
});
|
|
7520
7520
|
/**
|
|
7521
|
+
* A person's answer to one audit finding: the spec describes the code fine,
|
|
7522
|
+
* and the finding is wrong. Where an attestation speaks about the product,
|
|
7523
|
+
* this speaks about the *audit* — so it settles the audit axis rather than
|
|
7524
|
+
* the verdict, and the spec goes back to being run like any other.
|
|
7525
|
+
*
|
|
7526
|
+
* Pinned to the audit run whose finding it answers. A later audit is a new
|
|
7527
|
+
* observation of newer code, so it produces a new run and this stops
|
|
7528
|
+
* applying: the machine gets to raise the finding again, and the record of
|
|
7529
|
+
* the last dismissal is shown beside it rather than silently suppressing it.
|
|
7530
|
+
* No profile — an audit finding is about the repository, not an environment
|
|
7531
|
+
* (ADR-0013), which is also why this is scoped per project alone.
|
|
7532
|
+
*/
|
|
7533
|
+
const AuditDismissalSchema = z.object({
|
|
7534
|
+
by: z.string().min(1),
|
|
7535
|
+
at: z.string(),
|
|
7536
|
+
note: z.string().min(1),
|
|
7537
|
+
auditRunId: z.string(),
|
|
7538
|
+
label: DriftLabelSchema,
|
|
7539
|
+
headline: z.string()
|
|
7540
|
+
});
|
|
7541
|
+
/** The per-project dismissal document: "feature/spec" → the last dismissal. */
|
|
7542
|
+
const AuditDismissalsSchema = z.object({ specs: z.record(z.string(), AuditDismissalSchema).default({}) });
|
|
7543
|
+
/** Body of `PUT /projects/:project/audit-dismissals`. */
|
|
7544
|
+
const PutAuditDismissalRequestSchema = z.object({
|
|
7545
|
+
spec: z.string().min(1).max(512),
|
|
7546
|
+
by: z.string().min(1).max(256),
|
|
7547
|
+
note: z.string().min(1).max(4e3)
|
|
7548
|
+
});
|
|
7549
|
+
/** Body of `DELETE /projects/:project/audit-dismissals`. */
|
|
7550
|
+
const DeleteAuditDismissalRequestSchema = z.object({ spec: z.string().min(1).max(512) });
|
|
7551
|
+
z.object({
|
|
7552
|
+
project: z.string(),
|
|
7553
|
+
spec: z.string(),
|
|
7554
|
+
dismissal: AuditDismissalSchema
|
|
7555
|
+
});
|
|
7556
|
+
z.object({
|
|
7557
|
+
project: z.string(),
|
|
7558
|
+
specs: z.record(z.string(), AuditDismissalSchema)
|
|
7559
|
+
});
|
|
7560
|
+
/**
|
|
7521
7561
|
* One spec's verdict, the two axes it was derived from, and the three ledger
|
|
7522
7562
|
* coordinates the view shows alongside them. The coordinates are always
|
|
7523
7563
|
* present (null when the spec has no such entry); the optional fields appear
|
|
@@ -7533,6 +7573,8 @@ const SpecRerunSchema = z.object({
|
|
|
7533
7573
|
execution: ExecutionStateSchema,
|
|
7534
7574
|
driftLabel: DriftLabelSchema.exclude(["UNKNOWN"]).optional(),
|
|
7535
7575
|
auditAssumedReached: RerunUnknownReasonSchema.optional(),
|
|
7576
|
+
auditDismissed: AuditDismissalSchema.optional(),
|
|
7577
|
+
auditDismissalApplied: z.boolean().optional(),
|
|
7536
7578
|
executionAssumedReached: RerunUnknownReasonSchema.optional(),
|
|
7537
7579
|
specChangedSince: z.string().optional(),
|
|
7538
7580
|
manual: AttestationSchema.optional(),
|
|
@@ -9297,6 +9339,20 @@ function connect(opts) {
|
|
|
9297
9339
|
error("hub token is required (--hub-token or CCQA_HUB_TOKEN)");
|
|
9298
9340
|
process.exit(2);
|
|
9299
9341
|
}
|
|
9342
|
+
/**
|
|
9343
|
+
* The canonical "feature/spec" key for a CLI argument. Hub records are stored
|
|
9344
|
+
* and looked up under exactly this form, so an alias accepted here but kept
|
|
9345
|
+
* verbatim would silently never match.
|
|
9346
|
+
*/
|
|
9347
|
+
function requireSpecId(rawSpecId) {
|
|
9348
|
+
try {
|
|
9349
|
+
const parsed = parseSpecPath(rawSpecId);
|
|
9350
|
+
return `${parsed.featureName}/${parsed.specName}`;
|
|
9351
|
+
} catch (err) {
|
|
9352
|
+
error(errMessage(err));
|
|
9353
|
+
process.exit(2);
|
|
9354
|
+
}
|
|
9355
|
+
}
|
|
9300
9356
|
function validateSessionName(name) {
|
|
9301
9357
|
const parsed = SessionNameSchema.safeParse(name);
|
|
9302
9358
|
if (!parsed.success) {
|
|
@@ -9594,14 +9650,7 @@ const pushCommand = new Command("push").description("Upload the report directory
|
|
|
9594
9650
|
const attestCommand = new Command("attest").argument("<feature/spec>", "Spec id, e.g. checkout/happy-path").description("Record that a person checked a spec's behaviour by hand against the deployed environment. The verdict answers manuallyVerified instead of asking a person for what a person already did — the drift ledger is untouched, so the repair loop keeps its reason to fix the test. The attestation lapses on its own when a deploy reaches the spec or the spec is edited.").requiredOption("--profile <name>", "Environment that was checked (e.g. 'stg'). The attestation is anchored to its current deploy head.").option("--by <name>", "Who checked. Required unless --revoke.").option("--note <text>", "What was checked and how — the reader deciding whether to trust it sees this.").option("--revoke", "Withdraw the spec's attestation instead of recording one.").option(...hubUrlOption).option(...hubTokenOption).option("--project <name>", "Hub project. Defaults to the current directory's name.").option("--cwd <path>", "Directory the default --project name is resolved against.").action(withHubErrors(async (rawSpecId, opts) => {
|
|
9595
9651
|
const project = resolveProject(opts);
|
|
9596
9652
|
const hub = connect(opts);
|
|
9597
|
-
|
|
9598
|
-
try {
|
|
9599
|
-
const parsed = parseSpecPath(rawSpecId);
|
|
9600
|
-
specId = `${parsed.featureName}/${parsed.specName}`;
|
|
9601
|
-
} catch (err) {
|
|
9602
|
-
error(errMessage(err));
|
|
9603
|
-
process.exit(2);
|
|
9604
|
-
}
|
|
9653
|
+
const specId = requireSpecId(rawSpecId);
|
|
9605
9654
|
if (opts.revoke) {
|
|
9606
9655
|
await hub.deleteAttestation(project, { profile: opts.profile }, specId);
|
|
9607
9656
|
header("hub attest", `${specId} revoked`);
|
|
@@ -9621,7 +9670,30 @@ const attestCommand = new Command("attest").argument("<feature/spec>", "Spec id,
|
|
|
9621
9670
|
meta("anchored to deploy", res.attestation.deployedSha ?? "(no deploy log)");
|
|
9622
9671
|
info("the verdict answers manuallyVerified until a deploy reaches this spec or the spec is edited");
|
|
9623
9672
|
}));
|
|
9624
|
-
const
|
|
9673
|
+
const dismissCommand = new Command("dismiss").argument("<feature/spec>", "Spec id, e.g. checkout/happy-path").description("Record that a person judged the spec's current audit finding wrong: the spec describes the code fine. This settles the audit axis rather than the verdict — the spec goes back to being run like any other, and the next run says whether the person was right. The dismissal is pinned to the audit run that raised the finding, so a later audit can raise it again. No --profile: a finding is about the repository, not an environment.").option("--by <name>", "Who judged it wrong. Required unless --revoke.").option("--reason <text>", "Why the finding is wrong. Required unless --revoke — this is what a mis-firing audit learns from.").option("--revoke", "Withdraw the dismissal, putting the finding back in force.").option(...hubUrlOption).option(...hubTokenOption).option("--project <name>", "Hub project. Defaults to the current directory's name.").option("--cwd <path>", "Directory the default --project name is resolved against.").action(withHubErrors(async (rawSpecId, opts) => {
|
|
9674
|
+
const project = resolveProject(opts);
|
|
9675
|
+
const hub = connect(opts);
|
|
9676
|
+
const specId = requireSpecId(rawSpecId);
|
|
9677
|
+
if (opts.revoke) {
|
|
9678
|
+
await hub.deleteAuditDismissal(project, specId);
|
|
9679
|
+
header("hub dismiss", `${specId} revoked`);
|
|
9680
|
+
return;
|
|
9681
|
+
}
|
|
9682
|
+
if (!opts.by || !opts.reason) {
|
|
9683
|
+
error("--by <name> and --reason <text> are both required: a dismissal is a person's correction, and it needs the person and the correction");
|
|
9684
|
+
process.exit(2);
|
|
9685
|
+
}
|
|
9686
|
+
const res = await hub.putAuditDismissal(project, {
|
|
9687
|
+
spec: specId,
|
|
9688
|
+
by: opts.by,
|
|
9689
|
+
note: opts.reason
|
|
9690
|
+
});
|
|
9691
|
+
header("hub dismiss", specId);
|
|
9692
|
+
meta("by", res.dismissal.by);
|
|
9693
|
+
meta("dismissed", `${res.dismissal.label} — ${res.dismissal.headline || "(no headline)"}`);
|
|
9694
|
+
info("this finding no longer holds the spec back; a later audit can raise one of its own");
|
|
9695
|
+
}));
|
|
9696
|
+
const hubCommand = new Command("hub").description("Client for a ccqa hub: push run results and manage sessions/variables/prompts used by `ccqa run`. See docs/hub.md.").addCommand(pushCommand).addCommand(deployCommand).addCommand(costCommand).addCommand(sessionCommand).addCommand(varCommand).addCommand(promptCommand).addCommand(attestCommand).addCommand(dismissCommand);
|
|
9625
9697
|
/** Loose check that a fetched session is agent-browser storage-state, mirroring loadStorageState. */
|
|
9626
9698
|
function isStorageStateShape(state) {
|
|
9627
9699
|
return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
|
|
@@ -18929,7 +19001,7 @@ function createGetAuditNeedHandler(storage) {
|
|
|
18929
19001
|
//#endregion
|
|
18930
19002
|
//#region src/hub/api/handlers/locks.ts
|
|
18931
19003
|
/** A spec-key list and three short strings; nothing here should approach this. */
|
|
18932
|
-
const MAX_BODY_BYTES$
|
|
19004
|
+
const MAX_BODY_BYTES$5 = 1024 * 1024;
|
|
18933
19005
|
/**
|
|
18934
19006
|
* POST /api/v1/projects/:project/locks?profile=
|
|
18935
19007
|
*
|
|
@@ -18943,7 +19015,7 @@ function createAcquireLocksHandler(storage) {
|
|
|
18943
19015
|
return async (ctx) => {
|
|
18944
19016
|
const project = requireSafeSegment(ctx.params.project, "project");
|
|
18945
19017
|
const profile = requireProfileParam(ctx.url);
|
|
18946
|
-
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$
|
|
19018
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$5, AcquireLocksRequestSchema, "lock request");
|
|
18947
19019
|
let result = {
|
|
18948
19020
|
granted: [],
|
|
18949
19021
|
denied: []
|
|
@@ -18972,7 +19044,7 @@ function createReleaseLocksHandler(storage) {
|
|
|
18972
19044
|
return async (ctx) => {
|
|
18973
19045
|
const project = requireSafeSegment(ctx.params.project, "project");
|
|
18974
19046
|
const profile = requireProfileParam(ctx.url);
|
|
18975
|
-
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$
|
|
19047
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$5, ReleaseLocksRequestSchema, "release request");
|
|
18976
19048
|
await storage.locks.update(project, profile, (current) => releaseAll(current, body.holder));
|
|
18977
19049
|
ctx.res.writeHead(204).end();
|
|
18978
19050
|
};
|
|
@@ -18985,7 +19057,7 @@ function createReleaseLocksHandler(storage) {
|
|
|
18985
19057
|
* 5000 keys of 256 `\uXXXX`-escaped characters — so a conforming client is
|
|
18986
19058
|
* never answered 413 by a limit the documented bounds don't mention.
|
|
18987
19059
|
*/
|
|
18988
|
-
const MAX_BODY_BYTES$
|
|
19060
|
+
const MAX_BODY_BYTES$4 = 8 * 1024 * 1024;
|
|
18989
19061
|
function requireAckKey(ctx) {
|
|
18990
19062
|
return {
|
|
18991
19063
|
project: requireSafeSegment(ctx.params.project, "project"),
|
|
@@ -19012,7 +19084,7 @@ function createGetAckHandler(storage) {
|
|
|
19012
19084
|
function createPutAckHandler(storage) {
|
|
19013
19085
|
return async (ctx) => {
|
|
19014
19086
|
const key = requireAckKey(ctx);
|
|
19015
|
-
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$
|
|
19087
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$4, PutAckRequestSchema, "ack body");
|
|
19016
19088
|
const ack = await storage.acks.put(key.project, key.profile, key.name, body.keys);
|
|
19017
19089
|
sendJson(ctx.res, 200, {
|
|
19018
19090
|
...key,
|
|
@@ -19023,7 +19095,7 @@ function createPutAckHandler(storage) {
|
|
|
19023
19095
|
//#endregion
|
|
19024
19096
|
//#region src/hub/api/handlers/attestations.ts
|
|
19025
19097
|
/** Far above the largest body `PutAttestationRequestSchema`'s bounds admit. */
|
|
19026
|
-
const MAX_BODY_BYTES$
|
|
19098
|
+
const MAX_BODY_BYTES$3 = 64 * 1024;
|
|
19027
19099
|
function requireScope(ctx) {
|
|
19028
19100
|
return {
|
|
19029
19101
|
project: requireSafeSegment(ctx.params.project, "project"),
|
|
@@ -19056,7 +19128,7 @@ function createPutAttestationHandler(storage) {
|
|
|
19056
19128
|
return async (ctx) => {
|
|
19057
19129
|
const scope = requireScope(ctx);
|
|
19058
19130
|
const [body, head, targets] = await Promise.all([
|
|
19059
|
-
readJsonBody(ctx.req, MAX_BODY_BYTES$
|
|
19131
|
+
readJsonBody(ctx.req, MAX_BODY_BYTES$3, PutAttestationRequestSchema, "attestation body"),
|
|
19060
19132
|
storage.deploys.head(scope.project, scope.profile),
|
|
19061
19133
|
requireSpecTargets(storage.perspectives, scope.project, "what can be attested")
|
|
19062
19134
|
]);
|
|
@@ -19086,7 +19158,7 @@ function createPutAttestationHandler(storage) {
|
|
|
19086
19158
|
function createDeleteAttestationHandler(storage) {
|
|
19087
19159
|
return async (ctx) => {
|
|
19088
19160
|
const scope = requireScope(ctx);
|
|
19089
|
-
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$
|
|
19161
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$3, DeleteAttestationRequestSchema, "attestation body");
|
|
19090
19162
|
await storage.attestations.update(scope.project, scope.profile, (current) => {
|
|
19091
19163
|
const { [body.spec]: _, ...rest } = current.specs;
|
|
19092
19164
|
return { specs: rest };
|
|
@@ -19095,6 +19167,83 @@ function createDeleteAttestationHandler(storage) {
|
|
|
19095
19167
|
};
|
|
19096
19168
|
}
|
|
19097
19169
|
//#endregion
|
|
19170
|
+
//#region src/hub/api/handlers/audit-dismissals.ts
|
|
19171
|
+
/** Far above the largest body `PutAuditDismissalRequestSchema`'s bounds admit. */
|
|
19172
|
+
const MAX_BODY_BYTES$2 = 64 * 1024;
|
|
19173
|
+
/**
|
|
19174
|
+
* GET /api/v1/projects/:project/audit-dismissals — the raw document,
|
|
19175
|
+
* whether or not each entry still answers the spec's current finding. No
|
|
19176
|
+
* `?profile=`: a finding is about the repository (see `AuditDismissalSchema`).
|
|
19177
|
+
*/
|
|
19178
|
+
function createGetAuditDismissalsHandler(storage) {
|
|
19179
|
+
return async (ctx) => {
|
|
19180
|
+
const project = requireSafeSegment(ctx.params.project, "project");
|
|
19181
|
+
const doc = await storage.auditDismissals.get(project);
|
|
19182
|
+
sendJson(ctx.res, 200, {
|
|
19183
|
+
project,
|
|
19184
|
+
specs: doc.specs
|
|
19185
|
+
});
|
|
19186
|
+
};
|
|
19187
|
+
}
|
|
19188
|
+
/**
|
|
19189
|
+
* PUT /api/v1/projects/:project/audit-dismissals — record that a person
|
|
19190
|
+
* judged the spec's current audit finding wrong.
|
|
19191
|
+
*
|
|
19192
|
+
* The finding being answered is read from the ledger rather than taken from
|
|
19193
|
+
* the caller: a dismissal must name the run and the label it answers, and
|
|
19194
|
+
* only the hub knows which finding is current. A spec with no open finding is
|
|
19195
|
+
* a 400 — there is nothing to dismiss, and accepting it would write a record
|
|
19196
|
+
* that never applies to anything.
|
|
19197
|
+
*
|
|
19198
|
+
* The guard stops there on purpose. `/rerun` applies a dismissal only while
|
|
19199
|
+
* the audit is also *current* for the profile being asked about, and that is
|
|
19200
|
+
* a per-profile question this endpoint has no profile to ask it of (a finding
|
|
19201
|
+
* is about the repository, so the dismissal is project-scoped). A dismissal
|
|
19202
|
+
* written while a deploy has overtaken the audit is harmless: the next audit
|
|
19203
|
+
* supersedes the finding, and the record with it.
|
|
19204
|
+
*/
|
|
19205
|
+
function createPutAuditDismissalHandler(storage) {
|
|
19206
|
+
return async (ctx) => {
|
|
19207
|
+
const project = requireSafeSegment(ctx.params.project, "project");
|
|
19208
|
+
const [body, ledger] = await Promise.all([readJsonBody(ctx.req, MAX_BODY_BYTES$2, PutAuditDismissalRequestSchema, "dismissal body"), storage.driftLedger.getMerged(project)]);
|
|
19209
|
+
const entry = ledger.specs[body.spec];
|
|
19210
|
+
if (!entry || entry.label === null) throw new HttpError(400, "no_open_finding", `spec "${body.spec}" has no open audit finding in project "${project}" — there is nothing to dismiss`);
|
|
19211
|
+
const dismissal = {
|
|
19212
|
+
by: body.by,
|
|
19213
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
19214
|
+
note: body.note,
|
|
19215
|
+
auditRunId: entry.runId,
|
|
19216
|
+
label: entry.label,
|
|
19217
|
+
headline: entry.headline ?? ""
|
|
19218
|
+
};
|
|
19219
|
+
await storage.auditDismissals.update(project, (current) => ({ specs: {
|
|
19220
|
+
...current.specs,
|
|
19221
|
+
[body.spec]: dismissal
|
|
19222
|
+
} }));
|
|
19223
|
+
sendJson(ctx.res, 200, {
|
|
19224
|
+
project,
|
|
19225
|
+
spec: body.spec,
|
|
19226
|
+
dismissal
|
|
19227
|
+
});
|
|
19228
|
+
};
|
|
19229
|
+
}
|
|
19230
|
+
/**
|
|
19231
|
+
* DELETE /api/v1/projects/:project/audit-dismissals — withdraw a dismissal,
|
|
19232
|
+
* putting the audit's finding back in force. Deleting one that does not exist
|
|
19233
|
+
* is 200: the caller asked for its absence, and it is absent.
|
|
19234
|
+
*/
|
|
19235
|
+
function createDeleteAuditDismissalHandler(storage) {
|
|
19236
|
+
return async (ctx) => {
|
|
19237
|
+
const project = requireSafeSegment(ctx.params.project, "project");
|
|
19238
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$2, DeleteAuditDismissalRequestSchema, "dismissal body");
|
|
19239
|
+
await storage.auditDismissals.update(project, (current) => {
|
|
19240
|
+
const { [body.spec]: _, ...rest } = current.specs;
|
|
19241
|
+
return { specs: rest };
|
|
19242
|
+
});
|
|
19243
|
+
sendJson(ctx.res, 200, { removed: body.spec });
|
|
19244
|
+
};
|
|
19245
|
+
}
|
|
19246
|
+
//#endregion
|
|
19098
19247
|
//#region src/hub/api/handlers/spend.ts
|
|
19099
19248
|
/** One entry is a handful of short fields; anything larger is a malformed client. */
|
|
19100
19249
|
const MAX_BODY_BYTES$1 = 4 * 1024;
|
|
@@ -19167,7 +19316,7 @@ function specMovedSince(changedAt, baselineSha, baselineAt, deployTimes) {
|
|
|
19167
19316
|
return changedAt > (baselineSha && deployTimes.get(baselineSha) || baselineAt) ? changedAt : null;
|
|
19168
19317
|
}
|
|
19169
19318
|
function computeRerun(input) {
|
|
19170
|
-
const { specs, ledger, log, touchIndex, drift, locks, attestations, now } = input;
|
|
19319
|
+
const { specs, ledger, log, touchIndex, drift, locks, attestations, dismissals, now } = input;
|
|
19171
19320
|
const range = buildRange(log, touchIndex);
|
|
19172
19321
|
const deployTimes = deployedAt(log);
|
|
19173
19322
|
const out = {};
|
|
@@ -19180,6 +19329,9 @@ function computeRerun(input) {
|
|
|
19180
19329
|
let audit = auditState(drift, spec.key, range);
|
|
19181
19330
|
let execution = executionState(coords, (sha) => freshness(sha, spec.key, range));
|
|
19182
19331
|
const driftEntry = drift.specs[spec.key];
|
|
19332
|
+
const dismissal = dismissals.specs[spec.key];
|
|
19333
|
+
const dismissed = dismissal !== void 0 && driftEntry !== void 0 && dismissal.auditRunId === driftEntry.runId && dismissal.label === driftEntry.label && (audit.audit === "drifted" || audit.audit === "undecided");
|
|
19334
|
+
if (dismissed) audit = { audit: "clean" };
|
|
19183
19335
|
const auditMoved = specMovedSince(spec.changedAt, driftEntry?.gitHead ?? null, driftEntry?.at ?? "", deployTimes);
|
|
19184
19336
|
const runMoved = specMovedSince(spec.changedAt, coords.lastRun?.deployedSha ?? null, coords.lastRun?.at ?? "", deployTimes);
|
|
19185
19337
|
if (auditMoved && audit.audit !== "due") audit = { audit: "due" };
|
|
@@ -19193,6 +19345,10 @@ function computeRerun(input) {
|
|
|
19193
19345
|
...auditMoved || runMoved ? { specChangedSince: auditMoved ?? runMoved } : {},
|
|
19194
19346
|
...audit,
|
|
19195
19347
|
...execution,
|
|
19348
|
+
...dismissal ? {
|
|
19349
|
+
auditDismissed: dismissal,
|
|
19350
|
+
auditDismissalApplied: dismissed
|
|
19351
|
+
} : {},
|
|
19196
19352
|
...manualState?.kind === "covers" ? { manual: manualState.attest } : {},
|
|
19197
19353
|
...manualState?.kind === "lapsed" ? {
|
|
19198
19354
|
manualLapsed: {
|
|
@@ -19356,14 +19512,15 @@ function createGetRerunHandler(storage) {
|
|
|
19356
19512
|
return async (ctx) => {
|
|
19357
19513
|
const project = requireSafeSegment(ctx.params.project, "project");
|
|
19358
19514
|
const profile = requireProfileParam(ctx.url);
|
|
19359
|
-
const [specs, ledger, log, touchIndex, drift, locks, attestations] = await Promise.all([
|
|
19515
|
+
const [specs, ledger, log, touchIndex, drift, locks, attestations, dismissals] = await Promise.all([
|
|
19360
19516
|
requireSpecTargets(storage.perspectives, project, "which specs need a re-run"),
|
|
19361
19517
|
storage.ledger.getMerged(project, profile),
|
|
19362
19518
|
storage.deploys.getLog(project, profile),
|
|
19363
19519
|
storage.deploys.getTouchIndex(project, profile),
|
|
19364
19520
|
storage.driftLedger.getMerged(project),
|
|
19365
19521
|
storage.locks.get(project, profile),
|
|
19366
|
-
storage.attestations.get(project, profile)
|
|
19522
|
+
storage.attestations.get(project, profile),
|
|
19523
|
+
storage.auditDismissals.get(project)
|
|
19367
19524
|
]);
|
|
19368
19525
|
const head = log.entries[log.entries.length - 1];
|
|
19369
19526
|
sendJson(ctx.res, 200, {
|
|
@@ -19378,6 +19535,7 @@ function createGetRerunHandler(storage) {
|
|
|
19378
19535
|
drift,
|
|
19379
19536
|
locks,
|
|
19380
19537
|
attestations,
|
|
19538
|
+
dismissals,
|
|
19381
19539
|
now: /* @__PURE__ */ new Date()
|
|
19382
19540
|
})
|
|
19383
19541
|
});
|
|
@@ -20941,6 +21099,12 @@ const CSS = `
|
|
|
20941
21099
|
.notebox .nstatus { font-size: 12px; color: var(--muted); }
|
|
20942
21100
|
.notebox .nstatus.ok { color: var(--pass); }
|
|
20943
21101
|
.notebox .nstatus.err { color: var(--fail); }
|
|
21102
|
+
/* The inline form an audit-dismissal or environment-attestation button
|
|
21103
|
+
expands into, in place of the two window.prompt() calls this replaces. */
|
|
21104
|
+
.override-form { margin-top: 10px; max-width: 480px; display: flex; flex-direction: column; gap: 10px; }
|
|
21105
|
+
.override-form textarea { width: 100%; min-height: 54px; resize: vertical; font: inherit; font-size: 13px; color: var(--fg-dim); background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 8px 10px; }
|
|
21106
|
+
.override-form .of-act { display: flex; align-items: center; gap: 8px; }
|
|
21107
|
+
.override-form .of-hint { font-size: 12px; color: var(--muted); max-width: 62ch; line-height: 1.5; }
|
|
20944
21108
|
|
|
20945
21109
|
@media (max-width: 900px) { .app { grid-template-columns: 1fr; } .sidebar { display: none; } .logo .wm { display: none; } .split { grid-template-columns: 1fr; } .rd-head .meta { margin-left: 0; } }
|
|
20946
21110
|
@media (max-width: 700px) { .prompt-grid { grid-template-columns: 1fr; } .prompt-diff { grid-template-columns: 1fr; } }
|
|
@@ -21113,10 +21277,8 @@ const CLIENT_JS = `
|
|
|
21113
21277
|
"perspectives.rerun.noDeployLogBanner": "No deploy has been recorded for profile {profile}, so no case can be judged. Wire ccqa hub deploy record into the deploy job for this environment.",
|
|
21114
21278
|
"perspectives.rerun.deployHead": "deploy head",
|
|
21115
21279
|
"perspectives.drift.graded": "confirmed",
|
|
21116
|
-
"perspectives.manual.attestButton": "Mark as manually verified",
|
|
21117
21280
|
"perspectives.manual.revokeButton": "Revoke manual verification",
|
|
21118
|
-
"perspectives.manual.
|
|
21119
|
-
"perspectives.manual.promptNote": "Note (optional)",
|
|
21281
|
+
"perspectives.manual.envButton": "If the environment issue is resolved, use this",
|
|
21120
21282
|
"perspectives.manual.confirmRevoke": "Revoke the manual verification for this case?",
|
|
21121
21283
|
"perspectives.manual.error": "Could not save — retry",
|
|
21122
21284
|
"perspectives.manual.verifiedBy": "{by} manually verified this ({at})",
|
|
@@ -21126,6 +21288,18 @@ const CLIENT_JS = `
|
|
|
21126
21288
|
"perspectives.manual.lapsed.specEdited": "the manual verification lapsed when the spec was edited",
|
|
21127
21289
|
"perspectives.manual.lapsed.newerRed": "the manual verification lapsed after a later run failed",
|
|
21128
21290
|
"perspectives.manual.lapsed.unrecognized": "the manual verification lapsed for a reason this UI does not recognise",
|
|
21291
|
+
"perspectives.dismiss.offerButton": "If the test spec was fine, use this",
|
|
21292
|
+
"perspectives.dismiss.revokeButton": "Undo the dismissal",
|
|
21293
|
+
"perspectives.dismiss.confirmRevoke": "Undo the dismissal for this case?",
|
|
21294
|
+
"perspectives.dismiss.activeNote": "Audit finding “{headline}” was dismissed by {by} as a false positive ({at}) — “{note}”. The next run will settle it.",
|
|
21295
|
+
"perspectives.dismiss.priorNote": "This finding was previously dismissed by {by} ({at}) — “{note}”.",
|
|
21296
|
+
"perspectives.override.byLabel": "Verified by",
|
|
21297
|
+
"perspectives.override.reasonLabel": "Reason (required)",
|
|
21298
|
+
"perspectives.override.noteLabel": "What was resolved, and how you checked (required)",
|
|
21299
|
+
"perspectives.override.submit": "Record",
|
|
21300
|
+
"perspectives.override.cancel": "Never mind",
|
|
21301
|
+
"perspectives.override.dismissHint": "Overrides the audit finding and closes the case. The verdict moves to “re-run needed”, and the next run settles it.",
|
|
21302
|
+
"perspectives.override.envHint": "The failure stays on record, but the verdict becomes “manually verified” without waiting for a re-run. It lapses once a deploy reaches this spec.",
|
|
21129
21303
|
"prompt.card.record": "Recording browser actions",
|
|
21130
21304
|
"prompt.card.live": "Live run (AI-driven)",
|
|
21131
21305
|
"prompt.card.playwright": "Playwright test generation",
|
|
@@ -21293,10 +21467,8 @@ const CLIENT_JS = `
|
|
|
21293
21467
|
"perspectives.rerun.noDeployLogBanner": "プロファイル {profile} にデプロイの記録がないため、どのケースも判定できません。この環境のデプロイジョブに ccqa hub deploy record を組み込んでください。",
|
|
21294
21468
|
"perspectives.rerun.deployHead": "最新デプロイ",
|
|
21295
21469
|
"perspectives.drift.graded": "人が確認",
|
|
21296
|
-
"perspectives.manual.attestButton": "手動で確認した",
|
|
21297
21470
|
"perspectives.manual.revokeButton": "手動確認を取り消す",
|
|
21298
|
-
"perspectives.manual.
|
|
21299
|
-
"perspectives.manual.promptNote": "メモ(任意)",
|
|
21471
|
+
"perspectives.manual.envButton": "環境要因が解消した場合はこちら",
|
|
21300
21472
|
"perspectives.manual.confirmRevoke": "このケースの手動確認を取り消しますか?",
|
|
21301
21473
|
"perspectives.manual.error": "保存に失敗しました — 再試行してください",
|
|
21302
21474
|
"perspectives.manual.verifiedBy": "{by}さんが手動確認({at})",
|
|
@@ -21306,6 +21478,18 @@ const CLIENT_JS = `
|
|
|
21306
21478
|
"perspectives.manual.lapsed.specEdited": "手動確認はspecの編集により失効",
|
|
21307
21479
|
"perspectives.manual.lapsed.newerRed": "手動確認は直後の実行失敗により失効",
|
|
21308
21480
|
"perspectives.manual.lapsed.unrecognized": "このUIが認識できない理由により手動確認が失効",
|
|
21481
|
+
"perspectives.dismiss.offerButton": "テスト仕様に問題がなかった場合はこちら",
|
|
21482
|
+
"perspectives.dismiss.revokeButton": "棄却を取り消す",
|
|
21483
|
+
"perspectives.dismiss.confirmRevoke": "このケースの棄却を取り消しますか?",
|
|
21484
|
+
"perspectives.dismiss.activeNote": "監査指摘「{headline}」は{by}が誤検知として棄却({at})—「{note}」。次の実行が裁定します。",
|
|
21485
|
+
"perspectives.dismiss.priorNote": "前回この指摘は{by}が棄却しています({at})—「{note}」",
|
|
21486
|
+
"perspectives.override.byLabel": "確認した人",
|
|
21487
|
+
"perspectives.override.reasonLabel": "理由(必須)",
|
|
21488
|
+
"perspectives.override.noteLabel": "解消と確認の内容(必須)",
|
|
21489
|
+
"perspectives.override.submit": "記録する",
|
|
21490
|
+
"perspectives.override.cancel": "やめる",
|
|
21491
|
+
"perspectives.override.dismissHint": "監査の指摘を上書きして台帳を閉じます。判定は「要再実行」に移り、次の実行が正否を裁定します。",
|
|
21492
|
+
"perspectives.override.envHint": "失敗の記録は残したまま、判定は再実行を待たず「手動確認済み」になります。次のデプロイがこの spec に届くと失効します。",
|
|
21309
21493
|
"prompt.card.record": "ブラウザ操作の記録",
|
|
21310
21494
|
"prompt.card.live": "ライブ実行(AI操作)",
|
|
21311
21495
|
"prompt.card.playwright": "Playwrightテスト生成",
|
|
@@ -23403,6 +23587,13 @@ const CLIENT_JS = `
|
|
|
23403
23587
|
"/attestations?profile=" + encodeURIComponent(state.profile);
|
|
23404
23588
|
}
|
|
23405
23589
|
|
|
23590
|
+
// A person's answer to an audit finding, not an environment: the finding is
|
|
23591
|
+
// about the repository, not a deployed profile, so this carries no
|
|
23592
|
+
// ?profile= (unlike attestationsPath above).
|
|
23593
|
+
function auditDismissalsPath() {
|
|
23594
|
+
return "/api/v1/projects/" + encodeURIComponent(state.project) + "/audit-dismissals";
|
|
23595
|
+
}
|
|
23596
|
+
|
|
23406
23597
|
// Resolves { report } or { note } and never rejects: a hub that predates
|
|
23407
23598
|
// the endpoint costs only the columns it feeds, not the whole tab. A 404
|
|
23408
23599
|
// here can only mean "no such route" — the endpoint's own 404 is "the
|
|
@@ -24056,28 +24247,80 @@ const CLIENT_JS = `
|
|
|
24056
24247
|
}
|
|
24057
24248
|
// --- end pure: rerun detail labels ----------------------------------------
|
|
24058
24249
|
|
|
24250
|
+
// --- pure: audit dismissal reading ----------------------------------------
|
|
24251
|
+
// Self-contained (no DOM, no closures) for the same reason as the regions
|
|
24252
|
+
// above: read rr.auditDismissed against rr.audit, per the schema's own
|
|
24253
|
+
// comment on the field. "clean" means the dismissal is what is holding the
|
|
24254
|
+
// axis there; "drifted"/"undecided" means a later audit re-raised what it
|
|
24255
|
+
// answered, so the old dismissal no longer covers it.
|
|
24256
|
+
// The audit has something outstanding on this spec. Both values mean the
|
|
24257
|
+
// same thing to a reader deciding whether to answer it: the audit read the
|
|
24258
|
+
// code and did not clear the spec (ADR-0019).
|
|
24259
|
+
function auditOpen(rr) {
|
|
24260
|
+
return !!rr && (rr.audit === "drifted" || rr.audit === "undecided");
|
|
24261
|
+
}
|
|
24262
|
+
function auditDismissalActive(rr) {
|
|
24263
|
+
// The hub says whether the dismissal settled the axis. Inferring it from
|
|
24264
|
+
// "clean" would credit the person for a later audit clearing the spec on
|
|
24265
|
+
// its own, which reads identically here.
|
|
24266
|
+
return !!(rr && rr.auditDismissed && rr.auditDismissalApplied);
|
|
24267
|
+
}
|
|
24268
|
+
function auditDismissalReflagged(rr) {
|
|
24269
|
+
return !!(rr && rr.auditDismissed && auditOpen(rr));
|
|
24270
|
+
}
|
|
24271
|
+
// --- end pure: audit dismissal reading -------------------------------------
|
|
24272
|
+
|
|
24273
|
+
// The dismissal's own words, read against the current audit state: active,
|
|
24274
|
+
// it explains why the axis reads clean; re-flagged, it is a fact worth
|
|
24275
|
+
// keeping visible beside the finding that reopened it.
|
|
24276
|
+
function rerunDismissalLine(rr) {
|
|
24277
|
+
if (!rr || !rr.auditDismissed) return null;
|
|
24278
|
+
var d = rr.auditDismissed;
|
|
24279
|
+
if (auditDismissalActive(rr)) {
|
|
24280
|
+
return {
|
|
24281
|
+
muted: false,
|
|
24282
|
+
text: t("perspectives.dismiss.activeNote")
|
|
24283
|
+
.replace("{headline}", d.headline).replace("{by}", d.by).replace("{at}", relTime(d.at)).replace("{note}", d.note),
|
|
24284
|
+
};
|
|
24285
|
+
}
|
|
24286
|
+
if (auditDismissalReflagged(rr)) {
|
|
24287
|
+
return {
|
|
24288
|
+
muted: true,
|
|
24289
|
+
text: t("perspectives.dismiss.priorNote")
|
|
24290
|
+
.replace("{by}", d.by).replace("{at}", relTime(d.at)).replace("{note}", d.note),
|
|
24291
|
+
};
|
|
24292
|
+
}
|
|
24293
|
+
return null;
|
|
24294
|
+
}
|
|
24295
|
+
|
|
24059
24296
|
// The evidence behind the verdict, as the value of whichever row
|
|
24060
24297
|
// rerunEvidenceLabelKey chose. For needed/notNeeded that is what the deploy
|
|
24061
24298
|
// log holds since this case last ran, named by rerunChangeLine.
|
|
24062
24299
|
// The label already states the timeframe, so the value never repeats it.
|
|
24300
|
+
// A dismissal (active or superseded by a later finding) is appended below
|
|
24301
|
+
// whichever of those this case has, rather than replacing it — see
|
|
24302
|
+
// rerunDismissalLine.
|
|
24063
24303
|
function rerunEvidenceValue(rr) {
|
|
24064
24304
|
var wrap = el("div");
|
|
24065
24305
|
if (!rerunHasEvidence(rr)) {
|
|
24066
24306
|
wrap.appendChild(el("div", "d-prose", rerunWhyVerdict(rr)));
|
|
24067
|
-
|
|
24068
|
-
|
|
24069
|
-
|
|
24070
|
-
|
|
24071
|
-
|
|
24072
|
-
|
|
24073
|
-
|
|
24074
|
-
|
|
24075
|
-
|
|
24076
|
-
|
|
24077
|
-
|
|
24078
|
-
|
|
24079
|
-
|
|
24307
|
+
} else {
|
|
24308
|
+
// Both states require a non-empty deploy log, so a head-less report
|
|
24309
|
+
// contradicts itself; rerunChangeLine then names what is missing rather
|
|
24310
|
+
// than inventing a baseline.
|
|
24311
|
+
var line = rerunChangeLine(rr, perspState.rerun && perspState.rerun.deployHead);
|
|
24312
|
+
var text = t(line.key).replace("{sha}", shortSha(line.sha));
|
|
24313
|
+
if (line.at) text += " · " + relTime(line.at);
|
|
24314
|
+
wrap.appendChild(el("div", "d-prose", text));
|
|
24315
|
+
// A touch the index proved but cannot enumerate leaves no paths to
|
|
24316
|
+
// list; the line above still says a change landed, which is all that
|
|
24317
|
+
// is known.
|
|
24318
|
+
if (rr.verdict === "rerunNeeded" && rr.touchedBy && rr.touchedBy.length) {
|
|
24319
|
+
wrap.appendChild(pathCodes(rr.touchedBy));
|
|
24320
|
+
}
|
|
24080
24321
|
}
|
|
24322
|
+
var dismissLine = rerunDismissalLine(rr);
|
|
24323
|
+
if (dismissLine) wrap.appendChild(el("div", "d-prose" + (dismissLine.muted ? " muted" : ""), dismissLine.text));
|
|
24081
24324
|
return wrap;
|
|
24082
24325
|
}
|
|
24083
24326
|
|
|
@@ -24096,9 +24339,7 @@ const CLIENT_JS = `
|
|
|
24096
24339
|
return wrap;
|
|
24097
24340
|
}
|
|
24098
24341
|
|
|
24099
|
-
// Lets a person's own check stand in for the machine's verdict.
|
|
24100
|
-
// confirm() rather than a form: the action is rare and the two fields it
|
|
24101
|
-
// needs are short, so a modal would outweigh what it does. reloadRerun()
|
|
24342
|
+
// Lets a person's own check stand in for the machine's verdict. reloadRerun()
|
|
24102
24343
|
// is the same profile-scoped refresh a profile switch uses — it re-renders
|
|
24103
24344
|
// the whole table, so an open detail panel closes along with it.
|
|
24104
24345
|
function submitAttestation(method, body) {
|
|
@@ -24110,17 +24351,16 @@ const CLIENT_JS = `
|
|
|
24110
24351
|
.catch(function (err) { window.alert(t("perspectives.manual.error") + ": " + err.message); });
|
|
24111
24352
|
}
|
|
24112
24353
|
|
|
24113
|
-
|
|
24114
|
-
|
|
24115
|
-
|
|
24116
|
-
|
|
24117
|
-
|
|
24118
|
-
|
|
24119
|
-
|
|
24120
|
-
|
|
24121
|
-
|
|
24122
|
-
|
|
24123
|
-
return btn;
|
|
24354
|
+
// Lets a person say an audit finding was wrong. Same reload contract as
|
|
24355
|
+
// submitAttestation above; a different endpoint (no ?profile=, ADR: a
|
|
24356
|
+
// finding is about the repository).
|
|
24357
|
+
function submitAuditDismissal(method, body) {
|
|
24358
|
+
apiFetch(auditDismissalsPath(), {
|
|
24359
|
+
method: method,
|
|
24360
|
+
headers: { "Content-Type": "application/json" },
|
|
24361
|
+
body: JSON.stringify(body),
|
|
24362
|
+
}).then(function () { reloadRerun(); })
|
|
24363
|
+
.catch(function (err) { window.alert(t("perspectives.manual.error") + ": " + err.message); });
|
|
24124
24364
|
}
|
|
24125
24365
|
|
|
24126
24366
|
function manualRevokeButton(feature, spec) {
|
|
@@ -24133,6 +24373,120 @@ const CLIENT_JS = `
|
|
|
24133
24373
|
return btn;
|
|
24134
24374
|
}
|
|
24135
24375
|
|
|
24376
|
+
function auditDismissalRevokeButton(feature, spec) {
|
|
24377
|
+
var btn = el("button", "btn ghost sm del", t("perspectives.dismiss.revokeButton"));
|
|
24378
|
+
btn.type = "button";
|
|
24379
|
+
btn.addEventListener("click", function () {
|
|
24380
|
+
if (!window.confirm(t("perspectives.dismiss.confirmRevoke"))) return;
|
|
24381
|
+
submitAuditDismissal("DELETE", { spec: perspSpecKey(feature, spec) });
|
|
24382
|
+
});
|
|
24383
|
+
return btn;
|
|
24384
|
+
}
|
|
24385
|
+
|
|
24386
|
+
// The inline form a "dismiss" or "environment" offer button expands into,
|
|
24387
|
+
// in place of the two window.prompt() calls this replaces. Both kinds ask
|
|
24388
|
+
// for the same two things — who, and why — and differ only in wording and
|
|
24389
|
+
// which endpoint the answer goes to.
|
|
24390
|
+
function buildOverrideForm(kind, feature, spec, onCancel) {
|
|
24391
|
+
var wrap = el("div", "override-form");
|
|
24392
|
+
|
|
24393
|
+
var byRow = el("div", "form-row");
|
|
24394
|
+
byRow.appendChild(el("label", null, t("perspectives.override.byLabel")));
|
|
24395
|
+
var byInput = el("input", "input");
|
|
24396
|
+
byInput.type = "text";
|
|
24397
|
+
byInput.value = loadAttestBy();
|
|
24398
|
+
byRow.appendChild(byInput);
|
|
24399
|
+
wrap.appendChild(byRow);
|
|
24400
|
+
|
|
24401
|
+
var noteRow = el("div", "form-row");
|
|
24402
|
+
noteRow.appendChild(el("label", null, t(kind === "dismiss" ? "perspectives.override.reasonLabel" : "perspectives.override.noteLabel")));
|
|
24403
|
+
var noteInput = el("textarea");
|
|
24404
|
+
noteRow.appendChild(noteInput);
|
|
24405
|
+
wrap.appendChild(noteRow);
|
|
24406
|
+
|
|
24407
|
+
var act = el("div", "of-act");
|
|
24408
|
+
var submitBtn = el("button", "btn sm primary", t("perspectives.override.submit"));
|
|
24409
|
+
submitBtn.type = "button";
|
|
24410
|
+
submitBtn.disabled = true;
|
|
24411
|
+
var cancelBtn = el("button", "btn ghost sm", t("perspectives.override.cancel"));
|
|
24412
|
+
cancelBtn.type = "button";
|
|
24413
|
+
act.appendChild(submitBtn);
|
|
24414
|
+
act.appendChild(cancelBtn);
|
|
24415
|
+
wrap.appendChild(act);
|
|
24416
|
+
|
|
24417
|
+
wrap.appendChild(el("div", "of-hint", t(kind === "dismiss" ? "perspectives.override.dismissHint" : "perspectives.override.envHint")));
|
|
24418
|
+
|
|
24419
|
+
function syncEnabled() {
|
|
24420
|
+
submitBtn.disabled = !(byInput.value.trim() && noteInput.value.trim());
|
|
24421
|
+
}
|
|
24422
|
+
byInput.addEventListener("input", syncEnabled);
|
|
24423
|
+
noteInput.addEventListener("input", syncEnabled);
|
|
24424
|
+
cancelBtn.addEventListener("click", onCancel);
|
|
24425
|
+
|
|
24426
|
+
submitBtn.addEventListener("click", function () {
|
|
24427
|
+
var by = byInput.value.trim();
|
|
24428
|
+
var note = noteInput.value.trim();
|
|
24429
|
+
if (!by || !note) return;
|
|
24430
|
+
storeAttestBy(by);
|
|
24431
|
+
if (kind === "dismiss") submitAuditDismissal("PUT", { spec: perspSpecKey(feature, spec), by: by, note: note });
|
|
24432
|
+
else submitAttestation("PUT", { spec: perspSpecKey(feature, spec), by: by, note: note });
|
|
24433
|
+
});
|
|
24434
|
+
|
|
24435
|
+
return wrap;
|
|
24436
|
+
}
|
|
24437
|
+
|
|
24438
|
+
// A button that expands into buildOverrideForm above in place, rather than
|
|
24439
|
+
// a modal — the action is rare enough that swapping the button for its own
|
|
24440
|
+
// form reads fine without one.
|
|
24441
|
+
function buildOverrideOffer(kind, feature, spec) {
|
|
24442
|
+
var box = el("div", "manual-attest");
|
|
24443
|
+
var openBtn = el("button", "btn sm primary", t(kind === "dismiss" ? "perspectives.dismiss.offerButton" : "perspectives.manual.envButton"));
|
|
24444
|
+
openBtn.type = "button";
|
|
24445
|
+
box.appendChild(openBtn);
|
|
24446
|
+
openBtn.addEventListener("click", function () {
|
|
24447
|
+
box.removeChild(openBtn);
|
|
24448
|
+
var form = buildOverrideForm(kind, feature, spec, function () {
|
|
24449
|
+
box.removeChild(form);
|
|
24450
|
+
box.appendChild(openBtn);
|
|
24451
|
+
});
|
|
24452
|
+
box.appendChild(form);
|
|
24453
|
+
});
|
|
24454
|
+
return box;
|
|
24455
|
+
}
|
|
24456
|
+
|
|
24457
|
+
// The audit-axis override slot: dismiss an open finding, or revoke a
|
|
24458
|
+
// dismissal that is currently the reason the axis reads clean. At most one
|
|
24459
|
+
// of the two ever shows — a finding the axis
|
|
24460
|
+
// itself has cleared, dismissed or not, offers nothing here.
|
|
24461
|
+
function auditOverrideBox(feature, spec, rr) {
|
|
24462
|
+
if (auditDismissalActive(rr)) {
|
|
24463
|
+
var box = el("div", "manual-attest");
|
|
24464
|
+
box.appendChild(auditDismissalRevokeButton(feature, spec));
|
|
24465
|
+
return box;
|
|
24466
|
+
}
|
|
24467
|
+
if (auditOpen(rr)) return buildOverrideOffer("dismiss", feature, spec);
|
|
24468
|
+
return null;
|
|
24469
|
+
}
|
|
24470
|
+
|
|
24471
|
+
// The execution-axis override slot: revoke a standing attestation, or offer
|
|
24472
|
+
// one for an environment-caused failure — but only when the audit axis has
|
|
24473
|
+
// no open finding of its own, which is auditOverrideBox's problem to answer,
|
|
24474
|
+
// not this one's.
|
|
24475
|
+
function executionOverrideBox(feature, spec, rr) {
|
|
24476
|
+
if (rr.manual) {
|
|
24477
|
+
var box = el("div", "manual-attest");
|
|
24478
|
+
if (rr.verdict !== "manuallyVerified") box.appendChild(el("div", "d-prose", manualAttestationText(rr.manual)));
|
|
24479
|
+
box.appendChild(manualRevokeButton(feature, spec));
|
|
24480
|
+
return box;
|
|
24481
|
+
}
|
|
24482
|
+
// Only once the audit has actually cleared the spec: while it is still
|
|
24483
|
+
// due, nobody knows yet whether the environment was the only thing wrong.
|
|
24484
|
+
if (rr.audit === "clean" && rr.execution === "failed" && rr.lastRed && rr.lastRed.label === "ENVIRONMENT") {
|
|
24485
|
+
return buildOverrideOffer("environment", feature, spec);
|
|
24486
|
+
}
|
|
24487
|
+
return null;
|
|
24488
|
+
}
|
|
24489
|
+
|
|
24136
24490
|
// Detail row: a definition list of the case's fields plus the note editor.
|
|
24137
24491
|
// Built with createElement/textContent throughout — every field here is
|
|
24138
24492
|
// API-derived, so none of it may go through innerHTML.
|
|
@@ -24190,22 +24544,14 @@ const CLIENT_JS = `
|
|
|
24190
24544
|
frag.appendChild(stepsBox);
|
|
24191
24545
|
}
|
|
24192
24546
|
|
|
24193
|
-
// A
|
|
24194
|
-
//
|
|
24195
|
-
//
|
|
24196
|
-
|
|
24197
|
-
|
|
24198
|
-
|
|
24199
|
-
|
|
24200
|
-
|
|
24201
|
-
var manualBox = el("div", "manual-attest");
|
|
24202
|
-
if (rr.manual && rr.verdict !== "manuallyVerified") {
|
|
24203
|
-
manualBox.appendChild(el("div", "d-prose", manualAttestationText(rr.manual)));
|
|
24204
|
-
}
|
|
24205
|
-
var lapseLine = rerunManualLapseText(rr);
|
|
24206
|
-
if (lapseLine) manualBox.appendChild(el("div", "d-prose", lapseLine));
|
|
24207
|
-
manualBox.appendChild(manualBtn);
|
|
24208
|
-
frag.appendChild(manualBox);
|
|
24547
|
+
// A person's override, always at most one control per axis: which finding
|
|
24548
|
+
// is open decides whether that slot offers a new override or revokes a
|
|
24549
|
+
// standing one (auditOverrideBox / executionOverrideBox).
|
|
24550
|
+
if (rr) {
|
|
24551
|
+
var auditBox = auditOverrideBox(feature, spec, rr);
|
|
24552
|
+
if (auditBox) frag.appendChild(auditBox);
|
|
24553
|
+
var execBox = executionOverrideBox(feature, spec, rr);
|
|
24554
|
+
if (execBox) frag.appendChild(execBox);
|
|
24209
24555
|
}
|
|
24210
24556
|
|
|
24211
24557
|
var notebox = el("div", "notebox");
|
|
@@ -25502,6 +25848,9 @@ function registerRoutes(router, config, queue) {
|
|
|
25502
25848
|
router.get("/api/v1/projects/:project/attestations", createGetAttestationsHandler(storage));
|
|
25503
25849
|
router.put("/api/v1/projects/:project/attestations", createPutAttestationHandler(storage));
|
|
25504
25850
|
router.delete("/api/v1/projects/:project/attestations", createDeleteAttestationHandler(storage));
|
|
25851
|
+
router.get("/api/v1/projects/:project/audit-dismissals", createGetAuditDismissalsHandler(storage));
|
|
25852
|
+
router.put("/api/v1/projects/:project/audit-dismissals", createPutAuditDismissalHandler(storage));
|
|
25853
|
+
router.delete("/api/v1/projects/:project/audit-dismissals", createDeleteAuditDismissalHandler(storage));
|
|
25505
25854
|
router.get("/api/v1/projects/:project/acks/:name", createGetAckHandler(storage));
|
|
25506
25855
|
router.put("/api/v1/projects/:project/acks/:name", createPutAckHandler(storage));
|
|
25507
25856
|
router.post("/api/v1/projects/:project/spend", createRecordSpendHandler(storage));
|
|
@@ -25794,6 +26143,9 @@ function specLocksPath(root, project, profile) {
|
|
|
25794
26143
|
function attestationsPath(root, project, profile) {
|
|
25795
26144
|
return join(root, "attestations", project, profile, "attestations.json");
|
|
25796
26145
|
}
|
|
26146
|
+
function auditDismissalsPath(root, project) {
|
|
26147
|
+
return join(root, "audit-dismissals", `${project}.json`);
|
|
26148
|
+
}
|
|
25797
26149
|
function ackPath(root, project, profile, name) {
|
|
25798
26150
|
return join(root, "acks", project, profile, `${name}.json`);
|
|
25799
26151
|
}
|
|
@@ -25851,6 +26203,22 @@ function createFileAttestationStore(root) {
|
|
|
25851
26203
|
};
|
|
25852
26204
|
}
|
|
25853
26205
|
//#endregion
|
|
26206
|
+
//#region src/hub/core/storage/file/audit-dismissal-store.ts
|
|
26207
|
+
function toDismissals(doc) {
|
|
26208
|
+
const parsed = AuditDismissalsSchema.safeParse(doc);
|
|
26209
|
+
return parsed.success ? parsed.data : { specs: {} };
|
|
26210
|
+
}
|
|
26211
|
+
function createFileAuditDismissalStore(root) {
|
|
26212
|
+
return {
|
|
26213
|
+
async get(project) {
|
|
26214
|
+
return toDismissals(await readJson(auditDismissalsPath(root, project)));
|
|
26215
|
+
},
|
|
26216
|
+
async update(project, mutate) {
|
|
26217
|
+
return updateJson(auditDismissalsPath(root, project), (current) => mutate(toDismissals(current)));
|
|
26218
|
+
}
|
|
26219
|
+
};
|
|
26220
|
+
}
|
|
26221
|
+
//#endregion
|
|
25854
26222
|
//#region src/hub/core/storage/file/artifact-store.ts
|
|
25855
26223
|
/**
|
|
25856
26224
|
* Defense-in-depth: `relPath` is expected to already be validated by the
|
|
@@ -26335,7 +26703,8 @@ function createFileHubStorage(dataDir) {
|
|
|
26335
26703
|
locks: createFileLockStore(dataDir),
|
|
26336
26704
|
acks: createFileAckStore(dataDir),
|
|
26337
26705
|
spend: createFileSpendStore(dataDir),
|
|
26338
|
-
attestations: createFileAttestationStore(dataDir)
|
|
26706
|
+
attestations: createFileAttestationStore(dataDir),
|
|
26707
|
+
auditDismissals: createFileAuditDismissalStore(dataDir)
|
|
26339
26708
|
};
|
|
26340
26709
|
}
|
|
26341
26710
|
//#endregion
|
|
@@ -237,6 +237,39 @@ declare const AttestationsResponseSchema: z.ZodObject<{
|
|
|
237
237
|
}, z.core.$strip>>;
|
|
238
238
|
}, z.core.$strip>;
|
|
239
239
|
type AttestationsResponse = z.infer<typeof AttestationsResponseSchema>;
|
|
240
|
+
declare const AuditDismissalResponseSchema: z.ZodObject<{
|
|
241
|
+
project: z.ZodString;
|
|
242
|
+
spec: z.ZodString;
|
|
243
|
+
dismissal: z.ZodObject<{
|
|
244
|
+
by: z.ZodString;
|
|
245
|
+
at: z.ZodString;
|
|
246
|
+
note: z.ZodString;
|
|
247
|
+
auditRunId: z.ZodString;
|
|
248
|
+
label: z.ZodEnum<{
|
|
249
|
+
TEST_DRIFT: "TEST_DRIFT";
|
|
250
|
+
SPEC_CHANGE: "SPEC_CHANGE";
|
|
251
|
+
UNKNOWN: "UNKNOWN";
|
|
252
|
+
}>;
|
|
253
|
+
headline: z.ZodString;
|
|
254
|
+
}, z.core.$strip>;
|
|
255
|
+
}, z.core.$strip>;
|
|
256
|
+
type AuditDismissalResponse = z.infer<typeof AuditDismissalResponseSchema>;
|
|
257
|
+
declare const AuditDismissalsResponseSchema: z.ZodObject<{
|
|
258
|
+
project: z.ZodString;
|
|
259
|
+
specs: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
260
|
+
by: z.ZodString;
|
|
261
|
+
at: z.ZodString;
|
|
262
|
+
note: z.ZodString;
|
|
263
|
+
auditRunId: z.ZodString;
|
|
264
|
+
label: z.ZodEnum<{
|
|
265
|
+
TEST_DRIFT: "TEST_DRIFT";
|
|
266
|
+
SPEC_CHANGE: "SPEC_CHANGE";
|
|
267
|
+
UNKNOWN: "UNKNOWN";
|
|
268
|
+
}>;
|
|
269
|
+
headline: z.ZodString;
|
|
270
|
+
}, z.core.$strip>>;
|
|
271
|
+
}, z.core.$strip>;
|
|
272
|
+
type AuditDismissalsResponse = z.infer<typeof AuditDismissalsResponseSchema>;
|
|
240
273
|
/** Body of `GET /projects/:project/audit-needed?profile=`: one answer per spec. */
|
|
241
274
|
declare const AuditNeedReportSchema: z.ZodObject<{
|
|
242
275
|
project: z.ZodString;
|
|
@@ -303,6 +336,19 @@ declare const RerunReportSchema: z.ZodObject<{
|
|
|
303
336
|
deployedShaNotInLog: "deployedShaNotInLog";
|
|
304
337
|
gapInRange: "gapInRange";
|
|
305
338
|
}>>;
|
|
339
|
+
auditDismissed: z.ZodOptional<z.ZodObject<{
|
|
340
|
+
by: z.ZodString;
|
|
341
|
+
at: z.ZodString;
|
|
342
|
+
note: z.ZodString;
|
|
343
|
+
auditRunId: z.ZodString;
|
|
344
|
+
label: z.ZodEnum<{
|
|
345
|
+
TEST_DRIFT: "TEST_DRIFT";
|
|
346
|
+
SPEC_CHANGE: "SPEC_CHANGE";
|
|
347
|
+
UNKNOWN: "UNKNOWN";
|
|
348
|
+
}>;
|
|
349
|
+
headline: z.ZodString;
|
|
350
|
+
}, z.core.$strip>>;
|
|
351
|
+
auditDismissalApplied: z.ZodOptional<z.ZodBoolean>;
|
|
306
352
|
executionAssumedReached: z.ZodOptional<z.ZodEnum<{
|
|
307
353
|
noSelectionInRange: "noSelectionInRange";
|
|
308
354
|
selectionUnknown: "selectionUnknown";
|
|
@@ -1070,6 +1116,20 @@ interface HubClient {
|
|
|
1070
1116
|
deleteAttestation(project: string, q: {
|
|
1071
1117
|
profile: string;
|
|
1072
1118
|
}, spec: string): Promise<void>;
|
|
1119
|
+
/** Every dismissed audit finding for the project, current and superseded alike. No profile — findings are about the repository. */
|
|
1120
|
+
getAuditDismissals(project: string): Promise<AuditDismissalsResponse>;
|
|
1121
|
+
/**
|
|
1122
|
+
* Record that a person judged `spec`'s current audit finding wrong. The hub
|
|
1123
|
+
* reads which finding that is from the ledger and pins the dismissal to it;
|
|
1124
|
+
* a spec with no open finding is rejected.
|
|
1125
|
+
*/
|
|
1126
|
+
putAuditDismissal(project: string, body: {
|
|
1127
|
+
spec: string;
|
|
1128
|
+
by: string;
|
|
1129
|
+
note: string;
|
|
1130
|
+
}): Promise<AuditDismissalResponse>;
|
|
1131
|
+
/** Withdraw a dismissal, putting the audit's finding back in force. */
|
|
1132
|
+
deleteAuditDismissal(project: string, spec: string): Promise<void>;
|
|
1073
1133
|
/**
|
|
1074
1134
|
* Every spec's last `ccqa audit --report-to-hub` result, keyed by "feature/spec". No
|
|
1075
1135
|
* profile — drift asks whether a spec still describes the code, not
|
|
@@ -199,6 +199,23 @@ function createHubClient(opts) {
|
|
|
199
199
|
body: JSON.stringify({ spec })
|
|
200
200
|
});
|
|
201
201
|
},
|
|
202
|
+
getAuditDismissals(project) {
|
|
203
|
+
return json(auditDismissalsPath(project));
|
|
204
|
+
},
|
|
205
|
+
putAuditDismissal(project, body) {
|
|
206
|
+
return json(auditDismissalsPath(project), {
|
|
207
|
+
method: "PUT",
|
|
208
|
+
headers: { "Content-Type": "application/json" },
|
|
209
|
+
body: JSON.stringify(body)
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
async deleteAuditDismissal(project, spec) {
|
|
213
|
+
await request(auditDismissalsPath(project), {
|
|
214
|
+
method: "DELETE",
|
|
215
|
+
headers: { "Content-Type": "application/json" },
|
|
216
|
+
body: JSON.stringify({ spec })
|
|
217
|
+
});
|
|
218
|
+
},
|
|
202
219
|
getAuditNeed(project, q) {
|
|
203
220
|
return json(`/api/v1/projects/${encodeURIComponent(project)}/audit-needed?${queryString({ profile: q.profile })}`);
|
|
204
221
|
},
|
|
@@ -325,6 +342,9 @@ function locksPath(project) {
|
|
|
325
342
|
function attestationsPath(project) {
|
|
326
343
|
return `/api/v1/projects/${encodeURIComponent(project)}/attestations`;
|
|
327
344
|
}
|
|
345
|
+
function auditDismissalsPath(project) {
|
|
346
|
+
return `/api/v1/projects/${encodeURIComponent(project)}/audit-dismissals`;
|
|
347
|
+
}
|
|
328
348
|
/** Perspectives are one document per project: `/api/v1/projects/<project>/perspectives`. */
|
|
329
349
|
function perspectivesPath(project) {
|
|
330
350
|
return `/api/v1/projects/${encodeURIComponent(project)}/perspectives`;
|
package/dist/package.json
CHANGED