@printpractical/cruze 0.0.4 → 0.0.6
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/README.md +8 -8
- package/dist/adapters/inbound/cli/cli_context.js +2 -0
- package/dist/adapters/inbound/cli/commands/documents.js +5 -1
- package/dist/adapters/inbound/cli/commands/status.js +4 -1
- package/dist/adapters/inbound/cli/commands.js +5 -1
- package/dist/adapters/inbound/cli/parse_command.js +4 -0
- package/dist/app/use_cases/approve_artifact.js +26 -1
- package/dist/app/use_cases/new_work_item.js +1 -1
- package/dist/domain/approvals/agent_approval.js +68 -0
- package/dist/domain/config.js +30 -2
- package/dist/domain/journal.js +11 -1
- package/dist/domain/status/next_step.js +2 -2
- package/dist/domain/status/verification.js +3 -2
- package/dist/domain/validation/plan_rules.js +2 -0
- package/dist/domain/validation/size_rules.js +36 -0
- package/dist/domain/validation/work_item_rules.js +2 -0
- package/dist/domain/versions/rule_versions.js +3 -0
- package/package.json +1 -1
- package/skills/architect/SKILL.md +2 -2
- package/skills/architect/feature.md +9 -2
- package/skills/architect/walkthrough.md +22 -19
- package/skills/build/SKILL.md +6 -5
- package/skills/formats/SKILL.md +1 -1
- package/skills/formats/reference/config.md +11 -1
- package/skills/formats/reference/work-items.md +1 -1
- package/skills/formats/templates/change.md +1 -1
- package/skills/formats/templates/feature.md +1 -1
- package/skills/formats/templates/standalone-change.md +1 -1
- package/skills/land/SKILL.md +1 -1
- package/skills/plan/SKILL.md +4 -4
- package/skills/plan/tasks.md +2 -1
- package/skills/realign/notes/0.0.5.md +38 -0
- package/skills/realign/notes/0.0.6.md +44 -0
- package/skills/realign/notes.md +2 -0
- package/skills/research/SKILL.md +7 -1
- package/skills/rethink/SKILL.md +5 -1
- package/skills/rethink/levels.md +4 -4
- package/skills/roles/SKILL.md +23 -11
- package/skills/roles/code-reviewer.md +1 -1
- package/skills/roles/design-reviewer.md +6 -3
- package/skills/roles/verifier.md +3 -2
- package/skills/verify/SKILL.md +10 -6
- package/src/adapters/inbound/cli/cli_context.ts +2 -0
- package/src/adapters/inbound/cli/commands/documents.ts +5 -1
- package/src/adapters/inbound/cli/commands/status.ts +4 -1
- package/src/adapters/inbound/cli/commands.ts +5 -1
- package/src/adapters/inbound/cli/parse_command.ts +4 -0
- package/src/app/use_cases/approve_artifact.ts +26 -1
- package/src/app/use_cases/new_work_item.ts +1 -1
- package/src/domain/approvals/agent_approval.ts +68 -0
- package/src/domain/config.ts +30 -2
- package/src/domain/journal.ts +11 -1
- package/src/domain/status/next_step.ts +2 -2
- package/src/domain/status/verification.ts +3 -2
- package/src/domain/validation/plan_rules.ts +2 -0
- package/src/domain/validation/size_rules.ts +36 -0
- package/src/domain/validation/work_item_rules.ts +2 -0
- package/src/domain/versions/rule_versions.ts +3 -0
|
@@ -35,8 +35,11 @@ export async function runStatus(context: CliContext, _args: string[], options: O
|
|
|
35
35
|
return { json: report, human: lines.join("\n") };
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
const BASIS: Record<string, string> = { "plan-review": " (by the agent, after its plan review)", rethink: " (re-stamped by the agent after a rethink)" };
|
|
39
|
+
|
|
38
40
|
function describe(label: string, status: ApprovalStatus): string {
|
|
39
|
-
const
|
|
41
|
+
const byAgent = status.state === "approved" ? BASIS[status.record?.basis ?? ""] ?? "" : "";
|
|
42
|
+
const detail = status.changed.length > 0 ? ` (${status.changed.join(", ")})` : status.unjournaled === true ? " (no journal entry)" : byAgent;
|
|
40
43
|
return `${label}: ${status.state}${detail}`;
|
|
41
44
|
}
|
|
42
45
|
|
|
@@ -22,7 +22,11 @@ export const COMMANDS: Record<string, Command> = {
|
|
|
22
22
|
validate: { handler: runValidate, options: [], usage: "validate check every document against the formats" },
|
|
23
23
|
status: { handler: runStatus, options: ["gate", "override", "overlap", "change"], usage: "status [--gate build [--override <reason>]] [--overlap [--change <ref>]]" },
|
|
24
24
|
next: { handler: runNext, options: [], usage: "next the step to run next on this branch, and why" },
|
|
25
|
-
approve: {
|
|
25
|
+
approve: {
|
|
26
|
+
handler: runApprove,
|
|
27
|
+
options: ["replan", "rebase", "by-agent", "restamp-unchanged"],
|
|
28
|
+
usage: "approve <vision|architecture|roadmap|ref|path> [--replan <ID>...] [--rebase <ID>...] [--by-agent|--restamp-unchanged] record an approval of a document",
|
|
29
|
+
},
|
|
26
30
|
new: {
|
|
27
31
|
handler: runNew,
|
|
28
32
|
options: { vision: [], glossary: [], architecture: [], roadmap: [], feature: ["title", "roadmap"], change: ["title", "feature", "roadmap"], adr: ["title"], note: ["title"] },
|
|
@@ -52,6 +52,8 @@ export function parseCommand(argv: string[]): ParsedCommand {
|
|
|
52
52
|
rebase: { type: "string", multiple: true, default: [] },
|
|
53
53
|
base: { type: "string" },
|
|
54
54
|
blockers: { type: "string" },
|
|
55
|
+
"by-agent": { type: "boolean", default: false },
|
|
56
|
+
"restamp-unchanged": { type: "boolean", default: false },
|
|
55
57
|
},
|
|
56
58
|
});
|
|
57
59
|
} catch (error) {
|
|
@@ -74,6 +76,8 @@ export function parseCommand(argv: string[]): ParsedCommand {
|
|
|
74
76
|
full: values.full,
|
|
75
77
|
replan: values.replan,
|
|
76
78
|
rebase: values.rebase,
|
|
79
|
+
byAgent: values["by-agent"],
|
|
80
|
+
restampUnchanged: values["restamp-unchanged"],
|
|
77
81
|
...optional("name", values.name),
|
|
78
82
|
...optional("title", values.title),
|
|
79
83
|
...optional("feature", values.feature),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { requirePlanReviewClosed, requireUnchangedSinceApproval, type AgentBasis } from "../../domain/approvals/agent_approval.ts";
|
|
1
2
|
import { evaluateApproval } from "../../domain/approvals/evaluate.ts";
|
|
2
3
|
import { fingerprint, isApprovable } from "../../domain/approvals/fingerprint.ts";
|
|
3
4
|
import { latestApproval, serializeApprovals, withApproval, type ApprovalRecord } from "../../domain/approvals/records.ts";
|
|
@@ -29,6 +30,10 @@ export interface ApproveReport {
|
|
|
29
30
|
replanned: string[];
|
|
30
31
|
/** Elements whose current living text this feature accepted after a land conflict. */
|
|
31
32
|
rebased: string[];
|
|
33
|
+
/** Why the agent gave this approval for the user, when it did. */
|
|
34
|
+
basis?: AgentBasis;
|
|
35
|
+
/** For a re-stamp: the upstream IDs or documents that had changed. */
|
|
36
|
+
restamped?: string[];
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
export interface ApproveOptions {
|
|
@@ -36,6 +41,8 @@ export interface ApproveOptions {
|
|
|
36
41
|
replan?: string[];
|
|
37
42
|
/** Elements this work's delta changes whose living text changed since its last approval; the approval accepts the current text. */
|
|
38
43
|
rebase?: string[];
|
|
44
|
+
/** Approve for the user: a change whose plan review closed, or work a rethink left unchanged. */
|
|
45
|
+
agent?: AgentBasis;
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
/**
|
|
@@ -53,6 +60,9 @@ export async function approveArtifact(deps: ProjectDeps, ref: string, options: A
|
|
|
53
60
|
if (item !== undefined && item.kind !== "feature" && !isPlanned(item.doc)) {
|
|
54
61
|
throw new CruzeError("not-planned", `${path} has no test plan or tasks yet; plan it before approving`);
|
|
55
62
|
}
|
|
63
|
+
const before = evaluateApproval(view, path);
|
|
64
|
+
if (options.agent === "plan-review") requirePlanReviewClosed(view.journal, item ?? notWork(path));
|
|
65
|
+
if (options.agent === "rethink") requireUnchangedSinceApproval(view.journal, item, path, before);
|
|
56
66
|
|
|
57
67
|
const edits = new Map<string, string>();
|
|
58
68
|
let text = view.snapshot.get(path) ?? "";
|
|
@@ -103,12 +113,23 @@ export async function approveArtifact(deps: ProjectDeps, ref: string, options: A
|
|
|
103
113
|
upstream,
|
|
104
114
|
approvedAt: deps.clock.now().toISOString(),
|
|
105
115
|
by: (await deps.repository.userName()) ?? "unknown",
|
|
116
|
+
...(options.agent === undefined ? {} : { basis: options.agent }),
|
|
106
117
|
});
|
|
107
118
|
for (const [changed, content] of edits) await deps.files.writeText(changed, content);
|
|
108
119
|
await deps.files.writeText(approvalsPath(folder), serializeApprovals(approvals));
|
|
109
|
-
|
|
120
|
+
const restamped = options.agent === "rethink" ? before.changed : undefined;
|
|
121
|
+
await appendJournal(deps, folder, "approve", {
|
|
122
|
+
artifact: path,
|
|
123
|
+
hash,
|
|
124
|
+
...(replanned.length > 0 ? { replanned } : {}),
|
|
125
|
+
...(rebased.length > 0 ? { rebased } : {}),
|
|
126
|
+
...(options.agent === undefined ? {} : { basis: options.agent }),
|
|
127
|
+
...(restamped === undefined ? {} : { restamped }),
|
|
128
|
+
});
|
|
110
129
|
const report: ApproveReport = { artifact: path, hash, upstream: Object.keys(upstream), stamped, replanned, rebased };
|
|
111
130
|
if (bound !== undefined) report.bound = bound;
|
|
131
|
+
if (options.agent !== undefined) report.basis = options.agent;
|
|
132
|
+
if (restamped !== undefined) report.restamped = restamped;
|
|
112
133
|
return report;
|
|
113
134
|
}
|
|
114
135
|
|
|
@@ -133,6 +154,10 @@ function requireRebased(view: ProjectView, deltaIds: Set<string>, previous: Appr
|
|
|
133
154
|
}
|
|
134
155
|
}
|
|
135
156
|
|
|
157
|
+
function notWork(path: string): never {
|
|
158
|
+
throw new CruzeError("needs-user", `${path} is a project document, so the user approves it`);
|
|
159
|
+
}
|
|
160
|
+
|
|
136
161
|
/** A change needs its feature approved; a feature or standalone change needs the architecture approved. */
|
|
137
162
|
function requireUpstreamApproved(view: ProjectView, path: string): void {
|
|
138
163
|
const item = view.items.find((i) => i.path === path);
|
|
@@ -28,7 +28,7 @@ export interface NewReport {
|
|
|
28
28
|
export async function createWorkItem(deps: ProjectDeps, request: NewRequest): Promise<NewReport> {
|
|
29
29
|
const view = await loadView(deps.files);
|
|
30
30
|
const date = deps.clock.today();
|
|
31
|
-
const values = { id: "", title: request.title, date, project_name: "" };
|
|
31
|
+
const values = { id: "", title: request.title, title_quoted: JSON.stringify(request.title), date, project_name: "" };
|
|
32
32
|
if (request.title.trim() === "") throw new CruzeError("missing-title", "a title is required");
|
|
33
33
|
|
|
34
34
|
if (request.kind === "adr") {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { CruzeError } from "../cruze_error.ts";
|
|
2
|
+
import type { JournalEntry } from "../journal.ts";
|
|
3
|
+
import type { WorkItem } from "../project/work_items.ts";
|
|
4
|
+
import type { ApprovalStatus } from "./evaluate.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Why an approval was given without the user: a plan whose review closed, or work that a
|
|
8
|
+
* rethink left unchanged while something it cites moved. The user can still object, and
|
|
9
|
+
* editing the document makes the approval stale as usual.
|
|
10
|
+
*/
|
|
11
|
+
export type AgentBasis = "plan-review" | "rethink";
|
|
12
|
+
|
|
13
|
+
const byTime = (a: JournalEntry, b: JournalEntry): number => a.at.localeCompare(b.at);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A feature's change may be approved by the agent once its plan review has closed: a review
|
|
17
|
+
* recorded since the change was last approved, no blocker left open in its last round, and a
|
|
18
|
+
* disposition for every finding of its first round. The feature's design, and a standalone
|
|
19
|
+
* change's, stay the user's to approve.
|
|
20
|
+
*/
|
|
21
|
+
export function requirePlanReviewClosed(journal: JournalEntry[], item: WorkItem): void {
|
|
22
|
+
if (item.kind !== "change") {
|
|
23
|
+
throw new CruzeError("needs-user", `${item.ref} carries design, so the user approves it; only a feature's change can be approved after its plan review`);
|
|
24
|
+
}
|
|
25
|
+
const entries = journal.filter((e) => e["item"] === item.ref || e["artifact"] === item.path).sort(byTime);
|
|
26
|
+
const lastApproval = entries.filter((e) => e.event === "approve").at(-1);
|
|
27
|
+
const reviews = entries.filter((e) => e.event === "review" && e["review"] === "plan" && (lastApproval === undefined || e.at > lastApproval.at));
|
|
28
|
+
const last = reviews.at(-1);
|
|
29
|
+
if (last === undefined) {
|
|
30
|
+
const since = lastApproval === undefined ? "" : " since the change was last approved";
|
|
31
|
+
throw new CruzeError("plan-review-open", `no plan review is recorded for ${item.ref}${since}; run the plan review first`);
|
|
32
|
+
}
|
|
33
|
+
const blockers = Number(last["blockers"] ?? 0);
|
|
34
|
+
if (blockers > 0 && Number(last["round"]) === 1) {
|
|
35
|
+
throw new CruzeError("plan-review-open", `the plan review's round 1 found ${blockers} blocker(s); check their fixes in round 2`);
|
|
36
|
+
}
|
|
37
|
+
if (blockers > 0) {
|
|
38
|
+
throw new CruzeError("plan-review-open", `round 2 left ${blockers} blocker(s) open; the user decides them and approves the plan`);
|
|
39
|
+
}
|
|
40
|
+
const first = reviews.filter((e) => Number(e["round"]) === 1).at(-1) ?? last;
|
|
41
|
+
const findings = Number(first["blockers"] ?? 0) + Number(first["concerns"] ?? 0);
|
|
42
|
+
const decided = entries.filter((e) => e.event === "disposition" && e["review"] === "plan" && e.at > first.at).length;
|
|
43
|
+
if (decided < findings) {
|
|
44
|
+
throw new CruzeError("plan-review-open", `the plan review raised ${findings} finding(s) but ${decided} have a disposition; record one for each`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A rethink may re-approve, for the user, a feature or change it didn't edit: one whose own
|
|
50
|
+
* design is unchanged and that is stale only because something it cites changed. A change
|
|
51
|
+
* with a task reopened since its approval goes back to the user.
|
|
52
|
+
*/
|
|
53
|
+
export function requireUnchangedSinceApproval(journal: JournalEntry[], item: WorkItem | undefined, path: string, status: ApprovalStatus): void {
|
|
54
|
+
if (item === undefined) {
|
|
55
|
+
throw new CruzeError("needs-user", `${path} is a project document, so the user re-approves it; a rethink re-stamps only features and changes`);
|
|
56
|
+
}
|
|
57
|
+
if (status.state === "edited") {
|
|
58
|
+
throw new CruzeError("needs-user", `${item.ref} itself changed since its approval, so the user re-approves it`);
|
|
59
|
+
}
|
|
60
|
+
if (status.state !== "upstream-changed") {
|
|
61
|
+
throw new CruzeError("not-stale", `${item.ref} is ${status.state}; only work whose upstream changed can be re-stamped`);
|
|
62
|
+
}
|
|
63
|
+
const approvedAt = journal.filter((e) => e.event === "approve" && e["artifact"] === path).sort(byTime).at(-1)?.at ?? "";
|
|
64
|
+
const reopened = journal.filter((e) => e.event === "reopen" && e["change"] === item.ref && e.at > approvedAt).map((e) => String(e["task"]));
|
|
65
|
+
if (reopened.length > 0) {
|
|
66
|
+
throw new CruzeError("needs-user", `${item.ref} had ${reopened.join(", ")} reopened since its approval, so the user re-approves it`);
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/domain/config.ts
CHANGED
|
@@ -23,9 +23,25 @@ export interface CruzeConfig {
|
|
|
23
23
|
* How `cruze review` starts an agent in a fresh context: the program and its arguments.
|
|
24
24
|
* Without one, the calling agent runs the role's prompt in a helper of its own.
|
|
25
25
|
*/
|
|
26
|
-
review: { command?: string[] };
|
|
26
|
+
review: { command?: string[]; decide: ReviewDecide };
|
|
27
|
+
/** The size band of a change, so each one is worth its own plan, review and verify. */
|
|
28
|
+
changes: ChangeLimits;
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
/** Which review findings the user decides: only the exceptions, or every one. */
|
|
32
|
+
export type ReviewDecide = "exceptions" | "all";
|
|
33
|
+
|
|
34
|
+
export interface ChangeLimits {
|
|
35
|
+
/** A feature's change building fewer elements than this merges into a neighbour. */
|
|
36
|
+
minBuilds: number;
|
|
37
|
+
/** A change delivering more scenarios than this splits. */
|
|
38
|
+
maxScenarios: number;
|
|
39
|
+
/** A plan with more tasks than this splits. */
|
|
40
|
+
maxTasks: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const DEFAULT_CHANGE_LIMITS: ChangeLimits = { minBuilds: 3, maxScenarios: 30, maxTasks: 25 };
|
|
44
|
+
|
|
29
45
|
export interface Layer {
|
|
30
46
|
name: string;
|
|
31
47
|
paths: string[];
|
|
@@ -97,6 +113,14 @@ export function parseConfig(text: string): ConfigResult {
|
|
|
97
113
|
const review = asRecord(root["review"]) ?? {};
|
|
98
114
|
const reviewCommand = review["command"] === undefined ? undefined : stringList(review["command"], "review.command", problems);
|
|
99
115
|
if (reviewCommand?.length === 0) problems.push("review.command needs at least the program to run");
|
|
116
|
+
const decide = review["decide"] ?? "exceptions";
|
|
117
|
+
if (decide !== "exceptions" && decide !== "all") problems.push("review.decide must be exceptions or all");
|
|
118
|
+
const limits = asRecord(root["changes"]) ?? {};
|
|
119
|
+
const changes: ChangeLimits = {
|
|
120
|
+
minBuilds: positiveInt(limits["min_builds"], "changes.min_builds", problems) ?? DEFAULT_CHANGE_LIMITS.minBuilds,
|
|
121
|
+
maxScenarios: positiveInt(limits["max_scenarios"], "changes.max_scenarios", problems) ?? DEFAULT_CHANGE_LIMITS.maxScenarios,
|
|
122
|
+
maxTasks: positiveInt(limits["max_tasks"], "changes.max_tasks", problems) ?? DEFAULT_CHANGE_LIMITS.maxTasks,
|
|
123
|
+
};
|
|
100
124
|
|
|
101
125
|
const config: CruzeConfig = {
|
|
102
126
|
version: 1,
|
|
@@ -109,7 +133,11 @@ export function parseConfig(text: string): ConfigResult {
|
|
|
109
133
|
check: { maxLines: maxLines ?? 250, maxTypes: maxTypes ?? 5, exceptions },
|
|
110
134
|
layers,
|
|
111
135
|
commands,
|
|
112
|
-
review:
|
|
136
|
+
review: {
|
|
137
|
+
...(reviewCommand === undefined || reviewCommand.length === 0 ? {} : { command: reviewCommand }),
|
|
138
|
+
decide: decide === "all" ? "all" : "exceptions",
|
|
139
|
+
},
|
|
140
|
+
changes,
|
|
113
141
|
};
|
|
114
142
|
return { config, problems };
|
|
115
143
|
}
|
package/src/domain/journal.ts
CHANGED
|
@@ -15,12 +15,14 @@ export const RECORDED_EVENTS: Record<string, string[]> = {
|
|
|
15
15
|
override: ["gate", "reason"],
|
|
16
16
|
bug: ["summary", "cause"],
|
|
17
17
|
verification: ["result", "summary"],
|
|
18
|
+
checks: ["commit"],
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
/** Fields an event may carry beyond its required ones, checked when present. A defect rethink must say which step missed it. */
|
|
21
22
|
const OPTIONAL_FIELDS: Record<string, string[]> = {
|
|
22
23
|
rethink: ["missed_by"],
|
|
23
|
-
review: ["nits"],
|
|
24
|
+
review: ["nits", "checked_by"],
|
|
25
|
+
disposition: ["decided"],
|
|
24
26
|
};
|
|
25
27
|
|
|
26
28
|
/** The workflow steps and checks that find or miss a problem, so a retro can count them. */
|
|
@@ -30,8 +32,10 @@ export const FIELD_VALUES: Record<string, string[]> = {
|
|
|
30
32
|
level: ["task", "change", "feature", "architecture", "vision"],
|
|
31
33
|
kind: ["defect", "discovery"],
|
|
32
34
|
disposition: ["fixed", "waived", "deferred", "rejected"],
|
|
35
|
+
decided: ["agent", "user"],
|
|
33
36
|
review: ["design", "plan", "code", "realign"],
|
|
34
37
|
result: ["accepted", "sent-back"],
|
|
38
|
+
checked_by: ["agent", "reviewer"],
|
|
35
39
|
found_by: STEPS,
|
|
36
40
|
missed_by: STEPS,
|
|
37
41
|
};
|
|
@@ -77,6 +81,12 @@ export function recordedEventProblems(event: string, fields: Record<string, stri
|
|
|
77
81
|
if (allowed !== undefined && !allowed.includes(value)) problems.push(`${field} must be one of ${allowed.join(", ")}`);
|
|
78
82
|
if (COUNT_FIELDS.includes(field) && !/^\d+$/.test(value)) problems.push(`${field} must be a whole number`);
|
|
79
83
|
}
|
|
84
|
+
if (event === "checks" && fields["commit"] !== undefined && !/^[0-9a-f]{7,40}$/.test(fields["commit"])) {
|
|
85
|
+
problems.push("commit must be a commit hash, such as the output of git rev-parse --short HEAD");
|
|
86
|
+
}
|
|
87
|
+
if (event === "disposition" && fields["decided"] === "agent" && fields["disposition"] !== "fixed") {
|
|
88
|
+
problems.push("the agent decides only fixes; the user waives, defers or rejects a finding (decided=user)");
|
|
89
|
+
}
|
|
80
90
|
return problems;
|
|
81
91
|
}
|
|
82
92
|
|
|
@@ -66,7 +66,7 @@ function projectStep(view: ProjectView): NextStep | null {
|
|
|
66
66
|
{
|
|
67
67
|
path: PATHS.architecture,
|
|
68
68
|
missing: { step: "architect", reason: "the project has no architecture yet" },
|
|
69
|
-
unapproved: { step: "architect", reason: "finish the architecture,
|
|
69
|
+
unapproved: { step: "architect", reason: "finish the architecture, review it and approve it" },
|
|
70
70
|
changed: { step: "rethink", target: "architecture", reason: "the architecture changed since its approval" },
|
|
71
71
|
},
|
|
72
72
|
{
|
|
@@ -89,7 +89,7 @@ function itemSteps(view: ProjectView, item: WorkItem, branch: string | null): Ne
|
|
|
89
89
|
if (item.kind === "standalone") return [changeStep(view, item, branch)];
|
|
90
90
|
const state = evaluateApproval(view, item.path);
|
|
91
91
|
if (readChangesTable(item.doc).length === 0) return [{ step: "architect", target: item.ref, reason: "the feature has no design or changes yet" }];
|
|
92
|
-
if (state.state === "unapproved") return [{ step: "architect", target: item.ref, reason: "review the feature's design
|
|
92
|
+
if (state.state === "unapproved") return [{ step: "architect", target: item.ref, reason: "review the feature's design and approve it" }];
|
|
93
93
|
if (state.state !== "approved") return [{ step: "rethink", target: item.ref, reason: `the feature changed since its approval${state.changed.length > 0 ? ` (${state.changed.join(", ")})` : ""}` }];
|
|
94
94
|
const changes = changesOf(view.items, item);
|
|
95
95
|
const landed = new Set(changes.filter((c) => readProgress(c.doc).landed !== undefined).map((c) => c.folderName));
|
|
@@ -4,13 +4,14 @@ import type { WorkItem } from "../project/work_items.ts";
|
|
|
4
4
|
/**
|
|
5
5
|
* Whether the user accepted a change at verify. The latest verification recorded for the change
|
|
6
6
|
* counts. A later approval keeps it only when the change's own design is unchanged, as when it is
|
|
7
|
-
* re-approved because something upstream moved; a changed plan means verifying again.
|
|
7
|
+
* re-approved because something upstream moved; a changed plan means verifying again. Land's
|
|
8
|
+
* re-stamps don't count, since they follow the merge; an approval the agent gave does.
|
|
8
9
|
*/
|
|
9
10
|
export function isVerified(journal: JournalEntry[], change: WorkItem): boolean {
|
|
10
11
|
const byTime = (a: JournalEntry, b: JournalEntry): number => a.at.localeCompare(b.at);
|
|
11
12
|
const verification = journal.filter((e) => e.event === "verification" && e["item"] === change.ref).sort(byTime).at(-1);
|
|
12
13
|
if (verification?.["result"] !== "accepted") return false;
|
|
13
|
-
const approvals = journal.filter((e) => e.event === "approve" && e["artifact"] === change.path && e["basis"]
|
|
14
|
+
const approvals = journal.filter((e) => e.event === "approve" && e["artifact"] === change.path && !String(e["basis"] ?? "").startsWith("land ")).sort(byTime);
|
|
14
15
|
const verifiedHash = approvals.filter((e) => e.at < verification.at).at(-1)?.["hash"];
|
|
15
16
|
return approvals.filter((e) => e.at > verification.at).every((e) => e["hash"] === verifiedHash);
|
|
16
17
|
}
|
|
@@ -7,6 +7,7 @@ import type { Delta } from "../project/deltas.ts";
|
|
|
7
7
|
import { isPlanned, readScope, readTasks, readTestPlan, type Scope, type Task } from "../project/plan_parts.ts";
|
|
8
8
|
import type { ProjectView } from "../project/project_view.ts";
|
|
9
9
|
import { error, warning, type Problem } from "./problem.ts";
|
|
10
|
+
import { planSizeProblems } from "./size_rules.ts";
|
|
10
11
|
|
|
11
12
|
const TEST_KINDS = ["behaviour", "contract", "domain", "smoke"];
|
|
12
13
|
|
|
@@ -31,6 +32,7 @@ export function changePlanProblems(
|
|
|
31
32
|
problems.push(error(path, line, "task-format", "task lines read: - T<n>: `<owner>` in `<path>`[, `<path>`], proves <ID>[, <ID>]"));
|
|
32
33
|
}
|
|
33
34
|
if (tasks.length === 0 && malformed.length === 0) problems.push(error(path, undefined, "missing-tasks", "the change has no tasks"));
|
|
35
|
+
problems.push(...planSizeProblems(view, path, tasks.length));
|
|
34
36
|
|
|
35
37
|
const proved = new Set(tasks.flatMap((task) => task.proves));
|
|
36
38
|
const taskPaths = tasks.flatMap((task) => task.paths);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { DEFAULT_CHANGE_LIMITS, type ChangeLimits } from "../config.ts";
|
|
2
|
+
import { kindOf } from "../ids.ts";
|
|
3
|
+
import type { ChangeRow } from "../project/plan_parts.ts";
|
|
4
|
+
import type { ProjectView } from "../project/project_view.ts";
|
|
5
|
+
import { warning, type Problem } from "./problem.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every change pays for its own plan review, code review, verifier run and land, so a change
|
|
9
|
+
* should carry enough work to be worth them, and no more than one review and one run can hold.
|
|
10
|
+
*/
|
|
11
|
+
export function changeLimits(view: ProjectView): ChangeLimits {
|
|
12
|
+
return view.config?.config?.changes ?? DEFAULT_CHANGE_LIMITS;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A feature's changes not yet landed that fall outside the size band. A feature of one change has nothing to merge into. */
|
|
16
|
+
export function changeRowSizeProblems(view: ProjectView, path: string, rows: ChangeRow[], landed: Set<string>): Problem[] {
|
|
17
|
+
const limits = changeLimits(view);
|
|
18
|
+
const problems: Problem[] = [];
|
|
19
|
+
for (const row of rows.filter((r) => !landed.has(r.change))) {
|
|
20
|
+
if (rows.length > 1 && row.builds.length < limits.minBuilds) {
|
|
21
|
+
problems.push(warning(path, row.line, "change-too-small", `${row.change} builds ${row.builds.length} element(s), under ${limits.minBuilds}; merge it into the change it continues, since each change pays for its own plan, review and verify`));
|
|
22
|
+
}
|
|
23
|
+
const scenarios = row.delivers.filter((id) => kindOf(id) === "SCN").length;
|
|
24
|
+
if (scenarios > limits.maxScenarios) {
|
|
25
|
+
problems.push(warning(path, row.line, "change-too-large", `${row.change} delivers ${scenarios} scenarios, over ${limits.maxScenarios}; split it, since one verifier run has to go through them all`));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return problems;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A plan with more tasks than one code review and one verify can hold. */
|
|
32
|
+
export function planSizeProblems(view: ProjectView, path: string, tasks: number): Problem[] {
|
|
33
|
+
const { maxTasks } = changeLimits(view);
|
|
34
|
+
if (tasks <= maxTasks) return [];
|
|
35
|
+
return [warning(path, undefined, "plan-too-large", `the plan has ${tasks} tasks, over ${maxTasks}; split the change through rethink at feature level`)];
|
|
36
|
+
}
|
|
@@ -11,6 +11,7 @@ import { deltaProblems } from "./delta_rules.ts";
|
|
|
11
11
|
import { malformedIdHeadings, lineOf } from "./living_rules.ts";
|
|
12
12
|
import { changePlanProblems } from "./plan_rules.ts";
|
|
13
13
|
import { error, warning, type Problem } from "./problem.ts";
|
|
14
|
+
import { changeRowSizeProblems } from "./size_rules.ts";
|
|
14
15
|
|
|
15
16
|
const REQUIRED_SECTIONS: Record<WorkItem["kind"], string[]> = {
|
|
16
17
|
feature: ["Intent", "Spec delta", "Architecture delta", "Changes"],
|
|
@@ -71,6 +72,7 @@ function featureScopeProblems(view: ProjectView, feature: WorkItem, delta: Delta
|
|
|
71
72
|
if (extra !== undefined) {
|
|
72
73
|
problems.push(warning(feature.path, extra.line, "feature-size", `the feature has ${rows.length} changes, over ${MAX_CHANGES}; split it into features a single design review can hold`));
|
|
73
74
|
}
|
|
75
|
+
problems.push(...changeRowSizeProblems(view, feature.path, rows, landedChanges));
|
|
74
76
|
for (const row of rows) {
|
|
75
77
|
if (!/^\d\d-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(row.change)) problems.push(error(feature.path, row.line, "change-name", `change "${row.change}" must be named NN-slug`));
|
|
76
78
|
if (names.indexOf(row.change) !== names.lastIndexOf(row.change)) problems.push(error(feature.path, row.line, "change-name", `change ${row.change} is listed twice`));
|
|
@@ -13,6 +13,9 @@ export const RULE_SINCE: Record<string, string> = {
|
|
|
13
13
|
"validate:outside-module-map": "0.0.2",
|
|
14
14
|
"validate:readme-overview": "0.0.2",
|
|
15
15
|
"validate:feature-size": "0.0.2",
|
|
16
|
+
"validate:change-too-small": "0.0.5",
|
|
17
|
+
"validate:change-too-large": "0.0.5",
|
|
18
|
+
"validate:plan-too-large": "0.0.5",
|
|
16
19
|
};
|
|
17
20
|
|
|
18
21
|
/** Whether a rule arrived after the version the project's code meets, so it waits for `realign`. */
|