@printpractical/cruze 0.0.7 → 0.0.9
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/cli_context.js +2 -0
- package/dist/adapters/inbound/cli/commands/code.js +2 -1
- package/dist/adapters/inbound/cli/commands/journal.js +1 -1
- package/dist/adapters/inbound/cli/commands/show.js +32 -0
- package/dist/adapters/inbound/cli/commands/status.js +19 -3
- package/dist/adapters/inbound/cli/commands.js +3 -1
- package/dist/adapters/outbound/git_repository.js +40 -7
- package/dist/adapters/outbound/project_root.js +13 -0
- 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/project_status.js +4 -1
- package/dist/app/use_cases/show_scope.js +37 -0
- package/dist/domain/check/budget_text.js +61 -0
- package/dist/domain/check/check_source.js +7 -4
- package/dist/domain/config.js +23 -1
- package/dist/domain/edits/agents_file.js +6 -1
- package/dist/domain/journal.js +13 -2
- package/dist/domain/project/scope_view.js +134 -0
- package/dist/domain/status/next_step.js +25 -2
- package/dist/domain/status/verification.js +12 -0
- package/dist/domain/status/work_status.js +13 -2
- package/dist/domain/validation/element_rules.js +5 -0
- package/dist/domain/validation/plan_rules.js +1 -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 +14 -4
- package/package.json +1 -1
- package/skills/architect/SKILL.md +6 -1
- package/skills/architect/feature.md +1 -1
- package/skills/architect/walkthrough.md +7 -4
- package/skills/build/SKILL.md +10 -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 +13 -4
- 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 +2 -1
- 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 +2 -2
- package/skills/realign/notes/0.0.8.md +97 -0
- package/skills/realign/notes/0.0.9.md +59 -0
- package/skills/realign/notes.md +2 -0
- package/skills/research/SKILL.md +2 -1
- package/skills/rethink/SKILL.md +3 -3
- package/skills/rethink/levels.md +2 -2
- package/skills/roadmap/SKILL.md +2 -0
- package/skills/roles/SKILL.md +8 -10
- package/skills/roles/code-reviewer.md +10 -9
- package/skills/roles/design-reviewer.md +22 -12
- package/skills/roles/surveyor.md +1 -1
- package/skills/roles/verifier.md +8 -5
- package/skills/triage/SKILL.md +2 -0
- package/skills/verify/SKILL.md +6 -4
- package/src/adapters/inbound/cli/cli_context.ts +2 -0
- package/src/adapters/inbound/cli/commands/code.ts +2 -1
- package/src/adapters/inbound/cli/commands/journal.ts +1 -1
- package/src/adapters/inbound/cli/commands/show.ts +32 -0
- package/src/adapters/inbound/cli/commands/status.ts +19 -3
- package/src/adapters/inbound/cli/commands.ts +3 -1
- package/src/adapters/outbound/git_repository.ts +40 -7
- package/src/adapters/outbound/project_root.ts +13 -0
- 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/project_status.ts +4 -1
- package/src/app/use_cases/show_scope.ts +37 -0
- package/src/domain/check/budget_text.ts +61 -0
- package/src/domain/check/check_source.ts +7 -4
- package/src/domain/config.ts +23 -1
- package/src/domain/edits/agents_file.ts +6 -1
- package/src/domain/journal.ts +13 -2
- package/src/domain/project/scope_view.ts +134 -0
- package/src/domain/status/next_step.ts +25 -2
- package/src/domain/status/verification.ts +12 -0
- package/src/domain/status/work_status.ts +13 -2
- package/src/domain/validation/element_rules.ts +5 -0
- package/src/domain/validation/plan_rules.ts +1 -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 +14 -4
- package/templates/init/AGENTS.md +1 -1
- package/templates/init/config.yaml +5 -4
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.9 is the ninth preview. Designs now say how work flows and recovers: the design reviewer checks full queues, fairness, recovery that fails and cancellation, and `references:` names the specifications a design must follow. `cruze next` sends a change verify sent back to the rethink it needs, file budgets are a backstop rather than a size to design to, and every step reads a change's scope through `cruze show` instead of the living docs whole. 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.9/printpractical-cruze-0.0.9.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.9
|
|
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.9/printpractical-cruze-0.0.9.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
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
1
2
|
import { checkCode, traceTests } from "../../../../app/use_cases/check_code.js";
|
|
2
3
|
import { landChange } from "../../../../app/use_cases/land_change.js";
|
|
3
4
|
|
|
4
5
|
|
|
5
6
|
export async function runCheck(context , args , options ) {
|
|
6
|
-
const files = [...options.file, ...args];
|
|
7
|
+
const files = [...options.file, ...args].map((file) => posix.normalize(posix.join(context.workingDirectory, file)));
|
|
7
8
|
const report = await checkCode(context, { ci: options.ci, ...(files.length === 0 ? {} : { files }) });
|
|
8
9
|
const lines = report.findings.map((f) => `${f.severity} ${f.path}${f.line === undefined ? "" : `:${f.line}`} ${f.message} [${f.rule}]${f.pending === true ? " (pending realign)" : ""}`);
|
|
9
10
|
lines.push(`Checked ${report.checked} file(s): ${report.passed ? "passed" : "failed"}.`);
|
|
@@ -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,32 @@
|
|
|
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
|
+
if (report.feature === undefined) lines.push("## Settled decisions", "", ...(report.decisions.length === 0 ? ["None."] : report.decisions), "");
|
|
18
|
+
else {
|
|
19
|
+
lines.push("## Settled decisions of the feature", "", ...(report.decisions.length === 0 ? ["None."] : report.decisions), "");
|
|
20
|
+
lines.push("## Settled decisions of the change", "", ...(report.changeDecisions.length === 0 ? ["None."] : report.changeDecisions), "");
|
|
21
|
+
}
|
|
22
|
+
lines.push("## Dispositions", "");
|
|
23
|
+
lines.push(...(report.dispositions.length === 0 ? ["None."] : report.dispositions.map((d) => `- [${d.disposition}, ${d.review}] ${d.finding}: ${d.reason}`)), "");
|
|
24
|
+
if (report.references.length > 0) lines.push("## References", "", ...report.references.map((r) => `- \`${r.path}\`: ${r.use}`), "");
|
|
25
|
+
if (report.missing.length > 0) lines.push("## Missing", "", ...report.missing.map((id) => `- ${id} is defined nowhere`), "");
|
|
26
|
+
return lines.join("\n").trimEnd();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function blocks(elements ) {
|
|
30
|
+
if (elements.length === 0) return ["None.", ""];
|
|
31
|
+
return elements.flatMap((element) => [`<!-- ${element.source} -->`, ...element.body.join("\n").trimEnd().split("\n"), ""]);
|
|
32
|
+
}
|
|
@@ -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" },
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
|
|
4
4
|
|
|
@@ -37,13 +37,24 @@ export class GitRepository {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
async filesOnBranch(branch , dir ) {
|
|
40
|
-
const files = new Map ();
|
|
41
40
|
const listing = await this.git("ls-tree", "-r", "--name-only", branch, "--", dir);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
return
|
|
41
|
+
const paths = (listing ?? "").split("\n").filter((p) => p !== "");
|
|
42
|
+
if (paths.length === 0) return new Map();
|
|
43
|
+
// One git process for every file: a process per file took up to two minutes on a project with 26 branches.
|
|
44
|
+
const blobs = await this.catFiles(paths.map((path) => `${branch}:${path}`));
|
|
45
|
+
return new Map(paths.flatMap((path, i) => (blobs[i] === undefined ? [] : [[path, blobs[i]] ])));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The contents of each object, in order, through one `git cat-file --batch`; undefined for one that is missing. */
|
|
49
|
+
catFiles(objects ) {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
const child = spawn("git", ["cat-file", "--batch"], { cwd: this.root, stdio: ["pipe", "pipe", "ignore"] });
|
|
52
|
+
const chunks = [];
|
|
53
|
+
child.stdout.on("data", (chunk ) => chunks.push(chunk));
|
|
54
|
+
child.on("error", () => resolve(objects.map(() => undefined)));
|
|
55
|
+
child.on("close", () => resolve(parseBatch(Buffer.concat(chunks), objects.length)));
|
|
56
|
+
child.stdin.end(`${objects.join("\n")}\n`);
|
|
57
|
+
});
|
|
47
58
|
}
|
|
48
59
|
|
|
49
60
|
async git(...args ) {
|
|
@@ -55,3 +66,25 @@ export class GitRepository {
|
|
|
55
66
|
}
|
|
56
67
|
}
|
|
57
68
|
}
|
|
69
|
+
|
|
70
|
+
/** Splits `git cat-file --batch` output: a `<sha> <type> <size>` header, the bytes and a newline, or `<name> missing`. */
|
|
71
|
+
function parseBatch(output , count ) {
|
|
72
|
+
const results = [];
|
|
73
|
+
let at = 0;
|
|
74
|
+
while (results.length < count && at < output.length) {
|
|
75
|
+
const headerEnd = output.indexOf(10, at);
|
|
76
|
+
if (headerEnd === -1) break;
|
|
77
|
+
const header = output.subarray(at, headerEnd).toString("utf8");
|
|
78
|
+
const size = /^[0-9a-f]+ \w+ (\d+)$/.exec(header)?.[1];
|
|
79
|
+
if (size === undefined) {
|
|
80
|
+
results.push(undefined);
|
|
81
|
+
at = headerEnd + 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const start = headerEnd + 1;
|
|
85
|
+
results.push(output.subarray(start, start + Number(size)).toString("utf8"));
|
|
86
|
+
at = start + Number(size) + 1;
|
|
87
|
+
}
|
|
88
|
+
while (results.length < count) results.push(undefined);
|
|
89
|
+
return results;
|
|
90
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** The nearest directory at or above `start` that holds `.cruze/config.yaml`, or undefined when none does. */
|
|
5
|
+
export function findProjectRoot(start ) {
|
|
6
|
+
let dir = start;
|
|
7
|
+
for (;;) {
|
|
8
|
+
if (existsSync(join(dir, ".cruze", "config.yaml"))) return dir;
|
|
9
|
+
const parent = dirname(dir);
|
|
10
|
+
if (parent === dir) return undefined;
|
|
11
|
+
dir = parent;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -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
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CruzeError } from "../../domain/cruze_error.js";
|
|
2
|
+
import { PATHS } from "../../domain/project/layout.js";
|
|
2
3
|
import { readProgress } from "../../domain/project/progress.js";
|
|
3
4
|
import { buildProjectView } from "../../domain/project/project_view.js";
|
|
4
5
|
import { findWorkItem, } from "../../domain/project/work_items.js";
|
|
@@ -59,7 +60,9 @@ export async function findOverlaps(deps , ref )
|
|
|
59
60
|
const mine = touchedIds(view, change);
|
|
60
61
|
const overlaps = [];
|
|
61
62
|
for (const other of await deps.repository.otherBranches()) {
|
|
62
|
-
|
|
63
|
+
// Landed work is archived, so a branch's work in flight is under features/ and changes/ only.
|
|
64
|
+
const inFlight = [...(await deps.repository.filesOnBranch(other, PATHS.featuresDir)), ...(await deps.repository.filesOnBranch(other, PATHS.changesDir))];
|
|
65
|
+
const theirs = buildProjectView(new Map([...view.snapshot].filter(([p]) => p.startsWith("docs/")).concat(inFlight)));
|
|
63
66
|
// Work in flight on a branch is the change bound to it that hasn't landed, there or here.
|
|
64
67
|
const boundThere = theirs.items.filter((i) => {
|
|
65
68
|
const bound = readProgress(i.doc).branch;
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The design a change builds, or a feature's whole delta, with the elements it cites and the
|
|
23
|
+
* item's settled ledger: what plan, build and the reviewers read instead of the whole living docs.
|
|
24
|
+
*/
|
|
25
|
+
export async function showScope(deps , ref ) {
|
|
26
|
+
const view = await loadView(deps.files);
|
|
27
|
+
const { item } = resolveArtifact(view, ref);
|
|
28
|
+
if (item === undefined) throw new CruzeError("not-work", `"${ref}" is not a feature or change; show takes a feature or change ID`);
|
|
29
|
+
const scope = scopeView(view, item);
|
|
30
|
+
const dispositions = (await listEvents(deps, "disposition", scope.feature ?? item.ref, true)).map((entry) => ({
|
|
31
|
+
finding: String(entry["finding"] ?? ""),
|
|
32
|
+
disposition: String(entry["disposition"] ?? ""),
|
|
33
|
+
reason: String(entry["reason"] ?? ""),
|
|
34
|
+
review: String(entry["review"] ?? ""),
|
|
35
|
+
}));
|
|
36
|
+
return { ...scope, dispositions, references: view.config?.config?.references ?? [] };
|
|
37
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The part of a source file its budgets measure. Budgets apply to source, not tests, so a Rust
|
|
3
|
+
* file's test-only modules are left out: rustfmt closes a top-level module with `}` in column 0.
|
|
4
|
+
* A module is test-only when its `cfg` needs `test`, as `#[cfg(test)]` or
|
|
5
|
+
* `#[cfg(all(test, feature = "schema"))]` do; other attributes may sit between the two.
|
|
6
|
+
*/
|
|
7
|
+
export function budgetedText(path , text ) {
|
|
8
|
+
if (!path.endsWith(".rs")) return text;
|
|
9
|
+
const lines = text.split("\n");
|
|
10
|
+
const kept = [];
|
|
11
|
+
for (let i = 0; i < lines.length; i++) {
|
|
12
|
+
const start = testModuleStart(lines, i);
|
|
13
|
+
if (start === null) {
|
|
14
|
+
kept.push(lines[i] ?? "");
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if ((lines[start] ?? "").trimEnd().endsWith(";")) {
|
|
18
|
+
i = start;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
let end = start + 1;
|
|
22
|
+
while (end < lines.length && lines[end] !== "}") end++;
|
|
23
|
+
i = end;
|
|
24
|
+
}
|
|
25
|
+
return kept.join("\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The line of the `mod` a test-only `cfg` at `index` gates, or null when it gates no module. */
|
|
29
|
+
function testModuleStart(lines , index ) {
|
|
30
|
+
if (!needsTest(lines[index]?.trim() ?? "")) return null;
|
|
31
|
+
let next = index + 1;
|
|
32
|
+
while (/^\s*#\[/.test(lines[next] ?? "")) next++;
|
|
33
|
+
return /^(?:pub(?:\([^)]*\))?\s+)?mod\s+\w+\s*[{;]/.test(lines[next] ?? "") ? next : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function needsTest(attribute ) {
|
|
37
|
+
const predicate = /^#\[cfg\((.*)\)\]$/.exec(attribute)?.[1]?.trim();
|
|
38
|
+
if (predicate === undefined) return false;
|
|
39
|
+
if (predicate === "test") return true;
|
|
40
|
+
const all = /^all\((.*)\)$/.exec(predicate)?.[1];
|
|
41
|
+
return all !== undefined && topLevelArguments(all).includes("test");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Splits `a, b(c, d), e` at its top-level commas. */
|
|
45
|
+
function topLevelArguments(text ) {
|
|
46
|
+
const parts = [];
|
|
47
|
+
let depth = 0;
|
|
48
|
+
let current = "";
|
|
49
|
+
for (const char of text) {
|
|
50
|
+
if (char === "," && depth === 0) {
|
|
51
|
+
parts.push(current.trim());
|
|
52
|
+
current = "";
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (char === "(") depth++;
|
|
56
|
+
if (char === ")") depth--;
|
|
57
|
+
current += char;
|
|
58
|
+
}
|
|
59
|
+
parts.push(current.trim());
|
|
60
|
+
return parts;
|
|
61
|
+
}
|
|
@@ -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,13 +51,15 @@ 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 if it holds a second responsibility, or list it under check.exceptions` });
|
|
56
59
|
}
|
|
57
|
-
const types = countTypes(path,
|
|
60
|
+
const types = countTypes(path, budgeted);
|
|
58
61
|
if (!excepted && types !== null && types > config.check.maxTypes) {
|
|
59
|
-
findings.push({ path, rule: "max-types", severity: "warning", message: `${types} top-level types, over the budget of ${config.check.maxTypes}` });
|
|
62
|
+
findings.push({ path, rule: "max-types", severity: "warning", message: `${types} top-level types, over the budget of ${config.check.maxTypes}; split it if it holds a second responsibility, or list it under check.exceptions` });
|
|
60
63
|
}
|
|
61
64
|
if (!excepted && isRustTestFileInSource(path)) {
|
|
62
65
|
findings.push({ path, rule: "test-placement", severity: "warning", message: "Rust tests that leave their inline module go in the crate's tests/ directory, not under src/" });
|
package/dist/domain/config.js
CHANGED
|
@@ -26,6 +26,8 @@ import { parse as parseYaml } from "yaml";
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
|
|
30
|
+
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
/** Which review findings the user decides: only the exceptions, or every one. */
|
|
@@ -38,9 +40,11 @@ import { parse as parseYaml } from "yaml";
|
|
|
38
40
|
|
|
39
41
|
|
|
40
42
|
|
|
43
|
+
|
|
44
|
+
|
|
41
45
|
|
|
42
46
|
|
|
43
|
-
export const DEFAULT_CHANGE_LIMITS = { minBuilds: 3, maxScenarios: 30, maxTasks: 25 };
|
|
47
|
+
export const DEFAULT_CHANGE_LIMITS = { minBuilds: 3, maxScenarios: 30, maxTasks: 25, exceptions: [] };
|
|
44
48
|
|
|
45
49
|
|
|
46
50
|
|
|
@@ -120,8 +124,25 @@ export function parseConfig(text ) {
|
|
|
120
124
|
minBuilds: positiveInt(limits["min_builds"], "changes.min_builds", problems) ?? DEFAULT_CHANGE_LIMITS.minBuilds,
|
|
121
125
|
maxScenarios: positiveInt(limits["max_scenarios"], "changes.max_scenarios", problems) ?? DEFAULT_CHANGE_LIMITS.maxScenarios,
|
|
122
126
|
maxTasks: positiveInt(limits["max_tasks"], "changes.max_tasks", problems) ?? DEFAULT_CHANGE_LIMITS.maxTasks,
|
|
127
|
+
exceptions: (Array.isArray(limits["exceptions"]) ? limits["exceptions"] : []).flatMap((entry , i ) => {
|
|
128
|
+
const record = asRecord(entry);
|
|
129
|
+
if (typeof record?.["item"] !== "string" || typeof record["reason"] !== "string" || record["reason"].trim() === "") {
|
|
130
|
+
problems.push(`changes.exceptions[${i}] needs an item and a non-empty reason`);
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
return [{ item: record["item"], reason: record["reason"] }];
|
|
134
|
+
}),
|
|
123
135
|
};
|
|
124
136
|
|
|
137
|
+
const references = (Array.isArray(root["references"]) ? root["references"] : root["references"] === undefined ? [] : [null]).flatMap((entry , i ) => {
|
|
138
|
+
const record = asRecord(entry);
|
|
139
|
+
if (typeof record?.["path"] !== "string" || record["path"].trim() === "" || typeof record["use"] !== "string" || record["use"].trim() === "") {
|
|
140
|
+
problems.push(`references[${i}] needs a path and a non-empty use`);
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
return [{ path: record["path"], use: record["use"] }];
|
|
144
|
+
});
|
|
145
|
+
|
|
125
146
|
const config = {
|
|
126
147
|
version: 1,
|
|
127
148
|
...(typeof cruze === "string" && cruze.trim() !== "" ? { cruze } : {}),
|
|
@@ -138,6 +159,7 @@ export function parseConfig(text ) {
|
|
|
138
159
|
decide: decide === "all" ? "all" : "exceptions",
|
|
139
160
|
},
|
|
140
161
|
changes,
|
|
162
|
+
references,
|
|
141
163
|
};
|
|
142
164
|
return { config, problems };
|
|
143
165
|
}
|
|
@@ -19,9 +19,14 @@ export function renderAgentsFile(text , view ) {
|
|
|
19
19
|
return next;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The modules whose code exists: those built, and those a land built before a rethink set them
|
|
24
|
+
* back to `planned` for a change to rebuild, since their directories are still there.
|
|
25
|
+
*/
|
|
22
26
|
function builtModules(view ) {
|
|
27
|
+
const everBuilt = new Set(view.journal.filter((e) => e.event === "land").flatMap((e) => (Array.isArray(e["built"]) ? (e["built"] ).map(String) : [])));
|
|
23
28
|
return [...view.living.elements.values()]
|
|
24
|
-
.filter(({ element, doc }) => element.kind === "MOD" && statusOf(doc, element) === "built")
|
|
29
|
+
.filter(({ element, doc }) => element.kind === "MOD" && (statusOf(doc, element) === "built" || everBuilt.has(element.id)))
|
|
25
30
|
.map(({ element, doc }) => {
|
|
26
31
|
const facts = factsOf(doc, element);
|
|
27
32
|
const paths = (facts.get("Path") ?? []).flatMap((value) => [...value.matchAll(/`([^`]+)`/g)].map((m) => `\`${m[1] ?? ""}\``));
|
package/dist/domain/journal.js
CHANGED
|
@@ -23,6 +23,7 @@ const OPTIONAL_FIELDS = {
|
|
|
23
23
|
rethink: ["missed_by"],
|
|
24
24
|
review: ["nits", "checked_by"],
|
|
25
25
|
disposition: ["decided"],
|
|
26
|
+
verification: ["back_to"],
|
|
26
27
|
};
|
|
27
28
|
|
|
28
29
|
/** The workflow steps and checks that find or miss a problem, so a retro can count them. */
|
|
@@ -35,11 +36,21 @@ export const FIELD_VALUES = {
|
|
|
35
36
|
decided: ["agent", "user"],
|
|
36
37
|
review: ["design", "plan", "code", "realign", "improve"],
|
|
37
38
|
result: ["accepted", "sent-back"],
|
|
39
|
+
back_to: ["build", "rethink"],
|
|
38
40
|
checked_by: ["agent", "reviewer"],
|
|
39
41
|
found_by: STEPS,
|
|
40
42
|
missed_by: STEPS,
|
|
41
43
|
};
|
|
42
44
|
|
|
45
|
+
/** A defect rethink must say which step missed it, and a sent-back verification where the change goes back to. */
|
|
46
|
+
function conditionallyRequired(event , fields ) {
|
|
47
|
+
if (event === "rethink" && fields["kind"] === "defect") return ["missed_by"];
|
|
48
|
+
if (event === "verification" && fields["result"] === "sent-back") return ["back_to"];
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const CONDITION_NOTES = { missed_by: " for a defect", back_to: " when the change is sent back" };
|
|
53
|
+
|
|
43
54
|
/** Fields recorded as numbers rather than text. */
|
|
44
55
|
const COUNT_FIELDS = ["round", "blockers", "concerns", "nits"];
|
|
45
56
|
|
|
@@ -71,8 +82,8 @@ export function serializeEntry(entry ) {
|
|
|
71
82
|
export function recordedEventProblems(event , fields ) {
|
|
72
83
|
const required = RECORDED_EVENTS[event];
|
|
73
84
|
if (required === undefined) return [`unknown event "${event}"; recordable events: ${Object.keys(RECORDED_EVENTS).join(", ")}`];
|
|
74
|
-
const needed =
|
|
75
|
-
const problems = needed.filter((field) => (fields[field] ?? "").trim() === "").map((field) => `${event} needs ${field}${field
|
|
85
|
+
const needed = [...required, ...conditionallyRequired(event, fields)];
|
|
86
|
+
const problems = needed.filter((field) => (fields[field] ?? "").trim() === "").map((field) => `${event} needs ${field}${CONDITION_NOTES[field] ?? ""}`);
|
|
76
87
|
const known = [...required, ...(OPTIONAL_FIELDS[event] ?? [])];
|
|
77
88
|
for (const field of known) {
|
|
78
89
|
const value = fields[field];
|