@nklisch/pi-agile-workflow 0.15.3
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/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +28 -0
- package/CHANGELOG.md +591 -0
- package/README.md +107 -0
- package/docs/ARCHITECTURE.md +716 -0
- package/docs/MIGRATION.md +419 -0
- package/docs/PRINCIPLES.md +464 -0
- package/docs/ROADMAP.md +95 -0
- package/docs/SPEC.md +855 -0
- package/docs/VISION.md +106 -0
- package/extensions/agile-workflow.test.ts +419 -0
- package/extensions/agile-workflow.ts +467 -0
- package/hooks/hooks.json +52 -0
- package/hooks/scripts/prompt-context.py +628 -0
- package/hooks/scripts/substrate-maintainer.py +359 -0
- package/hooks/scripts/test_prompt_context.py +757 -0
- package/package.json +30 -0
- package/scripts/install-work-view.sh +165 -0
- package/scripts/tests/agent-metadata.test.sh +75 -0
- package/scripts/tests/bump-version.test.sh +552 -0
- package/scripts/tests/channel-parity.test.sh +117 -0
- package/scripts/tests/convert-content-integrity.test.sh +246 -0
- package/scripts/tests/convert-install-routing.test.sh +159 -0
- package/scripts/tests/install-work-view.test.sh +411 -0
- package/scripts/tests/pi-package-metadata.test.sh +119 -0
- package/scripts/tests/work-board-shim.test.sh +239 -0
- package/scripts/tests/work-view-dist-version.test.sh +105 -0
- package/scripts/work-board.sh +64 -0
- package/scripts/work-view.sh +423 -0
- package/skills/autopilot/SKILL.md +330 -0
- package/skills/autopilot/agents/openai.yaml +6 -0
- package/skills/board/SKILL.md +62 -0
- package/skills/board/agents/openai.yaml +6 -0
- package/skills/bold-refactor/SKILL.md +244 -0
- package/skills/bold-refactor/agents/openai.yaml +6 -0
- package/skills/bug-scan/SKILL.md +378 -0
- package/skills/bug-scan/agents/openai.yaml +6 -0
- package/skills/bug-scan/references/async-promises.md +119 -0
- package/skills/bug-scan/references/concurrency-races.md +148 -0
- package/skills/bug-scan/references/data-layer.md +167 -0
- package/skills/bug-scan/references/error-handling.md +197 -0
- package/skills/bug-scan/references/gate-item-template.md +94 -0
- package/skills/bug-scan/references/language-footguns.md +219 -0
- package/skills/bug-scan/references/parked-item-template.md +103 -0
- package/skills/bug-scan/references/report-template.md +115 -0
- package/skills/bug-scan/references/resource-leaks.md +199 -0
- package/skills/bug-scan/references/state-closures.md +148 -0
- package/skills/bug-scan/references/time-numbers.md +158 -0
- package/skills/convert/SKILL.md +1463 -0
- package/skills/convert/agents/openai.yaml +6 -0
- package/skills/convert/references/legacy-overlap-migration.md +179 -0
- package/skills/deep-code-scan/SKILL.md +378 -0
- package/skills/deep-code-scan/agents/openai.yaml +6 -0
- package/skills/deep-code-scan/references/artifact-ledger.md +148 -0
- package/skills/deep-code-scan/references/consolidation.md +91 -0
- package/skills/deep-code-scan/references/decomposition.md +127 -0
- package/skills/deep-code-scan/references/item-templates.md +263 -0
- package/skills/deep-code-scan/references/lane-catalog.md +129 -0
- package/skills/deep-code-scan/references/review-gauntlet.md +108 -0
- package/skills/deep-code-scan/references/scanner-brief.md +152 -0
- package/skills/e2e-test-design/SKILL.md +517 -0
- package/skills/e2e-test-design/references/anti-tautology.md +261 -0
- package/skills/e2e-test-design/references/service-mocks.md +234 -0
- package/skills/e2e-test-design/references/test-taxonomy.md +211 -0
- package/skills/epic-design/SKILL.md +476 -0
- package/skills/epicize/SKILL.md +210 -0
- package/skills/epicize/agents/openai.yaml +6 -0
- package/skills/feature-design/SKILL.md +472 -0
- package/skills/fix/SKILL.md +173 -0
- package/skills/gate-cruft/SKILL.md +258 -0
- package/skills/gate-docs/SKILL.md +278 -0
- package/skills/gate-patterns/SKILL.md +389 -0
- package/skills/gate-refactor/SKILL.md +408 -0
- package/skills/gate-security/SKILL.md +275 -0
- package/skills/gate-tests/SKILL.md +367 -0
- package/skills/groom/SKILL.md +162 -0
- package/skills/groom/agents/openai.yaml +6 -0
- package/skills/ideate/SKILL.md +185 -0
- package/skills/ideate/agents/openai.yaml +6 -0
- package/skills/implement/SKILL.md +288 -0
- package/skills/implement-orchestrator/SKILL.md +604 -0
- package/skills/park/SKILL.md +111 -0
- package/skills/perf-design/SKILL.md +427 -0
- package/skills/perf-scout/SKILL.md +404 -0
- package/skills/perf-scout/agents/openai.yaml +6 -0
- package/skills/perf-scout/references/algorithmic-and-data-structures.md +126 -0
- package/skills/perf-scout/references/approximation-and-precomputation.md +136 -0
- package/skills/perf-scout/references/caching-and-memoization.md +123 -0
- package/skills/perf-scout/references/compiler-runtime-and-language.md +136 -0
- package/skills/perf-scout/references/database-and-storage-internals.md +144 -0
- package/skills/perf-scout/references/distributed-systems.md +150 -0
- package/skills/perf-scout/references/game-engine-and-realtime.md +136 -0
- package/skills/perf-scout/references/gpu-and-accelerators.md +130 -0
- package/skills/perf-scout/references/idea-ranking.md +75 -0
- package/skills/perf-scout/references/io-and-batching.md +135 -0
- package/skills/perf-scout/references/memory-and-data-locality.md +137 -0
- package/skills/perf-scout/references/parallelism-and-vectorization.md +126 -0
- package/skills/perf-scout/references/parked-item-template.md +117 -0
- package/skills/perf-scout/references/peer-review-pass.md +150 -0
- package/skills/perf-scout/references/report-template.md +148 -0
- package/skills/principles/SKILL.md +678 -0
- package/skills/principles/references/models.md +191 -0
- package/skills/principles/references/subagents.md +158 -0
- package/skills/prose-author/SKILL.md +185 -0
- package/skills/refactor-conventions-creator/SKILL.md +359 -0
- package/skills/refactor-conventions-creator/agents/openai.yaml +6 -0
- package/skills/refactor-conventions-creator/references/common-structures.md +91 -0
- package/skills/refactor-conventions-creator/references/common-styles.md +90 -0
- package/skills/refactor-design/SKILL.md +384 -0
- package/skills/release-deploy/SKILL.md +667 -0
- package/skills/release-deploy/agents/openai.yaml +6 -0
- package/skills/research/SKILL.md +166 -0
- package/skills/research/agents/openai.yaml +6 -0
- package/skills/review/SKILL.md +267 -0
- package/skills/review/references/deep-review.md +98 -0
- package/skills/review/references/review-lenses.md +68 -0
- package/skills/review/references/substrate-side-effects.md +161 -0
- package/skills/review/references/target-resolution.md +52 -0
- package/skills/scope/SKILL.md +486 -0
- package/work-view/Cargo.lock +486 -0
- package/work-view/Cargo.toml +9 -0
- package/work-view/crates/cli/.work-view-version +1 -0
- package/work-view/crates/cli/Cargo.toml +18 -0
- package/work-view/crates/cli/src/actionable.rs +500 -0
- package/work-view/crates/cli/src/args.rs +792 -0
- package/work-view/crates/cli/src/board/assets/board.css +855 -0
- package/work-view/crates/cli/src/board/assets/board.js +259 -0
- package/work-view/crates/cli/src/board/assets/card.js +139 -0
- package/work-view/crates/cli/src/board/assets/components.css +290 -0
- package/work-view/crates/cli/src/board/assets/dependency.js +1193 -0
- package/work-view/crates/cli/src/board/assets/detail.js +256 -0
- package/work-view/crates/cli/src/board/assets/filters.js +389 -0
- package/work-view/crates/cli/src/board/assets/index.html +77 -0
- package/work-view/crates/cli/src/board/assets/kanban.js +231 -0
- package/work-view/crates/cli/src/board/assets/markdown.js +260 -0
- package/work-view/crates/cli/src/board/assets/motion.css +131 -0
- package/work-view/crates/cli/src/board/assets/state.js +245 -0
- package/work-view/crates/cli/src/board/assets/table.js +304 -0
- package/work-view/crates/cli/src/board/assets/tokens.css +602 -0
- package/work-view/crates/cli/src/board/assets/views.js +41 -0
- package/work-view/crates/cli/src/board/assets.rs +162 -0
- package/work-view/crates/cli/src/board/feed.rs +204 -0
- package/work-view/crates/cli/src/board/mod.rs +246 -0
- package/work-view/crates/cli/src/board/open.rs +144 -0
- package/work-view/crates/cli/src/board/server.rs +407 -0
- package/work-view/crates/cli/src/main.rs +195 -0
- package/work-view/crates/cli/src/render.rs +354 -0
- package/work-view/crates/cli/src/scope.rs +157 -0
- package/work-view/crates/cli/src/stale.rs +581 -0
- package/work-view/crates/cli/tests/board-js/dependency-table.test.mjs +64 -0
- package/work-view/crates/cli/tests/board-js/expanded-browsing.test.mjs +79 -0
- package/work-view/crates/cli/tests/board-js/harness-smoke.test.mjs +11 -0
- package/work-view/crates/cli/tests/board-js/harness.mjs +437 -0
- package/work-view/crates/cli/tests/board-js/kanban-detail.test.mjs +75 -0
- package/work-view/crates/cli/tests/board-js/markdown-filter.test.mjs +60 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/CONVENTIONS.md +3 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/epics/epic-alpha.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/features/feat-a.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/features/feat-b.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/stories/story-alpha-1.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/stories/story-research-1.md +19 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/archive/feat-done.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/archive/feat-shipped.md +18 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/backlog/idea-backlog.md +7 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/releases/v1.0/v1.0.md +16 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/CONVENTIONS.md +6 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-dep-done.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-design-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-impl-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/stories/story-review-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/CONVENTIONS.md +4 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/active/epics/malformed-no-closing-fence.md +8 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/active/features/good-item.md +16 -0
- package/work-view/crates/cli/tests/integration.rs +2833 -0
- package/work-view/crates/core/Cargo.toml +13 -0
- package/work-view/crates/core/src/error.rs +61 -0
- package/work-view/crates/core/src/filter.rs +804 -0
- package/work-view/crates/core/src/graph.rs +324 -0
- package/work-view/crates/core/src/index.rs +590 -0
- package/work-view/crates/core/src/lib.rs +54 -0
- package/work-view/crates/core/src/model.rs +202 -0
- package/work-view/crates/core/src/parse.rs +494 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/CONVENTIONS.md +15 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/epics/epic-core.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/features/feat-parser.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/features/feat-query.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/malformed-no-id.md +15 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/story-parse-unit.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/story-security-gate.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/archive/feat-old.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/archive/feat-parser.md +17 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/backlog/idea-future-board.md +7 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/releases/v1.0/v1.0.md +16 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/CONVENTIONS.md +10 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/archive/dup-item.md +18 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/releases/v2.0/dup-item.md +18 -0
- package/work-view/crates/core/tests/integration.rs +541 -0
- package/work-view/dist/.gitattributes +1 -0
- package/work-view/dist/README.md +42 -0
- package/work-view/dist/aarch64-apple-darwin/work-view +0 -0
- package/work-view/dist/aarch64-unknown-linux-musl/work-view +0 -0
- package/work-view/dist/x86_64-apple-darwin/work-view +0 -0
- package/work-view/dist/x86_64-unknown-linux-musl/work-view +0 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
//! Substrate root detection and item index.
|
|
2
|
+
//!
|
|
3
|
+
//! `find_substrate_root` walks up from a given directory looking for
|
|
4
|
+
//! `.work/CONVENTIONS.md`.
|
|
5
|
+
//!
|
|
6
|
+
//! `Substrate::load` scans the four tier directories (active, backlog,
|
|
7
|
+
//! releases, archive), sorts all `.md` paths by raw bytes (matching
|
|
8
|
+
//! `LC_ALL=C sort`), parses each file, and builds an in-memory index.
|
|
9
|
+
//!
|
|
10
|
+
//! Key invariants:
|
|
11
|
+
//! - Load order = global byte-sort over ALL four dirs combined.
|
|
12
|
+
//! - `by_id` resolves duplicates by tier precedence: active > releases > archive > backlog.
|
|
13
|
+
//! - Per-item read/parse failures → `ParseError` (non-fatal; item is skipped).
|
|
14
|
+
//! - Root-level traversal I/O failure → `LoadError::Io` (fatal).
|
|
15
|
+
//! - Validation is narrow + tier-aware: only active/releases items warn on
|
|
16
|
+
//! missing SPEC-required fields.
|
|
17
|
+
|
|
18
|
+
use std::{
|
|
19
|
+
collections::HashMap,
|
|
20
|
+
path::{Path, PathBuf},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
use crate::{
|
|
24
|
+
error::{LoadError, ParseError},
|
|
25
|
+
model::{Item, Tier},
|
|
26
|
+
parse::parse_item,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ── Public diagnostic types ───────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/// An actionable load-time warning attached to a specific file and tier.
|
|
32
|
+
#[derive(Debug)]
|
|
33
|
+
pub struct Diagnostic {
|
|
34
|
+
/// Absolute path to the offending file.
|
|
35
|
+
pub path: PathBuf,
|
|
36
|
+
/// Tier the file was loaded from.
|
|
37
|
+
pub tier: Tier,
|
|
38
|
+
/// Item id, if successfully parsed (None when parsing failed before id extraction).
|
|
39
|
+
pub id: Option<String>,
|
|
40
|
+
/// The field that triggered the diagnostic, if applicable.
|
|
41
|
+
pub field: Option<String>,
|
|
42
|
+
/// Human-readable explanation.
|
|
43
|
+
pub reason: String,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Summary of non-fatal issues discovered during `Substrate::load`.
|
|
47
|
+
#[derive(Debug, Default)]
|
|
48
|
+
pub struct LoadReport {
|
|
49
|
+
/// Per-item files that failed to parse (no id, bad YAML, or read failure).
|
|
50
|
+
/// These items are excluded from `Substrate::items()`.
|
|
51
|
+
pub parse_errors: Vec<ParseError>,
|
|
52
|
+
|
|
53
|
+
/// Active or releases items that are present in `items()` but are missing
|
|
54
|
+
/// a SPEC-required field. These items are surfaced, NOT dropped.
|
|
55
|
+
pub validation_warnings: Vec<Diagnostic>,
|
|
56
|
+
|
|
57
|
+
/// Duplicate id occurrences (one `Diagnostic` per *extra* location).
|
|
58
|
+
/// `by_id` resolves the canonical item by tier precedence.
|
|
59
|
+
pub duplicate_ids: Vec<Diagnostic>,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Substrate ─────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/// The in-memory index of a substrate's `.work/` directory.
|
|
65
|
+
pub struct Substrate {
|
|
66
|
+
/// Absolute path to the substrate root (contains `.work/CONVENTIONS.md`).
|
|
67
|
+
pub root: PathBuf,
|
|
68
|
+
/// All successfully parsed items in stable byte-sorted order.
|
|
69
|
+
items: Vec<Item>,
|
|
70
|
+
/// Map from item id → index into `items` for the canonical item.
|
|
71
|
+
/// Resolved by tier precedence: active > releases > archive > backlog.
|
|
72
|
+
by_id: HashMap<String, usize>,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
impl Substrate {
|
|
76
|
+
/// Return all items in stable byte-sorted load order.
|
|
77
|
+
pub fn items(&self) -> &[Item] {
|
|
78
|
+
&self.items
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Look up an item by id using tier-precedence resolution.
|
|
82
|
+
///
|
|
83
|
+
/// If the same id appears in multiple tiers, the highest-precedence copy
|
|
84
|
+
/// is returned (active > releases > archive > backlog).
|
|
85
|
+
pub fn by_id(&self, id: &str) -> Option<&Item> {
|
|
86
|
+
self.by_id.get(id).map(|&idx| &self.items[idx])
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// Scan the substrate and load all items.
|
|
90
|
+
///
|
|
91
|
+
/// # Errors
|
|
92
|
+
/// Returns `LoadError::Io` only if a root-level directory cannot be read.
|
|
93
|
+
/// Per-item read/parse failures are collected in `LoadReport.parse_errors`.
|
|
94
|
+
pub fn load(root: &Path) -> Result<(Substrate, LoadReport), LoadError> {
|
|
95
|
+
let mut report = LoadReport::default();
|
|
96
|
+
|
|
97
|
+
// Collect and byte-sort all *.md paths across the four tier dirs.
|
|
98
|
+
let paths = collect_sorted_paths(root)?;
|
|
99
|
+
|
|
100
|
+
// Parse every file in sorted order, collecting errors non-fatally.
|
|
101
|
+
let mut items: Vec<Item> = Vec::with_capacity(paths.len());
|
|
102
|
+
for (abs_path, tier) in &paths {
|
|
103
|
+
let text = match std::fs::read_to_string(abs_path) {
|
|
104
|
+
Ok(t) => t,
|
|
105
|
+
Err(e) => {
|
|
106
|
+
report.parse_errors.push(ParseError {
|
|
107
|
+
path: abs_path.clone(),
|
|
108
|
+
reason: format!("could not read file: {e}"),
|
|
109
|
+
});
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
let rel = abs_path
|
|
115
|
+
.strip_prefix(root)
|
|
116
|
+
.unwrap_or(abs_path.as_path())
|
|
117
|
+
.to_owned();
|
|
118
|
+
|
|
119
|
+
match parse_item(abs_path, &rel, *tier, &text) {
|
|
120
|
+
Ok(item) => items.push(item),
|
|
121
|
+
Err(e) => report.parse_errors.push(e),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Build by_id with tier-precedence resolution; collect duplicate diagnostics.
|
|
126
|
+
// We need to store the position of each item as we process them.
|
|
127
|
+
// First pass: build a map from id → best_index (by precedence).
|
|
128
|
+
// We also need to track "all indices for an id" to emit duplicate diagnostics.
|
|
129
|
+
let mut id_candidates: HashMap<String, Vec<usize>> = HashMap::new();
|
|
130
|
+
for (idx, item) in items.iter().enumerate() {
|
|
131
|
+
id_candidates.entry(item.id.clone()).or_default().push(idx);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let mut by_id: HashMap<String, usize> = HashMap::with_capacity(id_candidates.len());
|
|
135
|
+
|
|
136
|
+
for (id, indices) in &id_candidates {
|
|
137
|
+
// Pick the best candidate by tier precedence (lower precedence value wins).
|
|
138
|
+
let best_idx = indices
|
|
139
|
+
.iter()
|
|
140
|
+
.copied()
|
|
141
|
+
.min_by_key(|&i| items[i].tier.precedence())
|
|
142
|
+
.expect("indices is non-empty");
|
|
143
|
+
|
|
144
|
+
by_id.insert(id.clone(), best_idx);
|
|
145
|
+
|
|
146
|
+
// Emit a diagnostic for every *non-winning* location.
|
|
147
|
+
if indices.len() > 1 {
|
|
148
|
+
for &idx in indices {
|
|
149
|
+
if idx == best_idx {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
let item = &items[idx];
|
|
153
|
+
report.duplicate_ids.push(Diagnostic {
|
|
154
|
+
path: item.path.clone(),
|
|
155
|
+
tier: item.tier,
|
|
156
|
+
id: Some(item.id.clone()),
|
|
157
|
+
field: None,
|
|
158
|
+
reason: format!(
|
|
159
|
+
"duplicate id `{}` (canonical copy is in {:?} tier)",
|
|
160
|
+
item.id, items[best_idx].tier
|
|
161
|
+
),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Tier-aware validation: warn on active/releases items missing required fields.
|
|
168
|
+
for item in &items {
|
|
169
|
+
if !matches!(item.tier, Tier::Active | Tier::Releases) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
let required_fields: &[(&str, bool)] = &[
|
|
173
|
+
("kind", item.kind.is_none()),
|
|
174
|
+
("stage", item.stage.is_none()),
|
|
175
|
+
// parent, depends_on, release_binding, gate_origin are required per SPEC
|
|
176
|
+
// but may legitimately be null/[]. We only warn when the field is
|
|
177
|
+
// completely absent — the parse step would have set these to None/[] for
|
|
178
|
+
// missing fields, which is indistinguishable from explicitly `null`.
|
|
179
|
+
// Per spec: "required (may be [])" or "required (null for top-level)".
|
|
180
|
+
// So we cannot warn on None for parent/release_binding/gate_origin —
|
|
181
|
+
// they are valid as null. We warn only on kind/stage which have no
|
|
182
|
+
// "may be null" carve-out for active items.
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
for (field_name, is_missing) in required_fields {
|
|
186
|
+
if *is_missing {
|
|
187
|
+
report.validation_warnings.push(Diagnostic {
|
|
188
|
+
path: item.path.clone(),
|
|
189
|
+
tier: item.tier,
|
|
190
|
+
id: Some(item.id.clone()),
|
|
191
|
+
field: Some(field_name.to_string()),
|
|
192
|
+
reason: format!(
|
|
193
|
+
"active/releases item `{}` is missing required field `{}`",
|
|
194
|
+
item.id, field_name
|
|
195
|
+
),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
Ok((
|
|
202
|
+
Substrate {
|
|
203
|
+
root: root.to_owned(),
|
|
204
|
+
items,
|
|
205
|
+
by_id,
|
|
206
|
+
},
|
|
207
|
+
report,
|
|
208
|
+
))
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Internal helpers ──────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
/// The four tier directories in traversal order (matches bash `find` argument order).
|
|
215
|
+
const TIER_DIRS: [(&str, Tier); 4] = [
|
|
216
|
+
("active", Tier::Active),
|
|
217
|
+
("backlog", Tier::Backlog),
|
|
218
|
+
("releases", Tier::Releases),
|
|
219
|
+
("archive", Tier::Archive),
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
/// Collect all `*.md` files from the four tier dirs, byte-sorted globally.
|
|
223
|
+
///
|
|
224
|
+
/// Matches `find <active> <backlog> <releases> <archive> -name '*.md' | LC_ALL=C sort`.
|
|
225
|
+
///
|
|
226
|
+
/// Returns `(absolute_path, tier)` pairs. Returns `LoadError` if a tier
|
|
227
|
+
/// directory exists but cannot be read (its read-dir call fails).
|
|
228
|
+
fn collect_sorted_paths(root: &Path) -> Result<Vec<(PathBuf, Tier)>, LoadError> {
|
|
229
|
+
let work = root.join(".work");
|
|
230
|
+
|
|
231
|
+
let mut all: Vec<(PathBuf, Tier)> = Vec::new();
|
|
232
|
+
|
|
233
|
+
for (dir_name, tier) in &TIER_DIRS {
|
|
234
|
+
let dir = work.join(dir_name);
|
|
235
|
+
if !dir.exists() {
|
|
236
|
+
// Missing tier dir is not an error — substrate may not have all dirs.
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
collect_md_recursive(&dir, *tier, &mut all).map_err(LoadError::Io)?;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Sort by raw path bytes — matches `LC_ALL=C sort` on ASCII paths.
|
|
243
|
+
use std::os::unix::ffi::OsStrExt;
|
|
244
|
+
all.sort_by(|(a, _), (b, _)| a.as_os_str().as_bytes().cmp(b.as_os_str().as_bytes()));
|
|
245
|
+
|
|
246
|
+
Ok(all)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/// Recursively collect all `*.md` files under `dir`.
|
|
250
|
+
fn collect_md_recursive(
|
|
251
|
+
dir: &Path,
|
|
252
|
+
tier: Tier,
|
|
253
|
+
out: &mut Vec<(PathBuf, Tier)>,
|
|
254
|
+
) -> std::io::Result<()> {
|
|
255
|
+
for entry in std::fs::read_dir(dir)? {
|
|
256
|
+
let entry = entry?;
|
|
257
|
+
let ft = entry.file_type()?;
|
|
258
|
+
let path = entry.path();
|
|
259
|
+
if ft.is_dir() {
|
|
260
|
+
collect_md_recursive(&path, tier, out)?;
|
|
261
|
+
} else if ft.is_file() && path.extension().and_then(|e| e.to_str()) == Some("md") {
|
|
262
|
+
out.push((path, tier));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
Ok(())
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── Substrate-root detection ──────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
/// Walk up the directory tree from `start`, looking for `.work/CONVENTIONS.md`.
|
|
271
|
+
///
|
|
272
|
+
/// Returns the first ancestor directory that contains it, or `None` if not found.
|
|
273
|
+
pub fn find_substrate_root(start: &Path) -> Option<PathBuf> {
|
|
274
|
+
let mut dir = start.to_path_buf();
|
|
275
|
+
// Canonicalize to avoid symlink/relative issues if possible; ignore error.
|
|
276
|
+
if let Ok(canon) = start.canonicalize() {
|
|
277
|
+
dir = canon;
|
|
278
|
+
}
|
|
279
|
+
loop {
|
|
280
|
+
if dir.join(".work").join("CONVENTIONS.md").is_file() {
|
|
281
|
+
return Some(dir);
|
|
282
|
+
}
|
|
283
|
+
match dir.parent() {
|
|
284
|
+
Some(p) if p != dir => dir = p.to_path_buf(),
|
|
285
|
+
_ => return None,
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
#[cfg(test)]
|
|
291
|
+
mod tests {
|
|
292
|
+
use super::*;
|
|
293
|
+
use std::fs;
|
|
294
|
+
use tempfile::TempDir;
|
|
295
|
+
|
|
296
|
+
fn write_item(dir: &Path, filename: &str, content: &str) {
|
|
297
|
+
let path = dir.join(filename);
|
|
298
|
+
if let Some(parent) = path.parent() {
|
|
299
|
+
fs::create_dir_all(parent).unwrap();
|
|
300
|
+
}
|
|
301
|
+
fs::write(path, content).unwrap();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
fn create_substrate(tmp: &TempDir) -> PathBuf {
|
|
305
|
+
let root = tmp.path().to_path_buf();
|
|
306
|
+
fs::create_dir_all(root.join(".work")).unwrap();
|
|
307
|
+
fs::write(root.join(".work/CONVENTIONS.md"), "# Conventions\n").unwrap();
|
|
308
|
+
root
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── find_substrate_root ────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
#[test]
|
|
314
|
+
fn find_root_returns_none_when_absent() {
|
|
315
|
+
// Robust to $TMPDIR placement: TempDir honors $TMPDIR, which can itself
|
|
316
|
+
// sit inside a substrate tree (e.g. a repo-local tmp dir). There,
|
|
317
|
+
// find_substrate_root *correctly* walks up and finds that ancestor, so a
|
|
318
|
+
// bare `.is_none()` would spuriously fail. Detect a real substrate
|
|
319
|
+
// ancestor with an independent walk (std `ancestors()`, distinct from the
|
|
320
|
+
// production `.parent()` loop) and assert the true contract either way.
|
|
321
|
+
let tmp = TempDir::new().unwrap();
|
|
322
|
+
let canon = tmp.path().canonicalize().unwrap();
|
|
323
|
+
let ancestor_with_substrate = canon
|
|
324
|
+
.ancestors()
|
|
325
|
+
.find(|d| d.join(".work").join("CONVENTIONS.md").is_file());
|
|
326
|
+
match ancestor_with_substrate {
|
|
327
|
+
None => assert!(find_substrate_root(tmp.path()).is_none()),
|
|
328
|
+
Some(expected) => assert_eq!(
|
|
329
|
+
find_substrate_root(tmp.path()).unwrap().canonicalize().unwrap(),
|
|
330
|
+
expected.canonicalize().unwrap(),
|
|
331
|
+
),
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
#[test]
|
|
336
|
+
fn find_root_finds_root_at_start() {
|
|
337
|
+
let tmp = TempDir::new().unwrap();
|
|
338
|
+
let root = create_substrate(&tmp);
|
|
339
|
+
let found = find_substrate_root(&root).unwrap();
|
|
340
|
+
assert_eq!(found.canonicalize().unwrap(), root.canonicalize().unwrap());
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
#[test]
|
|
344
|
+
fn find_root_walks_up_from_subdirectory() {
|
|
345
|
+
let tmp = TempDir::new().unwrap();
|
|
346
|
+
let root = create_substrate(&tmp);
|
|
347
|
+
let subdir = root.join("deep/nested/dir");
|
|
348
|
+
fs::create_dir_all(&subdir).unwrap();
|
|
349
|
+
let found = find_substrate_root(&subdir).unwrap();
|
|
350
|
+
assert_eq!(found.canonicalize().unwrap(), root.canonicalize().unwrap());
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── Substrate::load ────────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
#[test]
|
|
356
|
+
fn load_empty_substrate_succeeds() {
|
|
357
|
+
let tmp = TempDir::new().unwrap();
|
|
358
|
+
let root = create_substrate(&tmp);
|
|
359
|
+
let (sub, report) = Substrate::load(&root).unwrap();
|
|
360
|
+
assert!(sub.items().is_empty());
|
|
361
|
+
assert!(report.parse_errors.is_empty());
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
#[test]
|
|
365
|
+
fn load_unreadable_tier_dir_returns_io_error() {
|
|
366
|
+
// A root-level traversal I/O failure (an unreadable tier dir) is the only
|
|
367
|
+
// fatal load condition: Substrate::load must return Err(LoadError::Io).
|
|
368
|
+
//
|
|
369
|
+
// Skipped when running as root, since root bypasses permission bits and
|
|
370
|
+
// the dir stays readable — detected by reading after chmod.
|
|
371
|
+
use std::os::unix::fs::PermissionsExt;
|
|
372
|
+
|
|
373
|
+
let tmp = TempDir::new().unwrap();
|
|
374
|
+
let root = create_substrate(&tmp);
|
|
375
|
+
let active = root.join(".work/active");
|
|
376
|
+
fs::create_dir_all(&active).unwrap();
|
|
377
|
+
|
|
378
|
+
// Make the active tier dir unreadable.
|
|
379
|
+
fs::set_permissions(&active, fs::Permissions::from_mode(0o000)).unwrap();
|
|
380
|
+
|
|
381
|
+
// Skip guard: if still readable (root / perms bypassed), restore and bail.
|
|
382
|
+
if fs::read_dir(&active).is_ok() {
|
|
383
|
+
let _ = fs::set_permissions(&active, fs::Permissions::from_mode(0o755));
|
|
384
|
+
eprintln!(
|
|
385
|
+
"skipping load_unreadable_tier_dir_returns_io_error: chmod 000 was bypassed \
|
|
386
|
+
(likely running as root); cannot make the tier dir unreadable"
|
|
387
|
+
);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let result = Substrate::load(&root);
|
|
392
|
+
|
|
393
|
+
// Restore perms BEFORE the TempDir drops so cleanup does not fail.
|
|
394
|
+
fs::set_permissions(&active, fs::Permissions::from_mode(0o755)).unwrap();
|
|
395
|
+
|
|
396
|
+
assert!(
|
|
397
|
+
matches!(result, Err(LoadError::Io(_))),
|
|
398
|
+
"unreadable tier dir should yield Err(LoadError::Io), got: {:?}",
|
|
399
|
+
result.map(|(s, _)| s.items().len())
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
#[test]
|
|
404
|
+
fn load_items_from_four_tiers() {
|
|
405
|
+
let tmp = TempDir::new().unwrap();
|
|
406
|
+
let root = create_substrate(&tmp);
|
|
407
|
+
let work = root.join(".work");
|
|
408
|
+
|
|
409
|
+
let active_dir = work.join("active/features");
|
|
410
|
+
let backlog_dir = work.join("backlog");
|
|
411
|
+
let releases_dir = work.join("releases/v1.0");
|
|
412
|
+
let archive_dir = work.join("archive");
|
|
413
|
+
|
|
414
|
+
write_item(
|
|
415
|
+
&active_dir,
|
|
416
|
+
"feat-a.md",
|
|
417
|
+
"---\nid: feat-a\nkind: feature\nstage: implementing\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n# Feat A\n",
|
|
418
|
+
);
|
|
419
|
+
write_item(
|
|
420
|
+
&backlog_dir,
|
|
421
|
+
"idea-x.md",
|
|
422
|
+
"---\nid: idea-x\ncreated: 2026-01-02\ntags: []\n---\nAn idea.\n",
|
|
423
|
+
);
|
|
424
|
+
write_item(
|
|
425
|
+
&releases_dir,
|
|
426
|
+
"v1.0.md",
|
|
427
|
+
"---\nid: v1.0\nkind: release\nstage: released\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: 2026-01-03\nupdated: 2026-01-03\n---\n\n# Release v1.0\n",
|
|
428
|
+
);
|
|
429
|
+
write_item(
|
|
430
|
+
&archive_dir,
|
|
431
|
+
"old-feat.md",
|
|
432
|
+
"---\nid: old-feat\nkind: feature\nstage: done\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: 2025-01-01\nupdated: 2025-01-01\n---\n\n# Old Feature\n",
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
let (sub, _report) = Substrate::load(&root).unwrap();
|
|
436
|
+
assert_eq!(sub.items().len(), 4);
|
|
437
|
+
|
|
438
|
+
// Check tiers
|
|
439
|
+
let by_id = |id: &str| sub.by_id(id).unwrap();
|
|
440
|
+
assert_eq!(by_id("feat-a").tier, Tier::Active);
|
|
441
|
+
assert_eq!(by_id("idea-x").tier, Tier::Backlog);
|
|
442
|
+
assert_eq!(by_id("v1.0").tier, Tier::Releases);
|
|
443
|
+
assert_eq!(by_id("old-feat").tier, Tier::Archive);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
#[test]
|
|
447
|
+
fn load_order_is_byte_sorted() {
|
|
448
|
+
let tmp = TempDir::new().unwrap();
|
|
449
|
+
let root = create_substrate(&tmp);
|
|
450
|
+
let active = root.join(".work/active/stories");
|
|
451
|
+
|
|
452
|
+
// Create files in reverse alphabetical order
|
|
453
|
+
write_item(&active, "z-story.md", "---\nid: z-story\nkind: story\nstage: implementing\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n");
|
|
454
|
+
write_item(&active, "a-story.md", "---\nid: a-story\nkind: story\nstage: implementing\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n");
|
|
455
|
+
write_item(&active, "m-story.md", "---\nid: m-story\nkind: story\nstage: implementing\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n");
|
|
456
|
+
|
|
457
|
+
let (sub, _) = Substrate::load(&root).unwrap();
|
|
458
|
+
let ids: Vec<&str> = sub.items().iter().map(|i| i.id.as_str()).collect();
|
|
459
|
+
assert_eq!(ids, vec!["a-story", "m-story", "z-story"]);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
#[test]
|
|
463
|
+
fn malformed_file_excluded_from_items_but_valid_siblings_load() {
|
|
464
|
+
let tmp = TempDir::new().unwrap();
|
|
465
|
+
let root = create_substrate(&tmp);
|
|
466
|
+
let active = root.join(".work/active/stories");
|
|
467
|
+
|
|
468
|
+
// A valid item
|
|
469
|
+
write_item(
|
|
470
|
+
&active,
|
|
471
|
+
"good.md",
|
|
472
|
+
"---\nid: good\nkind: story\nstage: implementing\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n# Good\n",
|
|
473
|
+
);
|
|
474
|
+
// A malformed item (no id)
|
|
475
|
+
write_item(
|
|
476
|
+
&active,
|
|
477
|
+
"bad.md",
|
|
478
|
+
"---\nkind: story\nstage: implementing\n---\n\n# Bad\n",
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
let (sub, report) = Substrate::load(&root).unwrap();
|
|
482
|
+
assert_eq!(sub.items().len(), 1);
|
|
483
|
+
assert_eq!(sub.items()[0].id, "good");
|
|
484
|
+
assert_eq!(report.parse_errors.len(), 1);
|
|
485
|
+
assert!(report.parse_errors[0].path.ends_with("bad.md"));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
#[test]
|
|
489
|
+
fn duplicate_ids_resolved_by_tier_precedence() {
|
|
490
|
+
let tmp = TempDir::new().unwrap();
|
|
491
|
+
let root = create_substrate(&tmp);
|
|
492
|
+
|
|
493
|
+
let item_content = |id: &str| {
|
|
494
|
+
format!("---\nid: {id}\nkind: feature\nstage: done\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n# {id}\n")
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
// Same id in archive and active tiers
|
|
498
|
+
write_item(
|
|
499
|
+
&root.join(".work/archive"),
|
|
500
|
+
"dup-feat.md",
|
|
501
|
+
&item_content("dup-feat"),
|
|
502
|
+
);
|
|
503
|
+
write_item(
|
|
504
|
+
&root.join(".work/active/features"),
|
|
505
|
+
"dup-feat.md",
|
|
506
|
+
&item_content("dup-feat"),
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
let (sub, report) = Substrate::load(&root).unwrap();
|
|
510
|
+
|
|
511
|
+
// Should report 1 duplicate diagnostic
|
|
512
|
+
assert_eq!(report.duplicate_ids.len(), 1);
|
|
513
|
+
|
|
514
|
+
// by_id should return the Active tier copy (higher precedence)
|
|
515
|
+
let canonical = sub.by_id("dup-feat").unwrap();
|
|
516
|
+
assert_eq!(canonical.tier, Tier::Active);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
#[test]
|
|
520
|
+
fn validation_warns_on_active_item_missing_required_field() {
|
|
521
|
+
let tmp = TempDir::new().unwrap();
|
|
522
|
+
let root = create_substrate(&tmp);
|
|
523
|
+
|
|
524
|
+
// Active item missing `kind` and `stage`
|
|
525
|
+
write_item(
|
|
526
|
+
&root.join(".work/active/features"),
|
|
527
|
+
"incomplete.md",
|
|
528
|
+
"---\nid: incomplete\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n# Incomplete\n",
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
let (sub, report) = Substrate::load(&root).unwrap();
|
|
532
|
+
|
|
533
|
+
// Item stays in items (not dropped)
|
|
534
|
+
assert!(sub.by_id("incomplete").is_some());
|
|
535
|
+
|
|
536
|
+
// Validation warnings for kind and stage
|
|
537
|
+
let fields: Vec<&str> = report
|
|
538
|
+
.validation_warnings
|
|
539
|
+
.iter()
|
|
540
|
+
.filter_map(|d| d.field.as_deref())
|
|
541
|
+
.collect();
|
|
542
|
+
assert!(fields.contains(&"kind"), "should warn on missing kind");
|
|
543
|
+
assert!(fields.contains(&"stage"), "should warn on missing stage");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
#[test]
|
|
547
|
+
fn backlog_item_missing_kind_stage_no_validation_warning() {
|
|
548
|
+
let tmp = TempDir::new().unwrap();
|
|
549
|
+
let root = create_substrate(&tmp);
|
|
550
|
+
|
|
551
|
+
write_item(
|
|
552
|
+
&root.join(".work/backlog"),
|
|
553
|
+
"idea.md",
|
|
554
|
+
"---\nid: backlog-idea\ncreated: 2026-01-01\ntags: []\n---\nAn idea.\n",
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
let (sub, report) = Substrate::load(&root).unwrap();
|
|
558
|
+
|
|
559
|
+
// Item loads fine
|
|
560
|
+
assert!(sub.by_id("backlog-idea").is_some());
|
|
561
|
+
|
|
562
|
+
// No validation warnings for backlog item
|
|
563
|
+
let backlog_warnings: Vec<_> = report
|
|
564
|
+
.validation_warnings
|
|
565
|
+
.iter()
|
|
566
|
+
.filter(|d| d.id.as_deref() == Some("backlog-idea"))
|
|
567
|
+
.collect();
|
|
568
|
+
assert!(backlog_warnings.is_empty());
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
#[test]
|
|
572
|
+
fn rel_path_is_relative_to_root() {
|
|
573
|
+
let tmp = TempDir::new().unwrap();
|
|
574
|
+
let root = create_substrate(&tmp);
|
|
575
|
+
let active = root.join(".work/active/features");
|
|
576
|
+
|
|
577
|
+
write_item(
|
|
578
|
+
&active,
|
|
579
|
+
"my-feat.md",
|
|
580
|
+
"---\nid: my-feat\nkind: feature\nstage: implementing\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n",
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
let (sub, _) = Substrate::load(&root).unwrap();
|
|
584
|
+
let item = sub.by_id("my-feat").unwrap();
|
|
585
|
+
|
|
586
|
+
// rel_path should be relative (not absolute)
|
|
587
|
+
assert!(item.rel_path.is_relative());
|
|
588
|
+
assert!(item.rel_path.starts_with(".work/active/features"));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
//! # work-view-core
|
|
2
|
+
//!
|
|
3
|
+
//! The shared `.work/` query and domain core for the agile-workflow substrate.
|
|
4
|
+
//!
|
|
5
|
+
//! This is the Single-Source-of-Truth layer (Ports & Adapters) that both the
|
|
6
|
+
//! CLI adapter (`epic-substrate-cli-adapter`) and the future web board
|
|
7
|
+
//! (`epic-substrate-board`) import. It does NOT contain CLI flag parsing,
|
|
8
|
+
//! output formatting, or process exit code logic.
|
|
9
|
+
//!
|
|
10
|
+
//! ## Public API
|
|
11
|
+
//!
|
|
12
|
+
//! ```text
|
|
13
|
+
//! work_view_core::
|
|
14
|
+
//! model::{Item, Tier}
|
|
15
|
+
//! parse::parse_item
|
|
16
|
+
//! index::{Substrate, LoadReport, Diagnostic, find_substrate_root}
|
|
17
|
+
//! filter::{Filter, Match}
|
|
18
|
+
//! error::{ParseError, LoadError}
|
|
19
|
+
//! ```
|
|
20
|
+
//!
|
|
21
|
+
//! ## Entry points
|
|
22
|
+
//!
|
|
23
|
+
//! 1. Detect the substrate root:
|
|
24
|
+
//! ```rust,no_run
|
|
25
|
+
//! use std::path::Path;
|
|
26
|
+
//! use work_view_core::index::find_substrate_root;
|
|
27
|
+
//! let root = find_substrate_root(Path::new("."));
|
|
28
|
+
//! ```
|
|
29
|
+
//!
|
|
30
|
+
//! 2. Load the substrate:
|
|
31
|
+
//! ```rust,no_run
|
|
32
|
+
//! use work_view_core::index::Substrate;
|
|
33
|
+
//! # let root = std::path::Path::new(".");
|
|
34
|
+
//! let (substrate, report) = Substrate::load(root).unwrap();
|
|
35
|
+
//! ```
|
|
36
|
+
//!
|
|
37
|
+
//! 3. Query items:
|
|
38
|
+
//! ```rust,no_run
|
|
39
|
+
//! use work_view_core::filter::{Filter, Match};
|
|
40
|
+
//! # use work_view_core::index::Substrate;
|
|
41
|
+
//! # let (substrate, _) = Substrate::load(std::path::Path::new(".")).unwrap();
|
|
42
|
+
//! let f = Filter {
|
|
43
|
+
//! stage: Match::Equals("implementing".into()),
|
|
44
|
+
//! ..Filter::default()
|
|
45
|
+
//! };
|
|
46
|
+
//! let items = substrate.query(&f);
|
|
47
|
+
//! ```
|
|
48
|
+
|
|
49
|
+
pub mod error;
|
|
50
|
+
pub mod filter;
|
|
51
|
+
pub mod graph;
|
|
52
|
+
pub mod index;
|
|
53
|
+
pub mod model;
|
|
54
|
+
pub mod parse;
|