@intentius/chant 0.124.23 → 0.124.25
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/ci-jobs.d.ts +41 -1
- package/dist/ci-jobs.d.ts.map +1 -1
- package/dist/ci-jobs.js +48 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +14 -0
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +9 -0
- package/dist/cli/handlers/emulator.d.ts +13 -2
- package/dist/cli/handlers/emulator.d.ts.map +1 -1
- package/dist/cli/handlers/emulator.js +63 -3
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +7 -1
- package/dist/cli/handlers/run-wave.d.ts.map +1 -1
- package/dist/cli/handlers/run-wave.js +5 -4
- package/dist/cli/handlers/run.d.ts +4 -3
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +73 -9
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +38 -11
- package/dist/cli/mcp/workspace-tools.d.ts +3 -3
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.js +27 -4
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +31 -0
- package/dist/cli/registry.d.ts +12 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +45 -6
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +97 -22
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +25 -10
- package/dist/effect-receipt.d.ts +13 -0
- package/dist/effect-receipt.d.ts.map +1 -1
- package/dist/effect-receipt.js +19 -0
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +24 -2
- package/dist/lexicon-module.d.ts +7 -1
- package/dist/lexicon-module.d.ts.map +1 -1
- package/dist/lexicon-module.js +7 -2
- package/dist/lifecycle/git.d.ts +11 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/git.js +62 -6
- package/dist/lifecycle/no-record.d.ts +59 -0
- package/dist/lifecycle/no-record.d.ts.map +1 -0
- package/dist/lifecycle/no-record.js +88 -0
- package/dist/lifecycle/receipt-plan.d.ts.map +1 -1
- package/dist/lifecycle/receipt-plan.js +4 -2
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activities/shell.js +30 -9
- package/dist/op/emulator-lifecycle.d.ts +23 -0
- package/dist/op/emulator-lifecycle.d.ts.map +1 -1
- package/dist/op/emulator-lifecycle.js +39 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +3 -0
- package/dist/op/index.d.ts +1 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +1 -1
- package/dist/op/local-executor.d.ts +10 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +71 -7
- package/dist/op/op-waves-notify.d.ts +2 -0
- package/dist/op/op-waves-notify.d.ts.map +1 -1
- package/dist/op/op-waves-notify.js +9 -3
- package/dist/op/op-waves-run.d.ts +8 -2
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +36 -11
- package/dist/op/op-waves.d.ts +85 -1
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/op-waves.js +153 -1
- package/dist/op/receipt-store.d.ts +5 -0
- package/dist/op/receipt-store.d.ts.map +1 -1
- package/dist/op/receipt-store.js +4 -3
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/runtimes/local.js +6 -0
- package/dist/op/step-console.d.ts +65 -0
- package/dist/op/step-console.d.ts.map +1 -0
- package/dist/op/step-console.js +140 -0
- package/dist/op/step-output-ref.d.ts +26 -18
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/dist/op/step-output-ref.js +86 -63
- package/dist/point-decision.d.ts.map +1 -1
- package/dist/point-decision.js +3 -0
- package/dist/wave-runtime.mjs +239 -239
- package/dist/workspace/agent-cli.d.ts.map +1 -1
- package/dist/workspace/agent-cli.js +3 -0
- package/dist/workspace/compose-graph.d.ts +22 -3
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/compose-graph.js +18 -5
- package/dist/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
- package/dist/workspace/conformance/writer.js +1 -1
- package/dist/workspace/decide.d.ts +51 -9
- package/dist/workspace/decide.d.ts.map +1 -1
- package/dist/workspace/decide.js +125 -14
- package/dist/workspace/graph.schema.json +68 -1
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.js +20 -11
- package/dist/workspace/nested-graph.d.ts +1 -1
- package/dist/workspace/nested-graph.d.ts.map +1 -1
- package/dist/workspace/nested-graph.js +5 -1
- package/dist/workspace/point-answer.schema.json +16 -1
- package/dist/workspace/points-cli.d.ts +5 -3
- package/dist/workspace/points-cli.d.ts.map +1 -1
- package/dist/workspace/points-cli.js +16 -7
- package/dist/workspace/points-write.schema.json +39 -4
- package/dist/workspace/points.schema.json +42 -3
- package/dist/workspace/reason-codes.d.ts +2 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +2 -1
- package/dist/workspace/records-cli.d.ts +12 -2
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-cli.js +27 -7
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records-write.js +2 -3
- package/dist/workspace/records.d.ts +10 -4
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/records.js +16 -3
- package/dist/workspace/records.schema.json +2 -1
- package/dist/workspace/runs.d.ts.map +1 -1
- package/dist/workspace/runs.js +4 -2
- package/dist/workspace/trust/verify-cli.d.ts.map +1 -1
- package/dist/workspace/trust/verify-cli.js +2 -1
- package/dist/workspace/trust/verify.d.ts +22 -0
- package/dist/workspace/trust/verify.d.ts.map +1 -1
- package/dist/workspace/trust/verify.js +94 -14
- package/dist/workspace/write-scope.d.ts +3 -1
- package/dist/workspace/write-scope.d.ts.map +1 -1
- package/dist/workspace/write-scope.js +11 -4
- package/package.json +1 -1
- package/src/ci-jobs.ts +58 -1
- package/src/cli/commands/build.ts +18 -0
- package/src/cli/compiled-cli.e2e.test.ts +45 -1
- package/src/cli/handlers/approve.ts +9 -0
- package/src/cli/handlers/emulator.test.ts +61 -1
- package/src/cli/handlers/emulator.ts +65 -4
- package/src/cli/handlers/promote.test.ts +7 -0
- package/src/cli/handlers/promote.ts +7 -1
- package/src/cli/handlers/run-no-record.e2e.test.ts +87 -0
- package/src/cli/handlers/run-wave.ts +5 -4
- package/src/cli/handlers/run.test.ts +126 -0
- package/src/cli/handlers/run.ts +69 -9
- package/src/cli/main.test.ts +34 -0
- package/src/cli/main.ts +38 -11
- package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
- package/src/cli/mcp/workspace-tools.ts +27 -4
- package/src/cli/parse-args.ts +21 -0
- package/src/cli/registry.ts +12 -0
- package/src/components/cli-support.test.ts +75 -0
- package/src/components/cli-support.ts +130 -25
- package/src/components/driver.test.ts +28 -0
- package/src/components/driver.ts +23 -10
- package/src/components/generate-disabled.test.ts +56 -0
- package/src/effect-receipt.ts +22 -0
- package/src/graph-ci-edges.test.ts +50 -0
- package/src/graph-detail.ts +21 -2
- package/src/lexicon-module.ts +9 -2
- package/src/lifecycle/git.ts +61 -6
- package/src/lifecycle/no-record.test.ts +175 -0
- package/src/lifecycle/no-record.ts +108 -0
- package/src/lifecycle/receipt-plan.test.ts +9 -0
- package/src/lifecycle/receipt-plan.ts +4 -1
- package/src/op/activities/shell.ts +28 -6
- package/src/op/effect-step.test.ts +95 -1
- package/src/op/emulator-lifecycle.test.ts +24 -1
- package/src/op/emulator-lifecycle.ts +58 -2
- package/src/op/gate.ts +3 -0
- package/src/op/index.ts +1 -1
- package/src/op/local-executor.test.ts +65 -0
- package/src/op/local-executor.ts +88 -8
- package/src/op/op-waves-jobs.test.ts +213 -0
- package/src/op/op-waves-notify.ts +11 -5
- package/src/op/op-waves-run.ts +42 -11
- package/src/op/op-waves.ts +202 -2
- package/src/op/receipt-store.ts +13 -3
- package/src/op/runtimes/local.ts +6 -0
- package/src/op/step-console.test.ts +150 -0
- package/src/op/step-console.ts +155 -0
- package/src/op/step-output-ref.test.ts +68 -24
- package/src/op/step-output-ref.ts +85 -65
- package/src/point-decision-bundle.test.ts +1 -0
- package/src/point-decision.ts +3 -0
- package/src/workspace/agent-cli.ts +2 -0
- package/src/workspace/compose-graph.test.ts +55 -0
- package/src/workspace/compose-graph.ts +28 -6
- package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
- package/src/workspace/conformance/writer.ts +1 -1
- package/src/workspace/decide.test.ts +87 -3
- package/src/workspace/decide.ts +142 -17
- package/src/workspace/graph-contract.test.ts +26 -1
- package/src/workspace/graph.schema.json +68 -1
- package/src/workspace/lineage-upgrade.test.ts +21 -0
- package/src/workspace/lineage-upgrade.ts +19 -11
- package/src/workspace/nested-graph.test.ts +14 -0
- package/src/workspace/nested-graph.ts +7 -2
- package/src/workspace/point-answer.schema.json +16 -1
- package/src/workspace/points-cli.ts +16 -8
- package/src/workspace/points-write.schema.json +39 -4
- package/src/workspace/points.schema.json +42 -3
- package/src/workspace/reason-codes.ts +2 -1
- package/src/workspace/record-assets.test.ts +14 -0
- package/src/workspace/records-cli.ts +28 -7
- package/src/workspace/records-write-commands.e2e.test.ts +32 -0
- package/src/workspace/records-write.ts +2 -3
- package/src/workspace/records.schema.json +2 -1
- package/src/workspace/records.ts +17 -4
- package/src/workspace/runs.ts +4 -2
- package/src/workspace/trust/trust.test.ts +105 -0
- package/src/workspace/trust/verify-cli.ts +2 -1
- package/src/workspace/trust/verify.ts +106 -13
- package/src/workspace/write-scope.test.ts +18 -0
- package/src/workspace/write-scope.ts +16 -5
|
@@ -10,7 +10,7 @@ import { join } from "node:path";
|
|
|
10
10
|
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
11
11
|
import { runDeclarationChecks } from "./checks";
|
|
12
12
|
import { cleanScratch, commitAll, contract, REPO, repo } from "./__fixtures__/contract-repo";
|
|
13
|
-
import { answerFields, answerPoint, askPoint, reasonFields, retractAnswer, type PointsWriteDocument } from "./decide";
|
|
13
|
+
import { answerFields, answerPoint, askPoint, reasonFields, retractAnswer, withdrawQuestion, type PointsWriteDocument } from "./decide";
|
|
14
14
|
import pointAnswerSchema from "./point-answer.schema.json";
|
|
15
15
|
import { responseAsk, workspacePoints } from "./points-cli";
|
|
16
16
|
import pointsSchema from "./points.schema.json";
|
|
@@ -293,13 +293,13 @@ describe("the intent walk's points, a note on an answer, and retracting it (#335
|
|
|
293
293
|
});
|
|
294
294
|
|
|
295
295
|
test("a workspace whose answer schema predates notes and retractions refuses them, rather than dropping them", () => {
|
|
296
|
-
expect(answerFields(pointAnswerSchema)).toEqual({ note: true, retractions: true, relayedBy: true, asked: true });
|
|
296
|
+
expect(answerFields(pointAnswerSchema)).toEqual({ note: true, retractions: true, relayedBy: true, asked: true, withdrawn: true });
|
|
297
297
|
const old = JSON.parse(JSON.stringify(pointAnswerSchema));
|
|
298
298
|
delete old.properties.note;
|
|
299
299
|
delete old.properties.retractions;
|
|
300
300
|
delete old.properties.relayed_by;
|
|
301
301
|
delete old.properties.asked;
|
|
302
|
-
expect(answerFields(old)).toEqual({ note: false, retractions: false, relayedBy: false, asked: false });
|
|
302
|
+
expect(answerFields(old)).toEqual({ note: false, retractions: false, relayedBy: false, asked: false, withdrawn: true });
|
|
303
303
|
});
|
|
304
304
|
|
|
305
305
|
test("an answer relayed for a person records who relayed it, and a retraction keeps it (#3402)", async () => {
|
|
@@ -399,6 +399,90 @@ describe("an ad-hoc question whose candidates come with the ask (#3403)", () =>
|
|
|
399
399
|
});
|
|
400
400
|
});
|
|
401
401
|
|
|
402
|
+
describe("withdrawing an open question that no longer applies (#2888)", () => {
|
|
403
|
+
const item = (title: string) => ({
|
|
404
|
+
"work-item.state": "proposed",
|
|
405
|
+
"work-item.title": title,
|
|
406
|
+
"work-item.source.ask.said": "make the port configurable",
|
|
407
|
+
"work-item.source.ask.by": "alice",
|
|
408
|
+
"work-item.source.ask.via": "hud",
|
|
409
|
+
"work-item.acceptance": [],
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("the earlier question about a changed item is withdrawn, and --open leaves it out", async () => {
|
|
413
|
+
const first = ok(await askPoint({ cwd: root, point: "understand", inputs: item("Configurable port"), subject: "W-101", on }));
|
|
414
|
+
expect(first.question).toMatchObject({ state: "escalated", open: true, withdrawn: null });
|
|
415
|
+
// The author changes the item: the next ask is another question about W-101, and both are open.
|
|
416
|
+
const second = ok(await askPoint({ cwd: root, point: "understand", inputs: item("Make the server port configurable"), subject: "W-101", on }));
|
|
417
|
+
expect(second.id).not.toBe(first.id);
|
|
418
|
+
const openAbout = async () => {
|
|
419
|
+
const listed = await workspacePoints({ cwd: root, open: true });
|
|
420
|
+
read.expectValid(listed);
|
|
421
|
+
if ("error" in listed) throw new Error(listed.error.message);
|
|
422
|
+
return listed.questions.filter((q) => q.subject === "W-101").map((q) => q.id);
|
|
423
|
+
};
|
|
424
|
+
expect((await openAbout()).sort()).toEqual([first.id, second.id].sort());
|
|
425
|
+
|
|
426
|
+
const withdrawn = ok(await withdrawQuestion({ cwd: root, id: first.id, by: ["factory"], note: `W-101 changed; ${second.id} asks it now.`, on: "2026-09-26" }));
|
|
427
|
+
expect(withdrawn).toMatchObject({ verb: "withdraw", written: true });
|
|
428
|
+
expect(withdrawn.question).toMatchObject({ state: "escalated", open: false, answer: null, withdrawn: { by: ["factory"], on: "2026-09-26", note: `W-101 changed; ${second.id} asks it now.` } });
|
|
429
|
+
expect(withdrawn.question.title).toMatch(/: withdrawn$/);
|
|
430
|
+
expect(fm(first.path)).toContain('withdrawn:\n by:\n - "factory"');
|
|
431
|
+
expect(await openAbout()).toEqual([second.id]);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("a withdrawn question is closed: nobody answers it, and refusals", async () => {
|
|
435
|
+
const asked = ok(await askPoint({ cwd: root, point: "understand", inputs: item("Refusals"), on }));
|
|
436
|
+
expect(refused(await withdrawQuestion({ cwd: root, id: asked.id, by: [" "] }))).toBe("write-usage-invalid");
|
|
437
|
+
expect(ok(await withdrawQuestion({ cwd: root, id: asked.id, by: ["factory"], dryRun: true }))).toMatchObject({ verb: "withdraw", written: false, question: { open: false } });
|
|
438
|
+
expect(fm(asked.path)).not.toContain("withdrawn:");
|
|
439
|
+
ok(await withdrawQuestion({ cwd: root, id: asked.id, by: ["factory"], on }));
|
|
440
|
+
expect(refused(await answerPoint({ cwd: root, id: asked.id, answer: "proceed", by: ["alice"] }))).toBe("record-closed");
|
|
441
|
+
expect(refused(await withdrawQuestion({ cwd: root, id: asked.id, by: ["factory"] }))).toBe("record-closed");
|
|
442
|
+
const table = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: { "work-item.fits_small": true }, on }));
|
|
443
|
+
expect(refused(await withdrawQuestion({ cwd: root, id: table.id, by: ["factory"] }))).toBe("record-closed");
|
|
444
|
+
const all = await workspacePoints({ cwd: root });
|
|
445
|
+
if ("error" in all) throw new Error(all.error.message);
|
|
446
|
+
expect(all.summary.withdrawn).toBe(all.questions.filter((q) => q.withdrawn !== null).length);
|
|
447
|
+
expect(all.summary.withdrawn).toBeGreaterThan(0);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("asking a withdrawn question again opens it again", async () => {
|
|
451
|
+
const asked = ok(await askPoint({ cwd: root, point: "understand", inputs: item("Port, again"), subject: "W-102", on }));
|
|
452
|
+
ok(await withdrawQuestion({ cwd: root, id: asked.id, by: ["factory"], on }));
|
|
453
|
+
// The item changed back: the same inputs ask the same question.
|
|
454
|
+
const again = ok(await askPoint({ cwd: root, point: "understand", inputs: item("Port, again"), on: "2026-09-27" }));
|
|
455
|
+
expect(again).toMatchObject({ id: asked.id, reused: false, written: true, question: { state: "escalated", open: true, withdrawn: null, subject: "W-102", askedOn: "2026-09-27" } });
|
|
456
|
+
expect(fm(asked.path)).not.toContain("withdrawn:");
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("a retracted question withdrawn and asked again is open for people again, its retractions kept", async () => {
|
|
460
|
+
const inputs = { "region.id": "region:app/server.mjs:1-1", "node.id": "decision:ref-009" };
|
|
461
|
+
const asked = ok(await askPoint({ cwd: root, point: "intent-origin", inputs, on }));
|
|
462
|
+
ok(await answerPoint({ cwd: root, id: asked.id, answer: "incidental", by: ["alice"], on }));
|
|
463
|
+
ok(await retractAnswer({ cwd: root, id: asked.id, by: ["alice"], on }));
|
|
464
|
+
ok(await withdrawQuestion({ cwd: root, id: asked.id, by: ["factory"], on }));
|
|
465
|
+
const again = ok(await askPoint({ cwd: root, point: "intent-origin", inputs, on }));
|
|
466
|
+
expect(again).toMatchObject({ reused: false, question: { state: "escalated", open: true, withdrawn: null, retractions: [{ answer: "incidental" }] } });
|
|
467
|
+
expect(again.question.title).toMatch(/: open for people$/);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("a workspace whose answer schema predates withdrawn refuses to withdraw", async () => {
|
|
471
|
+
expect(answerFields(pointAnswerSchema).withdrawn).toBe(true);
|
|
472
|
+
const asked = ok(await askPoint({ cwd: root, point: "understand", inputs: item("Old schema"), on }));
|
|
473
|
+
const schemaFile = join(root, "answers", "answer.schema.json");
|
|
474
|
+
const original = readFileSync(schemaFile, "utf-8");
|
|
475
|
+
const old = JSON.parse(original);
|
|
476
|
+
delete old.properties.withdrawn;
|
|
477
|
+
writeFileSync(schemaFile, JSON.stringify(old, null, 2));
|
|
478
|
+
try {
|
|
479
|
+
expect(refused(await withdrawQuestion({ cwd: root, id: asked.id, by: ["factory"] }))).toBe("answer-field-unsupported");
|
|
480
|
+
} finally {
|
|
481
|
+
writeFileSync(schemaFile, original);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
402
486
|
describe("the points file in check and on read (#2738)", () => {
|
|
403
487
|
test("a changed point warns on its answers, and an invalid points file is WSP116 and a points-invalid source", async () => {
|
|
404
488
|
const file = join(root, "decisions", "points.json");
|
package/src/workspace/decide.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* {@link askPoint} asks a point's deciders for one set of inputs and records
|
|
5
5
|
* the answer in the answer kind's records directory, {@link answerPoint}
|
|
6
6
|
* records people's answer to an open question, with an optional note, and
|
|
7
|
-
* {@link retractAnswer} takes an answer back (#3351)
|
|
7
|
+
* {@link retractAnswer} takes an answer back (#3351), and {@link withdrawQuestion}
|
|
8
|
+
* closes an open question that no longer applies (#2888). Each writes one Markdown
|
|
8
9
|
* record or none, never commits, and goes through the checks every records write goes
|
|
9
10
|
* through (`records-write.ts`): the record is read back with the kind's schema
|
|
10
11
|
* before it is written, and no other record may become invalid.
|
|
@@ -33,6 +34,14 @@
|
|
|
33
34
|
* and why. Asking a retracted question again returns it as it stands, since
|
|
34
35
|
* people took it back from the deciders; people answer it again.
|
|
35
36
|
*
|
|
37
|
+
* An open question that no longer applies, such as one about a work item its
|
|
38
|
+
* author changed while it was escalated, is withdrawn (#2888): it keeps its
|
|
39
|
+
* state, with who withdrew it, when and why as `withdrawn`, is no longer open,
|
|
40
|
+
* and nobody answers it. A changed item is another question with another id,
|
|
41
|
+
* so nothing closes the earlier one on its own; whoever asked withdraws it.
|
|
42
|
+
* Asking a withdrawn question again opens it again, as when the item changes
|
|
43
|
+
* back.
|
|
44
|
+
*
|
|
36
45
|
* Each write holds the working tree's write lock (#3173, ws-089) from the first read
|
|
37
46
|
* to the write, so two answers to one question are never both taken from the
|
|
38
47
|
* same reading of it.
|
|
@@ -95,11 +104,11 @@ import { RefCASConflictError } from "../lifecycle/git";
|
|
|
95
104
|
import { readLedgerAnswers, refreshLedger, withLedgerAnswers, writeLedgerAnswer, type LedgerAnswers } from "./answers-ledger";
|
|
96
105
|
import { currentGateOrigin, isModelAuthored, type GateOrigin } from "../lifecycle/gate-origin";
|
|
97
106
|
|
|
98
|
-
/** The version of the write documents `points ask`, `points answer` and `points
|
|
107
|
+
/** The version of the write documents `points ask`, `points answer`, `points retract` and `points withdraw` print. */
|
|
99
108
|
export const POINTS_WRITE_CONTRACT_VERSION = 1;
|
|
100
109
|
export const POINTS_WRITE_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/points-write/v1/points-write.schema.json";
|
|
101
110
|
|
|
102
|
-
/** Why `points ask`, `points answer` or `points
|
|
111
|
+
/** Why `points ask`, `points answer`, `points retract` or `points withdraw` wrote nothing. Closed: a reader may switch on it. */
|
|
103
112
|
export const POINTS_WRITE_ERROR_CODES = [
|
|
104
113
|
...LOAD_ERROR_CODES,
|
|
105
114
|
"write-usage-invalid",
|
|
@@ -129,7 +138,7 @@ export const POINTS_WRITE_ERROR_CODES = [
|
|
|
129
138
|
"principal-unidentified",
|
|
130
139
|
/** `points retract` names a question that has no answer: escalated or proposed (#3351). */
|
|
131
140
|
"answer-not-answered",
|
|
132
|
-
/** The answer kind's schema copy has no `note`, `retractions`, `relayed_by` or `
|
|
141
|
+
/** The answer kind's schema copy has no `note`, `retractions`, `relayed_by`, `asked` or `withdrawn` field for what the write was given (#3351, #3402, #3403, #2888). */
|
|
133
142
|
"answer-field-unsupported",
|
|
134
143
|
/** An ad-hoc point was asked without its question and candidates, a declared point with them, or they don't fit the point's question type (#3403). */
|
|
135
144
|
"point-candidates-invalid",
|
|
@@ -158,7 +167,7 @@ export interface QuestionView {
|
|
|
158
167
|
/** What the question is about, the record's first `constrains` entry, or null. */
|
|
159
168
|
subject: string | null;
|
|
160
169
|
state: "escalated" | "proposed" | "answered";
|
|
161
|
-
/** Not answered yet: escalated to people, or proposed by a model and waiting for a person to confirm it. */
|
|
170
|
+
/** Not answered yet: escalated to people, or proposed by a model and waiting for a person to confirm it, and not withdrawn (#2888). */
|
|
162
171
|
open: boolean;
|
|
163
172
|
questionType: string;
|
|
164
173
|
candidates: (string | boolean)[];
|
|
@@ -194,6 +203,8 @@ export interface QuestionView {
|
|
|
194
203
|
relayedBy: string | null;
|
|
195
204
|
/** Answers people took back, oldest first (`points retract`, #3351). Empty when none was. */
|
|
196
205
|
retractions: RetractionView[];
|
|
206
|
+
/** Who withdrew the question, when and why, when it no longer applies (`points withdraw`, #2888), or null. A withdrawn question is not open. */
|
|
207
|
+
withdrawn: { by: string[]; on: string | null; note: string | null } | null;
|
|
197
208
|
/**
|
|
198
209
|
* An ad-hoc point's question as its ask gave it (#3403): the question's text
|
|
199
210
|
* and its criteria, what each candidate means. null for a declared point's
|
|
@@ -255,6 +266,14 @@ function retractionsOf(d: Record<string, unknown>): RetractionView[] {
|
|
|
255
266
|
}));
|
|
256
267
|
}
|
|
257
268
|
|
|
269
|
+
/** The record's `withdrawn`, as a view, or null. */
|
|
270
|
+
function withdrawnOf(d: Record<string, unknown>): QuestionView["withdrawn"] {
|
|
271
|
+
const w = d.withdrawn;
|
|
272
|
+
if (w === null || typeof w !== "object" || Array.isArray(w)) return null;
|
|
273
|
+
const r = w as Record<string, unknown>;
|
|
274
|
+
return { by: Array.isArray(r.by) ? r.by.filter((x): x is string => typeof x === "string") : [], on: typeof r.on === "string" ? r.on : null, note: typeof r.note === "string" ? r.note : null };
|
|
275
|
+
}
|
|
276
|
+
|
|
258
277
|
/** The harness a steward's question names in its source block (#2749). */
|
|
259
278
|
export const STEWARD_HARNESS = "chant-steward";
|
|
260
279
|
|
|
@@ -304,6 +323,7 @@ export function questionView(entry: RecordEntry, point: Point | undefined, ledge
|
|
|
304
323
|
}
|
|
305
324
|
}
|
|
306
325
|
const constrains = Array.isArray(d.constrains) ? d.constrains.filter((c): c is string => typeof c === "string") : [];
|
|
326
|
+
const withdrawn = withdrawnOf(d);
|
|
307
327
|
return {
|
|
308
328
|
id: entry.id,
|
|
309
329
|
path: entry.path,
|
|
@@ -311,7 +331,7 @@ export function questionView(entry: RecordEntry, point: Point | undefined, ledge
|
|
|
311
331
|
title: str(d.title) ?? "",
|
|
312
332
|
subject: constrains[0] ?? null,
|
|
313
333
|
state,
|
|
314
|
-
open: state !== "answered",
|
|
334
|
+
open: state !== "answered" && withdrawn === null,
|
|
315
335
|
questionType: str(d.question_type) ?? "",
|
|
316
336
|
candidates: Array.isArray(d.candidates) ? (d.candidates as (string | boolean)[]) : [],
|
|
317
337
|
inputs: d.inputs !== null && typeof d.inputs === "object" && !Array.isArray(d.inputs) ? (d.inputs as Record<string, unknown>) : {},
|
|
@@ -332,6 +352,7 @@ export function questionView(entry: RecordEntry, point: Point | undefined, ledge
|
|
|
332
352
|
note: str(d.note),
|
|
333
353
|
relayedBy: str(d.relayed_by),
|
|
334
354
|
retractions: retractionsOf(d),
|
|
355
|
+
withdrawn,
|
|
335
356
|
asked: askedOf(d) ?? null,
|
|
336
357
|
askedBy: askedByOf(d),
|
|
337
358
|
ledger,
|
|
@@ -341,9 +362,9 @@ export function questionView(entry: RecordEntry, point: Point | undefined, ledge
|
|
|
341
362
|
}
|
|
342
363
|
|
|
343
364
|
/** The write verbs of `points`. */
|
|
344
|
-
export type PointsWriteVerb = "ask" | "answer" | "retract";
|
|
365
|
+
export type PointsWriteVerb = "ask" | "answer" | "retract" | "withdraw";
|
|
345
366
|
|
|
346
|
-
/** What `points ask`, `points answer` and `points
|
|
367
|
+
/** What `points ask`, `points answer`, `points retract` and `points withdraw` print. */
|
|
347
368
|
export type PointsWriteDocument =
|
|
348
369
|
| {
|
|
349
370
|
$schema: string;
|
|
@@ -460,7 +481,7 @@ const show = (answer: string | boolean, type: string): string => (type === "noul
|
|
|
460
481
|
|
|
461
482
|
/** A record's title: the point's title, or an ad-hoc question's own text (#3403), what it is about, and where it stands. */
|
|
462
483
|
function titleFor(point: Point, subject: string | undefined, state: string, answer: string | boolean | undefined, asked?: Asked): string {
|
|
463
|
-
const outcome = state === "escalated" ? "open for people" : state === "proposed" ? `${show(answer!, point.question.type)}, proposed` : show(answer!, point.question.type);
|
|
484
|
+
const outcome = state === "withdrawn" ? "withdrawn" : state === "escalated" ? "open for people" : state === "proposed" ? `${show(answer!, point.question.type)}, proposed` : show(answer!, point.question.type);
|
|
464
485
|
return `${asked ? asked.question : point.title}${subject ? ` (${subject})` : ""}: ${outcome}`;
|
|
465
486
|
}
|
|
466
487
|
|
|
@@ -639,9 +660,25 @@ async function askPointLocked(opts: AskPointOptions): Promise<PointsWriteDocumen
|
|
|
639
660
|
question: questionView(entry, point, extra.ledger !== undefined ? extra.ledger : heldAt(entry))!,
|
|
640
661
|
...(extra.text !== undefined ? { text: extra.text } : {}),
|
|
641
662
|
});
|
|
642
|
-
|
|
663
|
+
/** A retracted question withdrawn and now asked again: open for people again, as it stood before it was withdrawn (#2888). */
|
|
664
|
+
const reopen = async (entry: RecordEntry & { data: Record<string, unknown> }): Promise<PointsWriteDocument> => {
|
|
665
|
+
const { withdrawn: _, ...rest } = entry.data;
|
|
666
|
+
const subject = Array.isArray(rest.constrains) && typeof rest.constrains[0] === "string" ? (rest.constrains[0] as string) : undefined;
|
|
667
|
+
const title = titleFor(point, subject, String(rest.state), rest.answer as string | boolean | undefined, asked);
|
|
668
|
+
const data = recordData(o, { ...rest, title });
|
|
669
|
+
const text = renderRecord(data, body(point, title, asked));
|
|
670
|
+
const message = `Decision point ${opts.point}: asked again${subject ? ` (${subject})` : ""}`;
|
|
671
|
+
const wrote = await write(o, before, entry.path, text, false, !!opts.dryRun, { source: read, ledger, onLedger: toLedger || ledger.byPath.has(entry.path), message, who: { verb: "points ask", by: steward?.name ?? null } });
|
|
672
|
+
return done({ ...entry, data, valid: true, reasons: [], warnings: wrote.warnings }, { reused: false, written: !opts.dryRun, ledger: wrote.ledger, ...(opts.dryRun ? { text } : {}) });
|
|
673
|
+
};
|
|
674
|
+
// A withdrawn question is asked again (#2888): it applies again, so it is open again.
|
|
675
|
+
const withdrawn = existing !== undefined && existing.data !== null && existing.data.withdrawn !== undefined;
|
|
676
|
+
if (existing && existing.data !== null && !withdrawn && (existing.state === "answered" || existing.state === "proposed")) return done(existing, { reused: true, written: false });
|
|
643
677
|
// People took a retracted question back from the deciders (ws-084): they answer it again, and asking returns it as it stands.
|
|
644
|
-
if (existing && existing.data !== null && Array.isArray(existing.data.retractions) && existing.data.retractions.length > 0)
|
|
678
|
+
if (existing && existing.data !== null && Array.isArray(existing.data.retractions) && existing.data.retractions.length > 0) {
|
|
679
|
+
if (!withdrawn) return done(existing, { reused: true, written: false });
|
|
680
|
+
return reopen(existing as RecordEntry & { data: Record<string, unknown> });
|
|
681
|
+
}
|
|
645
682
|
|
|
646
683
|
let result: ChainResult;
|
|
647
684
|
try {
|
|
@@ -651,7 +688,7 @@ async function askPointLocked(opts: AskPointOptions): Promise<PointsWriteDocumen
|
|
|
651
688
|
throw err;
|
|
652
689
|
}
|
|
653
690
|
// A standing escalation is kept while the chain still escalates.
|
|
654
|
-
if (existing && existing.data !== null && result.status === "escalated") return done(existing, { reused: true, written: false });
|
|
691
|
+
if (existing && existing.data !== null && !withdrawn && result.status === "escalated") return done(existing, { reused: true, written: false });
|
|
655
692
|
|
|
656
693
|
const on = opts.on ?? today();
|
|
657
694
|
const state = result.status;
|
|
@@ -731,15 +768,16 @@ export interface AnswerPointOptions {
|
|
|
731
768
|
|
|
732
769
|
/**
|
|
733
770
|
* Whether an answer kind's schema copy takes the fields of #3351 (`note` on an
|
|
734
|
-
* answer, and `retractions`), of #3402 (`relayed_by`)
|
|
735
|
-
* A copy of point-answer.schema.json from before them
|
|
736
|
-
* retraction, a relay
|
|
771
|
+
* answer, and `retractions`), of #3402 (`relayed_by`), of #3403 (`asked`) and
|
|
772
|
+
* of #2888 (`withdrawn`). A copy of point-answer.schema.json from before them
|
|
773
|
+
* refuses a note, a retraction, a relay, an ad-hoc ask or a withdrawal with
|
|
774
|
+
* `answer-field-unsupported`, rather
|
|
737
775
|
* than dropping what a person wrote or the question an agent asked.
|
|
738
776
|
*/
|
|
739
|
-
export function answerFields(schema: Record<string, unknown>): { note: boolean; retractions: boolean; relayedBy: boolean; asked: boolean } {
|
|
777
|
+
export function answerFields(schema: Record<string, unknown>): { note: boolean; retractions: boolean; relayedBy: boolean; asked: boolean; withdrawn: boolean } {
|
|
740
778
|
const props = schema.properties as Record<string, unknown> | undefined;
|
|
741
779
|
const has = (key: string): boolean => props !== undefined && props !== null && typeof props === "object" && Object.prototype.hasOwnProperty.call(props, key);
|
|
742
|
-
return { note: has("note"), retractions: has("retractions"), relayedBy: has("relayed_by"), asked: has("asked") };
|
|
780
|
+
return { note: has("note"), retractions: has("retractions"), relayedBy: has("relayed_by"), asked: has("asked"), withdrawn: has("withdrawn") };
|
|
743
781
|
}
|
|
744
782
|
|
|
745
783
|
/** A note as given, trimmed, or undefined when it is empty. */
|
|
@@ -944,6 +982,9 @@ async function answerPointLocked(opts: AnswerPointOptions): Promise<PointsWriteD
|
|
|
944
982
|
if (target.state === "answered") {
|
|
945
983
|
throw new PointsWriteError("record-closed", `${opts.id} is answered, and an answer never changes in place: retract it with \`points retract ${opts.id}\` and answer again, ask again with other inputs, or change the point, which asks the question anew`);
|
|
946
984
|
}
|
|
985
|
+
if (d.withdrawn !== undefined) {
|
|
986
|
+
throw new PointsWriteError("record-closed", `${opts.id} was withdrawn, so nobody answers it: ask it again with \`points ask\`, which opens it again, when it applies again`);
|
|
987
|
+
}
|
|
947
988
|
const note = noteOf(opts.note);
|
|
948
989
|
if (note !== undefined && !answerFields(o.loaded.schema).note) {
|
|
949
990
|
throw new PointsWriteError("answer-field-unsupported", `the ${o.loaded.kind.name} kind's schema has no note field, so ${opts.id} can't keep the note: copy point-answer.schema.json from @intentius/chant anew`);
|
|
@@ -1151,3 +1192,87 @@ async function retractAnswerLocked(opts: RetractAnswerOptions): Promise<PointsWr
|
|
|
1151
1192
|
return failure("retract", err);
|
|
1152
1193
|
}
|
|
1153
1194
|
}
|
|
1195
|
+
|
|
1196
|
+
// ── points withdraw ──────────────────────────────────────────────────────────
|
|
1197
|
+
|
|
1198
|
+
export interface WithdrawQuestionOptions {
|
|
1199
|
+
cwd: string;
|
|
1200
|
+
/** The answer record's id. */
|
|
1201
|
+
id: string;
|
|
1202
|
+
/** Who withdraws the question, such as the factory that asked it. At least one. */
|
|
1203
|
+
by: string[];
|
|
1204
|
+
/** Why, such as the question that asks about the subject now. */
|
|
1205
|
+
note?: string;
|
|
1206
|
+
kind?: string;
|
|
1207
|
+
on?: string;
|
|
1208
|
+
dryRun?: boolean;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* Close an open question that no longer applies: `points withdraw` (#2888).
|
|
1213
|
+
* A run asks about a work item whose author then changes it, so the next ask
|
|
1214
|
+
* is another question, and the first one would wait on people forever. The
|
|
1215
|
+
* question keeps its state, escalated or proposed, and gains `withdrawn`: who
|
|
1216
|
+
* withdrew it, when and why. It is no longer open, so `points --open` leaves
|
|
1217
|
+
* it out, and `points answer` refuses it. Asking it again opens it again.
|
|
1218
|
+
*
|
|
1219
|
+
* Withdrawing answers nothing and passes no gate, so it needs no quorum, and
|
|
1220
|
+
* the asker withdraws its own question, a steward's turn or an MCP session
|
|
1221
|
+
* included. An answered question is not withdrawn: people retract it.
|
|
1222
|
+
*/
|
|
1223
|
+
export async function withdrawQuestion(opts: WithdrawQuestionOptions): Promise<PointsWriteDocument> {
|
|
1224
|
+
try {
|
|
1225
|
+
return await withWriteLock(opts.cwd, { verb: "points withdraw", by: (opts.by ?? []).join(", ") || null }, opts.dryRun, () => withdrawQuestionLocked(opts));
|
|
1226
|
+
} catch (err) {
|
|
1227
|
+
return failure("withdraw", err);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
async function withdrawQuestionLocked(opts: WithdrawQuestionOptions): Promise<PointsWriteDocument> {
|
|
1232
|
+
try {
|
|
1233
|
+
const by = [...new Set((opts.by ?? []).map((b) => b.trim()).filter((b) => b !== ""))];
|
|
1234
|
+
if (by.length === 0) throw new PointsWriteError("write-usage-invalid", "--by <name> is required: who withdraws the question");
|
|
1235
|
+
refuseUnidentified(scopeSource(opts.cwd), by, "--by");
|
|
1236
|
+
const found = await findQuestion(opts.cwd, opts.kind, opts.id);
|
|
1237
|
+
const { opened, before, target } = found;
|
|
1238
|
+
const ledger = found.read.ledger;
|
|
1239
|
+
const { o } = opened;
|
|
1240
|
+
const d = target.data;
|
|
1241
|
+
if (target.state === "answered") {
|
|
1242
|
+
throw new PointsWriteError("record-closed", `${opts.id} is answered, so it is not withdrawn: people take the answer back with \`points retract ${opts.id}\``);
|
|
1243
|
+
}
|
|
1244
|
+
if (d.withdrawn !== undefined) throw new PointsWriteError("record-closed", `${opts.id} is withdrawn already`);
|
|
1245
|
+
if (!answerFields(o.loaded.schema).withdrawn) {
|
|
1246
|
+
throw new PointsWriteError("answer-field-unsupported", `the ${o.loaded.kind.name} kind's schema has no withdrawn field, so ${opts.id} can't record that it no longer applies: copy point-answer.schema.json from @intentius/chant anew`);
|
|
1247
|
+
}
|
|
1248
|
+
const name = String(d.point);
|
|
1249
|
+
const point = pointOf(opened.points, name);
|
|
1250
|
+
if (!point) throw new PointsWriteError("point-unknown", `${opts.id} asks ${name}, which ${opened.pointsFile} no longer declares`);
|
|
1251
|
+
const on = opts.on ?? today();
|
|
1252
|
+
const note = noteOf(opts.note);
|
|
1253
|
+
const subject = Array.isArray(d.constrains) && typeof d.constrains[0] === "string" ? (d.constrains[0] as string) : undefined;
|
|
1254
|
+
const asked = askedOf(d);
|
|
1255
|
+
const title = titleFor(point, subject, "withdrawn", undefined, asked);
|
|
1256
|
+
const data = recordData(o, { ...d, title, withdrawn: Object.fromEntries(Object.entries({ by, on, note }).filter(([, v]) => v !== undefined)) });
|
|
1257
|
+
const text = renderRecord(data, body(point, title, asked));
|
|
1258
|
+
const onLedger = ledger.byPath.has(target.path);
|
|
1259
|
+
const message = `Decision point ${name}: withdrawn by ${by.join(", ")}${subject ? ` (${subject})` : ""}`;
|
|
1260
|
+
const wrote = await write(o, before, target.path, text, false, !!opts.dryRun, { source: found.read.source, ledger, onLedger, message, who: { verb: "points withdraw", by: by.join(", ") } });
|
|
1261
|
+
const entry: RecordEntry = { ...target, data, valid: true, reasons: [], warnings: wrote.warnings };
|
|
1262
|
+
return {
|
|
1263
|
+
$schema: POINTS_WRITE_SCHEMA_ID,
|
|
1264
|
+
contract: POINTS_WRITE_CONTRACT_VERSION,
|
|
1265
|
+
verb: "withdraw",
|
|
1266
|
+
kind: o.view,
|
|
1267
|
+
id: opts.id,
|
|
1268
|
+
path: target.path,
|
|
1269
|
+
reused: false,
|
|
1270
|
+
written: !opts.dryRun,
|
|
1271
|
+
dryRun: !!opts.dryRun,
|
|
1272
|
+
question: questionView(entry, point, onLedger ? (wrote.ledger ?? ledger.byPath.get(target.path)?.ledger ?? null) : null)!,
|
|
1273
|
+
...(opts.dryRun ? { text } : {}),
|
|
1274
|
+
};
|
|
1275
|
+
} catch (err) {
|
|
1276
|
+
return failure("withdraw", err);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
@@ -15,7 +15,7 @@ import { existsSync, readdirSync, readFileSync, rmSync, utimesSync, writeFileSyn
|
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { afterAll, describe, expect, test } from "vitest";
|
|
17
17
|
import { cleanScratch, commitAll, contract, declaration, declaration as declaration_, FAKE_FILE_GRAPH_CHANT, FAKE_GRAPH_CHANT, git, REPO, repo, scratchDir, validSchema } from "./__fixtures__/contract-repo";
|
|
18
|
-
import type { GraphIR } from "../graph-ir";
|
|
18
|
+
import type { GraphIR, IREdgeKind } from "../graph-ir";
|
|
19
19
|
import { composeWorkspaceGraph, MEMBER_RUN_REASON_CODES } from "./compose-graph";
|
|
20
20
|
import { parseDeclaration, WORKSPACE_ERROR_CODES } from "./declaration";
|
|
21
21
|
import type { LinkTableRow } from "./links";
|
|
@@ -106,6 +106,31 @@ describe("the links section (#2539)", () => {
|
|
|
106
106
|
});
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
describe("CI edges, pipelines and dangling references (#3922)", () => {
|
|
110
|
+
test("the edge kinds match IREdgeKind, and a composed CI member validates", () => {
|
|
111
|
+
const kinds: IREdgeKind[] = ["ref", "needs", "calls", "triggers", "includes", "extends"];
|
|
112
|
+
expect(schema.$defs.edge.properties.kind.enum).toEqual(kinds);
|
|
113
|
+
const ir: GraphIR = {
|
|
114
|
+
version: 1,
|
|
115
|
+
nodes: [
|
|
116
|
+
{ id: "ci", kind: "Workflow", lexicon: "github", attrs: {} },
|
|
117
|
+
{ id: "build", kind: "Job", lexicon: "github", attrs: {} },
|
|
118
|
+
{ id: "deploy", kind: "Job", lexicon: "github", attrs: {} },
|
|
119
|
+
],
|
|
120
|
+
edges: [
|
|
121
|
+
{ from: "deploy", to: "build", kind: "needs", viaAttr: "needs" },
|
|
122
|
+
{ from: "build", to: "ci", kind: "calls", viaAttr: "uses", crossPipeline: true },
|
|
123
|
+
],
|
|
124
|
+
groups: { byPipeline: { ci: ["build", "ci", "deploy"] } },
|
|
125
|
+
dangling: [{ from: "deploy", kind: "needs", path: "needs", value: "lint" }, { from: "build", kind: "calls", path: "uses", value: "o/r/.github/workflows/x.yml@v1", crossPipeline: true }],
|
|
126
|
+
};
|
|
127
|
+
const m = { name: "svc", dir: "svc", kind: "chant", status: "composed" as const, reason: null, chant: "0.124.20", irVersion: 1, live: false };
|
|
128
|
+
const graph = composeWorkspaceGraph({ name: "acme", root: "." }, [{ member: m, ir }]);
|
|
129
|
+
expect(graph.dangling).toHaveLength(2);
|
|
130
|
+
expectValid({ $schema: GRAPH_OUTPUT_SCHEMA_ID, contract: 1, chant: "0.124.20", at: null, ...graph });
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
109
134
|
describe("the collectors section (#2559)", () => {
|
|
110
135
|
const topology = {
|
|
111
136
|
pipelines: [{ id: "traces", signal: "traces", receivers: ["otlp"], processors: [], exporters: ["otlphttp"] }],
|
|
@@ -93,6 +93,13 @@
|
|
|
93
93
|
"$ref": "#/$defs/edge"
|
|
94
94
|
}
|
|
95
95
|
},
|
|
96
|
+
"dangling": {
|
|
97
|
+
"description": "Added in contract 1 by #3922. The members' references whose target is no node of their graph (#3910): a job `needs` a job no workflow declares, a `uses:` names another repository's workflow, a GitLab `include:` names a file chant did not generate. Each member's `ir.dangling` entry with `from` prefixed by its member and a `member` field, sorted by `from`, `path` and `value`. Absent when no member has one.",
|
|
98
|
+
"type": "array",
|
|
99
|
+
"items": {
|
|
100
|
+
"$ref": "#/$defs/dangling"
|
|
101
|
+
}
|
|
102
|
+
},
|
|
96
103
|
"groups": {
|
|
97
104
|
"type": "object",
|
|
98
105
|
"required": [
|
|
@@ -158,6 +165,16 @@
|
|
|
158
165
|
}
|
|
159
166
|
},
|
|
160
167
|
"description": "Keys prefixed with the member."
|
|
168
|
+
},
|
|
169
|
+
"byPipeline": {
|
|
170
|
+
"type": "object",
|
|
171
|
+
"additionalProperties": {
|
|
172
|
+
"type": "array",
|
|
173
|
+
"items": {
|
|
174
|
+
"type": "string"
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
"description": "Added in contract 1 by #3922. CI pipeline membership (#3910): a workflow or pipeline the member's CI lexicon reports, with the node ids it runs. Keys prefixed with the member, like byStack. Edges with `crossPipeline` run between these groups."
|
|
161
178
|
}
|
|
162
179
|
}
|
|
163
180
|
},
|
|
@@ -523,7 +540,15 @@
|
|
|
523
540
|
"type": "string"
|
|
524
541
|
},
|
|
525
542
|
"kind": {
|
|
526
|
-
"
|
|
543
|
+
"enum": [
|
|
544
|
+
"ref",
|
|
545
|
+
"needs",
|
|
546
|
+
"calls",
|
|
547
|
+
"triggers",
|
|
548
|
+
"includes",
|
|
549
|
+
"extends"
|
|
550
|
+
],
|
|
551
|
+
"description": "`ref` for an edge from a typed reference. The CI kinds (#3910): `needs` (a job waits on another), `calls` (a reusable workflow or action is used), `triggers` (a pipeline starts another), `includes` (a file is pulled in), `extends` (a GitLab job inherits another). The list grows within contract 1; a reader ignores a kind it does not know."
|
|
527
552
|
},
|
|
528
553
|
"viaAttr": {
|
|
529
554
|
"type": "string"
|
|
@@ -531,11 +556,53 @@
|
|
|
531
556
|
"toAttr": {
|
|
532
557
|
"type": "string"
|
|
533
558
|
},
|
|
559
|
+
"crossPipeline": {
|
|
560
|
+
"type": "boolean",
|
|
561
|
+
"description": "Added in contract 1 by #3922. True when the edge leaves the pipeline `from` belongs to (#3910): a reusable-workflow call, a `workflow_run` trigger, a GitLab child or multi-project trigger, an include. Absent on every in-pipeline edge."
|
|
562
|
+
},
|
|
534
563
|
"member": {
|
|
535
564
|
"type": "string"
|
|
536
565
|
}
|
|
537
566
|
}
|
|
538
567
|
},
|
|
568
|
+
"dangling": {
|
|
569
|
+
"type": "object",
|
|
570
|
+
"required": [
|
|
571
|
+
"from",
|
|
572
|
+
"kind",
|
|
573
|
+
"path",
|
|
574
|
+
"value",
|
|
575
|
+
"member"
|
|
576
|
+
],
|
|
577
|
+
"properties": {
|
|
578
|
+
"from": {
|
|
579
|
+
"type": "string",
|
|
580
|
+
"description": "The node holding the reference, prefixed with its member."
|
|
581
|
+
},
|
|
582
|
+
"kind": {
|
|
583
|
+
"$ref": "#/$defs/edge/properties/kind"
|
|
584
|
+
},
|
|
585
|
+
"path": {
|
|
586
|
+
"type": "string",
|
|
587
|
+
"description": "Where on `from` it was written, such as `needs`, `uses` or `trigger.include`."
|
|
588
|
+
},
|
|
589
|
+
"value": {
|
|
590
|
+
"type": "string",
|
|
591
|
+
"description": "The reference exactly as written."
|
|
592
|
+
},
|
|
593
|
+
"crossPipeline": {
|
|
594
|
+
"type": "boolean",
|
|
595
|
+
"description": "As on an edge."
|
|
596
|
+
},
|
|
597
|
+
"member": {
|
|
598
|
+
"type": "string"
|
|
599
|
+
},
|
|
600
|
+
"nested": {
|
|
601
|
+
"type": "string",
|
|
602
|
+
"description": "For a member of kind `workspace`: the nested workspace's member the reference was read through, as on a node."
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
},
|
|
539
606
|
"failure": {
|
|
540
607
|
"description": "The declaration could not be read, and nothing was composed. Exit code 1.",
|
|
541
608
|
"type": "object",
|
|
@@ -476,6 +476,27 @@ describe("chant workspace upgrade", () => {
|
|
|
476
476
|
expect(isGovernancePath("src/main.ts")).toBe(false);
|
|
477
477
|
});
|
|
478
478
|
|
|
479
|
+
test("the quorum counts the gates in a workspace member's Op files (#2892)", async () => {
|
|
480
|
+
const op = (overview: string): string =>
|
|
481
|
+
`export default { props: { name: "release", overview: ${JSON.stringify(overview)}, phases: [{ name: "Ship", steps: [{ kind: "gate", name: "ship", approval: { quorum: { count: 2, roles: ["maintainer"] } } }] }] } };\n`;
|
|
482
|
+
// A template whose Op lives in a member, and a project made from it.
|
|
483
|
+
release("v1.1.0", {
|
|
484
|
+
"chant.workspace.json": JSON.stringify({ name: "proj", schema: 1, members: [{ name: "delivery", dir: "delivery", kind: "chant" }] }, null, 2) + "\n",
|
|
485
|
+
"delivery/chant.config.ts": "export default {};\n",
|
|
486
|
+
"delivery/ops/release.op.ts": op("v1"),
|
|
487
|
+
});
|
|
488
|
+
proj = join(root, "members");
|
|
489
|
+
expect((await initFromCommand({ from: `${tpl}@v1.1.0`, path: proj })).error).toBeUndefined();
|
|
490
|
+
git(proj, ["init", "-q", "-b", "main"]);
|
|
491
|
+
commitProject("init from v1.1");
|
|
492
|
+
release("v2.0.0", { "delivery/ops/release.op.ts": op("v2") });
|
|
493
|
+
const port = ledger();
|
|
494
|
+
const result = await upgradeCommand({ root: proj, to: "v2.0.0", runChant: passing, ledger: port, now: "2026-09-23T10:00:00.000Z" });
|
|
495
|
+
expect(result.outcome).toBe("gated");
|
|
496
|
+
expect(result.staged!.governance).toMatchObject({ paths: ["delivery/ops/release.op.ts"], approval: { mode: "log-only", quorum: { count: 2, roles: ["maintainer"] } } });
|
|
497
|
+
expect(port.pending[0].approval).toEqual({ mode: "log-only", quorum: { count: 2, roles: ["maintainer"] } });
|
|
498
|
+
});
|
|
499
|
+
|
|
479
500
|
test("refuses a scope with uncommitted changes, and a scope that does not exist", async () => {
|
|
480
501
|
put(proj, "README.md", "dirty\n");
|
|
481
502
|
await expect(stageUpgrade({ root: proj, to: "v1.0.0" })).rejects.toThrow(/uncommitted changes/);
|
|
@@ -571,22 +571,30 @@ export function isGovernancePath(path: string): boolean {
|
|
|
571
571
|
* strictest gate declared at HEAD in any Op file the patch changes, at least
|
|
572
572
|
* one. The Op files are read from the project's tree, which the upgrade
|
|
573
573
|
* requires to match HEAD for every path the patch touches.
|
|
574
|
+
*
|
|
575
|
+
* Discovery runs from each changed Op file's own directory, so it finds the
|
|
576
|
+
* file under the project that owns it. Run from a workspace root, it would
|
|
577
|
+
* skip every member's Op files (#2892).
|
|
574
578
|
*/
|
|
575
|
-
async function rulesAtHead(
|
|
579
|
+
async function rulesAtHead(repo: string, paths: string[]): Promise<ResolvedGateApproval> {
|
|
576
580
|
const opFiles = new Set(paths.filter((p) => GOVERNANCE[0].test(p)));
|
|
577
581
|
let count = 1;
|
|
578
582
|
let roles: string[] | undefined;
|
|
579
583
|
if (opFiles.size > 0) {
|
|
580
584
|
const { discoverOps } = await import("../op/discover");
|
|
581
|
-
const
|
|
582
|
-
for (const
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
for (const
|
|
586
|
-
const
|
|
587
|
-
if (
|
|
588
|
-
|
|
589
|
-
|
|
585
|
+
const dirs = new Set([...opFiles].map((p) => dirname(join(repo, p))));
|
|
586
|
+
for (const dir of dirs) {
|
|
587
|
+
if (!existsSync(dir)) continue;
|
|
588
|
+
const { ops } = await discoverOps({ cwd: dir });
|
|
589
|
+
for (const { config, filePath } of ops.values()) {
|
|
590
|
+
const rel = relative(repo, filePath).split(sep).join("/");
|
|
591
|
+
if (!opFiles.has(rel)) continue;
|
|
592
|
+
for (const approval of gateApprovals(config)) {
|
|
593
|
+
const need = approval.quorum?.count ?? 1;
|
|
594
|
+
if (need > count) {
|
|
595
|
+
count = need;
|
|
596
|
+
roles = approval.quorum?.roles;
|
|
597
|
+
}
|
|
590
598
|
}
|
|
591
599
|
}
|
|
592
600
|
}
|
|
@@ -854,7 +862,7 @@ export async function stageUpgrade(options: UpgradeOptions): Promise<StagedUpgra
|
|
|
854
862
|
if (touched.length > 0) {
|
|
855
863
|
throw new UpgradeError(`the upgrade changes governance files that have uncommitted edits (${touched.map((l) => l.slice(3)).join(", ")}). Commit them first: their gates are read at HEAD.`);
|
|
856
864
|
}
|
|
857
|
-
governance = { paths: governed, approval: await rulesAtHead(
|
|
865
|
+
governance = { paths: governed, approval: await rulesAtHead(repo, governed), rules: head };
|
|
858
866
|
}
|
|
859
867
|
|
|
860
868
|
// 5. Checks.
|
|
@@ -97,6 +97,20 @@ describe("foldNested", () => {
|
|
|
97
97
|
});
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
+
describe("foldNested with CI pipelines and dangling references (#3922)", () => {
|
|
101
|
+
test("prefixes byPipeline and dangling with the outer member, and keeps the nested member on each reference", () => {
|
|
102
|
+
const outer = composeWorkspaceGraph({ name: "outer", root: "." }, [
|
|
103
|
+
{ member: member("platform", { kind: "workspace", status: "skipped", reason: { code: "kind-not-run", message: "nested" }, chant: null, irVersion: null }) },
|
|
104
|
+
]);
|
|
105
|
+
const inner = innerGraph();
|
|
106
|
+
inner.groups.byPipeline = { "api/ci": ["api/Queue"] };
|
|
107
|
+
inner.dangling = [{ from: "api/Queue", kind: "needs", path: "needs", value: "lint", member: "api" }];
|
|
108
|
+
foldNested(outer, outer.members[0], inner);
|
|
109
|
+
expect(outer.groups.byPipeline).toEqual({ "platform/api/ci": ["platform/api/Queue"] });
|
|
110
|
+
expect(outer.dangling).toEqual([{ from: "platform/api/Queue", kind: "needs", path: "needs", value: "lint", member: "platform", nested: "api" }]);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
100
114
|
describe("the outer workspace never writes inside a nested one", () => {
|
|
101
115
|
let root: string;
|
|
102
116
|
afterEach(() => rmSync(root, { recursive: true, force: true }));
|