@intentius/chant 0.93.0 → 0.94.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/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-plugins.d.ts +6 -6
- package/dist/workspace/box-intent.d.ts +85 -0
- package/dist/workspace/box-intent.d.ts.map +1 -0
- package/dist/workspace/chant-migrations.d.ts +5 -0
- package/dist/workspace/chant-migrations.d.ts.map +1 -1
- package/dist/workspace/checks/boxes.d.ts +14 -1
- package/dist/workspace/checks/boxes.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +4 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/decision-points.schema.json +3 -3
- package/dist/workspace/declaration.d.ts +6 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +6 -1
- package/dist/workspace/intent-joins.d.ts +5 -5
- package/dist/workspace/points-cli.d.ts +2 -0
- package/dist/workspace/points-cli.d.ts.map +1 -1
- package/dist/workspace/points.d.ts +3 -5
- package/dist/workspace/points.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +2 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/status-stewards.d.ts +8 -2
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/dist/workspace/status.d.ts +8 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/main.ts +4 -3
- package/src/cli/mcp/workspace-plugins.ts +6 -6
- package/src/cli/serve-mcp-workspace.test.ts +6 -6
- package/src/op/activities/decide.test.ts +8 -0
- package/src/op/steward-points.test.ts +51 -0
- package/src/workspace/box-intent.test.ts +205 -0
- package/src/workspace/box-intent.ts +159 -0
- package/src/workspace/chant-migrations.ts +5 -0
- package/src/workspace/check.schema.json +7 -3
- package/src/workspace/checks/boxes.test.ts +2 -1
- package/src/workspace/checks/boxes.ts +66 -0
- package/src/workspace/checks.ts +12 -1
- package/src/workspace/decision-points.schema.json +3 -3
- package/src/workspace/declaration.schema.json +6 -1
- package/src/workspace/declaration.ts +8 -0
- package/src/workspace/declared-kinds.test.ts +26 -0
- package/src/workspace/intent-joins.test.ts +5 -5
- package/src/workspace/intent-joins.ts +5 -5
- package/src/workspace/points-cli.ts +3 -0
- package/src/workspace/points.schema.json +18 -0
- package/src/workspace/points.test.ts +15 -14
- package/src/workspace/points.ts +4 -16
- package/src/workspace/read-contract.test.ts +8 -0
- package/src/workspace/reason-codes.test.ts +7 -7
- package/src/workspace/reason-codes.ts +3 -0
- package/src/workspace/records-cli.ts +14 -1
- package/src/workspace/records-formats.test.ts +15 -15
- package/src/workspace/records-since.test.ts +8 -7
- package/src/workspace/records.test.ts +1 -1
- package/src/workspace/status-contract.test.ts +1 -0
- package/src/workspace/status-stewards.ts +49 -3
- package/src/workspace/status.schema.json +19 -2
- package/src/workspace/status.ts +10 -0
- package/src/workspace/{work-readiness-chud.test.ts → work-readiness.test.ts} +30 -32
- package/src/workspace/work.test.ts +17 -0
|
@@ -37,11 +37,23 @@
|
|
|
37
37
|
* imports `Box` from `@intentius/chant-lexicon-fountain` and calls it, read as
|
|
38
38
|
* syntax and never run. Upstream, managoat/fountain#2497 asks for a way to run
|
|
39
39
|
* a persistent agent with no callback token.
|
|
40
|
+
*
|
|
41
|
+
* WSP126 (`box-intent-unknown`) and WSP127 (`box-intent-unconstrained`), #2850,
|
|
42
|
+
* read the decision record a box block names as its intent (`box-intent.ts`).
|
|
43
|
+
* WSP126 fails when no record of a declared kind named decision has the id.
|
|
44
|
+
* WSP127 warns when the record's constrains names no member or path of this
|
|
45
|
+
* workspace at all: no `member:` entry for a declared member and no `path:`
|
|
46
|
+
* entry at, above or inside one's directory. It need not reach the box's own
|
|
47
|
+
* member: the decision an intent names can constrain the member whose app the
|
|
48
|
+
* box runs, on a workspace where the box block sits on a different member,
|
|
49
|
+
* the box's steward (studio's template, #2857). Both read the working
|
|
50
|
+
* tree's records, so neither runs under `--at`.
|
|
40
51
|
*/
|
|
41
52
|
|
|
42
53
|
import * as ts from "typescript";
|
|
43
54
|
import type { WorkspaceCheck, WorkspaceCheckContext, WorkspaceDiagnostic } from "../checks";
|
|
44
55
|
import type { Member } from "../declaration";
|
|
56
|
+
import { constrainsWorkspace } from "../box-intent";
|
|
45
57
|
import type { ReasonCode } from "../reason-codes";
|
|
46
58
|
import { joinPath, skippedDir, type WorkspaceTree } from "../tree";
|
|
47
59
|
import { BOX_ISOLATION_CHECKS } from "./box-isolation";
|
|
@@ -53,11 +65,15 @@ export const BOX_FINDING_CODES = [
|
|
|
53
65
|
"box-isolation-collision",
|
|
54
66
|
"box-isolation-literal",
|
|
55
67
|
"box-fountain-callback-undeclared",
|
|
68
|
+
"box-intent-unknown",
|
|
69
|
+
"box-intent-unconstrained",
|
|
56
70
|
] as const satisfies readonly ReasonCode[];
|
|
57
71
|
|
|
58
72
|
export const WSP_BOX_CREDENTIAL = "WSP121";
|
|
59
73
|
export const WSP_BOX_UNBROKERED = "WSP122";
|
|
60
74
|
export const WSP_BOX_FOUNTAIN_CALLBACK = "WSP125";
|
|
75
|
+
export const WSP_BOX_INTENT_UNKNOWN = "WSP126";
|
|
76
|
+
export const WSP_BOX_INTENT_UNCONSTRAINED = "WSP127";
|
|
61
77
|
|
|
62
78
|
/** The fountain lexicon's package, whose `Box` composite runs a persistent sandbox. */
|
|
63
79
|
const FOUNTAIN_LEXICON = "@intentius/chant-lexicon-fountain";
|
|
@@ -439,4 +455,54 @@ export const BOX_CHECKS: readonly WorkspaceCheck[] = [
|
|
|
439
455
|
return out;
|
|
440
456
|
},
|
|
441
457
|
},
|
|
458
|
+
{
|
|
459
|
+
id: WSP_BOX_INTENT_UNKNOWN,
|
|
460
|
+
name: "box-intent-unknown",
|
|
461
|
+
description: "The intent a box block names is the id of a decision record: a record of a declared kind named decision.",
|
|
462
|
+
severity: "error",
|
|
463
|
+
configurable: true,
|
|
464
|
+
check(ctx) {
|
|
465
|
+
const out: WorkspaceDiagnostic[] = [];
|
|
466
|
+
for (const i of ctx.facts?.boxIntents ?? []) {
|
|
467
|
+
if (i.record) continue;
|
|
468
|
+
out.push({
|
|
469
|
+
checkId: this.id,
|
|
470
|
+
severity: this.severity,
|
|
471
|
+
code: "box-intent-unknown",
|
|
472
|
+
message: `box-intent-unknown: member ${i.member}'s box names the intent ${i.id}, and ${i.why}; propose the decision with chant workspace records new, or fix the id`,
|
|
473
|
+
entity: i.member,
|
|
474
|
+
pointer: i.pointer,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
return out;
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
id: WSP_BOX_INTENT_UNCONSTRAINED,
|
|
482
|
+
name: "box-intent-unconstrained",
|
|
483
|
+
description:
|
|
484
|
+
"The decision record a box names as its intent constrains a member or path of this workspace: member:<a declared member's name>, or a path: entry at, above or inside a declared member's directory. It need not be the box's own member: a box one member runs can be what a decision about another member constrains.",
|
|
485
|
+
severity: "warning",
|
|
486
|
+
configurable: true,
|
|
487
|
+
check(ctx) {
|
|
488
|
+
const out: WorkspaceDiagnostic[] = [];
|
|
489
|
+
const members = ctx.declaration.members;
|
|
490
|
+
for (const i of ctx.facts?.boxIntents ?? []) {
|
|
491
|
+
if (!i.record) continue;
|
|
492
|
+
if (i.record.constrains.some((c) => constrainsWorkspace(c, members))) continue;
|
|
493
|
+
out.push({
|
|
494
|
+
checkId: this.id,
|
|
495
|
+
severity: this.severity,
|
|
496
|
+
code: "box-intent-unconstrained",
|
|
497
|
+
message:
|
|
498
|
+
`box-intent-unconstrained: member ${i.member}'s box names the intent ${i.id} (${i.record.path}), whose constrains ` +
|
|
499
|
+
(i.record.constrains.length === 0 ? "is empty" : `names ${i.record.constrains.join(", ")}`) +
|
|
500
|
+
`, and none of it is a member or path of this workspace; add member:<name> for the member the intent is about, or a path: entry at, above or inside a member's directory`,
|
|
501
|
+
entity: i.member,
|
|
502
|
+
pointer: i.pointer,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
return out;
|
|
506
|
+
},
|
|
507
|
+
},
|
|
442
508
|
];
|
package/src/workspace/checks.ts
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
* | WSP121, WSP122 | boxes: no literal credential, every capability brokered (#2726) |
|
|
34
34
|
* | WSP123, WSP124 | box isolation: no shared port, state path or cookie, no literal machine path (#2727) |
|
|
35
35
|
* | WSP125 | boxes: a fountain Box declares the callback token fountain gives its sandbox (#2780) |
|
|
36
|
+
* | WSP126, WSP127 | box intent: the decision record a box names exists, and constrains a member or path of this workspace (#2850, #2857) |
|
|
36
37
|
* | WSP131 to WSP133 | diagram artifacts: source and render exist, a recorded source hash still matches (#2764) |
|
|
37
38
|
*/
|
|
38
39
|
|
|
@@ -54,6 +55,7 @@ import { RECORD_CHECKS, type RecordFacts } from "./checks/records";
|
|
|
54
55
|
import { BOX_CHECKS } from "./checks/boxes";
|
|
55
56
|
import { DIAGRAM_CHECKS } from "./checks/diagrams";
|
|
56
57
|
import { loadDeclaredKinds, type DeclaredKind } from "./declared-kinds";
|
|
58
|
+
import { resolveBoxIntents, type ResolvedBoxIntent } from "./box-intent";
|
|
57
59
|
|
|
58
60
|
/**
|
|
59
61
|
* What the checks beyond the declaration read, gathered from the checkout
|
|
@@ -71,6 +73,8 @@ export interface WorkspaceFacts {
|
|
|
71
73
|
records?: RecordFacts;
|
|
72
74
|
/** The record kinds the declaration names, each loaded, or only looked for under `--at` (#2680). */
|
|
73
75
|
declaredKinds?: readonly DeclaredKind[];
|
|
76
|
+
/** The decision record each box's intent names, read from the working tree (#2850). */
|
|
77
|
+
boxIntents?: readonly ResolvedBoxIntent[];
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
/** What every declaration check reads. */
|
|
@@ -469,7 +473,14 @@ export async function runDeclarationChecks(
|
|
|
469
473
|
// The declared record kinds load from the working tree; under --at only whether each exists at the revision is checked (#2680).
|
|
470
474
|
// A declared work kind's acceptance criteria are counted in the working tree too (#2772).
|
|
471
475
|
const declaredKinds = options.gather === false ? undefined : await loadDeclaredKinds(declaration, tree, root, { load: !options.tree, acceptance: !options.tree });
|
|
472
|
-
|
|
476
|
+
// A box's intent is read from the working tree's records, so not under --at (#2850).
|
|
477
|
+
const boxIntents = declaredKinds && !options.tree ? await resolveBoxIntents(declaration, root, declaredKinds) : undefined;
|
|
478
|
+
const facts: WorkspaceFacts = {
|
|
479
|
+
...gathered,
|
|
480
|
+
...(options.records ? { records: options.records } : {}),
|
|
481
|
+
...(declaredKinds ? { declaredKinds } : {}),
|
|
482
|
+
...(boxIntents ? { boxIntents } : {}),
|
|
483
|
+
};
|
|
473
484
|
const ctx: WorkspaceCheckContext = { declaration, tree, groups, kinds: registry, kindProblems: problems, facts };
|
|
474
485
|
const findings = runWorkspaceChecks(ctx);
|
|
475
486
|
const { active, suppressed } = applyCheckSettings(declaration, findings);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://intentius.io/chant/schemas/workspace/decision-points/v1/decision-points.schema.json",
|
|
4
4
|
"title": "Decision points",
|
|
5
|
-
"description": "A workspace's decision points (ws-058, #2738): the recurring questions it asks of its own graph, declared as data in a JSON file that an answer record kind names in `answers.points`. Each point is a typed question (`noul`, `choice` with 2 to 255 options, or `score` with 2 to 10 ordered levels, the question types of the POST /v1/systemone wire format, #2491), the inputs it reads, each named as a read-contract output, and an ordered chain of deciders: `table` rows, `model` deciders with a backend, a pinned model id and a threshold, and a `quorum` of people, always last.
|
|
5
|
+
"description": "A workspace's decision points (ws-058, #2738): the recurring questions it asks of its own graph, declared as data in a JSON file that an answer record kind names in `answers.points`. Each point is a typed question (`noul`, `choice` with 2 to 255 options, or `score` with 2 to 10 ordered levels, the question types of the POST /v1/systemone wire format, #2491), the inputs it reads, each named as a read-contract output, and an ordered chain of deciders: `table` rows, `model` deciders with a backend, a pinned model id and a threshold, and a `quorum` of people, always last. What JSON Schema cannot say is checked in code (`points.ts`): an input's output is one the read contract has, a table row tests declared inputs and answers a candidate, a field belongs to its decider's kind, a model id is pinned rather than an alias, and the chain ends in its one quorum. Unknown fields are refused, except fields whose names start with x-.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"required": ["points"],
|
|
8
8
|
"properties": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"type": "object",
|
|
45
45
|
"required": ["type", "instructions", "criteria"],
|
|
46
46
|
"properties": {
|
|
47
|
-
"type": { "enum": ["noul", "
|
|
47
|
+
"type": { "enum": ["noul", "choice", "score"] },
|
|
48
48
|
"instructions": { "$ref": "#/$defs/text" },
|
|
49
49
|
"criteria": {
|
|
50
50
|
"description": "The candidates. noul: {true, false} descriptions. choice: option to description, 2 to 255 options. score: 2 to 10 ordered levels.",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"additionalProperties": false,
|
|
55
55
|
"allOf": [
|
|
56
56
|
{
|
|
57
|
-
"if": { "properties": { "type": { "
|
|
57
|
+
"if": { "properties": { "type": { "const": "noul" } } },
|
|
58
58
|
"then": {
|
|
59
59
|
"properties": {
|
|
60
60
|
"criteria": {
|
|
@@ -402,8 +402,13 @@
|
|
|
402
402
|
},
|
|
403
403
|
"box": {
|
|
404
404
|
"type": "object",
|
|
405
|
-
"description": "The member is a box, or holds a box's declarations (#2726). A box holds no credential: each capability it needs outside itself is reached through a broker, a runtime such as a lobby or a door, which holds the credential and enforces the scope. chant workspace check fails when a file in the member's directory carries a literal secret (WSP121) and when a capability names no broker (WSP122). chant workspace status --json lists the capabilities, so a broker can read the scopes. Its host, slot, ports, state and cookies state the box's isolation (#2727): chant derives each value from the box's identity, its host, the member's name and its slot, and prints them in chant workspace status --json, so a runtime reads them instead of choosing its own. chant workspace check fails when two boxes on a host resolve to the same value (WSP123) and when a box hard-codes a machine path (WSP124). A member that builds the fountain lexicon's Box declares the callback token fountain gives its persistent sandbox as the capability fountain-callback, brokered by fountain, with scope owner, or chant workspace check fails (WSP125, #2780). Added in schema 1 by chant 0.91.0, so a declaration that uses it sets minReader to 0.91.0 or newer.",
|
|
405
|
+
"description": "The member is a box, or holds a box's declarations (#2726). A box holds no credential: each capability it needs outside itself is reached through a broker, a runtime such as a lobby or a door, which holds the credential and enforces the scope. chant workspace check fails when a file in the member's directory carries a literal secret (WSP121) and when a capability names no broker (WSP122). chant workspace status --json lists the capabilities, so a broker can read the scopes. Its host, slot, ports, state and cookies state the box's isolation (#2727): chant derives each value from the box's identity, its host, the member's name and its slot, and prints them in chant workspace status --json, so a runtime reads them instead of choosing its own. chant workspace check fails when two boxes on a host resolve to the same value (WSP123) and when a box hard-codes a machine path (WSP124). A member that builds the fountain lexicon's Box declares the callback token fountain gives its persistent sandbox as the capability fountain-callback, brokered by fountain, with scope owner, or chant workspace check fails (WSP125, #2780). Its intent names the decision record that says what the box is for (#2850): chant workspace status --json reports the record's state and answer, and chant workspace check fails when no decision record has the id (WSP126) and warns when the record constrains no member or path of this workspace (WSP127, #2857). Added in schema 1 by chant 0.91.0, so a declaration that uses it sets minReader to 0.91.0 or newer.",
|
|
406
406
|
"properties": {
|
|
407
|
+
"intent": {
|
|
408
|
+
"type": "string",
|
|
409
|
+
"minLength": 1,
|
|
410
|
+
"description": "The id of the decision record that says what the box is for (#2850), a record of a declared kind named decision. A new box starts as a question: the record is proposed with a null choice until the person who answers it decides it. The record constrains a member or path of this workspace, with member:<a member's name> or a path: entry at, above or inside a member's directory. It need not be this member: the intent can constrain the app the box runs when that app is another member (#2857). When it constrains this member, chant workspace graph --intent shows it for the box's files. Added by chant 0.94.0, so a declaration that uses it sets minReader to 0.94.0 or newer."
|
|
411
|
+
},
|
|
407
412
|
"capabilities": {
|
|
408
413
|
"type": "array",
|
|
409
414
|
"description": "Each capability the box reaches outside itself, named once. None when omitted.",
|
|
@@ -210,6 +210,12 @@ export interface BoxDeclaration {
|
|
|
210
210
|
capabilities: BoxCapability[];
|
|
211
211
|
/** The host and slot of the box's isolation, or null when the block declares none (#2727). The values are derived in `boxes.ts`. */
|
|
212
212
|
isolation: BoxIsolationDeclaration | null;
|
|
213
|
+
/**
|
|
214
|
+
* The id of the decision record that says what the box is for, or null when
|
|
215
|
+
* the block names none (#2850). A box starts as a question: the record is
|
|
216
|
+
* proposed with no choice until the person who answers it decides it.
|
|
217
|
+
*/
|
|
218
|
+
intent: string | null;
|
|
213
219
|
/** The block's JSON Pointer in the file, for messages. */
|
|
214
220
|
pointer: string;
|
|
215
221
|
}
|
|
@@ -713,6 +719,7 @@ function boxOf(raw: unknown, pointer: string): BoxDeclaration | null {
|
|
|
713
719
|
ports?: Record<string, number>;
|
|
714
720
|
state?: Record<string, string>;
|
|
715
721
|
cookies?: string[];
|
|
722
|
+
intent?: string;
|
|
716
723
|
};
|
|
717
724
|
return {
|
|
718
725
|
capabilities: (b.capabilities ?? []).map((c, i) => ({ name: c.name, broker: c.broker ?? null, scope: [...(c.scope ?? [])], pointer: `${pointer}/capabilities/${i}` })),
|
|
@@ -721,6 +728,7 @@ function boxOf(raw: unknown, pointer: string): BoxDeclaration | null {
|
|
|
721
728
|
b.host === undefined
|
|
722
729
|
? null
|
|
723
730
|
: { host: b.host, slot: b.slot!, ports: { ...(b.ports ?? {}) }, state: { ...(b.state ?? {}) }, cookies: [...(b.cookies ?? [])] },
|
|
731
|
+
intent: b.intent ?? null,
|
|
724
732
|
pointer,
|
|
725
733
|
};
|
|
726
734
|
}
|
|
@@ -242,6 +242,32 @@ describe("records without --kind reads every declared kind (#2680)", () => {
|
|
|
242
242
|
expect(await run(workspace(), "--current")).toBe(0);
|
|
243
243
|
expect(out.filter((l) => l.includes("("))).toEqual(["decision (decisions/decision.kind.mjs)", "notes (design/notes/note.kind.mjs)"]);
|
|
244
244
|
});
|
|
245
|
+
|
|
246
|
+
test("--at an unknown revision with --json prints the error document, not just text on stderr (#2860)", async () => {
|
|
247
|
+
const root = workspace();
|
|
248
|
+
expect(await run(root, "--at", "refs/heads/no-such-branch", "--json")).toBe(1);
|
|
249
|
+
expect(err).toEqual([]);
|
|
250
|
+
const printed = JSON.parse(out.join("\n"));
|
|
251
|
+
expectValid(printed);
|
|
252
|
+
expect(printed).toEqual({
|
|
253
|
+
$schema: recordsSchema.$id,
|
|
254
|
+
contract: 1,
|
|
255
|
+
error: { code: "revision-unknown", message: expect.stringContaining("refs/heads/no-such-branch") as unknown as string },
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
out.length = 0;
|
|
259
|
+
err.length = 0;
|
|
260
|
+
expect(await run(root, "--at", "refs/heads/no-such-branch")).toBe(1);
|
|
261
|
+
expect(out).toEqual([]);
|
|
262
|
+
expect(err.join("\n")).toContain("revision-unknown");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("a declaration-level code the records schema doesn't carry, such as declaration-invalid, stays text-only even with --json (#2860)", async () => {
|
|
266
|
+
const root = repo({ "chant.workspace.json": decl(members(), { records: [{ kind: "a.kind.mjs", file: "b" }] }), "app/x": "", "design/x": "" });
|
|
267
|
+
expect(await run(root, "--json")).toBe(1);
|
|
268
|
+
expect(out).toEqual([]);
|
|
269
|
+
expect(err.join("\n")).toContain("declaration-invalid");
|
|
270
|
+
});
|
|
245
271
|
});
|
|
246
272
|
|
|
247
273
|
describe("records new, amend and review without --kind (#2680)", () => {
|
|
@@ -36,7 +36,7 @@ describe("the data form reads every trailer value (#2663)", () => {
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
test("trailerValues keeps git's order, trims, and drops empty values and repeats", () => {
|
|
39
|
-
expect(trailerValues({ "
|
|
39
|
+
expect(trailerValues({ "Acme-Evidence": ["h1", "h2"], "acme-evidence": ["h2", " h3", " "] }, "ACME-EVIDENCE")).toEqual(["h1", "h2", "h3"]);
|
|
40
40
|
expect(trailerValues({}, "X")).toEqual([]);
|
|
41
41
|
expect(trailerValue({ X: [" "] }, "x")).toBeUndefined();
|
|
42
42
|
});
|
|
@@ -45,16 +45,16 @@ describe("the data form reads every trailer value (#2663)", () => {
|
|
|
45
45
|
describe("commitJoinsName (#2663)", () => {
|
|
46
46
|
test("names the findings of either form, and leaves the data form's own keys alone", () => {
|
|
47
47
|
const join = () => undefined;
|
|
48
|
-
expect(readCommitJoins({ commitJoins: join, commitJoinsName: "
|
|
49
|
-
expect(readCommitJoins({ commitJoins: { trailers: { unit: "Unit" } }, commitJoinsName: "
|
|
48
|
+
expect(readCommitJoins({ commitJoins: join, commitJoinsName: "acme" })).toEqual({ form: "function", join, name: "acme" });
|
|
49
|
+
expect(readCommitJoins({ commitJoins: { trailers: { unit: "Unit" } }, commitJoinsName: "acme" })).toEqual({ form: "data", data: { trailers: { unit: "Unit" } }, name: "acme" });
|
|
50
50
|
expect(readCommitJoins({ commitJoins: join })).toEqual({ form: "function", join });
|
|
51
51
|
// A name inside the data form is still a key the data form does not have.
|
|
52
|
-
expect(readCommitJoins({ commitJoins: { name: "
|
|
52
|
+
expect(readCommitJoins({ commitJoins: { name: "acme", trailers: {} } })).toMatch(/Unrecognized key/);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
test("a name that can't be a plugin:<name>: segment, or one with no joins, is refused", () => {
|
|
56
56
|
for (const bad of ["a:b", "a b", "", 7]) expect(readCommitJoins({ commitJoins: () => undefined, commitJoinsName: bad }), String(bad)).toMatch(/^commitJoinsName:/);
|
|
57
|
-
expect(readCommitJoins({ commitJoinsName: "
|
|
57
|
+
expect(readCommitJoins({ commitJoinsName: "acme" })).toMatch(/no commitJoins/);
|
|
58
58
|
expect(readCommitJoins({})).toBeUndefined();
|
|
59
59
|
});
|
|
60
60
|
});
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* The commit-join hook of the intent graph (#2651; #2650 C1 and C13).
|
|
3
3
|
*
|
|
4
4
|
* Commits, decisions and artifacts come from core. Units of work, contracts
|
|
5
|
-
* and evidence come from a plugin, such as
|
|
6
|
-
* core ships no model of them (#2555, "Core ships no decision
|
|
7
|
-
* file passed to `chant workspace graph --intent --kind <file>`
|
|
8
|
-
* through one export, `commitJoins`, in one of two forms:
|
|
5
|
+
* and evidence come from a plugin, such as a workspace's own development
|
|
6
|
+
* model, because core ships no model of them (#2555, "Core ships no decision
|
|
7
|
+
* kind"). A kind file passed to `chant workspace graph --intent --kind <file>`
|
|
8
|
+
* supplies them through one export, `commitJoins`, in one of two forms:
|
|
9
9
|
*
|
|
10
10
|
* - A function `commitJoins(commit, context)` that returns the unit, contract
|
|
11
11
|
* and evidence for one commit, or nothing. It is given the commit's sha,
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* `<name>` is the kind's name by default: its record kind's name, or the
|
|
37
37
|
* file's name without `.kind.mjs`. A kind file may name its findings itself
|
|
38
38
|
* with a sibling export, `commitJoinsName` (#2663), so joins that live beside
|
|
39
|
-
* a record kind called `decision` can still report `plugin:
|
|
39
|
+
* a record kind called `decision` can still report `plugin:acme:<code>`. It is
|
|
40
40
|
* a sibling export rather than a property because the data form is a closed
|
|
41
41
|
* object whose keys are all joins, and a function's `name` property is
|
|
42
42
|
* already its own name.
|
|
@@ -71,6 +71,8 @@ export interface PointView {
|
|
|
71
71
|
questionType: string;
|
|
72
72
|
instructions: string;
|
|
73
73
|
candidates: (string | boolean)[];
|
|
74
|
+
/** What each candidate means, as the points file declares it: an object of strings for noul and choice, an array for score. */
|
|
75
|
+
criteria: Record<string, string> | string[];
|
|
74
76
|
/** Each input: its name, the read-contract output it names, and its description. */
|
|
75
77
|
inputs: { name: string; output: string; description: string }[];
|
|
76
78
|
deciders: Decider[];
|
|
@@ -163,6 +165,7 @@ export async function workspacePoints(query: PointsQuery): Promise<PointsDocumen
|
|
|
163
165
|
questionType: p.question.type,
|
|
164
166
|
instructions: p.question.instructions,
|
|
165
167
|
candidates: candidates(p.question),
|
|
168
|
+
criteria: p.question.criteria,
|
|
166
169
|
inputs: Object.entries(p.inputs).map(([n, description]) => ({ name: n, output: inputOutput(n), description })),
|
|
167
170
|
deciders: p.deciders,
|
|
168
171
|
quorum: quorumOf(p),
|
|
@@ -191,6 +191,7 @@
|
|
|
191
191
|
"questionType",
|
|
192
192
|
"instructions",
|
|
193
193
|
"candidates",
|
|
194
|
+
"criteria",
|
|
194
195
|
"inputs",
|
|
195
196
|
"deciders",
|
|
196
197
|
"quorum"
|
|
@@ -236,6 +237,23 @@
|
|
|
236
237
|
]
|
|
237
238
|
}
|
|
238
239
|
},
|
|
240
|
+
"criteria": {
|
|
241
|
+
"description": "What each candidate means, as the points file declares it: noul and choice give an object of strings keyed by candidate, score an array of ordered level descriptions.",
|
|
242
|
+
"oneOf": [
|
|
243
|
+
{
|
|
244
|
+
"type": "object",
|
|
245
|
+
"additionalProperties": {
|
|
246
|
+
"type": "string"
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
"type": "array",
|
|
251
|
+
"items": {
|
|
252
|
+
"type": "string"
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
]
|
|
256
|
+
},
|
|
239
257
|
"inputs": {
|
|
240
258
|
"type": "array",
|
|
241
259
|
"items": {
|
|
@@ -24,10 +24,11 @@ import {
|
|
|
24
24
|
} from "./points";
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
28
|
-
* jhgaylor/chud@43afcf1
|
|
27
|
+
* Points shaped like an externally authored template's decisions file
|
|
28
|
+
* (ported from chud, jhgaylor/chud@43afcf1's
|
|
29
|
+
* template/delivery/decisions/points.yaml), as JSON.
|
|
29
30
|
*/
|
|
30
|
-
const
|
|
31
|
+
const IMPORTED = {
|
|
31
32
|
points: {
|
|
32
33
|
"slice-tier": {
|
|
33
34
|
title: "Which builder tier builds this contract",
|
|
@@ -63,7 +64,7 @@ const CHUD = {
|
|
|
63
64
|
"ship-skip": {
|
|
64
65
|
title: "May this release skip the human gate",
|
|
65
66
|
question: {
|
|
66
|
-
type: "
|
|
67
|
+
type: "noul",
|
|
67
68
|
instructions: "May this release pass the ship gate without a person approving it? The state is what the release plan would change.",
|
|
68
69
|
criteria: {
|
|
69
70
|
true: "An agent may pass the gate for this release (only in enforce mode).",
|
|
@@ -86,9 +87,9 @@ const CHUD = {
|
|
|
86
87
|
},
|
|
87
88
|
};
|
|
88
89
|
|
|
89
|
-
/**
|
|
90
|
-
function renamed(): typeof
|
|
91
|
-
const copy = JSON.parse(JSON.stringify(
|
|
90
|
+
/** The imported points with each input named as a read-contract output: the slice tier reads a work item, the ship gate a release. */
|
|
91
|
+
function renamed(): typeof IMPORTED {
|
|
92
|
+
const copy = JSON.parse(JSON.stringify(IMPORTED)) as typeof IMPORTED;
|
|
92
93
|
const prefix: Record<string, string> = { "slice-tier": "work-item", "ship-skip": "release" };
|
|
93
94
|
for (const [name, point] of Object.entries(copy.points) as [string, { inputs: Record<string, string>; deciders: { rows?: { when: Record<string, unknown> }[] }[] }][]) {
|
|
94
95
|
const to = (k: string) => `${prefix[name]}.${k}`;
|
|
@@ -123,14 +124,14 @@ describe("the decision points schema (#2738)", () => {
|
|
|
123
124
|
}
|
|
124
125
|
});
|
|
125
126
|
|
|
126
|
-
test("
|
|
127
|
-
// As
|
|
128
|
-
const found = problems(
|
|
127
|
+
test("the imported slice-tier and ship-skip validate unchanged, apart from the input names", () => {
|
|
128
|
+
// As the template declares them, only the input names are refused: each names no read-contract output.
|
|
129
|
+
const found = problems(IMPORTED);
|
|
129
130
|
expect(found.length).toBeGreaterThan(0);
|
|
130
131
|
for (const p of found) expect(p.message).toMatch(/is not a read-contract output|is not one of this point's inputs/);
|
|
131
132
|
expect(found.filter((p) => p.message.includes("read-contract output")).map((p) => p.field)).toEqual([
|
|
132
|
-
...Object.keys(
|
|
133
|
-
...Object.keys(
|
|
133
|
+
...Object.keys(IMPORTED.points["slice-tier"].inputs).map((k) => `points.slice-tier.inputs.${k}`),
|
|
134
|
+
...Object.keys(IMPORTED.points["ship-skip"].inputs).map((k) => `points.ship-skip.inputs.${k}`),
|
|
134
135
|
]);
|
|
135
136
|
// With the inputs renamed, nothing else changes and both validate.
|
|
136
137
|
const points = parsePoints(text(renamed()), "points.json");
|
|
@@ -156,7 +157,7 @@ describe("the decision points schema (#2738)", () => {
|
|
|
156
157
|
|
|
157
158
|
test("a point with an unknown input is refused, and so is a row testing an undeclared one", () => {
|
|
158
159
|
const v = renamed() as unknown as { points: Record<string, { inputs: Record<string, string>; deciders: { rows?: { when: Record<string, unknown> }[] }[] }> };
|
|
159
|
-
v.points["slice-tier"].inputs["contract.size"] = "
|
|
160
|
+
v.points["slice-tier"].inputs["contract.size"] = "a contract, which the read contract has no output for";
|
|
160
161
|
expect(problems(v)).toEqual([
|
|
161
162
|
{
|
|
162
163
|
field: "points.slice-tier.inputs.contract.size",
|
|
@@ -168,7 +169,7 @@ describe("the decision points schema (#2738)", () => {
|
|
|
168
169
|
expect(problems(w)).toEqual([{ field: "points.slice-tier.deciders.0.rows.0.when.work-item.tier", message: expect.stringContaining("is not one of this point's inputs") }]);
|
|
169
170
|
});
|
|
170
171
|
|
|
171
|
-
test("the schema and the code refuse the rest of what
|
|
172
|
+
test("the schema and the code refuse the rest of what an imported points file can get wrong, and an alias model id", () => {
|
|
172
173
|
const cases: [(p: Record<string, unknown>) => void, RegExp][] = [
|
|
173
174
|
[(p) => ((p.deciders as Record<string, unknown>[])[1].model = "jev-latest"), /is an alias/],
|
|
174
175
|
[(p) => ((p.deciders as Record<string, unknown>[])[1].count = 2), /is only for a quorum decider/],
|
package/src/workspace/points.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* runtime's decider or a test's stub supplies. Without one, a model decider is
|
|
21
21
|
* not asked and the chain moves on.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
23
|
+
* Ported from chud's `packages/runtime/src/decide.mjs` at 43afcf1: the chain,
|
|
24
24
|
* the observation rule, the point version and the inputs hash. The records
|
|
25
25
|
* are chant records: `decide.ts` writes them, and {@link applyAnswers} warns
|
|
26
26
|
* about them on read.
|
|
@@ -71,7 +71,6 @@ export function inputOutput(name: string): string {
|
|
|
71
71
|
export type QuestionType = "noul" | "choice" | "score";
|
|
72
72
|
|
|
73
73
|
export interface Question {
|
|
74
|
-
/** `boolean` in a points file is read as `noul`. */
|
|
75
74
|
type: QuestionType;
|
|
76
75
|
instructions: string;
|
|
77
76
|
criteria: Record<string, string> | string[];
|
|
@@ -220,20 +219,9 @@ export function schemaProblems(data: unknown): PointProblem[] {
|
|
|
220
219
|
return out;
|
|
221
220
|
}
|
|
222
221
|
|
|
223
|
-
/** `boolean` read as `noul`. The declaration is otherwise kept as written. */
|
|
224
|
-
function normalise(points: Record<string, Point>): Record<string, Point> {
|
|
225
|
-
const out: Record<string, Point> = {};
|
|
226
|
-
for (const [name, p] of Object.entries(points)) {
|
|
227
|
-
const type = (p.question.type as string) === "boolean" ? "noul" : p.question.type;
|
|
228
|
-
out[name] = { ...p, question: { ...p.question, type } };
|
|
229
|
-
}
|
|
230
|
-
return out;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
222
|
/**
|
|
234
|
-
* Parse and validate a points file's text. Returns its points
|
|
235
|
-
*
|
|
236
|
-
* each field.
|
|
223
|
+
* Parse and validate a points file's text. Returns its points. Throws a
|
|
224
|
+
* {@link PointsError} naming the file and each field.
|
|
237
225
|
*/
|
|
238
226
|
export function parsePoints(text: string, file: string): Record<string, Point> {
|
|
239
227
|
let data: unknown;
|
|
@@ -244,7 +232,7 @@ export function parsePoints(text: string, file: string): Record<string, Point> {
|
|
|
244
232
|
}
|
|
245
233
|
const problems = schemaProblems(data);
|
|
246
234
|
if (problems.length > 0) throw new PointsError(file, problems);
|
|
247
|
-
const points =
|
|
235
|
+
const points = (data as { points: Record<string, Point> }).points;
|
|
248
236
|
const more = pointProblems(points);
|
|
249
237
|
if (more.length > 0) throw new PointsError(file, more);
|
|
250
238
|
return points;
|
|
@@ -193,6 +193,14 @@ describe("every schema against the reference workspace (#2543)", () => {
|
|
|
193
193
|
expectValid(doc);
|
|
194
194
|
if ("error" in doc) throw new Error(doc.error.message);
|
|
195
195
|
expect(doc.points.map((p) => p.name)).toEqual(["slice-tier", "ship-skip", "finding-triage", "needs-a-decision"]);
|
|
196
|
+
const sliceTier = doc.points.find((p) => p.name === "slice-tier")!;
|
|
197
|
+
expect(sliceTier.criteria).toEqual({
|
|
198
|
+
small: "A haiku-class builder. The work item fits the small limits.",
|
|
199
|
+
medium: "A mid-size builder. The work item fits the medium limits.",
|
|
200
|
+
large: "The largest builder. The work item is bigger than the medium limits.",
|
|
201
|
+
});
|
|
202
|
+
const shipSkip = doc.points.find((p) => p.name === "ship-skip")!;
|
|
203
|
+
expect(shipSkip.criteria).toEqual({ true: "An agent may pass the gate for this release.", false: "A person approves the release at the gate." });
|
|
196
204
|
}
|
|
197
205
|
const open = await workspacePoints({ cwd: FIXTURE, open: true });
|
|
198
206
|
expectValid(open);
|
|
@@ -157,11 +157,11 @@ describe("the closed list of reason codes", () => {
|
|
|
157
157
|
});
|
|
158
158
|
|
|
159
159
|
test("a plugin's finding codes are in its own namespace, outside the list, and the intent schema accepts them (#2656)", () => {
|
|
160
|
-
expect(isPluginCode("plugin:
|
|
161
|
-
expect(isPluginCode("plugin:
|
|
162
|
-
expect(isPluginCode("plugin:
|
|
163
|
-
for (const bad of ["plugin:
|
|
164
|
-
expect(isReasonCode("plugin:
|
|
160
|
+
expect(isPluginCode("plugin:acme:contract-criteria-changed")).toBe(true);
|
|
161
|
+
expect(isPluginCode("plugin:acme:contract-criteria-changed", "acme")).toBe(true);
|
|
162
|
+
expect(isPluginCode("plugin:acme:contract-criteria-changed", "units")).toBe(false);
|
|
163
|
+
for (const bad of ["plugin:acme", "plugin::x", "plugin:acme:Upper", "plugin:acme:a:b", "intent-commit-bare", 7]) expect(isPluginCode(bad), String(bad)).toBe(false);
|
|
164
|
+
expect(isReasonCode("plugin:acme:contract-criteria-changed")).toBe(false);
|
|
165
165
|
const { validate } = contract(intentSchema);
|
|
166
166
|
const finding = (code: string) => ({ id: `finding:${code}:1`, kind: "finding", code, message: "m", concerns: [] });
|
|
167
167
|
const doc = (code: string) => ({
|
|
@@ -178,9 +178,9 @@ describe("the closed list of reason codes", () => {
|
|
|
178
178
|
reasons: [],
|
|
179
179
|
summary: { commits: 0, decisions: 0, artifacts: 0, findings: 1 },
|
|
180
180
|
});
|
|
181
|
-
expect(validate(doc("plugin:
|
|
181
|
+
expect(validate(doc("plugin:acme:contract-criteria-changed"))).toBe(true);
|
|
182
182
|
expect(validate(doc("intent-commit-bare"))).toBe(true);
|
|
183
|
-
expect(validate(doc("plugin:
|
|
183
|
+
expect(validate(doc("plugin:acme:Nope"))).toBe(false);
|
|
184
184
|
expect(validate(doc("made-up-code"))).toBe(false);
|
|
185
185
|
});
|
|
186
186
|
|
|
@@ -192,6 +192,9 @@ export const REASONS = {
|
|
|
192
192
|
"box-capability-unbrokered": "A capability in a member's box block names no broker, so the box would hold its credential.",
|
|
193
193
|
// The credential fountain itself hands a persistent box (check, #2780). Also a WSP finding.
|
|
194
194
|
"box-fountain-callback-undeclared": "A box member builds a fountain Box, whose persistent sandbox fountain gives a callback token scoped to its owner, and the member's box block does not declare the fountain-callback capability brokered by fountain with scope owner.",
|
|
195
|
+
// A box's intent, the decision record its box block names (check, #2850). Each is also a WSP finding.
|
|
196
|
+
"box-intent-unknown": "A box block names an intent, and no record of a declared kind named decision has that id.",
|
|
197
|
+
"box-intent-unconstrained": "The decision record a box names as its intent constrains no member or path of this workspace: no member: entry for a declared member and no path: entry at, above or inside one's directory.",
|
|
195
198
|
// The work lease (chant workspace work claim|renew|release, #2732): why the command could not run.
|
|
196
199
|
"work-kind-missing": "No work kind to find the item in: --kind names a kind with no work block, or the declaration names no work kind.",
|
|
197
200
|
"work-kind-ambiguous": "More than one declared work kind has a record with the id, so --kind must name one.",
|
|
@@ -510,6 +510,14 @@ export async function runWorkspaceRecords(ctx: CommandContext): Promise<number>
|
|
|
510
510
|
* names, or, when it names none or there is no declaration, the error it has
|
|
511
511
|
* always been. A kind whose read fails is listed with its error, the others
|
|
512
512
|
* are still read, and the exit code is 1.
|
|
513
|
+
*
|
|
514
|
+
* Locating the declaration itself can fail before any kind is known. For
|
|
515
|
+
* `not-a-git-repository` and `revision-unknown`, the codes a single kind's
|
|
516
|
+
* read can also fail with (#2860), `--json` prints the same
|
|
517
|
+
* `{ $schema, contract, error: { code, message } }` document that read
|
|
518
|
+
* failure would, so a reader of `--json` never sees a silent exit 1. A
|
|
519
|
+
* declaration-level code the schema doesn't carry (`declaration-invalid` and
|
|
520
|
+
* the rest of `WorkspaceErrorCode`) still prints text on stderr only.
|
|
513
521
|
*/
|
|
514
522
|
async function runDeclaredRecords(args: CommandContext["args"]): Promise<number> {
|
|
515
523
|
if (args.require !== undefined && args.require !== "attested") {
|
|
@@ -521,7 +529,12 @@ async function runDeclaredRecords(args: CommandContext["args"]): Promise<number>
|
|
|
521
529
|
declared = declaredKindFiles(process.cwd(), args.at);
|
|
522
530
|
} catch (err) {
|
|
523
531
|
if (!(err instanceof WorkspaceReadError)) throw err;
|
|
524
|
-
|
|
532
|
+
const message = `${err.describe()}; without --kind, the declaration names the record kinds`;
|
|
533
|
+
if (args.json && (err.code === "not-a-git-repository" || err.code === "revision-unknown")) {
|
|
534
|
+
console.log(JSON.stringify({ $schema: RECORDS_OUTPUT_SCHEMA_ID, contract: RECORDS_CONTRACT_VERSION, error: { code: err.code, message } }, null, 2));
|
|
535
|
+
} else {
|
|
536
|
+
console.error(formatError({ message: `${err.code}: ${message}`, hint: USAGE }));
|
|
537
|
+
}
|
|
525
538
|
return 1;
|
|
526
539
|
}
|
|
527
540
|
if (declared.length === 0) {
|