@printpractical/cruze 0.0.9 → 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 +4 -0
- 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 +66 -2
- 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 +9 -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/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 +15 -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/check_source.js +18 -1
- package/dist/domain/check/decision_comments.js +38 -0
- package/dist/domain/decision_citations.js +62 -0
- package/dist/domain/edits/agents_file.js +9 -0
- package/dist/domain/ids.js +3 -1
- package/dist/domain/journal.js +3 -1
- 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/skill_budget.js +36 -0
- package/dist/domain/status/next_step.js +27 -5
- package/dist/domain/status/pending_rethink.js +22 -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/validate_project.js +4 -0
- package/dist/domain/validation/vision_rules.js +41 -0
- package/dist/domain/versions/rule_versions.js +3 -0
- package/package.json +1 -1
- package/skills/architect/SKILL.md +2 -1
- 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 +2 -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 +7 -3
- 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/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 +13 -3
- package/skills/plan/tasks.md +3 -3
- 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 +1 -1
- package/skills/realign/notes/0.0.9.md +2 -2
- package/skills/realign/notes.md +1 -0
- package/skills/research/SKILL.md +3 -2
- package/skills/rethink/SKILL.md +5 -3
- package/skills/rethink/levels.md +6 -6
- package/skills/roadmap/SKILL.md +2 -1
- package/skills/roles/SKILL.md +3 -3
- package/skills/roles/auditor.md +1 -1
- package/skills/roles/code-reviewer.md +2 -2
- package/skills/roles/design-reviewer.md +4 -40
- 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/triage/SKILL.md +1 -1
- package/src/adapters/inbound/cli/cli_context.ts +4 -0
- 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 +66 -2
- 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 +9 -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/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 +15 -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/check_source.ts +18 -1
- package/src/domain/check/decision_comments.ts +38 -0
- package/src/domain/decision_citations.ts +62 -0
- package/src/domain/edits/agents_file.ts +9 -0
- package/src/domain/ids.ts +3 -1
- package/src/domain/journal.ts +3 -1
- 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/skill_budget.ts +36 -0
- package/src/domain/status/next_step.ts +27 -5
- package/src/domain/status/pending_rethink.ts +22 -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/validate_project.ts +4 -0
- package/src/domain/validation/vision_rules.ts +41 -0
- package/src/domain/versions/rule_versions.ts +3 -0
- package/templates/init/AGENTS.md +1 -1
|
@@ -10,4 +10,8 @@ export interface Repository {
|
|
|
10
10
|
otherBranches(): Promise<string[]>;
|
|
11
11
|
/** Files under a directory as they are on another branch, by project-relative path. */
|
|
12
12
|
filesOnBranch(branch: string, dir: string): Promise<Map<string, string>>;
|
|
13
|
+
/** The oldest commit whose change to a file added the text, or null. */
|
|
14
|
+
firstCommitAdding(path: string, text: string): Promise<string | null>;
|
|
15
|
+
/** A file's contents at a commit, or null when it didn't exist there. */
|
|
16
|
+
fileAt(commit: string, path: string): Promise<string | null>;
|
|
13
17
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { requirePlanReviewClosed, requireUnchangedSinceApproval, type AgentBasis } from "../../domain/approvals/agent_approval.ts";
|
|
2
2
|
import { evaluateApproval } from "../../domain/approvals/evaluate.ts";
|
|
3
3
|
import { fingerprint, isApprovable } from "../../domain/approvals/fingerprint.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { rebaseNeeded } from "../../domain/approvals/rebase.ts";
|
|
5
|
+
import { serializeApprovals, withApproval } from "../../domain/approvals/records.ts";
|
|
5
6
|
import { CruzeError } from "../../domain/cruze_error.ts";
|
|
6
7
|
import { updateProgress } from "../../domain/edits/progress_block.ts";
|
|
7
8
|
import { setStatus, stampMissingStatuses } from "../../domain/edits/status_lines.ts";
|
|
@@ -14,7 +15,7 @@ import { PATHS } from "../../domain/project/layout.ts";
|
|
|
14
15
|
import { isPlanned, readChangesTable } from "../../domain/project/plan_parts.ts";
|
|
15
16
|
import { readProgress } from "../../domain/project/progress.ts";
|
|
16
17
|
import { approvalFolder, approvalsPath, type ProjectView } from "../../domain/project/project_view.ts";
|
|
17
|
-
import { featureOf } from "../../domain/project/work_items.ts";
|
|
18
|
+
import { featureOf, type WorkItem } from "../../domain/project/work_items.ts";
|
|
18
19
|
import type { Problem } from "../../domain/validation/problem.ts";
|
|
19
20
|
import { SIZE_RULES } from "../../domain/validation/size_rules.ts";
|
|
20
21
|
import { validateProject } from "../../domain/validation/validate_project.ts";
|
|
@@ -96,13 +97,14 @@ export async function approveArtifact(deps: ProjectDeps, ref: string, options: A
|
|
|
96
97
|
throw new CruzeError("invalid", `${path} has ${errors.length} problem(s) to fix before approval:\n${listing}`);
|
|
97
98
|
}
|
|
98
99
|
requireSizesAccepted(problems, path);
|
|
100
|
+
if (item !== undefined) requireNoDecisionCitations(problems, path);
|
|
99
101
|
|
|
100
102
|
const { hash, upstream } = fingerprint(view, path, parseMarkdown(text));
|
|
101
103
|
const folder = approvalFolder(view, path);
|
|
102
104
|
const current = view.approvals.get(folder) ?? { version: 1, approvals: [], merged: {} };
|
|
103
105
|
const rebased = options.rebase ?? [];
|
|
104
106
|
const deltaIds = item === undefined || item.kind === "change" ? new Set<string>() : readDelta(item.doc).ids;
|
|
105
|
-
|
|
107
|
+
if (item !== undefined) requireRebased(view, item, rebased);
|
|
106
108
|
const merged = { ...current.merged };
|
|
107
109
|
for (const id of rebased) {
|
|
108
110
|
const living = view.living.elements.get(id);
|
|
@@ -143,13 +145,8 @@ export async function approveArtifact(deps: ProjectDeps, ref: string, options: A
|
|
|
143
145
|
* a rethink or another land, the delta would overwrite that edit, so approval waits until the
|
|
144
146
|
* delta is reconciled and the element is named with --rebase.
|
|
145
147
|
*/
|
|
146
|
-
function requireRebased(view: ProjectView,
|
|
147
|
-
|
|
148
|
-
const moved = [...deltaIds].filter((id) => {
|
|
149
|
-
const living = view.living.elements.get(id);
|
|
150
|
-
const before = previous.upstream[id];
|
|
151
|
-
return living !== undefined && before !== undefined && before !== elementHash(living.doc, living.element) && !rebased.includes(id);
|
|
152
|
-
});
|
|
148
|
+
function requireRebased(view: ProjectView, item: WorkItem, rebased: string[]): void {
|
|
149
|
+
const moved = rebaseNeeded(view, item).filter((id) => !rebased.includes(id));
|
|
153
150
|
if (moved.length > 0) {
|
|
154
151
|
throw new CruzeError(
|
|
155
152
|
"rebase-required",
|
|
@@ -172,6 +169,17 @@ function requireSizesAccepted(problems: Problem[], path: string): void {
|
|
|
172
169
|
);
|
|
173
170
|
}
|
|
174
171
|
|
|
172
|
+
/**
|
|
173
|
+
* A delta that cites decisions by number would merge them into the living docs, where they dangle once
|
|
174
|
+
* the work is archived. Existing docs only warn, so approval of new or redesigned work is where it stops.
|
|
175
|
+
*/
|
|
176
|
+
function requireNoDecisionCitations(problems: Problem[], path: string): void {
|
|
177
|
+
const standing = problems.filter((p) => p.rule === "decision-citation");
|
|
178
|
+
if (standing.length === 0) return;
|
|
179
|
+
const listing = standing.map((p) => ` ${p.path}:${p.line ?? ""} ${p.message}`).join("\n");
|
|
180
|
+
throw new CruzeError("decision-cited", `${path} cites decisions by number in its delta, which merges into the living docs:\n${listing}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
175
183
|
function notWork(path: string): never {
|
|
176
184
|
throw new CruzeError("needs-user", `${path} is a project document, so the user approves it`);
|
|
177
185
|
}
|
|
@@ -27,16 +27,18 @@ export async function checkCode(deps: ProjectDeps, options: { files?: string[];
|
|
|
27
27
|
const view = await loadView(deps.files);
|
|
28
28
|
const config = requireConfig(view);
|
|
29
29
|
const sources = await readMatching(deps.files, config.source);
|
|
30
|
+
const tests = await readMatching(deps.files, config.tests);
|
|
30
31
|
for (const file of options.files ?? []) {
|
|
31
|
-
if (!sources.has(file)) {
|
|
32
|
+
if (!sources.has(file) && !tests.has(file)) {
|
|
32
33
|
const text = await deps.files.readText(file);
|
|
33
34
|
if (text === undefined) throw new CruzeError("not-found", `${file} does not exist`);
|
|
34
35
|
if (matchesAny(file, config.source)) sources.set(file, text);
|
|
36
|
+
else if (matchesAny(file, config.tests)) tests.set(file, text);
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
|
-
const only = options.files?.filter((file) => sources.has(file));
|
|
39
|
+
const only = options.files?.filter((file) => sources.has(file) || tests.has(file));
|
|
38
40
|
const standards = config.standards ?? FIRST_RELEASE;
|
|
39
|
-
const findings = checkSource({ config, sources, modulePaths: modulePaths(view), ...(only === undefined ? {} : { only }) }).map((f) =>
|
|
41
|
+
const findings = checkSource({ config, sources, tests, modulePaths: modulePaths(view), ...(only === undefined ? {} : { only }) }).map((f) =>
|
|
40
42
|
isPending("check", f.rule, standards) ? { ...f, pending: true as const } : f,
|
|
41
43
|
);
|
|
42
44
|
const failing = findings.filter((f) => f.pending !== true && (f.severity === "error" || options.ci));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CruzeError } from "../../domain/cruze_error.ts";
|
|
2
|
+
import { knownPitfalls } from "../../domain/edits/agents_file.ts";
|
|
2
3
|
import { journalPath, parseJournal, recordedEventProblems, recordedFields, type JournalEntry } from "../../domain/journal.ts";
|
|
3
4
|
import { resolveArtifact } from "../../domain/project/artifact_ref.ts";
|
|
4
5
|
import { appendJournal, loadView, type ProjectDeps } from "../project_context.ts";
|
|
@@ -40,10 +41,12 @@ export interface FeedbackExport {
|
|
|
40
41
|
exported_at: string;
|
|
41
42
|
redacted: boolean;
|
|
42
43
|
entries: JournalEntry[];
|
|
44
|
+
/** The lines under `## Known pitfalls` in `AGENTS.md`: lessons that may belong in Cruze rather than the project. */
|
|
45
|
+
pitfalls: string[];
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
/**
|
|
46
|
-
* Bundles the journal for the framework feedback loop. By default the project name and
|
|
49
|
+
* Bundles the journal, and the project's known pitfalls, for the framework feedback loop. By default the project name and
|
|
47
50
|
* the approver's identity are redacted, since the bundle may leave the project.
|
|
48
51
|
*/
|
|
49
52
|
export async function exportFeedback(deps: ProjectDeps, redact: boolean): Promise<FeedbackExport> {
|
|
@@ -56,5 +59,6 @@ export async function exportFeedback(deps: ProjectDeps, redact: boolean): Promis
|
|
|
56
59
|
return value;
|
|
57
60
|
};
|
|
58
61
|
const entries = redact ? view.journal.map((entry) => ({ ...(scrub(entry) as JournalEntry), by: "<redacted>" })) : view.journal;
|
|
59
|
-
|
|
62
|
+
const pitfalls = knownPitfalls((await deps.files.readText("AGENTS.md")) ?? "").map((line) => (redact ? (scrub(line) as string) : line));
|
|
63
|
+
return { format: "cruze-feedback/1", cruze: deps.bundle.version, exported_at: deps.clock.now().toISOString(), redacted: redact, entries, pitfalls };
|
|
60
64
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CruzeError } from "../../domain/cruze_error.ts";
|
|
2
2
|
import { PATHS } from "../../domain/project/layout.ts";
|
|
3
|
+
import { NOTE_KINDS } from "../../domain/project/notes.ts";
|
|
3
4
|
import { requireSlug } from "../../domain/project/new_items.ts";
|
|
4
5
|
import { renderTemplate } from "../../domain/scaffold.ts";
|
|
5
6
|
import { appendJournal, loadView, type ProjectDeps } from "../project_context.ts";
|
|
@@ -27,14 +28,21 @@ export async function createProjectDoc(deps: ProjectDeps, kind: ProjectDocKind):
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
/** Creates a dated note in `.cruze/notes/`, such as a captured explore session or research evidence. */
|
|
30
|
-
|
|
31
|
+
/**
|
|
32
|
+
* A dated note, saying what kind it is and what it is about, so `cruze show` finds it for the work it
|
|
33
|
+
* informs: `project`, feature slugs, or context and capability names.
|
|
34
|
+
*/
|
|
35
|
+
export async function createNote(deps: ProjectDeps, slug: string, title: string, kind: string, about: string): Promise<NewDocReport> {
|
|
31
36
|
requireSlug(slug);
|
|
32
37
|
if (title.trim() === "") throw new CruzeError("missing-title", "a title is required");
|
|
38
|
+
if (!(NOTE_KINDS as readonly string[]).includes(kind)) throw new CruzeError("note-kind", `a note's kind is one of ${NOTE_KINDS.join(", ")}`);
|
|
39
|
+
const subjects = about.split(",").map((a) => a.trim()).filter((a) => a !== "");
|
|
40
|
+
if (subjects.length === 0) throw new CruzeError("note-about", "name what the note is about: project, feature slugs, or contexts, comma-separated");
|
|
33
41
|
const view = await loadView(deps.files);
|
|
34
42
|
const date = deps.clock.today();
|
|
35
43
|
const base = `${PATHS.notesDir}/${date}-${slug}`;
|
|
36
44
|
let path = `${base}.md`;
|
|
37
45
|
for (let n = 2; view.snapshot.has(path); n++) path = `${base}-${n}.md`;
|
|
38
|
-
await deps.files.writeText(path, `# ${title}\n\n- Date: ${date}\n\n`);
|
|
46
|
+
await deps.files.writeText(path, `# ${title}\n\n- Date: ${date}\n- Kind: ${kind}\n- About: ${subjects.join(", ")}\n\n`);
|
|
39
47
|
return { kind: "note", path };
|
|
40
48
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CruzeError } from "../../domain/cruze_error.ts";
|
|
2
2
|
import { resolveArtifact } from "../../domain/project/artifact_ref.ts";
|
|
3
|
-
import {
|
|
3
|
+
import { isPlanned } from "../../domain/project/plan_parts.ts";
|
|
4
|
+
import { featureOf, type WorkItem } from "../../domain/project/work_items.ts";
|
|
4
5
|
import type { AgentRunner } from "../ports/agent_runner.ts";
|
|
5
6
|
import { loadView, type ProjectDeps } from "../project_context.ts";
|
|
6
7
|
|
|
@@ -44,7 +45,7 @@ export async function runReview(deps: ProjectDeps & { runner: AgentRunner }, req
|
|
|
44
45
|
context.push(`Under review: ${item?.ref ?? path} (\`${path}\`)`);
|
|
45
46
|
const feature = item === undefined ? undefined : featureOf(view.items, item);
|
|
46
47
|
if (feature !== undefined) context.push(`Its feature: \`${feature.path}\``);
|
|
47
|
-
if (role === "design-reviewer" && item !== undefined
|
|
48
|
+
if (role === "design-reviewer" && item !== undefined) context.push(rubricLine(item));
|
|
48
49
|
} else {
|
|
49
50
|
context.push("Under review: the project documents the role lists for project scope");
|
|
50
51
|
}
|
|
@@ -83,3 +84,10 @@ async function bundledRole(deps: ProjectDeps, role: Role): Promise<string> {
|
|
|
83
84
|
if (file === undefined) throw new CruzeError("role-missing", `the ${role} role is not installed; run cruze install`);
|
|
84
85
|
return file.text;
|
|
85
86
|
}
|
|
87
|
+
|
|
88
|
+
/** A design reviewer reads one rubric: the plan rubric for a planned change, the design rubric otherwise. */
|
|
89
|
+
function rubricLine(item: WorkItem): string {
|
|
90
|
+
return item.kind !== "feature" && isPlanned(item.doc)
|
|
91
|
+
? "This is a change's plan: review it against `.agents/skills/cruze-roles/plan-rubric.md`."
|
|
92
|
+
: "This is a design: review it against `.agents/skills/cruze-roles/design-rubric.md`.";
|
|
93
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { CruzeError } from "../../domain/cruze_error.ts";
|
|
2
|
+
import { elementIndex, lookupElements, type ElementIndex } from "../../domain/project/element_lookup.ts";
|
|
3
|
+
import type { ShownElement } from "../../domain/project/scope_view.ts";
|
|
4
|
+
import { loadView, type ProjectDeps } from "../project_context.ts";
|
|
5
|
+
|
|
6
|
+
/** The bodies of the named elements, living and in each in-flight delta that changes them. */
|
|
7
|
+
export async function showElements(deps: ProjectDeps, ids: string[]): Promise<{ elements: ShownElement[] }> {
|
|
8
|
+
const { elements, missing } = lookupElements(await loadView(deps.files), ids);
|
|
9
|
+
if (missing.length > 0) throw new CruzeError("unknown-id", `${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} defined nowhere in the living docs or the work in flight; cruze show --index lists every element`);
|
|
10
|
+
return { elements };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** One line per element of the living architecture and specs, with what work in flight adds or changes. */
|
|
14
|
+
export async function showIndex(deps: ProjectDeps, scope?: string): Promise<ElementIndex & { scope?: string }> {
|
|
15
|
+
const index = elementIndex(await loadView(deps.files), scope);
|
|
16
|
+
if (scope !== undefined && index.entries.length === 0) throw new CruzeError("unknown-scope", `no element belongs to "${scope}"; name a context or capability, such as the part of an ID before its dot`);
|
|
17
|
+
return scope === undefined ? index : { ...index, scope };
|
|
18
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { notesFor, readNotes, type Note, type NotesFor } from "../../domain/project/notes.ts";
|
|
2
|
+
import { loadView, type ProjectDeps } from "../project_context.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every note with what it is about and the hand-off it holds, or, given a target such as
|
|
6
|
+
* `project`, `architect` or a feature slug, the notes and hand-off rows for it.
|
|
7
|
+
*/
|
|
8
|
+
export async function showNotes(deps: ProjectDeps, target?: string): Promise<{ notes: Note[] } | (NotesFor & { target: string })> {
|
|
9
|
+
const notes = readNotes((await loadView(deps.files)).snapshot);
|
|
10
|
+
return target === undefined ? { notes } : { target, ...notesFor(notes, [target]) };
|
|
11
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { CruzeError } from "../../domain/cruze_error.ts";
|
|
2
2
|
import { resolveArtifact } from "../../domain/project/artifact_ref.ts";
|
|
3
|
+
import { scopeOf } from "../../domain/ids.ts";
|
|
4
|
+
import { notesFor, readNotes, type NotesFor } from "../../domain/project/notes.ts";
|
|
3
5
|
import { scopeView, type ScopeView } from "../../domain/project/scope_view.ts";
|
|
6
|
+
import type { WorkItem } from "../../domain/project/work_items.ts";
|
|
4
7
|
import { loadView, type ProjectDeps } from "../project_context.ts";
|
|
5
8
|
import { listEvents } from "./journal_events.ts";
|
|
6
9
|
|
|
@@ -16,6 +19,8 @@ export interface ScopeReport extends ScopeView {
|
|
|
16
19
|
dispositions: Disposition[];
|
|
17
20
|
/** The external documents the design must comply with, from `references:` in `.cruze/config.yaml`. */
|
|
18
21
|
references: Array<{ path: string; use: string }>;
|
|
22
|
+
/** The notes about this work, and the parts of the user's input handed to it. */
|
|
23
|
+
notes: NotesFor;
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
/**
|
|
@@ -33,5 +38,14 @@ export async function showScope(deps: ProjectDeps, ref: string): Promise<ScopeRe
|
|
|
33
38
|
reason: String(entry["reason"] ?? ""),
|
|
34
39
|
review: String(entry["review"] ?? ""),
|
|
35
40
|
}));
|
|
36
|
-
|
|
41
|
+
const feature = scope.feature === undefined ? item : (view.items.find((i) => i.ref === scope.feature) ?? item);
|
|
42
|
+
const contexts = [...scope.elements, ...scope.scenarios].flatMap((e) => scopeOf(e.id) ?? []);
|
|
43
|
+
const notes = notesFor(readNotes(view.snapshot), [...slugsOf(feature), ...contexts]);
|
|
44
|
+
return { ...scope, dispositions, references: view.config?.config?.references ?? [], notes };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The names a note or a hand-off may use for a feature or standalone change: its roadmap slug, and its folder name without the date. */
|
|
48
|
+
export function slugsOf(item: WorkItem): string[] {
|
|
49
|
+
const roadmap = item.doc.frontmatter?.["roadmap"];
|
|
50
|
+
return [...new Set([...(typeof roadmap === "string" ? [roadmap] : []), item.folderName.replace(/^\d{4}-\d{2}-\d{2}-/, "")])];
|
|
37
51
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { evaluateApproval, type ApprovalState } from "../../domain/approvals/evaluate.ts";
|
|
2
|
+
import { rebaseNeeded } from "../../domain/approvals/rebase.ts";
|
|
3
|
+
import { upstreamLines, upstreamPath } from "../../domain/approvals/upstream_text.ts";
|
|
4
|
+
import { lineDiff } from "../../domain/line_diff.ts";
|
|
5
|
+
import { resolveArtifact } from "../../domain/project/artifact_ref.ts";
|
|
6
|
+
import { approvalFolder, approvalsPath } from "../../domain/project/project_view.ts";
|
|
7
|
+
import { loadView, type ProjectDeps } from "../project_context.ts";
|
|
8
|
+
|
|
9
|
+
export interface UpstreamChange {
|
|
10
|
+
/** The upstream element ID, or `doc:<path>` for a feature's design. */
|
|
11
|
+
key: string;
|
|
12
|
+
path?: string;
|
|
13
|
+
/** The approved text against the current text, when the approved text was found. */
|
|
14
|
+
diff?: string[];
|
|
15
|
+
note?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface UpstreamDiffReport {
|
|
19
|
+
artifact: string;
|
|
20
|
+
ref?: string;
|
|
21
|
+
state: ApprovalState;
|
|
22
|
+
changes: UpstreamChange[];
|
|
23
|
+
/** Elements the work's delta changes whose living text moved: approval needs --rebase for each. */
|
|
24
|
+
rebase: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What changed upstream of a document since its approval, element by element, so a person can
|
|
29
|
+
* judge a re-stamp from the text rather than the IDs. The approved text comes from the commit
|
|
30
|
+
* that recorded the approval. One project went to a rethink for two file-list merges that
|
|
31
|
+
* changed no behaviour, because status named only the IDs.
|
|
32
|
+
*/
|
|
33
|
+
export async function upstreamDiff(deps: ProjectDeps, ref: string): Promise<UpstreamDiffReport> {
|
|
34
|
+
const view = await loadView(deps.files);
|
|
35
|
+
const { path, item } = resolveArtifact(view, ref);
|
|
36
|
+
const status = evaluateApproval(view, path);
|
|
37
|
+
const report: UpstreamDiffReport = { artifact: path, state: status.state, changes: [], rebase: item === undefined ? [] : rebaseNeeded(view, item) };
|
|
38
|
+
if (item !== undefined) report.ref = item.ref;
|
|
39
|
+
const record = status.record;
|
|
40
|
+
if (record === undefined || status.changed.length === 0) return report;
|
|
41
|
+
const commit = await deps.repository.firstCommitAdding(approvalsPath(approvalFolder(view, path)), record.approvedAt);
|
|
42
|
+
for (const key of status.changed) {
|
|
43
|
+
const where = upstreamPath(view, key);
|
|
44
|
+
if (where === undefined) {
|
|
45
|
+
report.changes.push({ key, note: "it no longer exists" });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const now = upstreamLines(key, where, view.snapshot.get(where) ?? "") ?? [];
|
|
49
|
+
const then = commit === null ? null : await deps.repository.fileAt(commit, where);
|
|
50
|
+
const approved = then === null ? undefined : upstreamLines(key, where, then, record.upstream[key]);
|
|
51
|
+
if (approved !== undefined) report.changes.push({ key, path: where, diff: lineDiff(approved, now) });
|
|
52
|
+
else report.changes.push({ key, path: where, note: commit === null ? "the approval isn't committed yet; compare with git diff" : `its approved text isn't in ${where} at ${commit}, the commit that recorded the approval` });
|
|
53
|
+
}
|
|
54
|
+
return report;
|
|
55
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { elementHash } from "../hashing.ts";
|
|
2
|
+
import type { MarkdownDoc } from "../markdown.ts";
|
|
3
|
+
import { readDelta } from "../project/deltas.ts";
|
|
4
|
+
import { approvalFolder, type ProjectView } from "../project/project_view.ts";
|
|
5
|
+
import type { WorkItem } from "../project/work_items.ts";
|
|
6
|
+
import { latestApproval } from "./records.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Delta elements a feature or standalone change merged and hasn't changed since. The merge did
|
|
10
|
+
* their job, so the living text is the one to keep: an edit there by a rethink, a realign or
|
|
11
|
+
* another land asks nothing of the delta. One project's delta went stale this way, and every
|
|
12
|
+
* later edit of an element its first change had merged forced a rebase of text with no work left in it.
|
|
13
|
+
*/
|
|
14
|
+
export function settledIds(doc: MarkdownDoc, merged: Record<string, string>): Set<string> {
|
|
15
|
+
const settled = new Set<string>();
|
|
16
|
+
for (const entry of readDelta(doc).entries) {
|
|
17
|
+
for (const element of [entry.element, ...entry.scenarios]) {
|
|
18
|
+
if (merged[element.id] === elementHash(doc, element)) settled.add(element.id);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return settled;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Elements the work's delta still changes whose living text moved since its last approval:
|
|
26
|
+
* approving the delta as it stands would overwrite that edit. A change's own approval pins no delta.
|
|
27
|
+
*/
|
|
28
|
+
export function rebaseNeeded(view: ProjectView, item: WorkItem): string[] {
|
|
29
|
+
if (item.kind === "change") return [];
|
|
30
|
+
const file = view.approvals.get(approvalFolder(view, item.path));
|
|
31
|
+
const previous = file === undefined ? undefined : latestApproval(file, item.path);
|
|
32
|
+
if (previous === undefined) return [];
|
|
33
|
+
const settled = settledIds(item.doc, file?.merged ?? {});
|
|
34
|
+
return [...readDelta(item.doc).ids].filter((id) => {
|
|
35
|
+
const living = view.living.elements.get(id);
|
|
36
|
+
const before = previous.upstream[id];
|
|
37
|
+
return !settled.has(id) && living !== undefined && before !== undefined && before !== elementHash(living.doc, living.element);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { isStatusLine } from "../elements.ts";
|
|
2
|
+
import { designHash, elementHash } from "../hashing.ts";
|
|
3
|
+
import { parseMarkdown, type MarkdownDoc } from "../markdown.ts";
|
|
4
|
+
import { buildLivingModel } from "../project/living_model.ts";
|
|
5
|
+
import type { ProjectView } from "../project/project_view.ts";
|
|
6
|
+
|
|
7
|
+
/** The document that holds an upstream key now: an element's living document, or the path of a `doc:` key. */
|
|
8
|
+
export function upstreamPath(view: ProjectView, key: string): string | undefined {
|
|
9
|
+
if (key.startsWith("doc:")) return view.snapshot.has(key.slice("doc:".length)) ? key.slice("doc:".length) : undefined;
|
|
10
|
+
return view.living.elements.get(key)?.path;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The design lines behind an upstream key in one version of the document at `path`, without the
|
|
15
|
+
* managed blocks and status lines no hash covers. With `pinned`, only when they hash as pinned.
|
|
16
|
+
*/
|
|
17
|
+
export function upstreamLines(key: string, path: string, text: string, pinned?: string): string[] | undefined {
|
|
18
|
+
if (key.startsWith("doc:")) {
|
|
19
|
+
const doc = parseMarkdown(text);
|
|
20
|
+
if (pinned !== undefined && designHash(doc) !== pinned) return undefined;
|
|
21
|
+
return designLines(doc, 0, doc.lines.length);
|
|
22
|
+
}
|
|
23
|
+
const living = buildLivingModel(new Map([[path, text]])).elements.get(key);
|
|
24
|
+
if (living === undefined) return undefined;
|
|
25
|
+
if (pinned !== undefined && elementHash(living.doc, living.element) !== pinned) return undefined;
|
|
26
|
+
return designLines(living.doc, living.element.start, living.element.end);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function designLines(doc: MarkdownDoc, start: number, end: number): string[] {
|
|
30
|
+
const lines: string[] = [];
|
|
31
|
+
for (let i = start; i < end; i++) {
|
|
32
|
+
const line = doc.lines[i] ?? "";
|
|
33
|
+
if (!doc.inManaged[i] && (doc.inFence[i] || !isStatusLine(line))) lines.push(line);
|
|
34
|
+
}
|
|
35
|
+
return lines;
|
|
36
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type { CruzeConfig, Layer } from "../config.ts";
|
|
2
2
|
import { matchesAny } from "../glob.ts";
|
|
3
3
|
import { budgetedText } from "./budget_text.ts";
|
|
4
|
+
import { decisionCitationsInComments } from "./decision_comments.ts";
|
|
4
5
|
import { importsOf, resolveImport } from "./imports.ts";
|
|
5
6
|
import { countTypes } from "./type_count.ts";
|
|
6
7
|
|
|
7
8
|
export interface CheckFinding {
|
|
8
9
|
path: string;
|
|
9
10
|
line?: number;
|
|
10
|
-
rule: "layer" | "max-lines" | "max-types" | "outside-module-map" | "no-layer" | "test-placement";
|
|
11
|
+
rule: "layer" | "max-lines" | "max-types" | "outside-module-map" | "no-layer" | "test-placement" | "decision-citation";
|
|
11
12
|
message: string;
|
|
12
13
|
/** Layer violations always fail; the rest fail in CI unless excepted. */
|
|
13
14
|
severity: "error" | "warning";
|
|
@@ -21,6 +22,8 @@ export interface CheckInput {
|
|
|
21
22
|
sources: ReadonlyMap<string, string>;
|
|
22
23
|
/** Paths of the MOD elements in the architecture; files outside all of them are flagged. */
|
|
23
24
|
modulePaths: string[];
|
|
25
|
+
/** Test files outside config.source, by path: only their comments are checked. */
|
|
26
|
+
tests?: ReadonlyMap<string, string>;
|
|
24
27
|
/** Check only these files, when given; imports still resolve against every source. */
|
|
25
28
|
only?: string[];
|
|
26
29
|
}
|
|
@@ -67,10 +70,24 @@ export function checkSource(input: CheckInput): CheckFinding[] {
|
|
|
67
70
|
if (modulePaths.length > 0 && !insideModuleMap(path, modulePaths)) {
|
|
68
71
|
findings.push({ path, rule: "outside-module-map", severity: "warning", message: "the file is outside every module in the architecture's module map" });
|
|
69
72
|
}
|
|
73
|
+
findings.push(...decisionFindings(path, text));
|
|
74
|
+
}
|
|
75
|
+
for (const [path, text] of input.tests ?? []) {
|
|
76
|
+
if (!sources.has(path) && (input.only === undefined || input.only.includes(path))) findings.push(...decisionFindings(path, text));
|
|
70
77
|
}
|
|
71
78
|
return findings;
|
|
72
79
|
}
|
|
73
80
|
|
|
81
|
+
function decisionFindings(path: string, text: string): CheckFinding[] {
|
|
82
|
+
return decisionCitationsInComments(path, text).map((citation) => ({
|
|
83
|
+
path,
|
|
84
|
+
line: citation.line + 1,
|
|
85
|
+
rule: "decision-citation",
|
|
86
|
+
severity: "warning",
|
|
87
|
+
message: `the comment cites ${citation.text}, a decision number a reader of the code can't follow; say what was decided, or cite the element or ADR that holds it`,
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
|
|
74
91
|
/** Whether a module's Path covers the file: a directory path ending in `/` covers everything under it, any other names one file. */
|
|
75
92
|
export function insideModuleMap(path: string, modulePaths: string[]): boolean {
|
|
76
93
|
return modulePaths.some((module) => (module.endsWith("/") ? path.startsWith(module) : path === module));
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { findDecisionCitations, type DecisionCitation } from "../decision_citations.ts";
|
|
2
|
+
|
|
3
|
+
const HASH_COMMENTS = new Set(["py", "rb", "sh", "bash", "zsh", "pl", "r", "ex", "exs", "jl", "nim", "cr", "ps1"]);
|
|
4
|
+
const DASH_COMMENTS = new Set(["sql", "lua", "hs", "elm", "ada"]);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Decisions a source file's comments cite by number, such as `(D22)` or `(events D23)`. Each work
|
|
8
|
+
* item numbers its decisions from D1 and is archived when it finishes, so a number in code points
|
|
9
|
+
* at nothing a reader can find.
|
|
10
|
+
*/
|
|
11
|
+
export function decisionCitationsInComments(path: string, text: string): DecisionCitation[] {
|
|
12
|
+
const comments = commentText(path, text.split("\n"));
|
|
13
|
+
return findDecisionCitations(comments, new Set());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Each line with everything but its comment blanked, so a citation's line number stays right. */
|
|
17
|
+
function commentText(path: string, lines: string[]): string[] {
|
|
18
|
+
const extension = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
|
|
19
|
+
if (HASH_COMMENTS.has(extension)) return lines.map((line) => after(line, /(^|\s)#/));
|
|
20
|
+
if (DASH_COMMENTS.has(extension)) return lines.map((line) => after(line, /--/));
|
|
21
|
+
let inBlock = false;
|
|
22
|
+
return lines.map((line) => {
|
|
23
|
+
if (inBlock) {
|
|
24
|
+
if (line.includes("*/")) inBlock = false;
|
|
25
|
+
return line;
|
|
26
|
+
}
|
|
27
|
+
const open = line.indexOf("/*");
|
|
28
|
+
if (open !== -1 && !line.includes("*/", open + 2)) inBlock = true;
|
|
29
|
+
const slash = line.indexOf("//");
|
|
30
|
+
const start = [open, slash].filter((at) => at !== -1).sort((a, b) => a - b)[0];
|
|
31
|
+
return start === undefined ? "" : line.slice(start);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function after(line: string, marker: RegExp): string {
|
|
36
|
+
const match = marker.exec(line);
|
|
37
|
+
return match === null ? "" : line.slice(match.index);
|
|
38
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settled decisions are numbered `D<n>` within the document that records them: the architecture's
|
|
3
|
+
* `## Decisions`, or one feature or change, which is archived when it finishes. A number cited
|
|
4
|
+
* anywhere else either dangles or silently means another document's decision.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** One citation of decisions by number, such as `D5`, `D5, D19` or `events D17`. */
|
|
8
|
+
export interface DecisionCitation {
|
|
9
|
+
/** 0-based line. */
|
|
10
|
+
line: number;
|
|
11
|
+
text: string;
|
|
12
|
+
numbers: number[];
|
|
13
|
+
/** The work item named with it, as in `events D17` or `D1 of 02-links`. */
|
|
14
|
+
qualifier?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SLUG = "[a-z0-9]+(?:-[a-z0-9]+)*";
|
|
18
|
+
const CITATION = new RegExp(`(?:(?<![\\w-])(${SLUG})\\s+)?(?<![\\w-])(D\\d+(?:\\s*(?:,|and)\\s*D\\d+)*)(?![\\w-])(?:\\s+of\\s+(${SLUG}))?`, "g");
|
|
19
|
+
const DEFINITION = /^\s*- D(\d+):/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Every citation of decisions by number in the lines given, skipping the `- D<n>:` that defines one
|
|
23
|
+
* and anything inside backticks. `slugs` are the work items a citation may name.
|
|
24
|
+
*/
|
|
25
|
+
export function findDecisionCitations(lines: readonly string[], slugs: ReadonlySet<string>, skip: (line: number) => boolean = () => false): DecisionCitation[] {
|
|
26
|
+
const citations: DecisionCitation[] = [];
|
|
27
|
+
lines.forEach((raw, line) => {
|
|
28
|
+
if (skip(line)) return;
|
|
29
|
+
const text = raw.replace(DEFINITION, (m) => " ".repeat(m.length)).replace(/`[^`]*`/g, (m) => " ".repeat(m.length));
|
|
30
|
+
for (const match of text.matchAll(CITATION)) {
|
|
31
|
+
const before = match[1] !== undefined && slugs.has(match[1]) ? match[1] : undefined;
|
|
32
|
+
const after = match[3] !== undefined && slugs.has(match[3]) ? match[3] : undefined;
|
|
33
|
+
const numbers = [...(match[2] ?? "").matchAll(/D(\d+)/g)].map((m) => Number(m[1]));
|
|
34
|
+
const qualifier = before ?? after;
|
|
35
|
+
const cited = qualifier === undefined ? (match[2] ?? "") : (match[0] ?? "").trim();
|
|
36
|
+
citations.push(qualifier === undefined ? { line, text: cited, numbers } : { line, text: cited, numbers, qualifier });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
return citations;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The decisions a `## Decisions` section, or any lines, define as `- D<n>: ...`. */
|
|
43
|
+
export function definedDecisions(lines: readonly string[]): Set<number> {
|
|
44
|
+
return new Set(lines.flatMap((line) => {
|
|
45
|
+
const match = DEFINITION.exec(line);
|
|
46
|
+
return match?.[1] === undefined ? [] : [Number(match[1])];
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The names a citation may give a work item: a feature's slug with and without its date, and a
|
|
52
|
+
* change's folder name, such as `events`, `2026-10-08-events` and `02-links`.
|
|
53
|
+
*/
|
|
54
|
+
export function workItemSlugs(folderNames: Iterable<string>): Set<string> {
|
|
55
|
+
const slugs = new Set<string>();
|
|
56
|
+
for (const name of folderNames) {
|
|
57
|
+
slugs.add(name);
|
|
58
|
+
const undated = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
|
|
59
|
+
if (undated !== "") slugs.add(undated);
|
|
60
|
+
}
|
|
61
|
+
return slugs;
|
|
62
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { factsOf, statusOf } from "../elements.ts";
|
|
2
|
+
import { findSection, parseMarkdown } from "../markdown.ts";
|
|
2
3
|
import type { ProjectView } from "../project/project_view.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -19,6 +20,14 @@ export function renderAgentsFile(text: string, view: ProjectView): string {
|
|
|
19
20
|
return next;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/** The lines under `## Known pitfalls`, one per pitfall, which the feedback export carries back to Cruze. */
|
|
24
|
+
export function knownPitfalls(text: string): string[] {
|
|
25
|
+
const doc = parseMarkdown(text);
|
|
26
|
+
const range = findSection(doc, "Known pitfalls");
|
|
27
|
+
if (range === null) return [];
|
|
28
|
+
return doc.lines.slice(range.start, range.end).filter((line) => line.startsWith("- ")).map((line) => line.slice(2).trim());
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
/**
|
|
23
32
|
* The modules whose code exists: those built, and those a land built before a rethink set them
|
|
24
33
|
* back to `planned` for a change to rebuild, since their directories are still there.
|
package/src/domain/ids.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/** Element IDs: `<KIND>-<name>` or `<KIND>-<scope>.<name>`, named and never reused. */
|
|
2
2
|
|
|
3
|
-
export const KINDS = ["GOAL", "CTX", "ENT", "UC", "PORT", "ADP", "FLOW", "MOD", "RULE", "VIEW", "XC", "REQ", "SCN", "ADR"] as const;
|
|
3
|
+
export const KINDS = ["GOAL", "PRINCIPLE", "LATER", "CTX", "ENT", "UC", "PORT", "ADP", "FLOW", "MOD", "RULE", "VIEW", "XC", "REQ", "SCN", "ADR"] as const;
|
|
4
4
|
export type Kind = (typeof KINDS)[number];
|
|
5
5
|
|
|
6
|
+
/** Kinds of the vision's row elements: goals, the principles every design follows, and what must stay possible later. */
|
|
7
|
+
export const VISION_KINDS: readonly Kind[] = ["GOAL", "PRINCIPLE", "LATER"];
|
|
6
8
|
/** Kinds that describe code, and so carry a managed planned/built status. */
|
|
7
9
|
export const CODE_KINDS: readonly Kind[] = ["ENT", "UC", "PORT", "ADP", "FLOW", "MOD"];
|
|
8
10
|
/** Kinds whose elements carry a managed status: code kinds and scenarios. */
|
package/src/domain/journal.ts
CHANGED
|
@@ -16,6 +16,7 @@ export const RECORDED_EVENTS: Record<string, string[]> = {
|
|
|
16
16
|
bug: ["summary", "cause"],
|
|
17
17
|
verification: ["result", "summary"],
|
|
18
18
|
checks: ["commit"],
|
|
19
|
+
lesson: ["summary", "instead", "step"],
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
/** Fields an event may carry beyond its required ones, checked when present. A defect rethink must say which step missed it. */
|
|
@@ -32,7 +33,7 @@ export const STEPS = ["envision", "architect", "research", "design-review", "wal
|
|
|
32
33
|
export const FIELD_VALUES: Record<string, string[]> = {
|
|
33
34
|
level: ["task", "change", "feature", "architecture", "vision"],
|
|
34
35
|
kind: ["defect", "discovery"],
|
|
35
|
-
disposition: ["fixed", "waived", "deferred", "rejected"],
|
|
36
|
+
disposition: ["fixed", "waived", "deferred", "rejected", "rethink"],
|
|
36
37
|
decided: ["agent", "user"],
|
|
37
38
|
review: ["design", "plan", "code", "realign", "improve"],
|
|
38
39
|
result: ["accepted", "sent-back"],
|
|
@@ -40,6 +41,7 @@ export const FIELD_VALUES: Record<string, string[]> = {
|
|
|
40
41
|
checked_by: ["agent", "reviewer"],
|
|
41
42
|
found_by: STEPS,
|
|
42
43
|
missed_by: STEPS,
|
|
44
|
+
step: STEPS,
|
|
43
45
|
};
|
|
44
46
|
|
|
45
47
|
/** A defect rethink must say which step missed it, and a sent-back verification where the change goes back to. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { settledIds } from "../approvals/rebase.ts";
|
|
1
2
|
import { sectionForKind } from "../architecture_sections.ts";
|
|
2
3
|
import { CruzeError } from "../cruze_error.ts";
|
|
3
4
|
import { setStatus } from "../edits/status_lines.ts";
|
|
@@ -26,7 +27,8 @@ export interface MergePlan {
|
|
|
26
27
|
/**
|
|
27
28
|
* Merges a change's work into the living docs. The first change of a feature to land merges
|
|
28
29
|
* the whole delta; elements it doesn't build arrive as planned, and later changes flip what
|
|
29
|
-
* they build to built. Text is replaced only when nobody else changed it since this work merged it
|
|
30
|
+
* they build to built. Text is replaced only when nobody else changed it since this work merged it,
|
|
31
|
+
* and an element merged and unchanged in the delta since keeps its living text, whoever edited it.
|
|
30
32
|
*/
|
|
31
33
|
export function planMerge(view: ProjectView, change: WorkItem, source: WorkItem): MergePlan {
|
|
32
34
|
const scope = readScope(change.doc);
|
|
@@ -36,6 +38,7 @@ export function planMerge(view: ProjectView, change: WorkItem, source: WorkItem)
|
|
|
36
38
|
const files = new Map<string, string>();
|
|
37
39
|
const text = (path: string): string => files.get(path) ?? view.snapshot.get(path) ?? "";
|
|
38
40
|
const plan: MergePlan = { files, merged, mergedIds: [], built: [], removed: [] };
|
|
41
|
+
const settled = settledIds(source.doc, merged);
|
|
39
42
|
|
|
40
43
|
for (const op of delta.capabilities) {
|
|
41
44
|
const key = capabilityKey(op.capability);
|
|
@@ -56,8 +59,10 @@ export function planMerge(view: ProjectView, change: WorkItem, source: WorkItem)
|
|
|
56
59
|
}
|
|
57
60
|
continue;
|
|
58
61
|
}
|
|
59
|
-
|
|
62
|
+
const kept = settled.has(id) && view.living.elements.has(id);
|
|
63
|
+
if (!kept) mergeEntry(view, source, entry, path, text, files, merged, plan);
|
|
60
64
|
for (const target of [entry.element, ...entry.scenarios].filter((e) => STATUS_KINDS.includes(e.kind))) {
|
|
65
|
+
if (kept && !inScope.has(target.id)) continue;
|
|
61
66
|
files.set(path, setStatus(text(path), target.id, statusAfterMerge(view, source, target, inScope)));
|
|
62
67
|
if (inScope.has(target.id)) plan.built.push(target.id);
|
|
63
68
|
}
|