@printpractical/cruze 0.0.6 → 0.0.8
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 +9 -7
- package/dist/adapters/inbound/cli/commands/journal.js +1 -1
- package/dist/adapters/inbound/cli/commands/show.js +27 -0
- package/dist/adapters/inbound/cli/commands/status.js +19 -3
- package/dist/adapters/inbound/cli/commands.js +3 -1
- package/dist/app/use_cases/approve_artifact.js +19 -1
- package/dist/app/use_cases/check_code.js +10 -3
- package/dist/app/use_cases/journal_events.js +8 -5
- package/dist/app/use_cases/run_review.js +1 -1
- package/dist/app/use_cases/show_scope.js +35 -0
- package/dist/domain/check/budget_text.js +24 -0
- package/dist/domain/check/check_source.js +6 -3
- package/dist/domain/config.js +11 -1
- package/dist/domain/journal.js +2 -2
- package/dist/domain/project/scope_view.js +118 -0
- package/dist/domain/status/work_status.js +13 -2
- package/dist/domain/validation/element_rules.js +6 -1
- package/dist/domain/validation/size_rules.js +14 -5
- package/dist/domain/validation/work_item_rules.js +25 -3
- package/dist/domain/versions/rule_versions.js +1 -0
- package/dist/main.js +6 -0
- package/package.json +1 -1
- package/skills/architect/SKILL.md +2 -0
- package/skills/architect/feature.md +1 -1
- package/skills/architect/project.md +1 -1
- package/skills/architect/walkthrough.md +7 -4
- package/skills/build/SKILL.md +9 -8
- package/skills/dependency-approval/SKILL.md +8 -1
- package/skills/envision/SKILL.md +2 -0
- package/skills/envision/feature.md +1 -1
- package/skills/explore/SKILL.md +1 -1
- package/skills/formats/reference/architecture.md +1 -1
- package/skills/formats/reference/config.md +5 -1
- package/skills/formats/reference/specs.md +1 -1
- package/skills/formats/reference/work-items.md +1 -1
- package/skills/formats/templates/architecture.md +2 -2
- package/skills/grilling/SKILL.md +19 -7
- package/skills/grilling/illustrating.md +37 -0
- package/skills/hexagonal-design/SKILL.md +10 -3
- package/skills/hexagonal-design/languages/rust.md +3 -2
- package/skills/hexagonal-design/languages/swift.md +46 -0
- package/skills/hexagonal-design/shapes.md +49 -0
- package/skills/improve/SKILL.md +51 -0
- package/skills/land/SKILL.md +2 -2
- package/skills/next/SKILL.md +2 -2
- package/skills/plan/SKILL.md +3 -3
- package/skills/plan/tasks.md +9 -5
- package/skills/realign/SKILL.md +1 -1
- package/skills/realign/notes/0.0.7.md +11 -0
- package/skills/realign/notes/0.0.8.md +97 -0
- package/skills/realign/notes.md +2 -0
- package/skills/research/SKILL.md +2 -1
- package/skills/rethink/SKILL.md +2 -2
- package/skills/roadmap/SKILL.md +2 -0
- package/skills/roles/SKILL.md +9 -10
- package/skills/roles/code-reviewer.md +8 -8
- package/skills/roles/design-reviewer.md +10 -12
- package/skills/roles/surveyor.md +40 -0
- package/skills/roles/verifier.md +6 -5
- package/skills/triage/SKILL.md +2 -0
- package/skills/verify/SKILL.md +3 -1
- package/src/adapters/inbound/cli/commands/journal.ts +1 -1
- package/src/adapters/inbound/cli/commands/show.ts +27 -0
- package/src/adapters/inbound/cli/commands/status.ts +19 -3
- package/src/adapters/inbound/cli/commands.ts +3 -1
- package/src/app/use_cases/approve_artifact.ts +19 -1
- package/src/app/use_cases/check_code.ts +10 -3
- package/src/app/use_cases/journal_events.ts +8 -5
- package/src/app/use_cases/run_review.ts +1 -1
- package/src/app/use_cases/show_scope.ts +35 -0
- package/src/domain/check/budget_text.ts +24 -0
- package/src/domain/check/check_source.ts +6 -3
- package/src/domain/config.ts +11 -1
- package/src/domain/journal.ts +2 -2
- package/src/domain/project/scope_view.ts +118 -0
- package/src/domain/status/work_status.ts +13 -2
- package/src/domain/validation/element_rules.ts +6 -1
- package/src/domain/validation/size_rules.ts +14 -5
- package/src/domain/validation/work_item_rules.ts +25 -3
- package/src/domain/versions/rule_versions.ts +1 -0
- package/src/main.ts +6 -0
- package/templates/init/AGENTS.md +1 -1
package/skills/roles/verifier.md
CHANGED
|
@@ -4,23 +4,24 @@ You check that a change actually works when someone uses it. The tests passing i
|
|
|
4
4
|
|
|
5
5
|
## Inputs
|
|
6
6
|
|
|
7
|
-
- The change's `change.md`,
|
|
7
|
+
- The change's `change.md`, for its test plan, and the output of `cruze show <ref>`: the scenarios it delivers, written out, and the flows and elements it builds.
|
|
8
8
|
- The project's commands, from `commands:` in `.cruze/config.yaml`, and from `README.md` when they aren't there.
|
|
9
9
|
- The mechanical checks' results, with the commit they ran at.
|
|
10
|
-
- The flows in `docs/architecture.md` that serve those scenarios.
|
|
11
10
|
|
|
12
11
|
## Work
|
|
13
12
|
|
|
14
13
|
1. **Build.** Build the system with the project's build command, and record whether it passed. The test suite already ran at the commit in your inputs, and CI runs it again before the merge, so don't run it. Run a single test only when it is how a scenario reaches its real front door, such as a UI test that drives the app.
|
|
15
14
|
2. **Trace.** Run `cruze trace --change <ref>` and record its result.
|
|
16
|
-
3. **Run each scenario.**
|
|
15
|
+
3. **Run each scenario that has a front door.** A scenario whose test plan row names an inbound adapter, a screen or a real adapter as its seam is one a user meets, so run it:
|
|
17
16
|
- Set up its GIVEN with real things where you can: configuration files, directories, a local test server, a pseudo-terminal. Put them in a temporary directory outside the project.
|
|
18
17
|
- Perform its WHEN through the real inbound adapter, such as the CLI binary or an HTTP request, the way a user or client would.
|
|
19
18
|
- Compare what you observe with its THEN, word for word where the scenario quotes output.
|
|
20
|
-
|
|
19
|
+
|
|
20
|
+
A scenario whose row runs through a use case with fakes is proved by its behaviour test, which passed at the checks commit: give the test's file and name as `Ran:` and the commit as `Observed:`, and don't drive it. Driving every scenario cost one app project 31 minutes a change, for one defect in 28 runs.
|
|
21
|
+
4. **Probe one step further.** For each scenario you ran, try the nearest thing a user would plausibly do next or get wrong, such as the same command twice, a typo in the input, or an interrupt. Report surprises; they are not failures of the scenario.
|
|
21
22
|
5. **Clean up** everything you created outside the project.
|
|
22
23
|
|
|
23
|
-
When a scenario needs something you don't have, such as real hardware or a remote host, don't fake it. Mark it `could not run`, and say exactly what it needs, so a person can run it by hand.
|
|
24
|
+
When a scenario needs something you don't have, such as real hardware or a remote host, don't fake it. Mark it `could not run`, and say exactly what it needs, so a person can run it by hand. The same holds for time: don't idle through a timer, a scheduled notification or a device settling. Drive time through the project's seam for it when there is one; otherwise a wait over 30 seconds is `could not run`, with the wait a person needs.
|
|
24
25
|
|
|
25
26
|
## Report
|
|
26
27
|
|
package/skills/triage/SKILL.md
CHANGED
package/skills/verify/SKILL.md
CHANGED
|
@@ -16,7 +16,7 @@ Verify answers one question: does this change work when someone uses it? Green t
|
|
|
16
16
|
- When one fails, fix the code in a commit and run them again. When a fix needs design, stop and offer `rethink`.
|
|
17
17
|
3. **Check the dependencies.** Follow "Checking" in `.agents/skills/cruze-dependency-approval/SKILL.md` against the base commit. An unrecorded dependency is a blocker.
|
|
18
18
|
4. **Run the verifier.** Run the verifier role from `.agents/skills/cruze-roles/SKILL.md` for the change, with the mechanical checks' results and the commit they ran at. It must be able to build and run the system. When it runs through `cruze review`, check that `review.command` in `.cruze/config.yaml` allows the project's build and run commands, and add them with the user's agreement. Done when you have its report, with a result for every delivered scenario.
|
|
19
|
-
5. **Review the code.** Run the review in the roles skill with the code reviewer, `review=code`, `--item <ref>` and the base commit. Fix each finding whose disposition is `fixed` in a commit, running the tests each fix touches, and `test` when `commands:` has a `test_full`. Then check the fixes
|
|
19
|
+
5. **Review the code.** Run the review in the roles skill with the code reviewer, `review=code`, `--item <ref>` and the base commit. Fix each finding whose disposition is `fixed` in a commit, running the tests each fix touches, and `test` when `commands:` has a `test_full`. Then check the fixes yourself, as round 2 in the roles skill says.
|
|
20
20
|
6. **Write the manual test script** as `manual-test.md` in the change folder, following [manual-test.md](manual-test.md). Commit it.
|
|
21
21
|
7. **Check the fixes once.** When code changed since the last `checks` entry, run the format, lint and build commands and the full suite once more, and record them as in step 2. Done when the last `checks` entry is at a commit with no code changes since.
|
|
22
22
|
8. **Report.** Show the user four things:
|
|
@@ -41,3 +41,5 @@ Done when the decision is recorded.
|
|
|
41
41
|
|
|
42
42
|
- Accepted: `land`.
|
|
43
43
|
- Sent back: `build` for reopened tasks, or `rethink` for design.
|
|
44
|
+
|
|
45
|
+
Start it in a new session, so its context starts empty.
|
|
@@ -15,7 +15,7 @@ export async function runJournal(context: CliContext, args: string[], options: O
|
|
|
15
15
|
return { json: entry, human: `Recorded ${entry.event}.` };
|
|
16
16
|
}
|
|
17
17
|
if (args[0] === "list") {
|
|
18
|
-
const entries = await listEvents(context, options.event, options.item);
|
|
18
|
+
const entries = await listEvents(context, options.event, options.item, options.all);
|
|
19
19
|
return { json: entries, human: entries.map((e) => `${e.at} ${e.event} ${JSON.stringify({ ...e, at: undefined, event: undefined, by: undefined })}`).join("\n") || "No entries." };
|
|
20
20
|
}
|
|
21
21
|
throw new UsageError(`cruze journal takes add or list, not "${args[0]}"`);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { showScope, type ScopeReport } from "../../../../app/use_cases/show_scope.ts";
|
|
2
|
+
import type { ShownElement } from "../../../../domain/project/scope_view.ts";
|
|
3
|
+
import { requireArgs, type CliContext, type CommandResult } from "../cli_context.ts";
|
|
4
|
+
|
|
5
|
+
export async function runShow(context: CliContext, args: string[]): Promise<CommandResult> {
|
|
6
|
+
requireArgs(args, ["ref"]);
|
|
7
|
+
const report = await showScope(context, args[0] ?? "");
|
|
8
|
+
return { json: report, human: render(report) };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The scope as a document an agent reads in place of the living docs. */
|
|
12
|
+
function render(report: ScopeReport): string {
|
|
13
|
+
const lines = [`# ${report.ref}${report.feature === undefined ? "" : ` (feature ${report.feature})`}`, ""];
|
|
14
|
+
lines.push("## Elements", "", ...blocks(report.elements), "## Scenarios", "", ...blocks(report.scenarios));
|
|
15
|
+
lines.push("## Cited, not shown", "");
|
|
16
|
+
lines.push(...(report.cited.length === 0 ? ["None."] : report.cited.map((c) => `- ${c.id}: ${c.title} (${c.source})`)), "");
|
|
17
|
+
lines.push("## Settled decisions", "", ...(report.decisions.length === 0 ? ["None."] : report.decisions), "");
|
|
18
|
+
lines.push("## Dispositions", "");
|
|
19
|
+
lines.push(...(report.dispositions.length === 0 ? ["None."] : report.dispositions.map((d) => `- [${d.disposition}, ${d.review}] ${d.finding}: ${d.reason}`)), "");
|
|
20
|
+
if (report.missing.length > 0) lines.push("## Missing", "", ...report.missing.map((id) => `- ${id} is defined nowhere`), "");
|
|
21
|
+
return lines.join("\n").trimEnd();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function blocks(elements: ShownElement[]): string[] {
|
|
25
|
+
if (elements.length === 0) return ["None.", ""];
|
|
26
|
+
return elements.flatMap((element) => [`<!-- ${element.source} -->`, ...element.body.join("\n").trimEnd().split("\n"), ""]);
|
|
27
|
+
}
|
|
@@ -32,9 +32,25 @@ export async function runStatus(context: CliContext, _args: string[], options: O
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
for (const change of report.standalone) lines.push(`${describe(`change ${change.ref}`, change.approval)}, tasks ${change.tasksDone}/${change.tasksTotal}`);
|
|
35
|
-
|
|
35
|
+
const json = {
|
|
36
|
+
...report,
|
|
37
|
+
documents: report.documents.map(compact),
|
|
38
|
+
features: report.features.map((f) => ({ ...f, approval: compact(f.approval), changes: f.changes.map((c) => ({ ...c, approval: compact(c.approval) })) })),
|
|
39
|
+
standalone: report.standalone.map((c) => ({ ...c, approval: compact(c.approval) })),
|
|
40
|
+
};
|
|
41
|
+
return { json, human: lines.join("\n") };
|
|
36
42
|
}
|
|
37
43
|
|
|
44
|
+
/** The state without the record's hashes: an agent reads status often, and the hashes were a third of each read. */
|
|
45
|
+
function compact(status: ApprovalStatus): Omit<ApprovalStatus, "record"> & { approvedAt?: string; by?: string; basis?: string } {
|
|
46
|
+
const { record, ...rest } = status;
|
|
47
|
+
if (record === undefined) return rest;
|
|
48
|
+
return { ...rest, approvedAt: record.approvedAt, by: record.by, ...(record.basis === undefined ? {} : { basis: record.basis }) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Every step starts a new session: one session that ran plan, build, verify and land reached 745k tokens of context. */
|
|
52
|
+
const FRESH_SESSION = "Run it in a new session, so its context starts empty.";
|
|
53
|
+
|
|
38
54
|
const BASIS: Record<string, string> = { "plan-review": " (by the agent, after its plan review)", rethink: " (re-stamped by the agent after a rethink)" };
|
|
39
55
|
|
|
40
56
|
function describe(label: string, status: ApprovalStatus): string {
|
|
@@ -46,6 +62,6 @@ function describe(label: string, status: ApprovalStatus): string {
|
|
|
46
62
|
export async function runNext(context: CliContext): Promise<CommandResult> {
|
|
47
63
|
const report = await suggestNext(context);
|
|
48
64
|
const describe = (s: { step: string; target?: string; reason: string }): string => `${s.step}${s.target === undefined ? "" : ` ${s.target}`}: ${s.reason}`;
|
|
49
|
-
const lines = [`Next: ${describe(report.next)}`, ...report.also.map((s) => `Also: ${describe(s)}`)];
|
|
50
|
-
return { json: report, human: lines.join("\n") };
|
|
65
|
+
const lines = [`Next: ${describe(report.next)}`, ...report.also.map((s) => `Also: ${describe(s)}`), FRESH_SESSION];
|
|
66
|
+
return { json: { ...report, session: FRESH_SESSION }, human: lines.join("\n") };
|
|
51
67
|
}
|
|
@@ -7,6 +7,7 @@ import { runRealign } from "./commands/realign.ts";
|
|
|
7
7
|
import { runFeedback, runJournal } from "./commands/journal.ts";
|
|
8
8
|
import { runAbandon, runFeatures, runRoadmap, runTask } from "./commands/progress.ts";
|
|
9
9
|
import { runReviewCommand } from "./commands/review.ts";
|
|
10
|
+
import { runShow } from "./commands/show.ts";
|
|
10
11
|
import { runNext, runStatus } from "./commands/status.ts";
|
|
11
12
|
|
|
12
13
|
/** A command, and the options it takes: one list, or one per subcommand for a command that has them. */
|
|
@@ -22,6 +23,7 @@ export const COMMANDS: Record<string, Command> = {
|
|
|
22
23
|
validate: { handler: runValidate, options: [], usage: "validate check every document against the formats" },
|
|
23
24
|
status: { handler: runStatus, options: ["gate", "override", "overlap", "change"], usage: "status [--gate build [--override <reason>]] [--overlap [--change <ref>]]" },
|
|
24
25
|
next: { handler: runNext, options: [], usage: "next the step to run next on this branch, and why" },
|
|
26
|
+
show: { handler: runShow, options: [], usage: "show <ref> the design a change builds, or a feature's delta, with what it cites and its settled ledger" },
|
|
25
27
|
approve: {
|
|
26
28
|
handler: runApprove,
|
|
27
29
|
options: ["replan", "rebase", "by-agent", "restamp-unchanged"],
|
|
@@ -35,7 +37,7 @@ export const COMMANDS: Record<string, Command> = {
|
|
|
35
37
|
task: { handler: runTask, options: { done: ["change", "commit"], deviation: ["change"], reopen: ["change"] }, usage: "task <done|deviation|reopen> <T#> [text] [--change <ref>] [--commit <sha>]" },
|
|
36
38
|
features: { handler: runFeatures, options: { add: ["summary", "goals"], update: ["summary"], drop: ["reason"] }, usage: "features <add|update|drop> <slug> [--summary <text>] [--goals <ids>] [--reason <text>]" },
|
|
37
39
|
roadmap: { handler: runRoadmap, options: { prune: [] }, usage: "roadmap prune clear landed items from the roadmap status when a release closes" },
|
|
38
|
-
journal: { handler: runJournal, options: { add: ["set", "item"], list: ["event", "item"] }, usage: "journal <add <event> --set key=value... [--item <ref>]|list [--event <event>] [--item <ref>]>" },
|
|
40
|
+
journal: { handler: runJournal, options: { add: ["set", "item"], list: ["event", "item", "all"] }, usage: "journal <add <event> --set key=value... [--item <ref>]|list [--event <event>] [--item <ref>] [--all]>" },
|
|
39
41
|
review: { handler: runReviewCommand, options: ["item", "round", "blockers", "base"], usage: "review <role> [--item <ref>] [--round 1|2 --blockers <file>] [--base <commit>] run a role in a fresh agent context" },
|
|
40
42
|
feedback: { handler: runFeedback, options: { export: ["out", "no-redact"] }, usage: "feedback export [--out <file>] [--no-redact]" },
|
|
41
43
|
trace: { handler: runTrace, options: ["all", "change"], usage: "trace [--all] [--change <ref>] link scenarios to the tests that prove them" },
|
|
@@ -15,6 +15,8 @@ import { isPlanned, readChangesTable } from "../../domain/project/plan_parts.ts"
|
|
|
15
15
|
import { readProgress } from "../../domain/project/progress.ts";
|
|
16
16
|
import { approvalFolder, approvalsPath, type ProjectView } from "../../domain/project/project_view.ts";
|
|
17
17
|
import { featureOf } from "../../domain/project/work_items.ts";
|
|
18
|
+
import type { Problem } from "../../domain/validation/problem.ts";
|
|
19
|
+
import { SIZE_RULES } from "../../domain/validation/size_rules.ts";
|
|
18
20
|
import { validateProject } from "../../domain/validation/validate_project.ts";
|
|
19
21
|
import { appendJournal, loadView, withFiles, type ProjectDeps } from "../project_context.ts";
|
|
20
22
|
|
|
@@ -87,11 +89,13 @@ export async function approveArtifact(deps: ProjectDeps, ref: string, options: A
|
|
|
87
89
|
if (text !== view.snapshot.get(path)) edits.set(path, text);
|
|
88
90
|
view = withFiles(view, edits);
|
|
89
91
|
|
|
90
|
-
const
|
|
92
|
+
const problems = validateProject(view).filter((p) => p.path === path);
|
|
93
|
+
const errors = problems.filter((p) => p.severity === "error");
|
|
91
94
|
if (errors.length > 0) {
|
|
92
95
|
const listing = errors.map((p) => ` ${p.path}:${p.line ?? ""} ${p.message}`).join("\n");
|
|
93
96
|
throw new CruzeError("invalid", `${path} has ${errors.length} problem(s) to fix before approval:\n${listing}`);
|
|
94
97
|
}
|
|
98
|
+
requireSizesAccepted(problems, path);
|
|
95
99
|
|
|
96
100
|
const { hash, upstream } = fingerprint(view, path, parseMarkdown(text));
|
|
97
101
|
const folder = approvalFolder(view, path);
|
|
@@ -154,6 +158,20 @@ function requireRebased(view: ProjectView, deltaIds: Set<string>, previous: Appr
|
|
|
154
158
|
}
|
|
155
159
|
}
|
|
156
160
|
|
|
161
|
+
/**
|
|
162
|
+
* A size warning stands until the work is split or the user accepts it under `changes.exceptions`.
|
|
163
|
+
* A 28-task change approved over its warning went on to 26 deviations, so approval waits for one or the other.
|
|
164
|
+
*/
|
|
165
|
+
function requireSizesAccepted(problems: Problem[], path: string): void {
|
|
166
|
+
const standing = problems.filter((p) => SIZE_RULES.includes(p.rule));
|
|
167
|
+
if (standing.length === 0) return;
|
|
168
|
+
const listing = standing.map((p) => ` ${p.message} [${p.rule}]`).join("\n");
|
|
169
|
+
throw new CruzeError(
|
|
170
|
+
"size-unaccepted",
|
|
171
|
+
`${path} has ${standing.length} size warning(s). Split the work through rethink, or record the user's acceptance under changes.exceptions in .cruze/config.yaml, as the item's ref and their reason:\n${listing}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
157
175
|
function notWork(path: string): never {
|
|
158
176
|
throw new CruzeError("needs-user", `${path} is a project document, so the user approves it`);
|
|
159
177
|
}
|
|
@@ -6,6 +6,7 @@ import { FIRST_RELEASE, isPending } from "../../domain/versions/rule_versions.ts
|
|
|
6
6
|
import type { CruzeConfig } from "../../domain/config.ts";
|
|
7
7
|
import type { ProjectView } from "../../domain/project/project_view.ts";
|
|
8
8
|
import { resolveChange } from "../../domain/project/artifact_ref.ts";
|
|
9
|
+
import { readDelta } from "../../domain/project/deltas.ts";
|
|
9
10
|
import { activeChange } from "../../domain/status/work_status.ts";
|
|
10
11
|
import { traceScenarios, type TraceReport } from "../../domain/trace.ts";
|
|
11
12
|
import { loadView, type ProjectDeps } from "../project_context.ts";
|
|
@@ -88,8 +89,14 @@ function globBase(glob: string): string {
|
|
|
88
89
|
return fixed.join("/");
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Every module's Path: the living docs' modules, and the modules that work in progress adds or
|
|
94
|
+
* changes in its architecture delta, since their files are written before land merges them.
|
|
95
|
+
*/
|
|
91
96
|
function modulePaths(view: ProjectView): string[] {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
.
|
|
97
|
+
const living = [...view.living.elements.values()].filter((e) => e.id.startsWith("MOD-")).map((e) => ({ doc: e.doc, element: e.element }));
|
|
98
|
+
const inFlight = view.items
|
|
99
|
+
.filter((item) => item.kind !== "change" && !item.archived)
|
|
100
|
+
.flatMap((item) => readDelta(item.doc).entries.filter((entry) => entry.element.id.startsWith("MOD-") && entry.element.op !== "REMOVED").map((entry) => ({ doc: item.doc, element: entry.element })));
|
|
101
|
+
return [...living, ...inFlight].flatMap(({ doc, element }) => (factsOf(doc, element).get("Path") ?? []).flatMap((value) => [...value.matchAll(/`([^`]+)`/g)].map((m) => m[1] ?? "")));
|
|
95
102
|
}
|
|
@@ -18,16 +18,19 @@ export async function recordEvent(deps: ProjectDeps, event: string, fields: Reco
|
|
|
18
18
|
return appendJournal(deps, folder, event, { ...recorded, cruze: deps.bundle.version });
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The journal of the work in flight and the project, or of one feature or change (a feature's changes' too).
|
|
23
|
+
* Landed work's entries are history, listed only with `all`, since every entry ever recorded is too much to read.
|
|
24
|
+
*/
|
|
25
|
+
export async function listEvents(deps: ProjectDeps, event?: string, item?: string, all = false): Promise<JournalEntry[]> {
|
|
23
26
|
const view = await loadView(deps.files);
|
|
24
|
-
let
|
|
27
|
+
let folders = [".cruze", ...view.items.filter((i) => all || !i.archived).map((i) => i.folder)];
|
|
25
28
|
if (item !== undefined) {
|
|
26
29
|
const resolved = resolveArtifact(view, item).item;
|
|
27
30
|
if (resolved === undefined) throw new CruzeError("not-found", `"${item}" is not a feature or change`);
|
|
28
|
-
|
|
29
|
-
entries = parts.flatMap((part) => parseJournal(view.snapshot.get(journalPath(part.folder))).entries).sort((a, b) => a.at.localeCompare(b.at));
|
|
31
|
+
folders = [resolved, ...view.items.filter((i) => resolved.kind === "feature" && i.featureRef === resolved.ref)].map((part) => part.folder);
|
|
30
32
|
}
|
|
33
|
+
const entries = folders.flatMap((folder) => parseJournal(view.snapshot.get(journalPath(folder))).entries).sort((a, b) => a.at.localeCompare(b.at));
|
|
31
34
|
return event === undefined ? entries : entries.filter((entry) => entry.event === event);
|
|
32
35
|
}
|
|
33
36
|
|
|
@@ -4,7 +4,7 @@ import { featureOf } from "../../domain/project/work_items.ts";
|
|
|
4
4
|
import type { AgentRunner } from "../ports/agent_runner.ts";
|
|
5
5
|
import { loadView, type ProjectDeps } from "../project_context.ts";
|
|
6
6
|
|
|
7
|
-
export const ROLES = ["design-reviewer", "code-reviewer", "verifier", "researcher", "auditor"] as const;
|
|
7
|
+
export const ROLES = ["design-reviewer", "code-reviewer", "verifier", "researcher", "auditor", "surveyor"] as const;
|
|
8
8
|
export type Role = (typeof ROLES)[number];
|
|
9
9
|
|
|
10
10
|
export interface ReviewRequest {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { CruzeError } from "../../domain/cruze_error.ts";
|
|
2
|
+
import { resolveArtifact } from "../../domain/project/artifact_ref.ts";
|
|
3
|
+
import { scopeView, type ScopeView } from "../../domain/project/scope_view.ts";
|
|
4
|
+
import { loadView, type ProjectDeps } from "../project_context.ts";
|
|
5
|
+
import { listEvents } from "./journal_events.ts";
|
|
6
|
+
|
|
7
|
+
export interface Disposition {
|
|
8
|
+
finding: string;
|
|
9
|
+
disposition: string;
|
|
10
|
+
reason: string;
|
|
11
|
+
review: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ScopeReport extends ScopeView {
|
|
15
|
+
/** The settled ledger of the item: the feature's dispositions and its changes', or a standalone change's. */
|
|
16
|
+
dispositions: Disposition[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The design a change builds, or a feature's whole delta, with the elements it cites and the
|
|
21
|
+
* item's settled ledger: what plan, build and the reviewers read instead of the whole living docs.
|
|
22
|
+
*/
|
|
23
|
+
export async function showScope(deps: ProjectDeps, ref: string): Promise<ScopeReport> {
|
|
24
|
+
const view = await loadView(deps.files);
|
|
25
|
+
const { item } = resolveArtifact(view, ref);
|
|
26
|
+
if (item === undefined) throw new CruzeError("not-work", `"${ref}" is not a feature or change; show takes a feature or change ID`);
|
|
27
|
+
const scope = scopeView(view, item);
|
|
28
|
+
const dispositions = (await listEvents(deps, "disposition", scope.feature ?? item.ref, true)).map((entry) => ({
|
|
29
|
+
finding: String(entry["finding"] ?? ""),
|
|
30
|
+
disposition: String(entry["disposition"] ?? ""),
|
|
31
|
+
reason: String(entry["reason"] ?? ""),
|
|
32
|
+
review: String(entry["review"] ?? ""),
|
|
33
|
+
}));
|
|
34
|
+
return { ...scope, dispositions };
|
|
35
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The part of a source file its budgets measure. Budgets apply to source, not tests, so a Rust
|
|
3
|
+
* file's `#[cfg(test)]` modules are left out: rustfmt closes a top-level module with `}` in column 0.
|
|
4
|
+
*/
|
|
5
|
+
export function budgetedText(path: string, text: string): string {
|
|
6
|
+
if (!path.endsWith(".rs")) return text;
|
|
7
|
+
const lines = text.split("\n");
|
|
8
|
+
const kept: string[] = [];
|
|
9
|
+
for (let i = 0; i < lines.length; i++) {
|
|
10
|
+
const item = lines[i + 1] ?? "";
|
|
11
|
+
if (lines[i]?.trim() !== "#[cfg(test)]" || !/^(?:pub(?:\([^)]*\))?\s+)?mod\s+\w+\s*[{;]/.test(item)) {
|
|
12
|
+
kept.push(lines[i] ?? "");
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (item.trimEnd().endsWith(";")) {
|
|
16
|
+
i += 1;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
let end = i + 2;
|
|
20
|
+
while (end < lines.length && lines[end] !== "}") end++;
|
|
21
|
+
i = end;
|
|
22
|
+
}
|
|
23
|
+
return kept.join("\n");
|
|
24
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CruzeConfig, Layer } from "../config.ts";
|
|
2
2
|
import { matchesAny } from "../glob.ts";
|
|
3
|
+
import { budgetedText } from "./budget_text.ts";
|
|
3
4
|
import { importsOf, resolveImport } from "./imports.ts";
|
|
4
5
|
import { countTypes } from "./type_count.ts";
|
|
5
6
|
|
|
@@ -50,11 +51,13 @@ export function checkSource(input: CheckInput): CheckFinding[] {
|
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
const excepted = config.check.exceptions.some((exception) => exception.path === path);
|
|
53
|
-
const
|
|
54
|
+
const budgeted = budgetedText(path, text);
|
|
55
|
+
const lines = budgeted.split("\n").length - (budgeted.endsWith("\n") ? 1 : 0);
|
|
54
56
|
if (!excepted && lines > config.check.maxLines) {
|
|
55
|
-
|
|
57
|
+
const measured = budgeted === text ? "" : ", not counting its inline tests";
|
|
58
|
+
findings.push({ path, rule: "max-lines", severity: "warning", message: `${lines} lines${measured}, over the budget of ${config.check.maxLines}; split it by responsibility` });
|
|
56
59
|
}
|
|
57
|
-
const types = countTypes(path,
|
|
60
|
+
const types = countTypes(path, budgeted);
|
|
58
61
|
if (!excepted && types !== null && types > config.check.maxTypes) {
|
|
59
62
|
findings.push({ path, rule: "max-types", severity: "warning", message: `${types} top-level types, over the budget of ${config.check.maxTypes}` });
|
|
60
63
|
}
|
package/src/domain/config.ts
CHANGED
|
@@ -38,9 +38,11 @@ export interface ChangeLimits {
|
|
|
38
38
|
maxScenarios: number;
|
|
39
39
|
/** A plan with more tasks than this splits. */
|
|
40
40
|
maxTasks: number;
|
|
41
|
+
/** Features and changes the user accepted outside the band, by ref, each with the reason. */
|
|
42
|
+
exceptions: Array<{ item: string; reason: string }>;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
export const DEFAULT_CHANGE_LIMITS: ChangeLimits = { minBuilds: 3, maxScenarios: 30, maxTasks: 25 };
|
|
45
|
+
export const DEFAULT_CHANGE_LIMITS: ChangeLimits = { minBuilds: 3, maxScenarios: 30, maxTasks: 25, exceptions: [] };
|
|
44
46
|
|
|
45
47
|
export interface Layer {
|
|
46
48
|
name: string;
|
|
@@ -120,6 +122,14 @@ export function parseConfig(text: string): ConfigResult {
|
|
|
120
122
|
minBuilds: positiveInt(limits["min_builds"], "changes.min_builds", problems) ?? DEFAULT_CHANGE_LIMITS.minBuilds,
|
|
121
123
|
maxScenarios: positiveInt(limits["max_scenarios"], "changes.max_scenarios", problems) ?? DEFAULT_CHANGE_LIMITS.maxScenarios,
|
|
122
124
|
maxTasks: positiveInt(limits["max_tasks"], "changes.max_tasks", problems) ?? DEFAULT_CHANGE_LIMITS.maxTasks,
|
|
125
|
+
exceptions: (Array.isArray(limits["exceptions"]) ? limits["exceptions"] : []).flatMap((entry: unknown, i: number) => {
|
|
126
|
+
const record = asRecord(entry);
|
|
127
|
+
if (typeof record?.["item"] !== "string" || typeof record["reason"] !== "string" || record["reason"].trim() === "") {
|
|
128
|
+
problems.push(`changes.exceptions[${i}] needs an item and a non-empty reason`);
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
return [{ item: record["item"], reason: record["reason"] }];
|
|
132
|
+
}),
|
|
123
133
|
};
|
|
124
134
|
|
|
125
135
|
const config: CruzeConfig = {
|
package/src/domain/journal.ts
CHANGED
|
@@ -26,14 +26,14 @@ const OPTIONAL_FIELDS: Record<string, string[]> = {
|
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
/** The workflow steps and checks that find or miss a problem, so a retro can count them. */
|
|
29
|
-
export const STEPS = ["envision", "architect", "research", "design-review", "walkthrough", "plan", "plan-review", "build", "code-review", "verify", "land", "realign", "validate", "check", "user"];
|
|
29
|
+
export const STEPS = ["envision", "architect", "research", "design-review", "walkthrough", "plan", "plan-review", "build", "code-review", "verify", "land", "realign", "improve", "validate", "check", "user"];
|
|
30
30
|
|
|
31
31
|
export const FIELD_VALUES: Record<string, string[]> = {
|
|
32
32
|
level: ["task", "change", "feature", "architecture", "vision"],
|
|
33
33
|
kind: ["defect", "discovery"],
|
|
34
34
|
disposition: ["fixed", "waived", "deferred", "rejected"],
|
|
35
35
|
decided: ["agent", "user"],
|
|
36
|
-
review: ["design", "plan", "code", "realign"],
|
|
36
|
+
review: ["design", "plan", "code", "realign", "improve"],
|
|
37
37
|
result: ["accepted", "sent-back"],
|
|
38
38
|
checked_by: ["agent", "reviewer"],
|
|
39
39
|
found_by: STEPS,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { elementLines, type DeltaOp, type Element } from "../elements.ts";
|
|
2
|
+
import { findIds } from "../ids.ts";
|
|
3
|
+
import { findSection, type MarkdownDoc } from "../markdown.ts";
|
|
4
|
+
import { readDelta } from "./deltas.ts";
|
|
5
|
+
import { readScope } from "./plan_parts.ts";
|
|
6
|
+
import type { ProjectView } from "./project_view.ts";
|
|
7
|
+
import { featureOf, type WorkItem, type WorkItemKind } from "./work_items.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The design a piece of work builds, cut out of the documents that hold it, so a step reads
|
|
11
|
+
* the elements in its scope instead of the whole architecture and the whole feature.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface ShownElement {
|
|
15
|
+
id: string;
|
|
16
|
+
title: string;
|
|
17
|
+
op?: DeltaOp;
|
|
18
|
+
/** Where the body comes from, as `<path>:<line>`. */
|
|
19
|
+
source: string;
|
|
20
|
+
body: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CitedElement {
|
|
24
|
+
id: string;
|
|
25
|
+
title: string;
|
|
26
|
+
source: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ScopeView {
|
|
30
|
+
ref: string;
|
|
31
|
+
kind: WorkItemKind;
|
|
32
|
+
feature?: string;
|
|
33
|
+
/** The architecture elements the work builds or removes; for a feature, its whole architecture delta. */
|
|
34
|
+
elements: ShownElement[];
|
|
35
|
+
/** The scenarios and requirements the work delivers; for a feature, its whole spec delta. */
|
|
36
|
+
scenarios: ShownElement[];
|
|
37
|
+
/** Elements the shown bodies cite but don't include, with where to read them. */
|
|
38
|
+
cited: CitedElement[];
|
|
39
|
+
/** The settled decisions of the feature, then of the change. */
|
|
40
|
+
decisions: string[];
|
|
41
|
+
/** Scope IDs defined nowhere: the delta, the living docs or the archive. */
|
|
42
|
+
missing: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Located {
|
|
46
|
+
doc: MarkdownDoc;
|
|
47
|
+
path: string;
|
|
48
|
+
element: Element;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function scopeView(view: ProjectView, item: WorkItem): ScopeView {
|
|
52
|
+
const feature = featureOf(view.items, item);
|
|
53
|
+
const deltaItem = feature ?? item;
|
|
54
|
+
const locate = locator(view, deltaItem);
|
|
55
|
+
const wanted = item.kind === "change" || item.kind === "standalone" ? changeIds(item) : featureIds(deltaItem);
|
|
56
|
+
const missing: string[] = [];
|
|
57
|
+
const show = (ids: string[]): ShownElement[] =>
|
|
58
|
+
ids.flatMap((id) => {
|
|
59
|
+
const found = locate(id);
|
|
60
|
+
if (found === undefined) {
|
|
61
|
+
missing.push(id);
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
const shown: ShownElement = { id, title: found.element.title, source: `${found.path}:${found.element.start + 1}`, body: elementLines(found.doc, found.element) };
|
|
65
|
+
if (found.element.op !== undefined) shown.op = found.element.op;
|
|
66
|
+
return [shown];
|
|
67
|
+
});
|
|
68
|
+
const elements = show(wanted.elements);
|
|
69
|
+
const scenarios = show(wanted.scenarios);
|
|
70
|
+
const shownIds = new Set([...elements, ...scenarios].map((e) => e.id));
|
|
71
|
+
const cited = [...new Set([...elements, ...scenarios].flatMap((e) => findIds(e.body.join("\n"))))]
|
|
72
|
+
.filter((id) => !shownIds.has(id))
|
|
73
|
+
.flatMap((id) => {
|
|
74
|
+
const found = locate(id);
|
|
75
|
+
return found === undefined ? [] : [{ id, title: found.element.title, source: `${found.path}:${found.element.start + 1}` }];
|
|
76
|
+
});
|
|
77
|
+
const decisions = feature === undefined ? decisionLines(item.doc) : [...decisionLines(feature.doc), ...decisionLines(item.doc)];
|
|
78
|
+
const result: ScopeView = { ref: item.ref, kind: item.kind, elements, scenarios, cited, decisions, missing };
|
|
79
|
+
if (feature !== undefined) result.feature = feature.ref;
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A change shows what its scope builds, removes and delivers. */
|
|
84
|
+
function changeIds(change: WorkItem): { elements: string[]; scenarios: string[] } {
|
|
85
|
+
const scope = readScope(change.doc);
|
|
86
|
+
return { elements: [...(scope?.builds ?? []), ...(scope?.removes ?? [])], scenarios: scope?.delivers ?? [] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A feature shows its whole delta: every operation, with a requirement's scenarios inside its body. */
|
|
90
|
+
function featureIds(feature: WorkItem): { elements: string[]; scenarios: string[] } {
|
|
91
|
+
const entries = readDelta(feature.doc).entries;
|
|
92
|
+
return {
|
|
93
|
+
elements: entries.filter((e) => e.section === "architecture").map((e) => e.element.id),
|
|
94
|
+
scenarios: entries.filter((e) => e.section === "spec").map((e) => e.element.id),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Finds an element's body in the work's own delta first, since that is the text the work builds, then in the living docs. */
|
|
99
|
+
function locator(view: ProjectView, deltaItem: WorkItem): (id: string) => Located | undefined {
|
|
100
|
+
const delta = readDelta(deltaItem.doc);
|
|
101
|
+
const inDelta = new Map<string, Element>();
|
|
102
|
+
for (const entry of delta.entries) {
|
|
103
|
+
inDelta.set(entry.element.id, entry.element);
|
|
104
|
+
for (const scenario of entry.scenarios) inDelta.set(scenario.id, scenario);
|
|
105
|
+
}
|
|
106
|
+
return (id) => {
|
|
107
|
+
const own = inDelta.get(id);
|
|
108
|
+
if (own !== undefined) return { doc: deltaItem.doc, path: deltaItem.path, element: own };
|
|
109
|
+
const living = view.living.elements.get(id);
|
|
110
|
+
return living === undefined ? undefined : { doc: living.doc, path: living.path, element: living.element };
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function decisionLines(doc: MarkdownDoc): string[] {
|
|
115
|
+
const section = findSection(doc, "Settled decisions");
|
|
116
|
+
if (section === null) return [];
|
|
117
|
+
return doc.lines.slice(section.start, section.end).filter((line) => line.startsWith("- "));
|
|
118
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { evaluateApproval, type ApprovalStatus } from "../approvals/evaluate.ts";
|
|
2
2
|
import { PATHS } from "../project/layout.ts";
|
|
3
3
|
import { readDelta } from "../project/deltas.ts";
|
|
4
|
-
import { readScope } from "../project/plan_parts.ts";
|
|
4
|
+
import { readChangesTable, readScope } from "../project/plan_parts.ts";
|
|
5
5
|
import { readProgress } from "../project/progress.ts";
|
|
6
6
|
import type { ProjectView } from "../project/project_view.ts";
|
|
7
7
|
import { changesOf, featureOf, type WorkItem } from "../project/work_items.ts";
|
|
@@ -80,12 +80,23 @@ export function buildGateReasons(view: ProjectView, branch: string | null): stri
|
|
|
80
80
|
const feature = featureOf(view.items, change);
|
|
81
81
|
if (feature !== undefined) required.push(feature.path);
|
|
82
82
|
if (view.snapshot.has(PATHS.architecture)) required.push(PATHS.architecture);
|
|
83
|
-
|
|
83
|
+
const approvals = required.flatMap((path) => {
|
|
84
84
|
const status = evaluateApproval(view, path);
|
|
85
85
|
if (status.state === "approved") return [];
|
|
86
86
|
const detail = status.changed.length > 0 ? `: ${status.changed.join(", ")} changed` : status.unjournaled === true ? ": the approval has no journal entry" : "";
|
|
87
87
|
return [`${path} is ${status.state}${detail}`];
|
|
88
88
|
});
|
|
89
|
+
return [...approvals, ...unlandedDependencies(view, change, feature)];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The changes this change's row in its feature's Changes table depends on that haven't landed on this branch. */
|
|
93
|
+
function unlandedDependencies(view: ProjectView, change: WorkItem, feature: WorkItem | undefined): string[] {
|
|
94
|
+
if (feature === undefined) return [];
|
|
95
|
+
const row = readChangesTable(feature.doc).find((r) => r.change === change.folderName);
|
|
96
|
+
const landed = new Set(changesOf(view.items, feature).filter((c) => readProgress(c.doc).landed !== undefined).map((c) => c.folderName));
|
|
97
|
+
return (row?.dependsOn ?? [])
|
|
98
|
+
.filter((dependency) => !landed.has(dependency))
|
|
99
|
+
.map((dependency) => `${change.ref} depends on ${dependency}, which hasn't landed here; land it, then merge main into this branch`);
|
|
89
100
|
}
|
|
90
101
|
|
|
91
102
|
/** The elements a change touches: its scope and its delta, not the elements it only cites, such as a test seam. */
|
|
@@ -18,7 +18,7 @@ const REQUIRED_FACTS: Partial<Record<Kind, string[]>> = {
|
|
|
18
18
|
const ALLOWED_VALUES: Record<string, { kinds: Kind[]; values: string[] }> = {
|
|
19
19
|
Kind: { kinds: ["ENT"], values: ["aggregate", "entity", "value", "service", "policy", "event"] },
|
|
20
20
|
Direction: { kinds: ["PORT"], values: ["driven", "driving"] },
|
|
21
|
-
Layer: { kinds: ["MOD"], values: ["domain", "application", "adapter", "composition"] },
|
|
21
|
+
Layer: { kinds: ["MOD"], values: ["core", "domain", "application", "adapter", "composition"] },
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
export function elementProblems(path: string, doc: MarkdownDoc, element: Element): Problem[] {
|
|
@@ -41,6 +41,11 @@ export function elementProblems(path: string, doc: MarkdownDoc, element: Element
|
|
|
41
41
|
problems.push(error(path, element.start, "fact-value", `${element.id} ${key} must be one of ${rule.values.join(", ")}`));
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
if (element.kind === "MOD") {
|
|
45
|
+
for (const glob of (facts.get("Path") ?? []).flatMap((value) => [...value.matchAll(/`([^`]+)`/g)].map((m) => m[1] ?? "")).filter((p) => /[*?[{]/.test(p))) {
|
|
46
|
+
problems.push(error(path, element.start, "module-path-glob", `${element.id} Path \`${glob}\` is a glob, which matches no file; name a directory ending in / or a single file, one per Path value`));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
44
49
|
const module = facts.get("Module")?.[0];
|
|
45
50
|
if (module !== undefined && !findIds(module).some((id) => id.startsWith("MOD-"))) {
|
|
46
51
|
problems.push(error(path, element.start, "fact-value", `${element.id} Module must name a MOD element`));
|
|
@@ -12,17 +12,25 @@ export function changeLimits(view: ProjectView): ChangeLimits {
|
|
|
12
12
|
return view.config?.config?.changes ?? DEFAULT_CHANGE_LIMITS;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** The rules a size warning comes from. An item stays outside the band only with the user's recorded acceptance. */
|
|
16
|
+
export const SIZE_RULES = ["feature-size", "change-too-small", "change-too-large", "plan-too-large"];
|
|
17
|
+
|
|
18
|
+
/** Whether the user accepted this feature or change outside the band, under `changes.exceptions`. */
|
|
19
|
+
export function sizeAccepted(view: ProjectView, ref: string): boolean {
|
|
20
|
+
return changeLimits(view).exceptions.some((exception) => exception.item === ref);
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
/** 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[] {
|
|
24
|
+
export function changeRowSizeProblems(view: ProjectView, feature: { path: string; ref: string }, rows: ChangeRow[], landed: Set<string>): Problem[] {
|
|
17
25
|
const limits = changeLimits(view);
|
|
18
26
|
const problems: Problem[] = [];
|
|
19
|
-
for (const row of rows.filter((r) => !landed.has(r.change))) {
|
|
27
|
+
for (const row of rows.filter((r) => !landed.has(r.change) && !sizeAccepted(view, `${feature.ref}/${r.change}`))) {
|
|
20
28
|
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`));
|
|
29
|
+
problems.push(warning(feature.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
30
|
}
|
|
23
31
|
const scenarios = row.delivers.filter((id) => kindOf(id) === "SCN").length;
|
|
24
32
|
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`));
|
|
33
|
+
problems.push(warning(feature.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
34
|
}
|
|
27
35
|
}
|
|
28
36
|
return problems;
|
|
@@ -31,6 +39,7 @@ export function changeRowSizeProblems(view: ProjectView, path: string, rows: Cha
|
|
|
31
39
|
/** A plan with more tasks than one code review and one verify can hold. */
|
|
32
40
|
export function planSizeProblems(view: ProjectView, path: string, tasks: number): Problem[] {
|
|
33
41
|
const { maxTasks } = changeLimits(view);
|
|
34
|
-
|
|
42
|
+
const ref = view.items.find((item) => item.path === path)?.ref ?? path;
|
|
43
|
+
if (tasks <= maxTasks || sizeAccepted(view, ref)) return [];
|
|
35
44
|
return [warning(path, undefined, "plan-too-large", `the plan has ${tasks} tasks, over ${maxTasks}; split the change through rethink at feature level`)];
|
|
36
45
|
}
|