@printpractical/cruze 0.0.8 → 0.0.10
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 +17 -13
- package/dist/adapters/inbound/cli/cli_context.js +6 -0
- package/dist/adapters/inbound/cli/commands/code.js +2 -1
- package/dist/adapters/inbound/cli/commands/diff.js +23 -0
- package/dist/adapters/inbound/cli/commands/documents.js +1 -1
- package/dist/adapters/inbound/cli/commands/show.js +72 -3
- package/dist/adapters/inbound/cli/commands/status.js +7 -3
- package/dist/adapters/inbound/cli/commands.js +9 -3
- package/dist/adapters/inbound/cli/parse_command.js +8 -0
- package/dist/adapters/outbound/git_repository.js +49 -7
- package/dist/adapters/outbound/project_root.js +13 -0
- package/dist/app/ports/repository.js +4 -0
- package/dist/app/use_cases/approve_artifact.js +18 -10
- package/dist/app/use_cases/check_code.js +5 -3
- package/dist/app/use_cases/journal_events.js +6 -2
- package/dist/app/use_cases/new_project_doc.js +10 -2
- package/dist/app/use_cases/project_status.js +4 -1
- package/dist/app/use_cases/run_review.js +10 -2
- package/dist/app/use_cases/show_elements.js +18 -0
- package/dist/app/use_cases/show_notes.js +11 -0
- package/dist/app/use_cases/show_scope.js +17 -1
- package/dist/app/use_cases/upstream_diff.js +55 -0
- package/dist/domain/approvals/rebase.js +39 -0
- package/dist/domain/approvals/upstream_text.js +36 -0
- package/dist/domain/check/budget_text.js +43 -6
- package/dist/domain/check/check_source.js +20 -3
- package/dist/domain/check/decision_comments.js +38 -0
- package/dist/domain/config.js +12 -0
- package/dist/domain/decision_citations.js +62 -0
- package/dist/domain/edits/agents_file.js +15 -1
- package/dist/domain/ids.js +3 -1
- package/dist/domain/journal.js +16 -3
- package/dist/domain/land/merge_plan.js +7 -2
- package/dist/domain/line_diff.js +56 -0
- package/dist/domain/project/element_lookup.js +124 -0
- package/dist/domain/project/living_model.js +2 -1
- package/dist/domain/project/notes.js +74 -0
- package/dist/domain/project/scope_view.js +20 -4
- package/dist/domain/skill_budget.js +36 -0
- package/dist/domain/status/next_step.js +52 -7
- package/dist/domain/status/pending_rethink.js +22 -0
- package/dist/domain/status/verification.js +12 -0
- package/dist/domain/status/work_status.js +17 -2
- package/dist/domain/validation/decision_rules.js +76 -0
- package/dist/domain/validation/delta_rules.js +2 -0
- package/dist/domain/validation/future_rules.js +40 -0
- package/dist/domain/validation/living_rules.js +2 -0
- package/dist/domain/validation/plan_rules.js +1 -1
- package/dist/domain/validation/validate_project.js +4 -0
- package/dist/domain/validation/vision_rules.js +41 -0
- package/dist/domain/versions/rule_versions.js +3 -0
- package/dist/main.js +8 -4
- package/package.json +1 -1
- package/skills/architect/SKILL.md +6 -2
- package/skills/architect/feature.md +6 -4
- package/skills/architect/project.md +2 -2
- package/skills/behavioural-testing/SKILL.md +3 -2
- package/skills/behavioural-testing/fakes.md +3 -3
- package/skills/build/SKILL.md +3 -2
- package/skills/envision/SKILL.md +3 -2
- package/skills/envision/feature.md +3 -1
- package/skills/envision/intake.md +28 -0
- package/skills/envision/possibilities.md +13 -0
- package/skills/envision/project.md +5 -3
- package/skills/explore/SKILL.md +2 -2
- package/skills/formats/SKILL.md +13 -2
- package/skills/formats/reference/architecture.md +8 -4
- package/skills/formats/reference/config.md +9 -4
- package/skills/formats/reference/durable-docs.md +4 -1
- package/skills/formats/reference/work-items.md +1 -1
- package/skills/formats/templates/vision.md +12 -0
- package/skills/grilling/SKILL.md +10 -0
- package/skills/hexagonal-design/SKILL.md +2 -2
- package/skills/hexagonal-design/languages/rust.md +2 -1
- package/skills/hexagonal-design/runtime.md +1 -0
- package/skills/land/SKILL.md +6 -2
- package/skills/plan/SKILL.md +15 -5
- package/skills/plan/tasks.md +4 -4
- package/skills/realign/SKILL.md +5 -3
- package/skills/realign/notes/0.0.10.md +131 -0
- package/skills/realign/notes/0.0.2.md +1 -1
- package/skills/realign/notes/0.0.6.md +1 -1
- package/skills/realign/notes/0.0.8.md +2 -2
- package/skills/realign/notes/0.0.9.md +59 -0
- package/skills/realign/notes.md +2 -0
- package/skills/research/SKILL.md +3 -2
- package/skills/rethink/SKILL.md +6 -4
- package/skills/rethink/levels.md +8 -8
- package/skills/roadmap/SKILL.md +2 -1
- package/skills/roles/SKILL.md +4 -4
- package/skills/roles/auditor.md +1 -1
- package/skills/roles/code-reviewer.md +5 -4
- package/skills/roles/design-reviewer.md +5 -29
- package/skills/roles/design-rubric.md +40 -0
- package/skills/roles/plan-rubric.md +11 -0
- package/skills/roles/surveyor.md +3 -2
- package/skills/roles/verifier.md +2 -0
- package/skills/triage/SKILL.md +1 -1
- package/skills/verify/SKILL.md +3 -3
- package/src/adapters/inbound/cli/cli_context.ts +6 -0
- package/src/adapters/inbound/cli/commands/code.ts +2 -1
- package/src/adapters/inbound/cli/commands/diff.ts +23 -0
- package/src/adapters/inbound/cli/commands/documents.ts +1 -1
- package/src/adapters/inbound/cli/commands/show.ts +72 -3
- package/src/adapters/inbound/cli/commands/status.ts +7 -3
- package/src/adapters/inbound/cli/commands.ts +9 -3
- package/src/adapters/inbound/cli/parse_command.ts +8 -0
- package/src/adapters/outbound/git_repository.ts +49 -7
- package/src/adapters/outbound/project_root.ts +13 -0
- package/src/app/ports/repository.ts +4 -0
- package/src/app/use_cases/approve_artifact.ts +18 -10
- package/src/app/use_cases/check_code.ts +5 -3
- package/src/app/use_cases/journal_events.ts +6 -2
- package/src/app/use_cases/new_project_doc.ts +10 -2
- package/src/app/use_cases/project_status.ts +4 -1
- package/src/app/use_cases/run_review.ts +10 -2
- package/src/app/use_cases/show_elements.ts +18 -0
- package/src/app/use_cases/show_notes.ts +11 -0
- package/src/app/use_cases/show_scope.ts +17 -1
- package/src/app/use_cases/upstream_diff.ts +55 -0
- package/src/domain/approvals/rebase.ts +39 -0
- package/src/domain/approvals/upstream_text.ts +36 -0
- package/src/domain/check/budget_text.ts +43 -6
- package/src/domain/check/check_source.ts +20 -3
- package/src/domain/check/decision_comments.ts +38 -0
- package/src/domain/config.ts +12 -0
- package/src/domain/decision_citations.ts +62 -0
- package/src/domain/edits/agents_file.ts +15 -1
- package/src/domain/ids.ts +3 -1
- package/src/domain/journal.ts +16 -3
- package/src/domain/land/merge_plan.ts +7 -2
- package/src/domain/line_diff.ts +56 -0
- package/src/domain/project/element_lookup.ts +124 -0
- package/src/domain/project/living_model.ts +2 -1
- package/src/domain/project/notes.ts +74 -0
- package/src/domain/project/scope_view.ts +20 -4
- package/src/domain/skill_budget.ts +36 -0
- package/src/domain/status/next_step.ts +52 -7
- package/src/domain/status/pending_rethink.ts +22 -0
- package/src/domain/status/verification.ts +12 -0
- package/src/domain/status/work_status.ts +17 -2
- package/src/domain/validation/decision_rules.ts +76 -0
- package/src/domain/validation/delta_rules.ts +2 -0
- package/src/domain/validation/future_rules.ts +40 -0
- package/src/domain/validation/living_rules.ts +2 -0
- package/src/domain/validation/plan_rules.ts +1 -1
- package/src/domain/validation/validate_project.ts +4 -0
- package/src/domain/validation/vision_rules.ts +41 -0
- package/src/domain/versions/rule_versions.ts +3 -0
- package/src/main.ts +8 -4
- package/templates/init/AGENTS.md +1 -1
- package/templates/init/config.yaml +5 -4
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { headingElements, rowElements, type Element } from "../elements.ts";
|
|
2
|
+
import { VISION_KINDS } from "../ids.ts";
|
|
2
3
|
import { parseMarkdown, type MarkdownDoc } from "../markdown.ts";
|
|
3
4
|
import { PATHS, childFiles, type Snapshot } from "./layout.ts";
|
|
4
5
|
|
|
@@ -49,6 +50,6 @@ function elementsOf(path: string, doc: MarkdownDoc): Element[] {
|
|
|
49
50
|
if (path.startsWith(`${PATHS.adrDir}/`)) {
|
|
50
51
|
return [{ id: adrId(path), kind: "ADR", title: doc.headings[0]?.text ?? "", form: "heading", level: 1, start: 0, end: doc.lines.length }];
|
|
51
52
|
}
|
|
52
|
-
if (path === PATHS.vision) return rowElements(doc).filter((element) => element.kind
|
|
53
|
+
if (path === PATHS.vision) return rowElements(doc).filter((element) => VISION_KINDS.includes(element.kind));
|
|
53
54
|
return headingElements(doc);
|
|
54
55
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { findSection, parseMarkdown } from "../markdown.ts";
|
|
2
|
+
import { tablesIn } from "../markdown_tables.ts";
|
|
3
|
+
import { PATHS } from "./layout.ts";
|
|
4
|
+
import type { Snapshot } from "./layout.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Notes under `.cruze/notes/`: what the user brought, what explore captured, the evidence research
|
|
8
|
+
* gathered, and the hand-off envision wrote from them. Each says what it is about, so a step reads
|
|
9
|
+
* the notes for its work rather than guessing from file names: one project held 21 notes, and a
|
|
10
|
+
* 9,500-word seed whose design sections no step was told to read.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const NOTE_KINDS = ["input", "intake", "explore", "research"] as const;
|
|
14
|
+
export type NoteKind = (typeof NOTE_KINDS)[number];
|
|
15
|
+
|
|
16
|
+
/** The step an intake row hands a part to, besides a feature on the map. */
|
|
17
|
+
export const HANDOFF_STEPS = ["vision", "architect", "roadmap", "user"] as const;
|
|
18
|
+
|
|
19
|
+
export interface HandoffRow {
|
|
20
|
+
/** The part of the input, by its heading or a short quote. */
|
|
21
|
+
part: string;
|
|
22
|
+
/** Where it is: a note path and heading, or "below" for text saved in the intake note. */
|
|
23
|
+
source: string;
|
|
24
|
+
/** A step from HANDOFF_STEPS, or the slug of the feature that takes it up. */
|
|
25
|
+
goesTo: string;
|
|
26
|
+
/** `open` until the step that takes it up settles it with the user: `settled` or `dropped`. */
|
|
27
|
+
status: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Note {
|
|
31
|
+
path: string;
|
|
32
|
+
title: string;
|
|
33
|
+
kind?: string;
|
|
34
|
+
/** `project`, feature slugs, or context and capability names. */
|
|
35
|
+
about: string[];
|
|
36
|
+
handoff: HandoffRow[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function readNotes(snapshot: Snapshot): Note[] {
|
|
40
|
+
return [...snapshot.keys()]
|
|
41
|
+
.filter((path) => path.startsWith(`${PATHS.notesDir}/`) && path.endsWith(".md"))
|
|
42
|
+
.sort()
|
|
43
|
+
.map((path) => readNote(path, snapshot.get(path) ?? ""));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readNote(path: string, text: string): Note {
|
|
47
|
+
const doc = parseMarkdown(text);
|
|
48
|
+
const title = doc.headings.find((h) => h.level === 1)?.text ?? path.slice(path.lastIndexOf("/") + 1, -".md".length);
|
|
49
|
+
const firstSection = doc.headings.find((h) => h.level === 2)?.line ?? doc.lines.length;
|
|
50
|
+
const fact = (key: string): string | undefined =>
|
|
51
|
+
doc.lines.slice(0, firstSection).map((line) => new RegExp(`^- ${key}: (.+)$`).exec(line)?.[1]?.trim()).find((v) => v !== undefined);
|
|
52
|
+
const about = (fact("About") ?? "").split(",").map((a) => a.trim()).filter((a) => a !== "");
|
|
53
|
+
const kind = fact("Kind");
|
|
54
|
+
const section = findSection(doc, "Hand-off");
|
|
55
|
+
const rows = section === null ? [] : (tablesIn(doc.lines, doc.inFence, section.start, section.end)[0]?.rows ?? []);
|
|
56
|
+
const handoff = rows.map(({ cells }) => ({ part: cells[0] ?? "", source: cells[1] ?? "", goesTo: (cells[2] ?? "").replace(/`/g, ""), status: cells[3] ?? "open" }));
|
|
57
|
+
return { path, title, about, handoff, ...(kind === undefined ? {} : { kind }) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface NotesFor {
|
|
61
|
+
/** Notes about one of the targets, or about the whole project when it is a target. */
|
|
62
|
+
notes: Note[];
|
|
63
|
+
/** Intake rows handed to one of the targets, with the intake note that holds them. */
|
|
64
|
+
handoffs: Array<HandoffRow & { note: string }>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The notes and hand-off rows for a piece of work, named by its targets: `project`, a step, a feature slug or a context. */
|
|
68
|
+
export function notesFor(notes: Note[], targets: string[]): NotesFor {
|
|
69
|
+
const wanted = new Set(targets);
|
|
70
|
+
return {
|
|
71
|
+
notes: notes.filter((note) => note.about.some((a) => wanted.has(a))),
|
|
72
|
+
handoffs: notes.flatMap((note) => note.handoff.filter((row) => wanted.has(row.goesTo)).map((row) => ({ ...row, note: note.path }))),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -36,8 +36,10 @@ export interface ScopeView {
|
|
|
36
36
|
scenarios: ShownElement[];
|
|
37
37
|
/** Elements the shown bodies cite but don't include, with where to read them. */
|
|
38
38
|
cited: CitedElement[];
|
|
39
|
-
/** The settled decisions of the feature,
|
|
39
|
+
/** The settled decisions of the feature, or of a standalone change. */
|
|
40
40
|
decisions: string[];
|
|
41
|
+
/** A feature's change's own settled decisions, numbered apart from the feature's. */
|
|
42
|
+
changeDecisions: string[];
|
|
41
43
|
/** Scope IDs defined nowhere: the delta, the living docs or the archive. */
|
|
42
44
|
missing: string[];
|
|
43
45
|
}
|
|
@@ -66,7 +68,7 @@ export function scopeView(view: ProjectView, item: WorkItem): ScopeView {
|
|
|
66
68
|
return [shown];
|
|
67
69
|
});
|
|
68
70
|
const elements = show(wanted.elements);
|
|
69
|
-
const scenarios = show(wanted.scenarios);
|
|
71
|
+
const scenarios = withoutRepeatedScenarios(show(wanted.scenarios));
|
|
70
72
|
const shownIds = new Set([...elements, ...scenarios].map((e) => e.id));
|
|
71
73
|
const cited = [...new Set([...elements, ...scenarios].flatMap((e) => findIds(e.body.join("\n"))))]
|
|
72
74
|
.filter((id) => !shownIds.has(id))
|
|
@@ -74,12 +76,26 @@ export function scopeView(view: ProjectView, item: WorkItem): ScopeView {
|
|
|
74
76
|
const found = locate(id);
|
|
75
77
|
return found === undefined ? [] : [{ id, title: found.element.title, source: `${found.path}:${found.element.start + 1}` }];
|
|
76
78
|
});
|
|
77
|
-
const decisions =
|
|
78
|
-
const
|
|
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 };
|
|
79
82
|
if (feature !== undefined) result.feature = feature.ref;
|
|
80
83
|
return result;
|
|
81
84
|
}
|
|
82
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
|
+
|
|
83
99
|
/** A change shows what its scope builds, removes and delivers. */
|
|
84
100
|
function changeIds(change: WorkItem): { elements: string[]; scenarios: string[] } {
|
|
85
101
|
const scope = readScope(change.doc);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { SkillFiles } from "./skill_links.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The words one instruction file may hold. A longer file holds material only some runs need, so it
|
|
5
|
+
* splits by branch, as `docs/contributing/writing-skills.md` says, and each run reads only its part.
|
|
6
|
+
*/
|
|
7
|
+
export const MAX_FILE_WORDS = 2100;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The words agents may read across the whole bundle. Every retro adds rules, and between 0.0.1 and
|
|
11
|
+
* 0.0.10 the skills grew by half while nothing was removed. Lower this when text is cut. Raising it
|
|
12
|
+
* is the maintainer's decision, with the reason in the commit that raises it.
|
|
13
|
+
*/
|
|
14
|
+
export const BUNDLE_WORD_BUDGET = 44500;
|
|
15
|
+
|
|
16
|
+
/** Data the CLI reads or renders, rather than instructions an agent reads. */
|
|
17
|
+
const NOT_INSTRUCTIONS = /(^|\/)(templates|notes)\//;
|
|
18
|
+
|
|
19
|
+
export function wordCount(text: string): number {
|
|
20
|
+
return text.split(/\s+/).filter((word) => word !== "").length;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Instruction files over their limit, and the bundle when it is over its budget. */
|
|
24
|
+
export function budgetProblems(skills: SkillFiles[]): string[] {
|
|
25
|
+
const files = skills.flatMap((skill) =>
|
|
26
|
+
skill.files.filter((f) => f.path.endsWith(".md") && !NOT_INSTRUCTIONS.test(f.path)).map((f) => ({ path: `${skill.folder}/${f.path}`, words: wordCount(f.text) })),
|
|
27
|
+
);
|
|
28
|
+
const problems = files
|
|
29
|
+
.filter((f) => f.words > MAX_FILE_WORDS)
|
|
30
|
+
.map((f) => `${f.path}: ${f.words} words, over ${MAX_FILE_WORDS}; move what only some runs need into a sibling file`);
|
|
31
|
+
const total = files.reduce((sum, f) => sum + f.words, 0);
|
|
32
|
+
if (total > BUNDLE_WORD_BUDGET) {
|
|
33
|
+
problems.push(`the skills hold ${total} words, over the budget of ${BUNDLE_WORD_BUDGET}; cut or merge text to make room, or raise the budget with the reason in the commit`);
|
|
34
|
+
}
|
|
35
|
+
return problems;
|
|
36
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { evaluateApproval } from "../approvals/evaluate.ts";
|
|
1
|
+
import { evaluateApproval, type ApprovalStatus } from "../approvals/evaluate.ts";
|
|
2
|
+
import { rebaseNeeded } from "../approvals/rebase.ts";
|
|
2
3
|
import { PATHS } from "../project/layout.ts";
|
|
3
4
|
import { isPlanned, readChangesTable } from "../project/plan_parts.ts";
|
|
4
5
|
import { readProgress } from "../project/progress.ts";
|
|
@@ -7,7 +8,8 @@ import { readRoadmapItems, readRoadmapStatus } from "../project/roadmap.ts";
|
|
|
7
8
|
import { changesOf, featureOf, type WorkItem } from "../project/work_items.ts";
|
|
8
9
|
import { compareVersions } from "../versions/cruze_version.ts";
|
|
9
10
|
import { FIRST_RELEASE } from "../versions/rule_versions.ts";
|
|
10
|
-
import {
|
|
11
|
+
import { pendingReason, pendingRethinks } from "./pending_rethink.ts";
|
|
12
|
+
import { awaitsRethink, isVerified } from "./verification.ts";
|
|
11
13
|
import { activeChange } from "./work_status.ts";
|
|
12
14
|
|
|
13
15
|
/** The Cruze step to run next, computed from the project's state and the current branch. */
|
|
@@ -31,7 +33,7 @@ export function nextSteps(view: ProjectView, branch: string | null): NextReport
|
|
|
31
33
|
const project = projectStep(view);
|
|
32
34
|
if (project !== null) return { next: project, also: [] };
|
|
33
35
|
const active = activeChange(view, branch);
|
|
34
|
-
const inFlight = view.items.filter((item) => !item.archived && item.kind !== "change").flatMap((item) => itemSteps(view, item, branch));
|
|
36
|
+
const inFlight = inRoadmapOrder(view, view.items.filter((item) => !item.archived && item.kind !== "change")).flatMap((item) => itemSteps(view, item, branch));
|
|
35
37
|
const roadmap = roadmapStep(view);
|
|
36
38
|
const realign = realignStep(view);
|
|
37
39
|
const candidates = [
|
|
@@ -45,6 +47,28 @@ export function nextSteps(view: ProjectView, branch: string | null): NextReport
|
|
|
45
47
|
return { next: next ?? { step: "roadmap", reason: "every item on the roadmap has landed; close the release and plan the next" }, also };
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Work in flight in the roadmap's order, with items whose roadmap blockers haven't all landed
|
|
52
|
+
* last: a feature designed early still waits for the prefactors the user put before it.
|
|
53
|
+
*/
|
|
54
|
+
function inRoadmapOrder(view: ProjectView, items: WorkItem[]): WorkItem[] {
|
|
55
|
+
const text = view.snapshot.get(PATHS.roadmap);
|
|
56
|
+
if (text === undefined) return items;
|
|
57
|
+
const rows = readRoadmapStatus(text);
|
|
58
|
+
const state = new Map(rows.map((row) => [row.slug, row.state]));
|
|
59
|
+
const slugOf = new Map(rows.map((row) => [row.id, row.slug]));
|
|
60
|
+
const roadmap = readRoadmapItems(text);
|
|
61
|
+
const rank = (item: WorkItem): [number, number] => {
|
|
62
|
+
const at = roadmap.findIndex((r) => r.slug === slugOf.get(item.ref));
|
|
63
|
+
const blocked = at !== -1 && (roadmap[at]?.blockedBy ?? []).some((b) => state.get(b) !== "landed");
|
|
64
|
+
return [blocked ? 1 : 0, at === -1 ? roadmap.length : at];
|
|
65
|
+
};
|
|
66
|
+
return items
|
|
67
|
+
.map((item, i) => ({ item, key: [...rank(item), i] }))
|
|
68
|
+
.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))
|
|
69
|
+
.map(({ item }) => item);
|
|
70
|
+
}
|
|
71
|
+
|
|
48
72
|
/** Realign comes when nothing else is due: the installed skills expect more than the code meets. */
|
|
49
73
|
function realignStep(view: ProjectView): NextStep | null {
|
|
50
74
|
const config = view.config?.config;
|
|
@@ -90,7 +114,9 @@ function itemSteps(view: ProjectView, item: WorkItem, branch: string | null): Ne
|
|
|
90
114
|
const state = evaluateApproval(view, item.path);
|
|
91
115
|
if (readChangesTable(item.doc).length === 0) return [{ step: "architect", target: item.ref, reason: "the feature has no design or changes yet" }];
|
|
92
116
|
if (state.state === "unapproved") return [{ step: "architect", target: item.ref, reason: "review the feature's design and approve it" }];
|
|
93
|
-
if (state.state !== "approved") return [{ step: "rethink", target: item.ref, reason:
|
|
117
|
+
if (state.state !== "approved") return [{ step: "rethink", target: item.ref, reason: staleReason(view, item, "the feature", state) }];
|
|
118
|
+
const pending = pendingRethinks(view.journal, item);
|
|
119
|
+
if (pending.length > 0) return [{ step: "rethink", target: item.ref, reason: pendingReason(item, pending) }];
|
|
94
120
|
const changes = changesOf(view.items, item);
|
|
95
121
|
const landed = new Set(changes.filter((c) => readProgress(c.doc).landed !== undefined).map((c) => c.folderName));
|
|
96
122
|
const steps: NextStep[] = [];
|
|
@@ -109,14 +135,18 @@ function changeStep(view: ProjectView, change: WorkItem, branch: string | null):
|
|
|
109
135
|
const feature = featureOf(view.items, change);
|
|
110
136
|
const featureState = feature === undefined ? undefined : evaluateApproval(view, feature.path);
|
|
111
137
|
if (feature !== undefined && featureState !== undefined && featureState.state !== "approved") {
|
|
112
|
-
|
|
113
|
-
|
|
138
|
+
return { step: "rethink", target: feature.ref, reason: staleReason(view, feature, `the feature of ${change.ref}`, featureState) };
|
|
139
|
+
}
|
|
140
|
+
for (const part of [feature, change]) {
|
|
141
|
+
const pending = part === undefined ? [] : pendingRethinks(view.journal, part);
|
|
142
|
+
if (part !== undefined && pending.length > 0) return { step: "rethink", target: part.ref, reason: pendingReason(part, pending) };
|
|
114
143
|
}
|
|
115
144
|
const bound = readProgress(change.doc).branch;
|
|
116
145
|
const state = evaluateApproval(view, change.path);
|
|
117
146
|
if (!isPlanned(change.doc)) return { step: "plan", target: change.ref, reason: "the change is designed but has no test plan or tasks yet" };
|
|
118
147
|
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
|
-
if (state.state !== "approved") return { step: "rethink", target: change.ref, reason:
|
|
148
|
+
if (state.state !== "approved") return { step: "rethink", target: change.ref, reason: staleReason(view, change, "the change", state) };
|
|
149
|
+
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
150
|
if (bound !== undefined && bound !== branch) return { step: "switch-branch", target: bound, reason: `${change.ref} is built on branch ${bound}` };
|
|
121
151
|
const progress = readProgress(change.doc);
|
|
122
152
|
if ([...progress.tasks.values()].some((commit) => commit === null)) return { step: "build", target: change.ref, reason: "the change has open tasks" };
|
|
@@ -124,6 +154,21 @@ function changeStep(view: ProjectView, change: WorkItem, branch: string | null):
|
|
|
124
154
|
return { step: "land", target: change.ref, reason: "the change is verified and accepted" };
|
|
125
155
|
}
|
|
126
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Why work went stale, and what the rethink will take: a rebase when its delta still changes an
|
|
159
|
+
* element whose living text moved, or only a check of the diff and a re-stamp when nothing it cites
|
|
160
|
+
* asks anything of its own text. One project expected a re-stamp and met rebase-required.
|
|
161
|
+
*/
|
|
162
|
+
function staleReason(view: ProjectView, item: WorkItem, label: string, state: ApprovalStatus): string {
|
|
163
|
+
if (state.state === "edited") return `${label} changed since its approval`;
|
|
164
|
+
const changed = state.changed.join(", ");
|
|
165
|
+
const rebase = rebaseNeeded(view, item);
|
|
166
|
+
const then = rebase.length > 0
|
|
167
|
+
? `its delta changes ${rebase.join(", ")}, so rebase it, as cruze diff ${item.ref} shows`
|
|
168
|
+
: `read cruze diff ${item.ref}, and re-stamp it when the work still holds`;
|
|
169
|
+
return `something ${label} cites changed since its approval (${changed}); ${then}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
127
172
|
/** The first roadmap item that hasn't started and whose blockers have all landed. */
|
|
128
173
|
function roadmapStep(view: ProjectView): NextStep | null {
|
|
129
174
|
const text = view.snapshot.get(PATHS.roadmap);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { JournalEntry } from "../journal.ts";
|
|
2
|
+
import type { WorkItem } from "../project/work_items.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Findings decided `rethink` for a feature or change that no rethink of it has followed yet.
|
|
6
|
+
* One project's realign recorded seven design findings on a feature as fixed "by a rethink before
|
|
7
|
+
* its next change is planned"; nothing showed them, and they surfaced only in a commit message.
|
|
8
|
+
*/
|
|
9
|
+
export function pendingRethinks(journal: JournalEntry[], item: WorkItem): JournalEntry[] {
|
|
10
|
+
const lastRethink = journal
|
|
11
|
+
.filter((e) => e.event === "rethink" && e["item"] === item.ref)
|
|
12
|
+
.map((e) => e.at)
|
|
13
|
+
.sort()
|
|
14
|
+
.at(-1);
|
|
15
|
+
return journal.filter((e) => e.event === "disposition" && e["disposition"] === "rethink" && e["item"] === item.ref && (lastRethink === undefined || e.at > lastRethink));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Why work waits on a rethink, naming the first finding. */
|
|
19
|
+
export function pendingReason(item: WorkItem, pending: JournalEntry[]): string {
|
|
20
|
+
const more = pending.length > 1 ? ` and ${pending.length - 1} more` : "";
|
|
21
|
+
return `${pending.length} finding(s) on ${item.ref} were decided for a rethink that hasn't happened yet: ${String(pending[0]?.["finding"] ?? "")}${more}`;
|
|
22
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -5,6 +5,7 @@ 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";
|
|
8
|
+
import { pendingReason, pendingRethinks } from "./pending_rethink.ts";
|
|
8
9
|
|
|
9
10
|
export interface ChangeStatus {
|
|
10
11
|
ref: string;
|
|
@@ -15,6 +16,8 @@ export interface ChangeStatus {
|
|
|
15
16
|
tasksTotal: number;
|
|
16
17
|
/** When and in which commit the change landed, once it has. */
|
|
17
18
|
landed?: string;
|
|
19
|
+
/** Findings decided for a rethink of this change that hasn't happened yet. */
|
|
20
|
+
rethinkPending?: string[];
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export interface FeatureStatus {
|
|
@@ -22,6 +25,8 @@ export interface FeatureStatus {
|
|
|
22
25
|
path: string;
|
|
23
26
|
approval: ApprovalStatus;
|
|
24
27
|
changes: ChangeStatus[];
|
|
28
|
+
/** Findings decided for a rethink of this feature that hasn't happened yet. */
|
|
29
|
+
rethinkPending?: string[];
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export interface ProjectStatus {
|
|
@@ -42,6 +47,7 @@ export function projectStatus(view: ProjectView, branch: string | null): Project
|
|
|
42
47
|
path: feature.path,
|
|
43
48
|
approval: evaluateApproval(view, feature.path),
|
|
44
49
|
changes: changesOf(view.items, feature).map((change) => changeStatus(view, change)),
|
|
50
|
+
...withPending(view, feature),
|
|
45
51
|
}));
|
|
46
52
|
const standalone = active.filter((item) => item.kind === "standalone").map((change) => changeStatus(view, change));
|
|
47
53
|
const status: ProjectStatus = { documents, features, standalone };
|
|
@@ -69,7 +75,12 @@ function changeStatus(view: ProjectView, change: WorkItem): ChangeStatus {
|
|
|
69
75
|
};
|
|
70
76
|
if (progress.branch !== undefined) status.branch = progress.branch;
|
|
71
77
|
if (progress.landed !== undefined) status.landed = progress.landed;
|
|
72
|
-
return status;
|
|
78
|
+
return { ...status, ...withPending(view, change) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function withPending(view: ProjectView, item: WorkItem): { rethinkPending?: string[] } {
|
|
82
|
+
const pending = pendingRethinks(view.journal, item);
|
|
83
|
+
return pending.length === 0 ? {} : { rethinkPending: pending.map((e) => String(e["finding"] ?? "")) };
|
|
73
84
|
}
|
|
74
85
|
|
|
75
86
|
/** Why building may not start on this branch; empty when the build gate passes. */
|
|
@@ -86,7 +97,11 @@ export function buildGateReasons(view: ProjectView, branch: string | null): stri
|
|
|
86
97
|
const detail = status.changed.length > 0 ? `: ${status.changed.join(", ")} changed` : status.unjournaled === true ? ": the approval has no journal entry" : "";
|
|
87
98
|
return [`${path} is ${status.state}${detail}`];
|
|
88
99
|
});
|
|
89
|
-
|
|
100
|
+
const rethinks = [feature, change].flatMap((part) => {
|
|
101
|
+
const pending = part === undefined ? [] : pendingRethinks(view.journal, part);
|
|
102
|
+
return part === undefined || pending.length === 0 ? [] : [`${pendingReason(part, pending)}; run rethink`];
|
|
103
|
+
});
|
|
104
|
+
return [...approvals, ...rethinks, ...unlandedDependencies(view, change, feature)];
|
|
90
105
|
}
|
|
91
106
|
|
|
92
107
|
/** The changes this change's row in its feature's Changes table depends on that haven't landed on this branch. */
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { definedDecisions, findDecisionCitations, workItemSlugs, type DecisionCitation } from "../decision_citations.ts";
|
|
2
|
+
import type { Element } from "../elements.ts";
|
|
3
|
+
import { findSection, type MarkdownDoc } from "../markdown.ts";
|
|
4
|
+
import { readDelta } from "../project/deltas.ts";
|
|
5
|
+
import { PATHS } from "../project/layout.ts";
|
|
6
|
+
import type { ProjectView } from "../project/project_view.ts";
|
|
7
|
+
import type { WorkItem } from "../project/work_items.ts";
|
|
8
|
+
import { warning, type Problem } from "./problem.ts";
|
|
9
|
+
|
|
10
|
+
const INSTEAD = "write what it decided into this text, or cite the ADR that records it";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The architecture cites only the decisions its own `## Decisions` defines. The other living docs
|
|
14
|
+
* cite none by number, and no living doc cites a feature's or change's, which is archived when it finishes.
|
|
15
|
+
*/
|
|
16
|
+
export function livingDecisionProblems(view: ProjectView): Problem[] {
|
|
17
|
+
const slugs = slugsOf(view);
|
|
18
|
+
const project = projectDecisions(view);
|
|
19
|
+
const problems: Problem[] = [];
|
|
20
|
+
for (const [path, doc] of view.living.docs) {
|
|
21
|
+
for (const citation of citationsIn(doc, slugs)) {
|
|
22
|
+
if (citation.qualifier !== undefined) {
|
|
23
|
+
problems.push(warning(path, citation.line, "decision-citation", `cites ${citation.text}, a decision of ${citation.qualifier}, which the living docs don't hold; ${INSTEAD}`));
|
|
24
|
+
} else if (path !== PATHS.architecture) {
|
|
25
|
+
problems.push(warning(path, citation.line, "decision-citation", `cites ${citation.text}, but only the architecture cites decisions by number, its own; ${INSTEAD}`));
|
|
26
|
+
} else {
|
|
27
|
+
const unknown = citation.numbers.filter((n) => !project.has(n));
|
|
28
|
+
if (unknown.length > 0) {
|
|
29
|
+
problems.push(warning(path, citation.line, "decision-citation", `cites ${unknown.map((n) => `D${n}`).join(", ")}, which "## Decisions" doesn't define; ${INSTEAD}`));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return problems;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A delta's elements merge into the living docs, where the work's own decisions don't follow them.
|
|
39
|
+
* They may cite a project decision the work doesn't renumber, or one the living element already cites.
|
|
40
|
+
*/
|
|
41
|
+
export function deltaDecisionProblems(view: ProjectView, item: WorkItem): Problem[] {
|
|
42
|
+
const slugs = slugsOf(view);
|
|
43
|
+
const project = projectDecisions(view);
|
|
44
|
+
const local = definedDecisions(sectionLines(item.doc, "Settled decisions"));
|
|
45
|
+
const problems: Problem[] = [];
|
|
46
|
+
for (const { element } of readDelta(item.doc).entries) {
|
|
47
|
+
const living = view.living.elements.get(element.id);
|
|
48
|
+
const carried = new Set(living === undefined ? [] : citationsIn(living.doc, slugs, living.element).flatMap((c) => (c.qualifier === undefined ? c.numbers : [])));
|
|
49
|
+
const allowed = (n: number): boolean => project.has(n) && (!local.has(n) || carried.has(n));
|
|
50
|
+
for (const citation of citationsIn(item.doc, slugs, element)) {
|
|
51
|
+
if (citation.qualifier === undefined && citation.numbers.every(allowed)) continue;
|
|
52
|
+
problems.push(warning(item.path, citation.line, "decision-citation", `${element.id} cites ${citation.text}, which won't mean the same in the living docs once this delta merges; ${INSTEAD}`));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return problems;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function citationsIn(doc: MarkdownDoc, slugs: Set<string>, element?: Element): DecisionCitation[] {
|
|
59
|
+
const from = element === undefined ? doc.bodyStart : element.start + 1;
|
|
60
|
+
const to = element === undefined ? doc.lines.length : element.end;
|
|
61
|
+
return findDecisionCitations(doc.lines, slugs, (line) => line < from || line >= to || doc.inFence[line] === true || doc.inManaged[line] === true);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function projectDecisions(view: ProjectView): Set<number> {
|
|
65
|
+
const architecture = view.living.docs.get(PATHS.architecture);
|
|
66
|
+
return architecture === undefined ? new Set() : definedDecisions(sectionLines(architecture, "Decisions"));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sectionLines(doc: MarkdownDoc, title: string): string[] {
|
|
70
|
+
const range = findSection(doc, title);
|
|
71
|
+
return range === null ? [] : doc.lines.slice(range.start, range.end);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function slugsOf(view: ProjectView): Set<string> {
|
|
75
|
+
return workItemSlugs(view.items.map((item) => item.folderName));
|
|
76
|
+
}
|
|
@@ -4,6 +4,7 @@ import { specPath } from "../project/layout.ts";
|
|
|
4
4
|
import { orphanDeltaElements, readDelta, type Delta } from "../project/deltas.ts";
|
|
5
5
|
import type { ProjectView } from "../project/project_view.ts";
|
|
6
6
|
import type { WorkItem } from "../project/work_items.ts";
|
|
7
|
+
import { deltaDecisionProblems } from "./decision_rules.ts";
|
|
7
8
|
import { elementProblems } from "./element_rules.ts";
|
|
8
9
|
import { error, type Problem } from "./problem.ts";
|
|
9
10
|
|
|
@@ -44,6 +45,7 @@ export function deltaProblems(view: ProjectView, item: WorkItem): Problem[] {
|
|
|
44
45
|
problems.push(error(path, op.start, "capability-exists", `capability ${op.capability} already has ${specPath(op.capability)}`));
|
|
45
46
|
}
|
|
46
47
|
}
|
|
48
|
+
problems.push(...deltaDecisionProblems(view, item));
|
|
47
49
|
return problems;
|
|
48
50
|
}
|
|
49
51
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { factsOf, headingElements } from "../elements.ts";
|
|
2
|
+
import { readRows } from "../edits/managed_tables.ts";
|
|
3
|
+
import { findIds, kindOf } from "../ids.ts";
|
|
4
|
+
import type { MarkdownDoc } from "../markdown.ts";
|
|
5
|
+
import { PATHS } from "../project/layout.ts";
|
|
6
|
+
import { readDelta } from "../project/deltas.ts";
|
|
7
|
+
import type { ProjectView } from "../project/project_view.ts";
|
|
8
|
+
import { readRoadmapItems } from "../project/roadmap.ts";
|
|
9
|
+
import { warning, type Problem } from "./problem.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A `Future` fact keeps room for a need someone stated: a `LATER` capability of the vision, or a
|
|
13
|
+
* feature on the feature map or the roadmap. One that names neither reserves room for a need nobody
|
|
14
|
+
* stated, which the design reviewer treats as a finding; this makes it visible without a review.
|
|
15
|
+
*/
|
|
16
|
+
export function futureProblems(view: ProjectView): Problem[] {
|
|
17
|
+
const slugs = anchorSlugs(view);
|
|
18
|
+
const unanchored = (text: string): boolean =>
|
|
19
|
+
!findIds(text).some((id) => kindOf(id) === "LATER") && ![...slugs].some((slug) => new RegExp(`(^|[^a-z0-9-])${slug}([^a-z0-9-]|$)`).test(text));
|
|
20
|
+
const check = (path: string, doc: MarkdownDoc, from = 0, to = doc.lines.length): Problem[] =>
|
|
21
|
+
headingElements(doc, from, to).flatMap((element) =>
|
|
22
|
+
(factsOf(doc, element).get("Future") ?? [])
|
|
23
|
+
.filter(unanchored)
|
|
24
|
+
.map(() => warning(path, element.start, "future-unanchored", `${element.id} keeps room for a need its Future fact doesn't name; cite the LATER capability, or the feature on the map or roadmap, that it serves`)),
|
|
25
|
+
);
|
|
26
|
+
const living = view.living.docs.get(PATHS.architecture);
|
|
27
|
+
const deltas = view.items
|
|
28
|
+
.filter((item) => !item.archived && item.kind !== "change")
|
|
29
|
+
.flatMap((item) => readDelta(item.doc).entries.flatMap((entry) => check(item.path, item.doc, entry.element.start, entry.element.end)));
|
|
30
|
+
return [...(living === undefined ? [] : check(PATHS.architecture, living)), ...deltas];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Feature slugs a Future fact may name: the feature map's, implemented or future, and the roadmap's items. */
|
|
34
|
+
function anchorSlugs(view: ProjectView): Set<string> {
|
|
35
|
+
const vision = view.snapshot.get(PATHS.vision) ?? "";
|
|
36
|
+
const roadmap = view.snapshot.get(PATHS.roadmap);
|
|
37
|
+
const mapped = ["Implemented", "Future"].flatMap((heading) => readRows(vision, heading).map((row) => row[0] ?? ""));
|
|
38
|
+
const planned = roadmap === undefined ? [] : readRoadmapItems(roadmap).map((item) => item.slug);
|
|
39
|
+
return new Set([...mapped, ...planned].map((slug) => slug.trim()).filter((slug) => /^[a-z0-9-]+$/.test(slug)));
|
|
40
|
+
}
|
|
@@ -4,6 +4,7 @@ import { findIds, scopeOf, STATUS_KINDS } from "../ids.ts";
|
|
|
4
4
|
import type { MarkdownDoc } from "../markdown.ts";
|
|
5
5
|
import { PATHS } from "../project/layout.ts";
|
|
6
6
|
import type { ProjectView } from "../project/project_view.ts";
|
|
7
|
+
import { livingDecisionProblems } from "./decision_rules.ts";
|
|
7
8
|
import { elementProblems } from "./element_rules.ts";
|
|
8
9
|
import { error, warning, type Problem } from "./problem.ts";
|
|
9
10
|
|
|
@@ -26,6 +27,7 @@ export function livingProblems(view: ProjectView): Problem[] {
|
|
|
26
27
|
if (!view.living.elements.has(id)) problems.push(error(path, lineOf(text, id), "unknown-id", `${id} is cited but not defined in the living docs`));
|
|
27
28
|
}
|
|
28
29
|
}
|
|
30
|
+
problems.push(...livingDecisionProblems(view));
|
|
29
31
|
return problems;
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -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) ?? [];
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { ProjectView } from "../project/project_view.ts";
|
|
2
|
+
import { futureProblems } from "./future_rules.ts";
|
|
2
3
|
import { livingProblems } from "./living_rules.ts";
|
|
3
4
|
import { error, sortProblems, type Problem } from "./problem.ts";
|
|
4
5
|
import { configProblems, readmeProblems, roadmapProblems, templateLeftovers } from "./project_rules.ts";
|
|
6
|
+
import { visionProblems } from "./vision_rules.ts";
|
|
5
7
|
import { workItemProblems } from "./work_item_rules.ts";
|
|
6
8
|
|
|
7
9
|
/** Every rule in the cruze-formats skill, applied to the whole project. */
|
|
@@ -13,6 +15,8 @@ export function validateProject(view: ProjectView): Problem[] {
|
|
|
13
15
|
return sortProblems([
|
|
14
16
|
...configProblems(view),
|
|
15
17
|
...livingProblems(view),
|
|
18
|
+
...visionProblems(view),
|
|
19
|
+
...futureProblems(view),
|
|
16
20
|
...roadmapProblems(view),
|
|
17
21
|
...workItemProblems(view),
|
|
18
22
|
...templateLeftovers(view),
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { rowElements } from "../elements.ts";
|
|
2
|
+
import { findSection } from "../markdown.ts";
|
|
3
|
+
import { splitRow } from "../markdown_tables.ts";
|
|
4
|
+
import { PATHS } from "../project/layout.ts";
|
|
5
|
+
import type { ProjectView } from "../project/project_view.ts";
|
|
6
|
+
import { error, type Problem } from "./problem.ts";
|
|
7
|
+
|
|
8
|
+
/** The section and the cells after the ID that each long-term row of the vision holds. */
|
|
9
|
+
const INTENT_ROWS: Record<string, { section: string; cells: string[] }> = {
|
|
10
|
+
PRINCIPLE: { section: "Principles", cells: ["Principle", "Why"] },
|
|
11
|
+
LATER: { section: "Must stay possible", cells: ["Capability", "Likelihood", "Why", "Leave room for"] },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const LIKELIHOODS = ["likely", "possible"];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The vision's principles and the capabilities that must stay possible are rows, so designs cite
|
|
18
|
+
* them and approvals pin them. One project kept its principles in a seed note and its long-term
|
|
19
|
+
* plans in the user's head: a feature reversed a principle unnoticed, and a later review proposed
|
|
20
|
+
* removing what those plans needed.
|
|
21
|
+
*/
|
|
22
|
+
export function visionProblems(view: ProjectView): Problem[] {
|
|
23
|
+
const doc = view.living.docs.get(PATHS.vision);
|
|
24
|
+
if (doc === undefined) return [];
|
|
25
|
+
return rowElements(doc).flatMap((row) => {
|
|
26
|
+
const shape = INTENT_ROWS[row.kind];
|
|
27
|
+
if (shape === undefined) return [];
|
|
28
|
+
const section = findSection(doc, shape.section);
|
|
29
|
+
if (section === null || row.start < section.start || row.start >= section.end) {
|
|
30
|
+
return [error(PATHS.vision, row.start, "vision-row", `${row.id} belongs in the table under ## ${shape.section}`)];
|
|
31
|
+
}
|
|
32
|
+
const cells = splitRow(doc.lines[row.start] ?? "").slice(1);
|
|
33
|
+
if (cells.length !== shape.cells.length || cells.some((cell) => cell === "")) {
|
|
34
|
+
return [error(PATHS.vision, row.start, "vision-row", `${row.id} needs ${shape.cells.join(", ")}, each filled`)];
|
|
35
|
+
}
|
|
36
|
+
if (row.kind === "LATER" && !LIKELIHOODS.includes(cells[1] ?? "")) {
|
|
37
|
+
return [error(PATHS.vision, row.start, "vision-row", `${row.id}'s likelihood must be ${LIKELIHOODS.join(" or ")}`)];
|
|
38
|
+
}
|
|
39
|
+
return [];
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -17,6 +17,9 @@ export const RULE_SINCE: Record<string, string> = {
|
|
|
17
17
|
"validate:change-too-large": "0.0.5",
|
|
18
18
|
"validate:plan-too-large": "0.0.5",
|
|
19
19
|
"validate:module-path-glob": "0.0.8",
|
|
20
|
+
"validate:decision-citation": "0.0.10",
|
|
21
|
+
"check:decision-citation": "0.0.10",
|
|
22
|
+
"validate:future-unanchored": "0.0.10",
|
|
20
23
|
};
|
|
21
24
|
|
|
22
25
|
/** Whether a rule arrived after the version the project's code meets, so it waits for `realign`. */
|