@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CruzeError } from "../../domain/cruze_error.ts";
|
|
2
|
+
import { PATHS } from "../../domain/project/layout.ts";
|
|
2
3
|
import { readProgress } from "../../domain/project/progress.ts";
|
|
3
4
|
import { buildProjectView } from "../../domain/project/project_view.ts";
|
|
4
5
|
import { findWorkItem, type WorkItem } from "../../domain/project/work_items.ts";
|
|
@@ -59,7 +60,9 @@ export async function findOverlaps(deps: ProjectDeps, ref?: string): Promise<{ c
|
|
|
59
60
|
const mine = touchedIds(view, change);
|
|
60
61
|
const overlaps: Overlap[] = [];
|
|
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.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
|
+
/** The external documents the design must comply with, from `references:` in `.cruze/config.yaml`. */
|
|
18
|
+
references: Array<{ path: string; use: string }>;
|
|
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: ProjectDeps, ref: string): Promise<ScopeReport> {
|
|
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: string, text: string): string {
|
|
8
|
+
if (!path.endsWith(".rs")) return text;
|
|
9
|
+
const lines = text.split("\n");
|
|
10
|
+
const kept: string[] = [];
|
|
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: string[], index: number): number | null {
|
|
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: string): boolean {
|
|
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: string): string[] {
|
|
46
|
+
const parts: string[] = [];
|
|
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
|
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,13 +51,15 @@ 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 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/src/domain/config.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface CruzeConfig {
|
|
|
26
26
|
review: { command?: string[]; decide: ReviewDecide };
|
|
27
27
|
/** The size band of a change, so each one is worth its own plan, review and verify. */
|
|
28
28
|
changes: ChangeLimits;
|
|
29
|
+
/** External documents the design must comply with, such as a protocol specification, each with what it governs. */
|
|
30
|
+
references: Array<{ path: string; use: string }>;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/** Which review findings the user decides: only the exceptions, or every one. */
|
|
@@ -38,9 +40,11 @@ export interface ChangeLimits {
|
|
|
38
40
|
maxScenarios: number;
|
|
39
41
|
/** A plan with more tasks than this splits. */
|
|
40
42
|
maxTasks: number;
|
|
43
|
+
/** Features and changes the user accepted outside the band, by ref, each with the reason. */
|
|
44
|
+
exceptions: Array<{ item: string; reason: string }>;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
export const DEFAULT_CHANGE_LIMITS: ChangeLimits = { minBuilds: 3, maxScenarios: 30, maxTasks: 25 };
|
|
47
|
+
export const DEFAULT_CHANGE_LIMITS: ChangeLimits = { minBuilds: 3, maxScenarios: 30, maxTasks: 25, exceptions: [] };
|
|
44
48
|
|
|
45
49
|
export interface Layer {
|
|
46
50
|
name: string;
|
|
@@ -120,8 +124,25 @@ export function parseConfig(text: string): ConfigResult {
|
|
|
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: unknown, i: number) => {
|
|
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: unknown, i: number) => {
|
|
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: CruzeConfig = {
|
|
126
147
|
version: 1,
|
|
127
148
|
...(typeof cruze === "string" && cruze.trim() !== "" ? { cruze } : {}),
|
|
@@ -138,6 +159,7 @@ export function parseConfig(text: string): ConfigResult {
|
|
|
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: string, view: ProjectView): string {
|
|
|
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: ProjectView): string[] {
|
|
27
|
+
const everBuilt = new Set(view.journal.filter((e) => e.event === "land").flatMap((e) => (Array.isArray(e["built"]) ? (e["built"] as unknown[]).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/src/domain/journal.ts
CHANGED
|
@@ -23,6 +23,7 @@ const OPTIONAL_FIELDS: Record<string, string[]> = {
|
|
|
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: Record<string, string[]> = {
|
|
|
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: string, fields: Record<string, string>): string[] {
|
|
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: Record<string, string> = { 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: JournalEntry): string {
|
|
|
71
82
|
export function recordedEventProblems(event: string, fields: Record<string, string>): string[] {
|
|
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];
|
|
@@ -0,0 +1,134 @@
|
|
|
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, or of a standalone change. */
|
|
40
|
+
decisions: string[];
|
|
41
|
+
/** A feature's change's own settled decisions, numbered apart from the feature's. */
|
|
42
|
+
changeDecisions: string[];
|
|
43
|
+
/** Scope IDs defined nowhere: the delta, the living docs or the archive. */
|
|
44
|
+
missing: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface Located {
|
|
48
|
+
doc: MarkdownDoc;
|
|
49
|
+
path: string;
|
|
50
|
+
element: Element;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function scopeView(view: ProjectView, item: WorkItem): ScopeView {
|
|
54
|
+
const feature = featureOf(view.items, item);
|
|
55
|
+
const deltaItem = feature ?? item;
|
|
56
|
+
const locate = locator(view, deltaItem);
|
|
57
|
+
const wanted = item.kind === "change" || item.kind === "standalone" ? changeIds(item) : featureIds(deltaItem);
|
|
58
|
+
const missing: string[] = [];
|
|
59
|
+
const show = (ids: string[]): ShownElement[] =>
|
|
60
|
+
ids.flatMap((id) => {
|
|
61
|
+
const found = locate(id);
|
|
62
|
+
if (found === undefined) {
|
|
63
|
+
missing.push(id);
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
const shown: ShownElement = { id, title: found.element.title, source: `${found.path}:${found.element.start + 1}`, body: elementLines(found.doc, found.element) };
|
|
67
|
+
if (found.element.op !== undefined) shown.op = found.element.op;
|
|
68
|
+
return [shown];
|
|
69
|
+
});
|
|
70
|
+
const elements = show(wanted.elements);
|
|
71
|
+
const scenarios = withoutRepeatedScenarios(show(wanted.scenarios));
|
|
72
|
+
const shownIds = new Set([...elements, ...scenarios].map((e) => e.id));
|
|
73
|
+
const cited = [...new Set([...elements, ...scenarios].flatMap((e) => findIds(e.body.join("\n"))))]
|
|
74
|
+
.filter((id) => !shownIds.has(id))
|
|
75
|
+
.flatMap((id) => {
|
|
76
|
+
const found = locate(id);
|
|
77
|
+
return found === undefined ? [] : [{ id, title: found.element.title, source: `${found.path}:${found.element.start + 1}` }];
|
|
78
|
+
});
|
|
79
|
+
const decisions = decisionLines((feature ?? item).doc);
|
|
80
|
+
const changeDecisions = feature === undefined ? [] : decisionLines(item.doc);
|
|
81
|
+
const result: ScopeView = { ref: item.ref, kind: item.kind, elements, scenarios, cited, decisions, changeDecisions, missing };
|
|
82
|
+
if (feature !== undefined) result.feature = feature.ref;
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A requirement shown beside its own scenarios keeps only its own text: its body would repeat
|
|
88
|
+
* each of them, which made up a third of one change's output.
|
|
89
|
+
*/
|
|
90
|
+
function withoutRepeatedScenarios(shown: ShownElement[]): ShownElement[] {
|
|
91
|
+
const ids = new Set(shown.map((e) => e.id));
|
|
92
|
+
return shown.map((element) => {
|
|
93
|
+
if (!element.id.startsWith("REQ-")) return element;
|
|
94
|
+
const first = element.body.findIndex((line, i) => i > 0 && /^#{2,6} /.test(line) && findIds(line).some((id) => ids.has(id)));
|
|
95
|
+
return first === -1 ? element : { ...element, body: element.body.slice(0, first) };
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A change shows what its scope builds, removes and delivers. */
|
|
100
|
+
function changeIds(change: WorkItem): { elements: string[]; scenarios: string[] } {
|
|
101
|
+
const scope = readScope(change.doc);
|
|
102
|
+
return { elements: [...(scope?.builds ?? []), ...(scope?.removes ?? [])], scenarios: scope?.delivers ?? [] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A feature shows its whole delta: every operation, with a requirement's scenarios inside its body. */
|
|
106
|
+
function featureIds(feature: WorkItem): { elements: string[]; scenarios: string[] } {
|
|
107
|
+
const entries = readDelta(feature.doc).entries;
|
|
108
|
+
return {
|
|
109
|
+
elements: entries.filter((e) => e.section === "architecture").map((e) => e.element.id),
|
|
110
|
+
scenarios: entries.filter((e) => e.section === "spec").map((e) => e.element.id),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 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. */
|
|
115
|
+
function locator(view: ProjectView, deltaItem: WorkItem): (id: string) => Located | undefined {
|
|
116
|
+
const delta = readDelta(deltaItem.doc);
|
|
117
|
+
const inDelta = new Map<string, Element>();
|
|
118
|
+
for (const entry of delta.entries) {
|
|
119
|
+
inDelta.set(entry.element.id, entry.element);
|
|
120
|
+
for (const scenario of entry.scenarios) inDelta.set(scenario.id, scenario);
|
|
121
|
+
}
|
|
122
|
+
return (id) => {
|
|
123
|
+
const own = inDelta.get(id);
|
|
124
|
+
if (own !== undefined) return { doc: deltaItem.doc, path: deltaItem.path, element: own };
|
|
125
|
+
const living = view.living.elements.get(id);
|
|
126
|
+
return living === undefined ? undefined : { doc: living.doc, path: living.path, element: living.element };
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function decisionLines(doc: MarkdownDoc): string[] {
|
|
131
|
+
const section = findSection(doc, "Settled decisions");
|
|
132
|
+
if (section === null) return [];
|
|
133
|
+
return doc.lines.slice(section.start, section.end).filter((line) => line.startsWith("- "));
|
|
134
|
+
}
|
|
@@ -7,7 +7,7 @@ import { readRoadmapItems, readRoadmapStatus } from "../project/roadmap.ts";
|
|
|
7
7
|
import { changesOf, featureOf, type WorkItem } from "../project/work_items.ts";
|
|
8
8
|
import { compareVersions } from "../versions/cruze_version.ts";
|
|
9
9
|
import { FIRST_RELEASE } from "../versions/rule_versions.ts";
|
|
10
|
-
import { isVerified } from "./verification.ts";
|
|
10
|
+
import { awaitsRethink, isVerified } from "./verification.ts";
|
|
11
11
|
import { activeChange } from "./work_status.ts";
|
|
12
12
|
|
|
13
13
|
/** The Cruze step to run next, computed from the project's state and the current branch. */
|
|
@@ -31,7 +31,7 @@ export function nextSteps(view: ProjectView, branch: string | null): NextReport
|
|
|
31
31
|
const project = projectStep(view);
|
|
32
32
|
if (project !== null) return { next: project, also: [] };
|
|
33
33
|
const active = activeChange(view, branch);
|
|
34
|
-
const inFlight = view.items.filter((item) => !item.archived && item.kind !== "change").flatMap((item) => itemSteps(view, item, branch));
|
|
34
|
+
const inFlight = inRoadmapOrder(view, view.items.filter((item) => !item.archived && item.kind !== "change")).flatMap((item) => itemSteps(view, item, branch));
|
|
35
35
|
const roadmap = roadmapStep(view);
|
|
36
36
|
const realign = realignStep(view);
|
|
37
37
|
const candidates = [
|
|
@@ -45,6 +45,28 @@ export function nextSteps(view: ProjectView, branch: string | null): NextReport
|
|
|
45
45
|
return { next: next ?? { step: "roadmap", reason: "every item on the roadmap has landed; close the release and plan the next" }, also };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Work in flight in the roadmap's order, with items whose roadmap blockers haven't all landed
|
|
50
|
+
* last: a feature designed early still waits for the prefactors the user put before it.
|
|
51
|
+
*/
|
|
52
|
+
function inRoadmapOrder(view: ProjectView, items: WorkItem[]): WorkItem[] {
|
|
53
|
+
const text = view.snapshot.get(PATHS.roadmap);
|
|
54
|
+
if (text === undefined) return items;
|
|
55
|
+
const rows = readRoadmapStatus(text);
|
|
56
|
+
const state = new Map(rows.map((row) => [row.slug, row.state]));
|
|
57
|
+
const slugOf = new Map(rows.map((row) => [row.id, row.slug]));
|
|
58
|
+
const roadmap = readRoadmapItems(text);
|
|
59
|
+
const rank = (item: WorkItem): [number, number] => {
|
|
60
|
+
const at = roadmap.findIndex((r) => r.slug === slugOf.get(item.ref));
|
|
61
|
+
const blocked = at !== -1 && (roadmap[at]?.blockedBy ?? []).some((b) => state.get(b) !== "landed");
|
|
62
|
+
return [blocked ? 1 : 0, at === -1 ? roadmap.length : at];
|
|
63
|
+
};
|
|
64
|
+
return items
|
|
65
|
+
.map((item, i) => ({ item, key: [...rank(item), i] }))
|
|
66
|
+
.sort((a, b) => (a.key[0] ?? 0) - (b.key[0] ?? 0) || (a.key[1] ?? 0) - (b.key[1] ?? 0) || (a.key[2] ?? 0) - (b.key[2] ?? 0))
|
|
67
|
+
.map(({ item }) => item);
|
|
68
|
+
}
|
|
69
|
+
|
|
48
70
|
/** Realign comes when nothing else is due: the installed skills expect more than the code meets. */
|
|
49
71
|
function realignStep(view: ProjectView): NextStep | null {
|
|
50
72
|
const config = view.config?.config;
|
|
@@ -117,6 +139,7 @@ function changeStep(view: ProjectView, change: WorkItem, branch: string | null):
|
|
|
117
139
|
if (!isPlanned(change.doc)) return { step: "plan", target: change.ref, reason: "the change is designed but has no test plan or tasks yet" };
|
|
118
140
|
if (state.state === "unapproved") return { step: "plan", target: change.ref, reason: "review the plan and approve it on the branch that will build it" };
|
|
119
141
|
if (state.state !== "approved") return { step: "rethink", target: change.ref, reason: `the change or something it cites changed since its approval${state.changed.length > 0 ? ` (${state.changed.join(", ")})` : ""}` };
|
|
142
|
+
if (awaitsRethink(view.journal, change, feature)) return { step: "rethink", target: feature?.ref ?? change.ref, reason: `verify sent ${change.ref} back for a design change` };
|
|
120
143
|
if (bound !== undefined && bound !== branch) return { step: "switch-branch", target: bound, reason: `${change.ref} is built on branch ${bound}` };
|
|
121
144
|
const progress = readProgress(change.doc);
|
|
122
145
|
if ([...progress.tasks.values()].some((commit) => commit === null)) return { step: "build", target: change.ref, reason: "the change has open tasks" };
|
|
@@ -15,3 +15,15 @@ export function isVerified(journal: JournalEntry[], change: WorkItem): boolean {
|
|
|
15
15
|
const verifiedHash = approvals.filter((e) => e.at < verification.at).at(-1)?.["hash"];
|
|
16
16
|
return approvals.filter((e) => e.at > verification.at).every((e) => e["hash"] === verifiedHash);
|
|
17
17
|
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether verify sent the change back for a rethink that hasn't been recorded yet. Tasks the
|
|
21
|
+
* agent reopens at verify would otherwise make the change look ready to build again.
|
|
22
|
+
*/
|
|
23
|
+
export function awaitsRethink(journal: JournalEntry[], change: WorkItem, feature: WorkItem | undefined): boolean {
|
|
24
|
+
const verification = journal.filter((e) => e.event === "verification" && e["item"] === change.ref).sort((a, b) => a.at.localeCompare(b.at)).at(-1);
|
|
25
|
+
if (verification?.["result"] !== "sent-back" || verification["back_to"] !== "rethink") return false;
|
|
26
|
+
// An architecture-level rethink records no item, and counts too.
|
|
27
|
+
const refs = new Set([change.ref, feature?.ref, undefined]);
|
|
28
|
+
return !journal.some((e) => e.event === "rethink" && e.at > verification.at && refs.has(e["item"] as string | undefined));
|
|
29
|
+
}
|
|
@@ -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. */
|
|
@@ -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`));
|
|
@@ -46,7 +46,7 @@ export function changePlanProblems(
|
|
|
46
46
|
if (kindOf(id) === "MOD") {
|
|
47
47
|
const modulePaths = modulePathsOf(view, delta.doc, delta.delta, id);
|
|
48
48
|
if (!taskPaths.some((p) => modulePaths.some((m) => p.startsWith(m)))) {
|
|
49
|
-
problems.push(error(path, scope.line, "unproved-scope", `${id} is in scope but no task writes inside ${modulePaths.join(" or ") || "its path"}`));
|
|
49
|
+
problems.push(error(path, scope.line, "unproved-scope", `${id} is in scope but no task writes inside ${modulePaths.join(" or ") || "its path"}; a module that owns other files, such as a build script, lists each under its Path`));
|
|
50
50
|
}
|
|
51
51
|
} else if (kindOf(id) === "REQ") {
|
|
52
52
|
const scenarios = delta.delta.entries.find((e) => e.element.id === id)?.scenarios.map((s) => s.id) ?? [];
|
|
@@ -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
|
}
|
|
@@ -11,7 +11,7 @@ import { deltaProblems } from "./delta_rules.ts";
|
|
|
11
11
|
import { malformedIdHeadings, lineOf } from "./living_rules.ts";
|
|
12
12
|
import { changePlanProblems } from "./plan_rules.ts";
|
|
13
13
|
import { error, warning, type Problem } from "./problem.ts";
|
|
14
|
-
import { changeRowSizeProblems } from "./size_rules.ts";
|
|
14
|
+
import { changeRowSizeProblems, sizeAccepted } from "./size_rules.ts";
|
|
15
15
|
|
|
16
16
|
const REQUIRED_SECTIONS: Record<WorkItem["kind"], string[]> = {
|
|
17
17
|
feature: ["Intent", "Spec delta", "Architecture delta", "Changes"],
|
|
@@ -45,6 +45,10 @@ function itemProblems(view: ProjectView, item: WorkItem): Problem[] {
|
|
|
45
45
|
for (const cited of findIds(text).filter((cited) => !available.has(cited))) {
|
|
46
46
|
problems.push(error(path, lineOf(text, cited), "unknown-id", `${cited} is cited but defined neither in the living docs nor in this work's deltas`));
|
|
47
47
|
}
|
|
48
|
+
const dropped = droppedScenarios(view, delta);
|
|
49
|
+
for (const cited of findIds(text).filter((cited) => dropped.has(cited))) {
|
|
50
|
+
problems.push(error(path, lineOf(text, cited), "cites-dropped", `${cited} is cited, but ${dropped.get(cited)} in this work's delta leaves it out, so it won't exist once the work lands; describe it in words instead`));
|
|
51
|
+
}
|
|
48
52
|
if (item.kind !== "change") problems.push(...deltaProblems(view, item));
|
|
49
53
|
if (item.kind === "feature") problems.push(...featureScopeProblems(view, item, delta));
|
|
50
54
|
// A landed change is history: its scope named what it built, which is now built.
|
|
@@ -69,10 +73,10 @@ function featureScopeProblems(view: ProjectView, feature: WorkItem, delta: Delta
|
|
|
69
73
|
);
|
|
70
74
|
const names = rows.map((row) => row.change);
|
|
71
75
|
const extra = rows[MAX_CHANGES];
|
|
72
|
-
if (extra !== undefined) {
|
|
76
|
+
if (extra !== undefined && !sizeAccepted(view, feature.ref)) {
|
|
73
77
|
problems.push(warning(feature.path, extra.line, "feature-size", `the feature has ${rows.length} changes, over ${MAX_CHANGES}; split it into features a single design review can hold`));
|
|
74
78
|
}
|
|
75
|
-
problems.push(...changeRowSizeProblems(view, feature
|
|
79
|
+
problems.push(...changeRowSizeProblems(view, feature, rows, landedChanges));
|
|
76
80
|
for (const row of rows) {
|
|
77
81
|
if (!/^\d\d-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(row.change)) problems.push(error(feature.path, row.line, "change-name", `change "${row.change}" must be named NN-slug`));
|
|
78
82
|
if (names.indexOf(row.change) !== names.lastIndexOf(row.change)) problems.push(error(feature.path, row.line, "change-name", `change ${row.change} is listed twice`));
|
|
@@ -151,3 +155,21 @@ function scopeIds(item: WorkItem): string[] {
|
|
|
151
155
|
const scope = readScope(item.doc);
|
|
152
156
|
return scope === null ? [] : [...scope.delivers, ...scope.builds, ...scope.removes];
|
|
153
157
|
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Living scenarios that a MODIFIED requirement in the delta leaves out, each with that requirement.
|
|
161
|
+
* Land drops them, so a citation of one passes validation now and breaks only at land.
|
|
162
|
+
*/
|
|
163
|
+
function droppedScenarios(view: ProjectView, delta: Delta): Map<string, string> {
|
|
164
|
+
const dropped = new Map<string, string>();
|
|
165
|
+
for (const entry of delta.entries.filter((e) => e.element.op === "MODIFIED" && kindOf(e.element.id) === "REQ")) {
|
|
166
|
+
const living = view.living.elements.get(entry.element.id);
|
|
167
|
+
if (living === undefined) continue;
|
|
168
|
+
const kept = new Set(entry.scenarios.map((s) => s.id));
|
|
169
|
+
for (const scenario of view.living.elements.values()) {
|
|
170
|
+
const nested = scenario.doc === living.doc && scenario.element.start > living.element.start && scenario.element.start < living.element.end;
|
|
171
|
+
if (nested && kindOf(scenario.id) === "SCN" && !kept.has(scenario.id) && !delta.ids.has(scenario.id)) dropped.set(scenario.id, entry.element.id);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return dropped;
|
|
175
|
+
}
|
|
@@ -16,6 +16,7 @@ export const RULE_SINCE: Record<string, string> = {
|
|
|
16
16
|
"validate:change-too-small": "0.0.5",
|
|
17
17
|
"validate:change-too-large": "0.0.5",
|
|
18
18
|
"validate:plan-too-large": "0.0.5",
|
|
19
|
+
"validate:module-path-glob": "0.0.8",
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
/** Whether a rule arrived after the version the project's code meets, so it waits for `realign`. */
|