@printpractical/cruze 0.0.7 → 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 +6 -5
- 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/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/project/scope_view.js +118 -0
- package/dist/domain/status/work_status.js +13 -2
- package/dist/domain/validation/element_rules.js +5 -0
- 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/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/grilling/SKILL.md +19 -7
- package/skills/grilling/illustrating.md +37 -0
- package/skills/hexagonal-design/SKILL.md +1 -0
- package/skills/hexagonal-design/languages/rust.md +2 -1
- package/skills/improve/SKILL.md +3 -3
- package/skills/land/SKILL.md +2 -2
- package/skills/next/SKILL.md +1 -1
- 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.8.md +97 -0
- package/skills/realign/notes.md +1 -0
- package/skills/research/SKILL.md +2 -1
- package/skills/rethink/SKILL.md +1 -1
- package/skills/roadmap/SKILL.md +2 -0
- package/skills/roles/SKILL.md +7 -9
- package/skills/roles/code-reviewer.md +8 -8
- package/skills/roles/design-reviewer.md +9 -11
- package/skills/roles/surveyor.md +1 -1
- 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/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/project/scope_view.ts +118 -0
- package/src/domain/status/work_status.ts +13 -2
- package/src/domain/validation/element_rules.ts +5 -0
- 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/README.md
CHANGED
|
@@ -10,7 +10,7 @@ It works with any agent that supports the [Agent Skills](https://agentskills.io)
|
|
|
10
10
|
|
|
11
11
|
## Status
|
|
12
12
|
|
|
13
|
-
Version 0.0.
|
|
13
|
+
Version 0.0.8 is the eighth preview. It cuts what each step reads and tightens the gates: every step runs in a new session, plan, build and the reviewers read a change's scope through `cruze show` instead of the living docs whole, round 2 of a review is checked by the agent, `cruze approve` refuses a size warning the user hasn't accepted, build waits for the changes it depends on and runs the same check as CI, and neither a file budget nor avoiding a dependency shapes code. Early development. The CLI is complete for the V1 lifecycle. The document formats ship as the `cruze-formats` skill, the standards an agent designs and codes to ship as knowledge skills, and the whole workflow, from explore to land, ships as workflow skills. Claude Code is the agent tested so far.
|
|
14
14
|
|
|
15
15
|
## Why
|
|
16
16
|
|
|
@@ -35,7 +35,7 @@ The skills run `cruze` commands, so the agent needs `cruze` on its path. Install
|
|
|
35
35
|
From a GitHub Release, without npm. Each release attaches the packed package:
|
|
36
36
|
|
|
37
37
|
```sh
|
|
38
|
-
npm install -g https://github.com/PrintPractical/cruze/releases/download/v0.0.
|
|
38
|
+
npm install -g https://github.com/PrintPractical/cruze/releases/download/v0.0.8/printpractical-cruze-0.0.8.tgz
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
From npm, once a version is published there:
|
|
@@ -47,7 +47,7 @@ npm install -g @printpractical/cruze
|
|
|
47
47
|
From the git repository, at a tag or `#main`. This needs git and access to the repository. npm 11 needs `--install-links` for a global git install, and the first `cruze` command builds the CLI, which takes a moment:
|
|
48
48
|
|
|
49
49
|
```sh
|
|
50
|
-
npm install -g --install-links github:PrintPractical/cruze#v0.0.
|
|
50
|
+
npm install -g --install-links github:PrintPractical/cruze#v0.0.8
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
### 2. Set up a repository
|
|
@@ -61,7 +61,7 @@ cruze init
|
|
|
61
61
|
When you didn't install from npm, tell `init` where Cruze came from, so the CI it writes runs the same source instead of the npm package:
|
|
62
62
|
|
|
63
63
|
```sh
|
|
64
|
-
cruze init --package https://github.com/PrintPractical/cruze/releases/download/v0.0.
|
|
64
|
+
cruze init --package https://github.com/PrintPractical/cruze/releases/download/v0.0.8/printpractical-cruze-0.0.8.tgz
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
`init` asks for the project name, and which coding agent you use: `claude` for Claude Code, or `other` for any agent that reads `AGENTS.md` and `.agents/skills/`, such as Codex or OpenCode. It doesn't ask when you pass `--agent claude` or `--agent other`, or when the repository already has `CLAUDE.md` or `.claude/`. With `--yes`, or without a terminal, it sets up Claude Code. Then it creates:
|
|
@@ -121,6 +121,7 @@ Skills call these at fixed points, and CI runs `check` and `trace` on every push
|
|
|
121
121
|
| `cruze validate` | Checks every document against the formats: IDs, elements, deltas, scope rules, task lines, test plans, roadmap and config, and warns when a change falls outside the size band in `changes:` |
|
|
122
122
|
| `cruze approve <doc>` | Records an approval as a fingerprint: the document's design hash plus the hash of every upstream element it cites. `--replan` sets built elements a rethink changed back to planned; `--rebase` accepts living text a feature must now build on. `--by-agent` approves a change for you once its plan review has closed, and `--restamp-unchanged` re-stamps work a rethink didn't change; both record that the agent gave the approval |
|
|
123
123
|
| `cruze next` | Names the step to run next on this branch, and why, from computed status |
|
|
124
|
+
| `cruze show <ref>` | Prints the design a change builds, or a feature's delta: each element's full body, the delivered scenarios, what they cite with where to find it, the settled decisions and the item's dispositions, so a step reads its scope instead of the whole architecture |
|
|
124
125
|
| `cruze status` | Computes each document's state (approved, edited, upstream changed or unapproved) from content, never from stored state |
|
|
125
126
|
| `cruze status --gate build` | Passes only when this branch's change, its feature and the architecture are approved and current |
|
|
126
127
|
| `cruze status --overlap` | Lists changes on other branches that touch the same elements as this branch's change |
|
|
@@ -135,7 +136,7 @@ Skills call these at fixed points, and CI runs `check` and `trace` on every push
|
|
|
135
136
|
| `cruze realign status` | Lists what the installed Cruze expects of existing code since its `standards:` version, with what the CLI rules find now; `--full` covers every release |
|
|
136
137
|
| `cruze realign done` | Moves `standards:` up once every finding is fixed, waived or scheduled in a change named with `--change` |
|
|
137
138
|
| `cruze abandon <ref>` | Archives a feature or standalone change that stops for good, with its reason, and puts a feature back on the future list |
|
|
138
|
-
| `cruze journal add <event>` | Records a rethink, review round, disposition, verification, override or bug; `cruze feedback export` bundles the journal for improving Cruze itself |
|
|
139
|
+
| `cruze journal add <event>` | Records a rethink, review round, disposition, verification, override or bug; `cruze journal list` reads the work in flight's entries, or landed work's too with `--all`; `cruze feedback export` bundles the journal for improving Cruze itself |
|
|
139
140
|
|
|
140
141
|
Stepping back is editing: change an upstream document and every approval that cites a changed element shows as stale, with the element named. Re-approving is the rewind; there are no phases to reset.
|
|
141
142
|
|
|
@@ -15,7 +15,7 @@ export async function runJournal(context , args , options
|
|
|
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, } from "../../../../app/use_cases/show_scope.js";
|
|
2
|
+
|
|
3
|
+
import { requireArgs, } from "../cli_context.js";
|
|
4
|
+
|
|
5
|
+
export async function runShow(context , args ) {
|
|
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 ) {
|
|
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 ) {
|
|
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 , _args , options
|
|
|
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 ) {
|
|
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 = { "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 , status ) {
|
|
@@ -46,6 +62,6 @@ function describe(label , status ) {
|
|
|
46
62
|
export async function runNext(context ) {
|
|
47
63
|
const report = await suggestNext(context);
|
|
48
64
|
const describe = (s ) => `${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.js";
|
|
|
7
7
|
import { runFeedback, runJournal } from "./commands/journal.js";
|
|
8
8
|
import { runAbandon, runFeatures, runRoadmap, runTask } from "./commands/progress.js";
|
|
9
9
|
import { runReviewCommand } from "./commands/review.js";
|
|
10
|
+
import { runShow } from "./commands/show.js";
|
|
10
11
|
import { runNext, runStatus } from "./commands/status.js";
|
|
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 = {
|
|
|
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 = {
|
|
|
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.js"
|
|
|
15
15
|
import { readProgress } from "../../domain/project/progress.js";
|
|
16
16
|
import { approvalFolder, approvalsPath, } from "../../domain/project/project_view.js";
|
|
17
17
|
import { featureOf } from "../../domain/project/work_items.js";
|
|
18
|
+
|
|
19
|
+
import { SIZE_RULES } from "../../domain/validation/size_rules.js";
|
|
18
20
|
import { validateProject } from "../../domain/validation/validate_project.js";
|
|
19
21
|
import { appendJournal, loadView, withFiles, } from "../project_context.js";
|
|
20
22
|
|
|
@@ -87,11 +89,13 @@ export async function approveArtifact(deps , ref , options
|
|
|
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 , deltaIds , previous
|
|
|
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 , path ) {
|
|
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 ) {
|
|
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.js
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
import { resolveChange } from "../../domain/project/artifact_ref.js";
|
|
9
|
+
import { readDelta } from "../../domain/project/deltas.js";
|
|
9
10
|
import { activeChange } from "../../domain/status/work_status.js";
|
|
10
11
|
import { traceScenarios, } from "../../domain/trace.js";
|
|
11
12
|
import { loadView, } from "../project_context.js";
|
|
@@ -88,8 +89,14 @@ function globBase(glob ) {
|
|
|
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 ) {
|
|
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 , event , fields
|
|
|
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 , event , item , all = false) {
|
|
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
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { CruzeError } from "../../domain/cruze_error.js";
|
|
2
|
+
import { resolveArtifact } from "../../domain/project/artifact_ref.js";
|
|
3
|
+
import { scopeView, } from "../../domain/project/scope_view.js";
|
|
4
|
+
import { loadView, } from "../project_context.js";
|
|
5
|
+
import { listEvents } from "./journal_events.js";
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
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 , ref ) {
|
|
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 , text ) {
|
|
6
|
+
if (!path.endsWith(".rs")) return text;
|
|
7
|
+
const lines = text.split("\n");
|
|
8
|
+
const kept = [];
|
|
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
|
|
|
2
2
|
import { matchesAny } from "../glob.js";
|
|
3
|
+
import { budgetedText } from "./budget_text.js";
|
|
3
4
|
import { importsOf, resolveImport } from "./imports.js";
|
|
4
5
|
import { countTypes } from "./type_count.js";
|
|
5
6
|
|
|
@@ -50,11 +51,13 @@ export function checkSource(input ) {
|
|
|
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/dist/domain/config.js
CHANGED
|
@@ -38,9 +38,11 @@ import { parse as parseYaml } from "yaml";
|
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
|
|
42
|
+
|
|
41
43
|
|
|
42
44
|
|
|
43
|
-
export const DEFAULT_CHANGE_LIMITS = { minBuilds: 3, maxScenarios: 30, maxTasks: 25 };
|
|
45
|
+
export const DEFAULT_CHANGE_LIMITS = { minBuilds: 3, maxScenarios: 30, maxTasks: 25, exceptions: [] };
|
|
44
46
|
|
|
45
47
|
|
|
46
48
|
|
|
@@ -120,6 +122,14 @@ export function parseConfig(text ) {
|
|
|
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 , i ) => {
|
|
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 = {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { elementLines, } from "../elements.js";
|
|
2
|
+
import { findIds } from "../ids.js";
|
|
3
|
+
import { findSection, } from "../markdown.js";
|
|
4
|
+
import { readDelta } from "./deltas.js";
|
|
5
|
+
import { readScope } from "./plan_parts.js";
|
|
6
|
+
|
|
7
|
+
import { featureOf, } from "./work_items.js";
|
|
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
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
export function scopeView(view , item ) {
|
|
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 = [];
|
|
57
|
+
const show = (ids ) =>
|
|
58
|
+
ids.flatMap((id) => {
|
|
59
|
+
const found = locate(id);
|
|
60
|
+
if (found === undefined) {
|
|
61
|
+
missing.push(id);
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
const shown = { 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 = { 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 ) {
|
|
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 ) {
|
|
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 , deltaItem ) {
|
|
100
|
+
const delta = readDelta(deltaItem.doc);
|
|
101
|
+
const inDelta = new Map ();
|
|
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 ) {
|
|
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, } from "../approvals/evaluate.js";
|
|
2
2
|
import { PATHS } from "../project/layout.js";
|
|
3
3
|
import { readDelta } from "../project/deltas.js";
|
|
4
|
-
import { readScope } from "../project/plan_parts.js";
|
|
4
|
+
import { readChangesTable, readScope } from "../project/plan_parts.js";
|
|
5
5
|
import { readProgress } from "../project/progress.js";
|
|
6
6
|
|
|
7
7
|
import { changesOf, featureOf, } from "../project/work_items.js";
|
|
@@ -80,12 +80,23 @@ export function buildGateReasons(view , branch )
|
|
|
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 , change , feature ) {
|
|
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. */
|
|
@@ -41,6 +41,11 @@ export function elementProblems(path , doc , 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 ) {
|
|
|
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 , ref ) {
|
|
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 ,
|
|
24
|
+
export function changeRowSizeProblems(view , feature , rows , landed ) {
|
|
17
25
|
const limits = changeLimits(view);
|
|
18
26
|
const problems = [];
|
|
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 , path , rows
|
|
|
31
39
|
/** A plan with more tasks than one code review and one verify can hold. */
|
|
32
40
|
export function planSizeProblems(view , path , tasks ) {
|
|
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
|
}
|