@printpractical/cruze 0.0.1
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/LICENSE +201 -0
- package/README.md +167 -0
- package/bin/cruze.mjs +17 -0
- package/dist/adapters/inbound/cli/cli_context.js +75 -0
- package/dist/adapters/inbound/cli/commands/code.js +30 -0
- package/dist/adapters/inbound/cli/commands/documents.js +52 -0
- package/dist/adapters/inbound/cli/commands/init.js +23 -0
- package/dist/adapters/inbound/cli/commands/install.js +15 -0
- package/dist/adapters/inbound/cli/commands/journal.js +31 -0
- package/dist/adapters/inbound/cli/commands/progress.js +51 -0
- package/dist/adapters/inbound/cli/commands/review.js +18 -0
- package/dist/adapters/inbound/cli/commands/status.js +45 -0
- package/dist/adapters/inbound/cli/commands.js +36 -0
- package/dist/adapters/inbound/cli/parse_command.js +90 -0
- package/dist/adapters/inbound/cli/run_cli.js +50 -0
- package/dist/adapters/outbound/git_repository.js +57 -0
- package/dist/adapters/outbound/node_project_files.js +101 -0
- package/dist/adapters/outbound/package_bundle.js +56 -0
- package/dist/adapters/outbound/process_agent_runner.js +26 -0
- package/dist/adapters/outbound/system_clock.js +14 -0
- package/dist/adapters/outbound/terminal_prompter.js +22 -0
- package/dist/app/ports/agent_runner.js +11 -0
- package/dist/app/ports/bundle.js +22 -0
- package/dist/app/ports/clock.js +6 -0
- package/dist/app/ports/project_files.js +20 -0
- package/dist/app/ports/prompter.js +5 -0
- package/dist/app/ports/repository.js +13 -0
- package/dist/app/project_context.js +45 -0
- package/dist/app/use_cases/abandon_work.js +42 -0
- package/dist/app/use_cases/approve_artifact.js +146 -0
- package/dist/app/use_cases/check_code.js +90 -0
- package/dist/app/use_cases/init_project.js +59 -0
- package/dist/app/use_cases/install_skills.js +93 -0
- package/dist/app/use_cases/journal_events.js +48 -0
- package/dist/app/use_cases/land_change.js +113 -0
- package/dist/app/use_cases/new_project_doc.js +40 -0
- package/dist/app/use_cases/new_work_item.js +75 -0
- package/dist/app/use_cases/project_status.js +73 -0
- package/dist/app/use_cases/record_progress.js +101 -0
- package/dist/app/use_cases/run_review.js +71 -0
- package/dist/app/use_cases/validate_project.js +22 -0
- package/dist/domain/agent_skill_homes.js +25 -0
- package/dist/domain/approvals/evaluate.js +36 -0
- package/dist/domain/approvals/fingerprint.js +52 -0
- package/dist/domain/approvals/records.js +50 -0
- package/dist/domain/architecture_sections.js +23 -0
- package/dist/domain/check/check_source.js +64 -0
- package/dist/domain/check/imports.js +81 -0
- package/dist/domain/check/rust_imports.js +84 -0
- package/dist/domain/check/type_count.js +17 -0
- package/dist/domain/config.js +128 -0
- package/dist/domain/cruze_error.js +10 -0
- package/dist/domain/edits/agents_file.js +44 -0
- package/dist/domain/edits/managed_tables.js +60 -0
- package/dist/domain/edits/progress_block.js +33 -0
- package/dist/domain/edits/status_lines.js +52 -0
- package/dist/domain/elements.js +126 -0
- package/dist/domain/glob.js +46 -0
- package/dist/domain/hashing.js +52 -0
- package/dist/domain/ids.js +37 -0
- package/dist/domain/journal.js +62 -0
- package/dist/domain/land/bookkeeping.js +54 -0
- package/dist/domain/land/merge_plan.js +132 -0
- package/dist/domain/land/text_ops.js +66 -0
- package/dist/domain/markdown.js +79 -0
- package/dist/domain/markdown_tables.js +34 -0
- package/dist/domain/project/artifact_ref.js +42 -0
- package/dist/domain/project/deltas.js +49 -0
- package/dist/domain/project/layout.js +40 -0
- package/dist/domain/project/living_model.js +54 -0
- package/dist/domain/project/new_items.js +48 -0
- package/dist/domain/project/plan_parts.js +111 -0
- package/dist/domain/project/progress.js +43 -0
- package/dist/domain/project/project_view.js +72 -0
- package/dist/domain/project/roadmap.js +43 -0
- package/dist/domain/project/work_items.js +68 -0
- package/dist/domain/project_name.js +20 -0
- package/dist/domain/scaffold.js +29 -0
- package/dist/domain/skill.js +106 -0
- package/dist/domain/skill_links.js +103 -0
- package/dist/domain/status/next_step.js +119 -0
- package/dist/domain/status/verification.js +16 -0
- package/dist/domain/status/work_status.js +101 -0
- package/dist/domain/trace.js +44 -0
- package/dist/domain/validation/delta_rules.js +79 -0
- package/dist/domain/validation/element_rules.js +99 -0
- package/dist/domain/validation/living_rules.js +97 -0
- package/dist/domain/validation/plan_rules.js +105 -0
- package/dist/domain/validation/problem.js +21 -0
- package/dist/domain/validation/project_rules.js +99 -0
- package/dist/domain/validation/validate_project.js +31 -0
- package/dist/domain/validation/work_item_rules.js +144 -0
- package/dist/main.js +32 -0
- package/package.json +40 -0
- package/scripts/build.mjs +37 -0
- package/skills/about/SKILL.md +21 -0
- package/skills/architect/SKILL.md +43 -0
- package/skills/architect/feature.md +29 -0
- package/skills/architect/project.md +58 -0
- package/skills/architect/tweak.md +22 -0
- package/skills/architect/walkthrough.md +29 -0
- package/skills/behavioural-testing/SKILL.md +63 -0
- package/skills/behavioural-testing/examples.md +102 -0
- package/skills/behavioural-testing/fakes.md +30 -0
- package/skills/build/SKILL.md +62 -0
- package/skills/dependency-approval/SKILL.md +48 -0
- package/skills/domain-language/SKILL.md +37 -0
- package/skills/envision/SKILL.md +29 -0
- package/skills/envision/feature.md +22 -0
- package/skills/envision/project.md +20 -0
- package/skills/explore/SKILL.md +39 -0
- package/skills/formats/SKILL.md +84 -0
- package/skills/formats/reference/architecture.md +80 -0
- package/skills/formats/reference/config.md +57 -0
- package/skills/formats/reference/durable-docs.md +71 -0
- package/skills/formats/reference/specs.md +65 -0
- package/skills/formats/reference/work-items.md +100 -0
- package/skills/formats/templates/adr.md +7 -0
- package/skills/formats/templates/architecture.md +100 -0
- package/skills/formats/templates/change.md +29 -0
- package/skills/formats/templates/feature.md +49 -0
- package/skills/formats/templates/glossary.md +8 -0
- package/skills/formats/templates/roadmap.md +32 -0
- package/skills/formats/templates/spec.md +11 -0
- package/skills/formats/templates/standalone-change.md +46 -0
- package/skills/formats/templates/vision.md +37 -0
- package/skills/grilling/SKILL.md +66 -0
- package/skills/hexagonal-design/SKILL.md +119 -0
- package/skills/hexagonal-design/contracts.md +68 -0
- package/skills/hexagonal-design/fundamentals.md +50 -0
- package/skills/hexagonal-design/languages/cpp.md +37 -0
- package/skills/hexagonal-design/languages/rust.md +40 -0
- package/skills/hexagonal-design/runtime.md +48 -0
- package/skills/land/SKILL.md +43 -0
- package/skills/next/SKILL.md +30 -0
- package/skills/plan/SKILL.md +52 -0
- package/skills/plan/tasks.md +26 -0
- package/skills/plan/test-plan.md +14 -0
- package/skills/research/SKILL.md +41 -0
- package/skills/research/evaluation.md +27 -0
- package/skills/rethink/SKILL.md +53 -0
- package/skills/rethink/levels.md +45 -0
- package/skills/roadmap/SKILL.md +51 -0
- package/skills/roles/SKILL.md +57 -0
- package/skills/roles/code-reviewer.md +55 -0
- package/skills/roles/design-reviewer.md +63 -0
- package/skills/roles/researcher.md +33 -0
- package/skills/roles/verifier.md +38 -0
- package/skills/triage/SKILL.md +35 -0
- package/skills/verify/SKILL.md +39 -0
- package/skills/verify/manual-test.md +23 -0
- package/src/adapters/inbound/cli/cli_context.ts +75 -0
- package/src/adapters/inbound/cli/commands/code.ts +30 -0
- package/src/adapters/inbound/cli/commands/documents.ts +52 -0
- package/src/adapters/inbound/cli/commands/init.ts +23 -0
- package/src/adapters/inbound/cli/commands/install.ts +15 -0
- package/src/adapters/inbound/cli/commands/journal.ts +31 -0
- package/src/adapters/inbound/cli/commands/progress.ts +51 -0
- package/src/adapters/inbound/cli/commands/review.ts +18 -0
- package/src/adapters/inbound/cli/commands/status.ts +45 -0
- package/src/adapters/inbound/cli/commands.ts +36 -0
- package/src/adapters/inbound/cli/parse_command.ts +90 -0
- package/src/adapters/inbound/cli/run_cli.ts +50 -0
- package/src/adapters/outbound/git_repository.ts +57 -0
- package/src/adapters/outbound/node_project_files.ts +101 -0
- package/src/adapters/outbound/package_bundle.ts +56 -0
- package/src/adapters/outbound/process_agent_runner.ts +26 -0
- package/src/adapters/outbound/system_clock.ts +14 -0
- package/src/adapters/outbound/terminal_prompter.ts +22 -0
- package/src/app/ports/agent_runner.ts +11 -0
- package/src/app/ports/bundle.ts +22 -0
- package/src/app/ports/clock.ts +6 -0
- package/src/app/ports/project_files.ts +20 -0
- package/src/app/ports/prompter.ts +5 -0
- package/src/app/ports/repository.ts +13 -0
- package/src/app/project_context.ts +45 -0
- package/src/app/use_cases/abandon_work.ts +42 -0
- package/src/app/use_cases/approve_artifact.ts +146 -0
- package/src/app/use_cases/check_code.ts +90 -0
- package/src/app/use_cases/init_project.ts +59 -0
- package/src/app/use_cases/install_skills.ts +93 -0
- package/src/app/use_cases/journal_events.ts +48 -0
- package/src/app/use_cases/land_change.ts +113 -0
- package/src/app/use_cases/new_project_doc.ts +40 -0
- package/src/app/use_cases/new_work_item.ts +75 -0
- package/src/app/use_cases/project_status.ts +73 -0
- package/src/app/use_cases/record_progress.ts +101 -0
- package/src/app/use_cases/run_review.ts +71 -0
- package/src/app/use_cases/validate_project.ts +22 -0
- package/src/domain/agent_skill_homes.ts +25 -0
- package/src/domain/approvals/evaluate.ts +36 -0
- package/src/domain/approvals/fingerprint.ts +52 -0
- package/src/domain/approvals/records.ts +50 -0
- package/src/domain/architecture_sections.ts +23 -0
- package/src/domain/check/check_source.ts +64 -0
- package/src/domain/check/imports.ts +81 -0
- package/src/domain/check/rust_imports.ts +84 -0
- package/src/domain/check/type_count.ts +17 -0
- package/src/domain/config.ts +128 -0
- package/src/domain/cruze_error.ts +10 -0
- package/src/domain/edits/agents_file.ts +44 -0
- package/src/domain/edits/managed_tables.ts +60 -0
- package/src/domain/edits/progress_block.ts +33 -0
- package/src/domain/edits/status_lines.ts +52 -0
- package/src/domain/elements.ts +126 -0
- package/src/domain/glob.ts +46 -0
- package/src/domain/hashing.ts +52 -0
- package/src/domain/ids.ts +37 -0
- package/src/domain/journal.ts +62 -0
- package/src/domain/land/bookkeeping.ts +54 -0
- package/src/domain/land/merge_plan.ts +132 -0
- package/src/domain/land/text_ops.ts +66 -0
- package/src/domain/markdown.ts +79 -0
- package/src/domain/markdown_tables.ts +34 -0
- package/src/domain/project/artifact_ref.ts +42 -0
- package/src/domain/project/deltas.ts +49 -0
- package/src/domain/project/layout.ts +40 -0
- package/src/domain/project/living_model.ts +54 -0
- package/src/domain/project/new_items.ts +48 -0
- package/src/domain/project/plan_parts.ts +111 -0
- package/src/domain/project/progress.ts +43 -0
- package/src/domain/project/project_view.ts +72 -0
- package/src/domain/project/roadmap.ts +43 -0
- package/src/domain/project/work_items.ts +68 -0
- package/src/domain/project_name.ts +20 -0
- package/src/domain/scaffold.ts +29 -0
- package/src/domain/skill.ts +106 -0
- package/src/domain/skill_links.ts +103 -0
- package/src/domain/status/next_step.ts +119 -0
- package/src/domain/status/verification.ts +16 -0
- package/src/domain/status/work_status.ts +101 -0
- package/src/domain/trace.ts +44 -0
- package/src/domain/validation/delta_rules.ts +79 -0
- package/src/domain/validation/element_rules.ts +99 -0
- package/src/domain/validation/living_rules.ts +97 -0
- package/src/domain/validation/plan_rules.ts +105 -0
- package/src/domain/validation/problem.ts +21 -0
- package/src/domain/validation/project_rules.ts +99 -0
- package/src/domain/validation/validate_project.ts +31 -0
- package/src/domain/validation/work_item_rules.ts +144 -0
- package/src/main.ts +32 -0
- package/templates/init/AGENTS.md +28 -0
- package/templates/init/CHANGELOG.md +8 -0
- package/templates/init/CLAUDE.md +1 -0
- package/templates/init/README.md +1 -0
- package/templates/init/ci.yml +23 -0
- package/templates/init/config.yaml +33 -0
- package/templates/init/gitattributes +2 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { CruzeError } from "../../domain/cruze_error.ts";
|
|
2
|
+
import { readRows, removeRow, upsertRow } from "../../domain/edits/managed_tables.ts";
|
|
3
|
+
import { updateProgress } from "../../domain/edits/progress_block.ts";
|
|
4
|
+
import { resolveChange } from "../../domain/project/artifact_ref.ts";
|
|
5
|
+
import { PATHS } from "../../domain/project/layout.ts";
|
|
6
|
+
import { readTasks } from "../../domain/project/plan_parts.ts";
|
|
7
|
+
import { requireSlug } from "../../domain/project/new_items.ts";
|
|
8
|
+
import { activeChange } from "../../domain/status/work_status.ts";
|
|
9
|
+
import type { WorkItem } from "../../domain/project/work_items.ts";
|
|
10
|
+
import { appendJournal, loadView, type ProjectDeps } from "../project_context.ts";
|
|
11
|
+
|
|
12
|
+
/** Commands that write the CLI-owned progress records: task ticks, deviations and the feature map. */
|
|
13
|
+
|
|
14
|
+
export interface TaskReport {
|
|
15
|
+
change: string;
|
|
16
|
+
task: string;
|
|
17
|
+
done: number;
|
|
18
|
+
total: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Marks a task done, recording the commit that completed it (HEAD by default). */
|
|
22
|
+
export async function completeTask(deps: ProjectDeps, task: string, options: { change?: string; commit?: string }): Promise<TaskReport> {
|
|
23
|
+
const change = await changeFor(deps, options.change);
|
|
24
|
+
requireTask(change, task);
|
|
25
|
+
const commit = options.commit ?? (await deps.repository.headCommit()) ?? "";
|
|
26
|
+
const text = updateProgress(change.doc.lines.join("\n"), (progress) => progress.tasks.set(task, commit));
|
|
27
|
+
await deps.files.writeText(change.path, text);
|
|
28
|
+
const ticks = [...text.matchAll(/^- \[([ x])\] T\d+/gm)];
|
|
29
|
+
return { change: change.ref, task, done: ticks.filter((m) => m[1] === "x").length, total: ticks.length };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Reopens a done task whose design a rethink changed, so build does it again. */
|
|
33
|
+
export async function reopenTask(deps: ProjectDeps, task: string, reason: string, options: { change?: string }): Promise<{ change: string; task: string }> {
|
|
34
|
+
const change = await changeFor(deps, options.change);
|
|
35
|
+
requireTask(change, task);
|
|
36
|
+
if (reason.trim() === "") throw new CruzeError("missing-reason", "say why the task reopens");
|
|
37
|
+
await deps.files.writeText(change.path, updateProgress(change.doc.lines.join("\n"), (progress) => progress.tasks.set(task, null)));
|
|
38
|
+
await appendJournal(deps, change.folder, "reopen", { change: change.ref, task, reason: reason.trim() });
|
|
39
|
+
return { change: change.ref, task };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Records a task-level choice the agent made on its own. Anything larger is a rethink. */
|
|
43
|
+
export async function recordDeviation(deps: ProjectDeps, task: string, text: string, options: { change?: string }): Promise<{ change: string; task: string }> {
|
|
44
|
+
const change = await changeFor(deps, options.change);
|
|
45
|
+
requireTask(change, task);
|
|
46
|
+
if (text.trim() === "") throw new CruzeError("missing-text", "describe the deviation");
|
|
47
|
+
await deps.files.writeText(change.path, updateProgress(change.doc.lines.join("\n"), (progress) => progress.deviations.push(`${task}: ${text.trim()}`)));
|
|
48
|
+
await appendJournal(deps, change.folder, "deviation", { change: change.ref, task, text: text.trim() });
|
|
49
|
+
return { change: change.ref, task };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Adds a feature to the future list of the feature map, or replaces its entry. */
|
|
53
|
+
export async function addFutureFeature(deps: ProjectDeps, slug: string, summary: string, goals: string[]): Promise<{ feature: string }> {
|
|
54
|
+
requireSlug(slug);
|
|
55
|
+
const vision = await requireVision(deps);
|
|
56
|
+
await deps.files.writeText(PATHS.vision, upsertRow(vision, "Future", [slug, summary, goals.join(", ")]));
|
|
57
|
+
await appendJournal(deps, ".cruze", "feature-map", { action: "add", feature: slug, summary, goals });
|
|
58
|
+
return { feature: slug };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function dropFutureFeature(deps: ProjectDeps, slug: string, reason: string): Promise<{ feature: string }> {
|
|
62
|
+
const vision = await requireVision(deps);
|
|
63
|
+
const { text, removed } = removeRow(vision, "Future", slug);
|
|
64
|
+
if (removed === null) throw new CruzeError("not-found", `${slug} is not on the future list`);
|
|
65
|
+
if (reason.trim() === "") throw new CruzeError("missing-reason", "dropping a feature needs a reason");
|
|
66
|
+
await deps.files.writeText(PATHS.vision, text);
|
|
67
|
+
await appendJournal(deps, ".cruze", "feature-map", { action: "drop", feature: slug, reason });
|
|
68
|
+
return { feature: slug };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Clears the landed rows from the roadmap's Status table, when a release closes. */
|
|
72
|
+
export async function pruneRoadmap(deps: ProjectDeps): Promise<{ removed: string[] }> {
|
|
73
|
+
const roadmap = await deps.files.readText(PATHS.roadmap);
|
|
74
|
+
if (roadmap === undefined) throw new CruzeError("not-found", `${PATHS.roadmap} does not exist yet`);
|
|
75
|
+
const landed = readRows(roadmap, "Status").filter((row) => row[2] === "landed").map((row) => row[0] ?? "");
|
|
76
|
+
let text = roadmap;
|
|
77
|
+
for (const item of landed) text = removeRow(text, "Status", item).text;
|
|
78
|
+
if (landed.length > 0) {
|
|
79
|
+
await deps.files.writeText(PATHS.roadmap, text);
|
|
80
|
+
await appendJournal(deps, ".cruze", "roadmap-prune", { items: landed });
|
|
81
|
+
}
|
|
82
|
+
return { removed: landed };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function changeFor(deps: ProjectDeps, ref: string | undefined): Promise<WorkItem> {
|
|
86
|
+
const view = await loadView(deps.files);
|
|
87
|
+
if (ref !== undefined) return resolveChange(view, ref);
|
|
88
|
+
const change = activeChange(view, await deps.repository.currentBranch());
|
|
89
|
+
if (change === undefined) throw new CruzeError("no-active-change", "no change is bound to this branch; name one with --change");
|
|
90
|
+
return change;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function requireTask(change: WorkItem, task: string): void {
|
|
94
|
+
if (!readTasks(change.doc).tasks.some((t) => t.number === task)) throw new CruzeError("unknown-task", `${change.ref} has no task ${task}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function requireVision(deps: ProjectDeps): Promise<string> {
|
|
98
|
+
const vision = await deps.files.readText(PATHS.vision);
|
|
99
|
+
if (vision === undefined) throw new CruzeError("not-found", `${PATHS.vision} does not exist yet`);
|
|
100
|
+
return vision;
|
|
101
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { CruzeError } from "../../domain/cruze_error.ts";
|
|
2
|
+
import { resolveArtifact } from "../../domain/project/artifact_ref.ts";
|
|
3
|
+
import { featureOf } from "../../domain/project/work_items.ts";
|
|
4
|
+
import type { AgentRunner } from "../ports/agent_runner.ts";
|
|
5
|
+
import { loadView, type ProjectDeps } from "../project_context.ts";
|
|
6
|
+
|
|
7
|
+
export const ROLES = ["design-reviewer", "code-reviewer", "verifier", "researcher"] as const;
|
|
8
|
+
export type Role = (typeof ROLES)[number];
|
|
9
|
+
|
|
10
|
+
export interface ReviewRequest {
|
|
11
|
+
role: string;
|
|
12
|
+
/** The feature or change under review; omitted for project-scope documents. */
|
|
13
|
+
item?: string;
|
|
14
|
+
round?: number;
|
|
15
|
+
/** The round-1 blockers, for round 2. */
|
|
16
|
+
blockers?: string;
|
|
17
|
+
/** The commit the diff under review starts from. */
|
|
18
|
+
base?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ReviewReport {
|
|
22
|
+
role: Role;
|
|
23
|
+
item?: string;
|
|
24
|
+
command: string[];
|
|
25
|
+
report: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Runs a Cruze role in a fresh agent context: the role's prompt, what it reviews, and nothing
|
|
30
|
+
* from the conversation that produced the work. Returns the role's report as written.
|
|
31
|
+
*/
|
|
32
|
+
export async function runReview(deps: ProjectDeps & { runner: AgentRunner }, request: ReviewRequest): Promise<ReviewReport> {
|
|
33
|
+
if (!(ROLES as readonly string[]).includes(request.role)) throw new CruzeError("unknown-role", `the roles are ${ROLES.join(", ")}`);
|
|
34
|
+
const role = request.role as Role;
|
|
35
|
+
const view = await loadView(deps.files);
|
|
36
|
+
const roleText = (await deps.files.readText(`.agents/skills/cruze-roles/${role}.md`)) ?? (await bundledRole(deps, role));
|
|
37
|
+
|
|
38
|
+
const context = [`Round: ${request.round ?? 1}`];
|
|
39
|
+
if (request.item !== undefined) {
|
|
40
|
+
const { path, item } = resolveArtifact(view, request.item);
|
|
41
|
+
context.push(`Under review: ${item?.ref ?? path} (\`${path}\`)`);
|
|
42
|
+
const feature = item === undefined ? undefined : featureOf(view.items, item);
|
|
43
|
+
if (feature !== undefined) context.push(`Its feature: \`${feature.path}\``);
|
|
44
|
+
if (role === "design-reviewer" && item !== undefined && item.kind !== "feature") context.push("This is a change's plan: review it with the plan rubric.");
|
|
45
|
+
} else {
|
|
46
|
+
context.push("Under review: the project documents the role lists for project scope");
|
|
47
|
+
}
|
|
48
|
+
if (request.base !== undefined) context.push(`The changes under review: run \`git diff ${request.base}\`.`);
|
|
49
|
+
if (request.blockers !== undefined) context.push(`Round-1 blockers to check:\n\n${request.blockers}`);
|
|
50
|
+
|
|
51
|
+
const prompt = [
|
|
52
|
+
roleText.trim(),
|
|
53
|
+
"## This run",
|
|
54
|
+
"You are running the role above in a fresh context, in the project's repository. Read every input it lists from the files, and run the commands it names. Edit no files. End with the report the role describes.",
|
|
55
|
+
context.map((line) => `- ${line}`).join("\n"),
|
|
56
|
+
].join("\n\n");
|
|
57
|
+
|
|
58
|
+
const command = view.config?.config?.review.command ?? [];
|
|
59
|
+
const run = await deps.runner.run(command, prompt);
|
|
60
|
+
if (run.exitCode !== 0) throw new CruzeError("review-failed", `${command.join(" ")} exited with ${run.exitCode}: ${run.error.trim() || run.output.trim()}`);
|
|
61
|
+
const report: ReviewReport = { role, command, report: run.output.trim() };
|
|
62
|
+
if (request.item !== undefined) report.item = request.item;
|
|
63
|
+
return report;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function bundledRole(deps: ProjectDeps, role: Role): Promise<string> {
|
|
67
|
+
const roles = (await deps.bundle.skills()).find((skill) => skill.folder === "roles");
|
|
68
|
+
const file = roles?.files.find((f) => f.path === `${role}.md`);
|
|
69
|
+
if (file === undefined) throw new CruzeError("role-missing", `the ${role} role is not installed; run cruze install`);
|
|
70
|
+
return file.text;
|
|
71
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { hasErrors, validateProject } from "../../domain/validation/validate_project.ts";
|
|
2
|
+
import type { Problem } from "../../domain/validation/problem.ts";
|
|
3
|
+
import { loadView } from "../project_context.ts";
|
|
4
|
+
import type { ProjectFiles } from "../ports/project_files.ts";
|
|
5
|
+
|
|
6
|
+
export interface ValidateReport {
|
|
7
|
+
valid: boolean;
|
|
8
|
+
errors: number;
|
|
9
|
+
warnings: number;
|
|
10
|
+
problems: Problem[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Checks every document in the project against the cruze-formats rules. */
|
|
14
|
+
export async function validate(deps: { files: ProjectFiles }): Promise<ValidateReport> {
|
|
15
|
+
const problems = validateProject(await loadView(deps.files));
|
|
16
|
+
return {
|
|
17
|
+
valid: !hasErrors(problems),
|
|
18
|
+
errors: problems.filter((p) => p.severity === "error").length,
|
|
19
|
+
warnings: problems.filter((p) => p.severity === "warning").length,
|
|
20
|
+
problems,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where agents look for skills. Skills are installed once into the shared
|
|
3
|
+
* Agent Skills directory; agents that don't read it get links in their own.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const SHARED_SKILLS_DIR = ".agents/skills";
|
|
7
|
+
|
|
8
|
+
export interface AgentSkillHome {
|
|
9
|
+
agent: string;
|
|
10
|
+
skillsDir: string;
|
|
11
|
+
/** Paths whose presence shows the project uses this agent. */
|
|
12
|
+
markers: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const AGENT_SKILL_HOMES: readonly AgentSkillHome[] = [
|
|
16
|
+
{ agent: "claude", skillsDir: ".claude/skills", markers: ["CLAUDE.md", ".claude"] },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export function knownAgents(): string[] {
|
|
20
|
+
return AGENT_SKILL_HOMES.map((home) => home.agent);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function agentSkillHome(agent: string): AgentSkillHome | undefined {
|
|
24
|
+
return AGENT_SKILL_HOMES.find((home) => home.agent === agent);
|
|
25
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { parseMarkdown } from "../markdown.ts";
|
|
2
|
+
import { approvalFolder, type ProjectView } from "../project/project_view.ts";
|
|
3
|
+
import { currentUpstreamHash, fingerprint } from "./fingerprint.ts";
|
|
4
|
+
import { latestApproval, type ApprovalRecord } from "./records.ts";
|
|
5
|
+
|
|
6
|
+
export type ApprovalState = "approved" | "edited" | "upstream-changed" | "unapproved";
|
|
7
|
+
|
|
8
|
+
export interface ApprovalStatus {
|
|
9
|
+
artifact: string;
|
|
10
|
+
state: ApprovalState;
|
|
11
|
+
/** Upstream IDs or documents that changed or disappeared since approval. */
|
|
12
|
+
changed: string[];
|
|
13
|
+
record?: ApprovalRecord;
|
|
14
|
+
/** Set when an approval exists but the journal has no matching entry. */
|
|
15
|
+
unjournaled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Computes an artifact's approval state from content, never from stored state:
|
|
20
|
+
* editing an upstream document is all it takes to make downstream work stale.
|
|
21
|
+
*/
|
|
22
|
+
export function evaluateApproval(view: ProjectView, artifact: string): ApprovalStatus {
|
|
23
|
+
const record = latestApproval(view.approvals.get(approvalFolder(view, artifact)) ?? { version: 1, approvals: [], merged: {} }, artifact);
|
|
24
|
+
if (record === undefined) return { artifact, state: "unapproved", changed: [] };
|
|
25
|
+
const journaled = view.journal.some((e) => e.event === "approve" && e.artifact === artifact && e.hash === record.hash);
|
|
26
|
+
if (!journaled) return { artifact, state: "unapproved", changed: [], record, unjournaled: true };
|
|
27
|
+
|
|
28
|
+
const text = view.snapshot.get(artifact);
|
|
29
|
+
if (text === undefined) return { artifact, state: "unapproved", changed: [], record };
|
|
30
|
+
const { hash } = fingerprint(view, artifact, parseMarkdown(text));
|
|
31
|
+
if (hash !== record.hash) return { artifact, state: "edited", changed: [], record };
|
|
32
|
+
const changed = Object.entries(record.upstream)
|
|
33
|
+
.filter(([key, pinned]) => currentUpstreamHash(view, key) !== pinned)
|
|
34
|
+
.map(([key]) => key);
|
|
35
|
+
return { artifact, state: changed.length > 0 ? "upstream-changed" : "approved", changed, record };
|
|
36
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { headingElements, rowElements } from "../elements.ts";
|
|
2
|
+
import { designHash, elementHash } from "../hashing.ts";
|
|
3
|
+
import { findIds } from "../ids.ts";
|
|
4
|
+
import type { MarkdownDoc } from "../markdown.ts";
|
|
5
|
+
import { PATHS } from "../project/layout.ts";
|
|
6
|
+
import type { ProjectView } from "../project/project_view.ts";
|
|
7
|
+
import { featureOf } from "../project/work_items.ts";
|
|
8
|
+
|
|
9
|
+
/** What an approval pins: the document's design hash and the hash of everything upstream it relies on. */
|
|
10
|
+
export interface Fingerprint {
|
|
11
|
+
hash: string;
|
|
12
|
+
upstream: Record<string, string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Documents a person can approve. Specs, the glossary and ADRs change only through approved work. */
|
|
16
|
+
export function isApprovable(view: ProjectView, path: string): boolean {
|
|
17
|
+
return [PATHS.vision, PATHS.architecture, PATHS.roadmap].includes(path as never) || view.items.some((i) => i.path === path && !i.archived);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function fingerprint(view: ProjectView, path: string, doc: MarkdownDoc): Fingerprint {
|
|
21
|
+
const upstream: Record<string, string> = {};
|
|
22
|
+
const ownIds = new Set([...headingElements(doc), ...rowElements(doc)].filter((e) => e.op !== "ADDED").map((e) => e.id));
|
|
23
|
+
const isLivingDoc = !view.items.some((item) => item.path === path);
|
|
24
|
+
const designText = doc.lines.filter((_, i) => !doc.inManaged[i]).join("\n");
|
|
25
|
+
|
|
26
|
+
for (const id of findIds(designText)) {
|
|
27
|
+
if (isLivingDoc && ownIds.has(id)) continue;
|
|
28
|
+
const living = view.living.elements.get(id);
|
|
29
|
+
if (living !== undefined) upstream[id] = elementHash(living.doc, living.element);
|
|
30
|
+
}
|
|
31
|
+
const item = view.items.find((i) => i.path === path);
|
|
32
|
+
if (item !== undefined) {
|
|
33
|
+
// Work always depends on the dependency rules, cited or not.
|
|
34
|
+
for (const [id, living] of view.living.elements) {
|
|
35
|
+
if (id.startsWith("RULE-")) upstream[id] = elementHash(living.doc, living.element);
|
|
36
|
+
}
|
|
37
|
+
const feature = item.kind === "change" ? featureOf(view.items, item) : undefined;
|
|
38
|
+
if (feature !== undefined) upstream[`doc:${feature.path}`] = designHash(feature.doc);
|
|
39
|
+
}
|
|
40
|
+
return { hash: designHash(doc), upstream };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The current hash of one upstream key, or undefined when it no longer exists. */
|
|
44
|
+
export function currentUpstreamHash(view: ProjectView, key: string): string | undefined {
|
|
45
|
+
if (key.startsWith("doc:")) {
|
|
46
|
+
const path = key.slice("doc:".length);
|
|
47
|
+
const item = view.items.find((i) => i.path === path);
|
|
48
|
+
return item === undefined ? undefined : designHash(item.doc);
|
|
49
|
+
}
|
|
50
|
+
const living = view.living.elements.get(key);
|
|
51
|
+
return living === undefined ? undefined : elementHash(living.doc, living.element);
|
|
52
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** What `approvals.json` holds. Only the CLI writes it. */
|
|
2
|
+
|
|
3
|
+
export interface ApprovalRecord {
|
|
4
|
+
/** Path of the approved document. */
|
|
5
|
+
artifact: string;
|
|
6
|
+
/** Design hash of the document when approved. */
|
|
7
|
+
hash: string;
|
|
8
|
+
/** Hash of each upstream element or document the approval depends on, by ID or `doc:<path>`. */
|
|
9
|
+
upstream: Record<string, string>;
|
|
10
|
+
approvedAt: string;
|
|
11
|
+
by: string;
|
|
12
|
+
/** Why the approval exists when no person gave it, such as a merge by `land`. */
|
|
13
|
+
basis?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ApprovalsFile {
|
|
17
|
+
version: 1;
|
|
18
|
+
approvals: ApprovalRecord[];
|
|
19
|
+
/** For a feature or standalone change: hash of each living element it merged, by ID. */
|
|
20
|
+
merged: Record<string, string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function emptyApprovals(): ApprovalsFile {
|
|
24
|
+
return { version: 1, approvals: [], merged: {} };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseApprovals(text: string | undefined): { file: ApprovalsFile; problem?: string } {
|
|
28
|
+
if (text === undefined) return { file: emptyApprovals() };
|
|
29
|
+
try {
|
|
30
|
+
const raw = JSON.parse(text) as Partial<ApprovalsFile>;
|
|
31
|
+
if (raw.version !== 1 || !Array.isArray(raw.approvals)) return { file: emptyApprovals(), problem: "approvals.json has an unknown format" };
|
|
32
|
+
return { file: { version: 1, approvals: raw.approvals, merged: raw.merged ?? {} } };
|
|
33
|
+
} catch {
|
|
34
|
+
return { file: emptyApprovals(), problem: "approvals.json is not valid JSON" };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function serializeApprovals(file: ApprovalsFile): string {
|
|
39
|
+
return `${JSON.stringify(file, null, 2)}\n`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The latest approval of an artifact, if any. */
|
|
43
|
+
export function latestApproval(file: ApprovalsFile, artifact: string): ApprovalRecord | undefined {
|
|
44
|
+
return file.approvals.filter((record) => record.artifact === artifact).at(-1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Replaces the artifact's approval: one current approval per artifact; history lives in the journal. */
|
|
48
|
+
export function withApproval(file: ApprovalsFile, record: ApprovalRecord): ApprovalsFile {
|
|
49
|
+
return { ...file, approvals: [...file.approvals.filter((r) => r.artifact !== record.artifact), record] };
|
|
50
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Kind } from "./ids.ts";
|
|
2
|
+
|
|
3
|
+
/** The `##` sections of architecture.md, in order, and the element kind each holds. */
|
|
4
|
+
export const ARCHITECTURE_SECTIONS: ReadonlyArray<{ title: string; kind: Kind | null }> = [
|
|
5
|
+
{ title: "Overview", kind: "VIEW" },
|
|
6
|
+
{ title: "Bounded contexts", kind: "CTX" },
|
|
7
|
+
{ title: "Domain model", kind: "ENT" },
|
|
8
|
+
{ title: "Use cases", kind: "UC" },
|
|
9
|
+
{ title: "Ports", kind: "PORT" },
|
|
10
|
+
{ title: "Adapters", kind: "ADP" },
|
|
11
|
+
{ title: "Flows", kind: "FLOW" },
|
|
12
|
+
{ title: "Modules", kind: "MOD" },
|
|
13
|
+
{ title: "Dependency rules", kind: "RULE" },
|
|
14
|
+
{ title: "Cross-cutting concerns", kind: "XC" },
|
|
15
|
+
{ title: "Decisions", kind: null },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export const ARCHITECTURE_KINDS: readonly Kind[] = ["VIEW", "CTX", "ENT", "UC", "PORT", "ADP", "FLOW", "MOD", "RULE", "XC"];
|
|
19
|
+
export const SPEC_KINDS: readonly Kind[] = ["REQ", "SCN"];
|
|
20
|
+
|
|
21
|
+
export function sectionForKind(kind: Kind): string | undefined {
|
|
22
|
+
return ARCHITECTURE_SECTIONS.find((section) => section.kind === kind)?.title;
|
|
23
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { CruzeConfig, Layer } from "../config.ts";
|
|
2
|
+
import { matchesAny } from "../glob.ts";
|
|
3
|
+
import { importsOf, resolveImport } from "./imports.ts";
|
|
4
|
+
import { countTypes } from "./type_count.ts";
|
|
5
|
+
|
|
6
|
+
export interface CheckFinding {
|
|
7
|
+
path: string;
|
|
8
|
+
line?: number;
|
|
9
|
+
rule: "layer" | "max-lines" | "max-types" | "outside-module-map" | "no-layer";
|
|
10
|
+
message: string;
|
|
11
|
+
/** Layer violations always fail; the rest fail in CI unless excepted. */
|
|
12
|
+
severity: "error" | "warning";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CheckInput {
|
|
16
|
+
config: CruzeConfig;
|
|
17
|
+
/** Every source file matched by config.source, by path. */
|
|
18
|
+
sources: ReadonlyMap<string, string>;
|
|
19
|
+
/** Paths of the MOD elements in the architecture; files outside all of them are flagged. */
|
|
20
|
+
modulePaths: string[];
|
|
21
|
+
/** Check only these files, when given; imports still resolve against every source. */
|
|
22
|
+
only?: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Enforces the architecture's layer rules and the file budgets on source code. */
|
|
26
|
+
export function checkSource(input: CheckInput): CheckFinding[] {
|
|
27
|
+
const { config, sources, modulePaths } = input;
|
|
28
|
+
const all = [...sources.keys()];
|
|
29
|
+
const targets = input.only ?? all;
|
|
30
|
+
const layerOf = (path: string): Layer | undefined => config.layers.find((layer) => matchesAny(path, layer.paths));
|
|
31
|
+
const findings: CheckFinding[] = [];
|
|
32
|
+
|
|
33
|
+
for (const path of targets) {
|
|
34
|
+
const text = sources.get(path);
|
|
35
|
+
if (text === undefined) continue;
|
|
36
|
+
const layer = layerOf(path);
|
|
37
|
+
if (config.layers.length > 0 && layer === undefined) {
|
|
38
|
+
findings.push({ path, rule: "no-layer", severity: "warning", message: "the file is in no layer, so its imports are unchecked" });
|
|
39
|
+
}
|
|
40
|
+
if (layer !== undefined) {
|
|
41
|
+
for (const found of importsOf(path, text)) {
|
|
42
|
+
const target = resolveImport(found, all);
|
|
43
|
+
const targetLayer = target === undefined ? undefined : layerOf(target);
|
|
44
|
+
if (target === undefined || targetLayer === undefined || targetLayer.name === layer.name) continue;
|
|
45
|
+
if (!layer.mayImport.includes(targetLayer.name)) {
|
|
46
|
+
findings.push({ path, line: found.line + 1, rule: "layer", severity: "error", message: `${layer.name} may not import ${targetLayer.name} (${target})` });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const excepted = config.check.exceptions.some((exception) => exception.path === path);
|
|
51
|
+
const lines = text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
52
|
+
if (!excepted && lines > config.check.maxLines) {
|
|
53
|
+
findings.push({ path, rule: "max-lines", severity: "warning", message: `${lines} lines, over the budget of ${config.check.maxLines}; split it by responsibility` });
|
|
54
|
+
}
|
|
55
|
+
const types = countTypes(path, text);
|
|
56
|
+
if (!excepted && types !== null && types > config.check.maxTypes) {
|
|
57
|
+
findings.push({ path, rule: "max-types", severity: "warning", message: `${types} top-level types, over the budget of ${config.check.maxTypes}` });
|
|
58
|
+
}
|
|
59
|
+
if (modulePaths.length > 0 && !modulePaths.some((module) => (module.endsWith("/") ? path.startsWith(module) : path === module))) {
|
|
60
|
+
findings.push({ path, rule: "outside-module-map", severity: "warning", message: "the file is outside every module in the architecture's module map" });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return findings;
|
|
64
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import { rustImportCandidates } from "./rust_imports.ts";
|
|
3
|
+
|
|
4
|
+
/** One import in a source file, with the project files it could refer to, most specific first. */
|
|
5
|
+
export interface SourceImport {
|
|
6
|
+
line: number;
|
|
7
|
+
candidates: string[];
|
|
8
|
+
/** Match candidates against the end of project paths rather than exactly. */
|
|
9
|
+
bySuffix: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const JS = /\.(?:[cm]?[jt]sx?)$/;
|
|
13
|
+
const JS_EXTENSIONS = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", "/index.ts", "/index.js"];
|
|
14
|
+
|
|
15
|
+
/** Imports that point inside the project. Imports of libraries and the standard library are left out. */
|
|
16
|
+
export function importsOf(path: string, text: string): SourceImport[] {
|
|
17
|
+
if (JS.test(path)) return jsImports(path, text);
|
|
18
|
+
if (path.endsWith(".py")) return pythonImports(path, text);
|
|
19
|
+
if (path.endsWith(".rs")) return rustImportCandidates(path, text).map((i) => ({ ...i, bySuffix: false }));
|
|
20
|
+
if (/\.(?:c|h|cc|cpp|cxx|hh|hpp|hxx)$/.test(path)) return matches(text, /^\s*#\s*include\s+"([^"]+)"/gm, (target) => [target], true);
|
|
21
|
+
if (path.endsWith(".go")) return goImports(text);
|
|
22
|
+
if (/\.(?:java|kt)$/.test(path)) return matches(text, /^import\s+(?:static\s+)?([\w.]+)(?:\.\*)?;?\s*$/gm, (t) => [`${t.replace(/\./g, "/")}.java`, `${t.replace(/\./g, "/")}.kt`], true);
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function jsImports(path: string, text: string): SourceImport[] {
|
|
27
|
+
const pattern = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)["']([^"']+)["']/g;
|
|
28
|
+
return matches(text, pattern, (target) => {
|
|
29
|
+
if (!target.startsWith(".")) return [];
|
|
30
|
+
const base = posix.normalize(posix.join(posix.dirname(path), target));
|
|
31
|
+
const stripped = base.replace(/\.[cm]?jsx?$/, "");
|
|
32
|
+
return [...new Set([...JS_EXTENSIONS.map((ext) => base + ext), ...JS_EXTENSIONS.map((ext) => stripped + ext)])];
|
|
33
|
+
}, false);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function pythonImports(path: string, text: string): SourceImport[] {
|
|
37
|
+
const results: SourceImport[] = [];
|
|
38
|
+
for (const match of text.matchAll(/^\s*(?:from\s+(\.*)([\w.]*)\s+import\s+([\w, ]+)|import\s+([\w.]+))/gm)) {
|
|
39
|
+
const line = text.slice(0, match.index).split("\n").length - 1;
|
|
40
|
+
const dots = match[1] ?? "";
|
|
41
|
+
const module = (match[2] ?? match[4] ?? "").split(".").filter((s) => s !== "");
|
|
42
|
+
const names = (match[3] ?? "").split(",").map((s) => s.trim().split(" ")[0] ?? "").filter((s) => s !== "");
|
|
43
|
+
const bases = dots === "" ? [module] : [[...posix.dirname(path).split("/").slice(0, dots.length === 1 ? undefined : -(dots.length - 1)), ...module]];
|
|
44
|
+
const candidates = bases.flatMap((segments) => [
|
|
45
|
+
...names.map((name) => `${[...segments, name].join("/")}.py`),
|
|
46
|
+
`${segments.join("/")}.py`,
|
|
47
|
+
`${segments.join("/")}/__init__.py`,
|
|
48
|
+
]);
|
|
49
|
+
results.push({ line, candidates, bySuffix: dots === "" });
|
|
50
|
+
}
|
|
51
|
+
return results;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function goImports(text: string): SourceImport[] {
|
|
55
|
+
const results: SourceImport[] = [];
|
|
56
|
+
for (const match of text.matchAll(/^\s*(?:import\s+)?(?:\w+\s+)?"([\w./-]+)"\s*$/gm)) {
|
|
57
|
+
const line = text.slice(0, match.index).split("\n").length - 1;
|
|
58
|
+
results.push({ line, candidates: [`${match[1] ?? ""}/`], bySuffix: true });
|
|
59
|
+
}
|
|
60
|
+
return results;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function matches(text: string, pattern: RegExp, candidates: (target: string) => string[], bySuffix: boolean): SourceImport[] {
|
|
64
|
+
return [...text.matchAll(pattern)]
|
|
65
|
+
.map((match) => ({ line: text.slice(0, match.index).split("\n").length - 1, candidates: candidates(match[1] ?? ""), bySuffix }))
|
|
66
|
+
.filter((found) => found.candidates.length > 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The project file an import refers to, or undefined when it points outside the project. */
|
|
70
|
+
export function resolveImport(found: SourceImport, files: readonly string[]): string | undefined {
|
|
71
|
+
for (const candidate of found.candidates) {
|
|
72
|
+
if (candidate.endsWith("/")) {
|
|
73
|
+
const inDir = files.find((f) => (found.bySuffix ? `/${f}`.includes(`/${candidate}`) : f.startsWith(candidate)));
|
|
74
|
+
if (inDir !== undefined) return inDir;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const hit = found.bySuffix ? files.find((f) => f === candidate || f.endsWith(`/${candidate}`)) : files.find((f) => f === candidate);
|
|
78
|
+
if (hit !== undefined) return hit;
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** Resolves Rust `use` paths inside a crate to candidate source files. */
|
|
2
|
+
|
|
3
|
+
/** Candidate files for each `use crate::…`, `use super::…` and `use self::…` path in a Rust file. */
|
|
4
|
+
export function rustImportCandidates(path: string, text: string): Array<{ line: number; candidates: string[] }> {
|
|
5
|
+
const root = crateRoot(path);
|
|
6
|
+
if (root === null) return [];
|
|
7
|
+
const own = moduleSegments(path, root);
|
|
8
|
+
const results: Array<{ line: number; candidates: string[] }> = [];
|
|
9
|
+
const pattern = /^\s*(?:pub(?:\([^)]*\))?\s+)?use\s+([^;]+);/gms;
|
|
10
|
+
for (const match of text.matchAll(pattern)) {
|
|
11
|
+
const line = text.slice(0, match.index).split("\n").length - 1;
|
|
12
|
+
for (const segments of expandUse((match[1] ?? "").replace(/\s+/g, ""))) {
|
|
13
|
+
const absolute = absoluteSegments(segments, own);
|
|
14
|
+
if (absolute !== null) results.push({ line, candidates: candidatesFor(root, absolute) });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return results;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The directory holding the crate root: everything up to and including `src/`. */
|
|
21
|
+
function crateRoot(path: string): string | null {
|
|
22
|
+
const index = path.lastIndexOf("src/");
|
|
23
|
+
return index === -1 ? null : path.slice(0, index + "src/".length);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The module path of a file: `src/a/b.rs` and `src/a/b/mod.rs` are both `a::b`. */
|
|
27
|
+
function moduleSegments(path: string, root: string): string[] {
|
|
28
|
+
const rest = path.slice(root.length).replace(/\.rs$/, "");
|
|
29
|
+
const segments = rest.split("/");
|
|
30
|
+
const last = segments[segments.length - 1];
|
|
31
|
+
if (last === "mod" || (segments.length === 1 && (last === "lib" || last === "main"))) segments.pop();
|
|
32
|
+
return segments;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `a::{b, c::d}` becomes `[a, b]` and `[a, c, d]`. */
|
|
36
|
+
function expandUse(tree: string): string[][] {
|
|
37
|
+
const brace = tree.indexOf("{");
|
|
38
|
+
if (brace === -1) return [tree.split("::").filter((s) => s !== "" && s !== "*").map((s) => s.split(" as ")[0] ?? s)];
|
|
39
|
+
const prefix = tree.slice(0, brace).split("::").filter((s) => s !== "");
|
|
40
|
+
const inner = tree.slice(brace + 1, tree.lastIndexOf("}"));
|
|
41
|
+
return splitTopLevel(inner).flatMap((part) => (part === "self" ? [prefix] : expandUse(part).map((rest) => [...prefix, ...rest])));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function splitTopLevel(inner: string): string[] {
|
|
45
|
+
const parts: string[] = [];
|
|
46
|
+
let depth = 0;
|
|
47
|
+
let current = "";
|
|
48
|
+
for (const char of inner) {
|
|
49
|
+
if (char === "{") depth++;
|
|
50
|
+
if (char === "}") depth--;
|
|
51
|
+
if (char === "," && depth === 0) {
|
|
52
|
+
parts.push(current);
|
|
53
|
+
current = "";
|
|
54
|
+
} else current += char;
|
|
55
|
+
}
|
|
56
|
+
if (current !== "") parts.push(current);
|
|
57
|
+
return parts.filter((part) => part !== "");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function absoluteSegments(segments: string[], own: string[]): string[] | null {
|
|
61
|
+
const [head, ...rest] = segments;
|
|
62
|
+
if (head === "crate") return rest;
|
|
63
|
+
if (head === "self") return [...own, ...rest];
|
|
64
|
+
if (head === "super") {
|
|
65
|
+
let base = own.slice(0, -1);
|
|
66
|
+
let tail = rest;
|
|
67
|
+
while (tail[0] === "super") {
|
|
68
|
+
base = base.slice(0, -1);
|
|
69
|
+
tail = tail.slice(1);
|
|
70
|
+
}
|
|
71
|
+
return [...base, ...tail];
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The longest module path first: an imported item may be a type inside a module file. */
|
|
77
|
+
function candidatesFor(root: string, segments: string[]): string[] {
|
|
78
|
+
const candidates: string[] = [];
|
|
79
|
+
for (let n = segments.length; n > 0; n--) {
|
|
80
|
+
const module = segments.slice(0, n).join("/");
|
|
81
|
+
candidates.push(`${root}${module}.rs`, `${root}${module}/mod.rs`);
|
|
82
|
+
}
|
|
83
|
+
return candidates;
|
|
84
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Counts top-level type declarations, by the file's language. */
|
|
2
|
+
|
|
3
|
+
const PATTERNS: Array<{ extensions: RegExp; declaration: RegExp }> = [
|
|
4
|
+
{ extensions: /\.(?:[cm]?[jt]sx?)$/, declaration: /^(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?(?:class|interface|enum|type)\s+[A-Za-z_]/ },
|
|
5
|
+
{ extensions: /\.py$/, declaration: /^class\s+[A-Za-z_]/ },
|
|
6
|
+
{ extensions: /\.rs$/, declaration: /^(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|union|type)\s+[A-Za-z_]/ },
|
|
7
|
+
{ extensions: /\.go$/, declaration: /^type\s+[A-Za-z_]/ },
|
|
8
|
+
{ extensions: /\.(?:c|h|cc|cpp|cxx|hh|hpp|hxx)$/, declaration: /^(?:typedef\s+)?(?:struct|class|enum|union)\s+[A-Za-z_]\w*(?:\s*[:{].*)?$/ },
|
|
9
|
+
{ extensions: /\.(?:java|kt|swift|scala)$/, declaration: /^(?:(?:public|private|internal|open|final|abstract|data|sealed|static)\s+)*(?:class|interface|enum|object|struct|protocol|record)\s+[A-Za-z_]/ },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
/** Top-level type declarations in the file, or null when its language isn't recognised. */
|
|
13
|
+
export function countTypes(path: string, text: string): number | null {
|
|
14
|
+
const pattern = PATTERNS.find((p) => p.extensions.test(path));
|
|
15
|
+
if (pattern === undefined) return null;
|
|
16
|
+
return text.split("\n").filter((line) => pattern.declaration.test(line)).length;
|
|
17
|
+
}
|