@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,581 @@
|
|
|
1
|
+
//! Backlog staleness filter for `--stale`.
|
|
2
|
+
//!
|
|
3
|
+
//! Reads `backlog_staleness_days` from `.work/CONVENTIONS.md`, then filters
|
|
4
|
+
//! the loaded items to those in the backlog tier whose last-touched date is
|
|
5
|
+
//! more than that many days before today (local time).
|
|
6
|
+
//!
|
|
7
|
+
//! Design decisions:
|
|
8
|
+
//! - Last-touched date: `updated` if `Some`, else `created`; if both `None`,
|
|
9
|
+
//! the item is treated as infinitely stale and surfaces unconditionally. A
|
|
10
|
+
//! dateless backlog item has no evidence of recency — surfacing it is
|
|
11
|
+
//! correct and conservative.
|
|
12
|
+
//! - Absent `backlog_staleness_days` key → `StalenessThreshold::NotConfigured`
|
|
13
|
+
//! (caller prints a one-line notice and exits 0 — politely inert, same
|
|
14
|
+
//! pattern as opt-in gates).
|
|
15
|
+
//! - Date comparison is done in local time, matching the PostToolUse hook that
|
|
16
|
+
//! writes `datetime.now().strftime("%Y-%m-%d")`.
|
|
17
|
+
//! - No `chrono` or other time dependency is added — `std::time::SystemTime`
|
|
18
|
+
//! suffices for local-date arithmetic (see `today_local_days`). The crate is
|
|
19
|
+
//! intentionally dependency-light; confirm Cargo.toml before adding anything.
|
|
20
|
+
//!
|
|
21
|
+
//! NOTE: `--stale` is Rust-only; do NOT back-port to `work-view.sh` (the bash
|
|
22
|
+
//! script is a frozen degraded fallback; `--scope` is also Rust-only, same
|
|
23
|
+
//! rationale).
|
|
24
|
+
|
|
25
|
+
use std::path::Path;
|
|
26
|
+
|
|
27
|
+
use work_view_core::model::{Item, Tier};
|
|
28
|
+
|
|
29
|
+
// ── Public types ──────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/// The result of reading `backlog_staleness_days` from CONVENTIONS.md.
|
|
32
|
+
pub enum StalenessThreshold {
|
|
33
|
+
/// The key `backlog_staleness_days: N` was found and parsed.
|
|
34
|
+
Days(u64),
|
|
35
|
+
/// The key is absent from CONVENTIONS.md. `--stale` should be inert.
|
|
36
|
+
NotConfigured,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/// Read `backlog_staleness_days` from `<root>/.work/CONVENTIONS.md`.
|
|
42
|
+
///
|
|
43
|
+
/// The file is scanned line by line; the first line matching
|
|
44
|
+
/// `backlog_staleness_days: <integer>` wins. Lines with leading whitespace,
|
|
45
|
+
/// YAML comments, or surrounding blank values are skipped.
|
|
46
|
+
///
|
|
47
|
+
/// Returns `StalenessThreshold::NotConfigured` when:
|
|
48
|
+
/// - the file cannot be read (non-fatal; same graceful-skip contract as the
|
|
49
|
+
/// absent-key case), or
|
|
50
|
+
/// - no matching line is found.
|
|
51
|
+
///
|
|
52
|
+
/// Returns `StalenessThreshold::Days(0)` if the value parses as zero (every
|
|
53
|
+
/// backlog item would be stale — unusual but valid).
|
|
54
|
+
pub fn read_staleness_threshold(root: &Path) -> StalenessThreshold {
|
|
55
|
+
let conventions_path = root.join(".work").join("CONVENTIONS.md");
|
|
56
|
+
let text = match std::fs::read_to_string(&conventions_path) {
|
|
57
|
+
Ok(t) => t,
|
|
58
|
+
Err(_) => return StalenessThreshold::NotConfigured,
|
|
59
|
+
};
|
|
60
|
+
parse_staleness_days(&text)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Apply the staleness filter to a slice of items.
|
|
64
|
+
///
|
|
65
|
+
/// Keeps only `Tier::Backlog` items whose last-touched date (see module doc)
|
|
66
|
+
/// is more than `threshold_days` days before `today_days`.
|
|
67
|
+
///
|
|
68
|
+
/// `today_days` should be produced by `today_as_days()`.
|
|
69
|
+
pub fn apply_stale(
|
|
70
|
+
items: Vec<&Item>,
|
|
71
|
+
threshold_days: u64,
|
|
72
|
+
today_days: u64,
|
|
73
|
+
) -> Vec<&Item> {
|
|
74
|
+
items
|
|
75
|
+
.into_iter()
|
|
76
|
+
.filter(|item| {
|
|
77
|
+
if item.tier != Tier::Backlog {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
is_stale(item, threshold_days, today_days)
|
|
81
|
+
})
|
|
82
|
+
.collect()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Return today's date as a count of days since the Unix epoch, in local time.
|
|
86
|
+
///
|
|
87
|
+
/// Local-time date: matches `datetime.now().strftime("%Y-%m-%d")` from Python,
|
|
88
|
+
/// which the PostToolUse hook uses to stamp `updated` / `created` fields.
|
|
89
|
+
///
|
|
90
|
+
/// Uses `SystemTime::now()` + UTC offset detection via `/etc/localtime` parsing
|
|
91
|
+
/// is complex and not worth the complexity for a "days ago" comparison, so we
|
|
92
|
+
/// use a UTC-adjusted approximation: read the UTC timestamp and add the local
|
|
93
|
+
/// UTC offset in seconds obtained via `time(2)` libc call.
|
|
94
|
+
///
|
|
95
|
+
/// # Simpler approach used here
|
|
96
|
+
///
|
|
97
|
+
/// We read `SystemTime::now()` (UTC epoch seconds), then apply the local-time
|
|
98
|
+
/// offset by calling the C `localtime_r` (POSIX) to get the local calendar
|
|
99
|
+
/// date — using `std::time` primitives only. On platforms without `libc`, we
|
|
100
|
+
/// fall back to UTC, which differs by at most one calendar day; the staleness
|
|
101
|
+
/// threshold is days, not hours, so a one-day error on the boundary is
|
|
102
|
+
/// acceptable for this use case.
|
|
103
|
+
///
|
|
104
|
+
/// Since this is not a cryptographic primitive and the crate forbids adding
|
|
105
|
+
/// new dependencies, we use `std::time::SystemTime` and compute the local date
|
|
106
|
+
/// via the POSIX `time_t` + `localtime` approach using `extern "C"` declarations
|
|
107
|
+
/// (available on all POSIX targets where the crate runs: Linux + macOS).
|
|
108
|
+
pub fn today_as_days() -> u64 {
|
|
109
|
+
// SAFETY: localtime_r is thread-safe (re-entrant) and widely available on
|
|
110
|
+
// all Linux/macOS targets. We call it with a valid stack-allocated tm.
|
|
111
|
+
#[cfg(unix)]
|
|
112
|
+
{
|
|
113
|
+
use std::time::{SystemTime, UNIX_EPOCH};
|
|
114
|
+
|
|
115
|
+
#[repr(C)]
|
|
116
|
+
#[allow(non_camel_case_types)]
|
|
117
|
+
struct tm {
|
|
118
|
+
tm_sec: i32,
|
|
119
|
+
tm_min: i32,
|
|
120
|
+
tm_hour: i32,
|
|
121
|
+
tm_mday: i32,
|
|
122
|
+
tm_mon: i32,
|
|
123
|
+
tm_year: i32,
|
|
124
|
+
tm_wday: i32,
|
|
125
|
+
tm_yday: i32,
|
|
126
|
+
tm_isdst: i32,
|
|
127
|
+
// glibc adds two more longs for timezone info; include them so the
|
|
128
|
+
// struct is large enough on any libc without ABI mismatch.
|
|
129
|
+
_tm_gmtoff: i64,
|
|
130
|
+
_tm_zone: *const u8,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
extern "C" {
|
|
134
|
+
fn localtime_r(timep: *const i64, result: *mut tm) -> *mut tm;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let now = SystemTime::now()
|
|
138
|
+
.duration_since(UNIX_EPOCH)
|
|
139
|
+
.unwrap_or_default()
|
|
140
|
+
.as_secs() as i64;
|
|
141
|
+
|
|
142
|
+
let mut local_tm: tm = unsafe { std::mem::zeroed() };
|
|
143
|
+
unsafe { localtime_r(&now, &mut local_tm) };
|
|
144
|
+
|
|
145
|
+
let year = local_tm.tm_year + 1900;
|
|
146
|
+
let month = local_tm.tm_mon + 1; // 1..=12
|
|
147
|
+
let day = local_tm.tm_mday; // 1..=31
|
|
148
|
+
|
|
149
|
+
date_to_days(year, month as u32, day as u32)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#[cfg(not(unix))]
|
|
153
|
+
{
|
|
154
|
+
// Non-POSIX fallback: use UTC. Off by at most one calendar day.
|
|
155
|
+
use std::time::{SystemTime, UNIX_EPOCH};
|
|
156
|
+
let secs = SystemTime::now()
|
|
157
|
+
.duration_since(UNIX_EPOCH)
|
|
158
|
+
.unwrap_or_default()
|
|
159
|
+
.as_secs();
|
|
160
|
+
// days since epoch (UTC)
|
|
161
|
+
secs / 86400
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Internal helpers ──────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
/// Parse `backlog_staleness_days` from CONVENTIONS.md text.
|
|
168
|
+
fn parse_staleness_days(text: &str) -> StalenessThreshold {
|
|
169
|
+
for line in text.lines() {
|
|
170
|
+
let line = line.trim();
|
|
171
|
+
if let Some(rest) = line.strip_prefix("backlog_staleness_days:") {
|
|
172
|
+
let value = rest.trim();
|
|
173
|
+
if let Ok(n) = value.parse::<u64>() {
|
|
174
|
+
return StalenessThreshold::Days(n);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
StalenessThreshold::NotConfigured
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/// Return `true` if `item` is stale: its last-touched date is more than
|
|
182
|
+
/// `threshold_days` before `today_days`.
|
|
183
|
+
///
|
|
184
|
+
/// Last-touched: `updated` if `Some`, else `created`; if both `None`, the
|
|
185
|
+
/// item is treated as infinitely stale (always surfaces). See module doc.
|
|
186
|
+
fn is_stale(item: &Item, threshold_days: u64, today_days: u64) -> bool {
|
|
187
|
+
let date_str = item
|
|
188
|
+
.updated
|
|
189
|
+
.as_deref()
|
|
190
|
+
.or(item.created.as_deref());
|
|
191
|
+
|
|
192
|
+
match date_str {
|
|
193
|
+
None => {
|
|
194
|
+
// No date at all: treat as infinitely stale — surface it.
|
|
195
|
+
// A dateless backlog item is at least as suspect as an old one.
|
|
196
|
+
true
|
|
197
|
+
}
|
|
198
|
+
Some(date) => match parse_date(date) {
|
|
199
|
+
Some(item_days) => {
|
|
200
|
+
// Stale if: today_days - item_days > threshold_days
|
|
201
|
+
// Guard against underflow: if item_days > today_days (clock
|
|
202
|
+
// skew or future date), the item is not stale.
|
|
203
|
+
today_days
|
|
204
|
+
.saturating_sub(item_days)
|
|
205
|
+
.gt(&threshold_days)
|
|
206
|
+
}
|
|
207
|
+
None => {
|
|
208
|
+
// Unparseable date: treat as stale (conservative).
|
|
209
|
+
true
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/// Parse a `YYYY-MM-DD` string into a day count (days since the proleptic
|
|
216
|
+
/// Gregorian epoch 0000-01-01, a stable internal unit).
|
|
217
|
+
///
|
|
218
|
+
/// Returns `None` if the string is not in `YYYY-MM-DD` format or if any
|
|
219
|
+
/// component is out of range.
|
|
220
|
+
fn parse_date(s: &str) -> Option<u64> {
|
|
221
|
+
// Expect exactly "YYYY-MM-DD": 10 chars, '-' at positions 4 and 7.
|
|
222
|
+
if s.len() != 10 {
|
|
223
|
+
return None;
|
|
224
|
+
}
|
|
225
|
+
let bytes = s.as_bytes();
|
|
226
|
+
if bytes[4] != b'-' || bytes[7] != b'-' {
|
|
227
|
+
return None;
|
|
228
|
+
}
|
|
229
|
+
let year: i32 = s[..4].parse().ok()?;
|
|
230
|
+
let month: u32 = s[5..7].parse().ok()?;
|
|
231
|
+
let day: u32 = s[8..10].parse().ok()?;
|
|
232
|
+
if !(1..=12).contains(&month) {
|
|
233
|
+
return None;
|
|
234
|
+
}
|
|
235
|
+
// Validate the day against the actual length of the month (leap-aware), so
|
|
236
|
+
// impossible calendar dates like 2026-02-31 or non-leap 2026-02-29 fail
|
|
237
|
+
// closed (None) rather than being fed into staleness comparisons as if real.
|
|
238
|
+
if day < 1 || day > days_in_month(year, month) {
|
|
239
|
+
return None;
|
|
240
|
+
}
|
|
241
|
+
Some(date_to_days(year, month, day))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/// Number of days in a given month, accounting for Gregorian leap years.
|
|
245
|
+
fn days_in_month(year: i32, month: u32) -> u32 {
|
|
246
|
+
match month {
|
|
247
|
+
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
|
248
|
+
4 | 6 | 9 | 11 => 30,
|
|
249
|
+
2 if is_leap_year(year) => 29,
|
|
250
|
+
2 => 28,
|
|
251
|
+
_ => 0, // unreachable: callers guard 1..=12
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/// Proleptic Gregorian leap-year rule.
|
|
256
|
+
fn is_leap_year(year: i32) -> bool {
|
|
257
|
+
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/// Convert a proleptic Gregorian calendar date to a day number.
|
|
261
|
+
///
|
|
262
|
+
/// Uses the algorithm from the C Standard (ISO 8601 / RFC 3339) for Gregorian
|
|
263
|
+
/// date arithmetic. The epoch (day 0) is 0000-03-01 (an internal reference
|
|
264
|
+
/// point chosen so the leap-day boundary falls at month start).
|
|
265
|
+
///
|
|
266
|
+
/// This is a pure integer calculation with no dependencies.
|
|
267
|
+
fn date_to_days(year: i32, month: u32, day: u32) -> u64 {
|
|
268
|
+
// Shift the epoch so March = month 1; this puts the leap day at end of year.
|
|
269
|
+
let (y, m) = if month <= 2 {
|
|
270
|
+
(year - 1, month + 9)
|
|
271
|
+
} else {
|
|
272
|
+
(year, month - 3)
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
let era = y.div_euclid(400);
|
|
276
|
+
let yoe = y.rem_euclid(400) as u64; // year-of-era [0, 399]
|
|
277
|
+
let doy = (153 * m as u64 + 2) / 5 + day as u64 - 1; // day-of-year [0, 365]
|
|
278
|
+
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // day-of-era [0, 146096]
|
|
279
|
+
|
|
280
|
+
// Add a large offset so the result fits in u64 even for years near 0000.
|
|
281
|
+
const EPOCH_OFFSET: u64 = 146097 * 10; // 10 full 400-year cycles
|
|
282
|
+
(era as u64)
|
|
283
|
+
.wrapping_add(10) // same 10-cycle offset
|
|
284
|
+
.wrapping_mul(146097)
|
|
285
|
+
.wrapping_add(doe)
|
|
286
|
+
.wrapping_add(EPOCH_OFFSET)
|
|
287
|
+
.wrapping_sub((10u64).wrapping_mul(146097))
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── Unit tests ────────────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
#[cfg(test)]
|
|
293
|
+
mod tests {
|
|
294
|
+
use super::*;
|
|
295
|
+
use std::path::PathBuf;
|
|
296
|
+
use work_view_core::model::Tier;
|
|
297
|
+
|
|
298
|
+
fn make_backlog_item(id: &str, created: Option<&str>, updated: Option<&str>) -> Item {
|
|
299
|
+
Item {
|
|
300
|
+
id: id.to_string(),
|
|
301
|
+
kind: None,
|
|
302
|
+
stage: None,
|
|
303
|
+
tags: vec![],
|
|
304
|
+
parent: None,
|
|
305
|
+
depends_on: vec![],
|
|
306
|
+
release_binding: None,
|
|
307
|
+
gate_origin: None,
|
|
308
|
+
research_refs: vec![],
|
|
309
|
+
research_origin: None,
|
|
310
|
+
scan_origin: None,
|
|
311
|
+
created: created.map(str::to_string),
|
|
312
|
+
updated: updated.map(str::to_string),
|
|
313
|
+
tier: Tier::Backlog,
|
|
314
|
+
path: PathBuf::from("/fake/backlog/item.md"),
|
|
315
|
+
rel_path: PathBuf::from(".work/backlog/item.md"),
|
|
316
|
+
raw_text: String::new(),
|
|
317
|
+
body: String::new(),
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
fn make_active_item(id: &str, created: Option<&str>) -> Item {
|
|
322
|
+
Item {
|
|
323
|
+
id: id.to_string(),
|
|
324
|
+
kind: Some("feature".to_string()),
|
|
325
|
+
stage: Some("implementing".to_string()),
|
|
326
|
+
tags: vec![],
|
|
327
|
+
parent: None,
|
|
328
|
+
depends_on: vec![],
|
|
329
|
+
release_binding: None,
|
|
330
|
+
gate_origin: None,
|
|
331
|
+
research_refs: vec![],
|
|
332
|
+
research_origin: None,
|
|
333
|
+
scan_origin: None,
|
|
334
|
+
created: created.map(str::to_string),
|
|
335
|
+
updated: None,
|
|
336
|
+
tier: Tier::Active,
|
|
337
|
+
path: PathBuf::from("/fake/active/item.md"),
|
|
338
|
+
rel_path: PathBuf::from(".work/active/features/item.md"),
|
|
339
|
+
raw_text: String::new(),
|
|
340
|
+
body: String::new(),
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── parse_staleness_days ──────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
#[test]
|
|
347
|
+
fn parse_key_present_returns_days() {
|
|
348
|
+
let text = "# Conventions\nbacklog_staleness_days: 90\n";
|
|
349
|
+
assert!(matches!(
|
|
350
|
+
parse_staleness_days(text),
|
|
351
|
+
StalenessThreshold::Days(90)
|
|
352
|
+
));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
#[test]
|
|
356
|
+
fn parse_key_absent_returns_not_configured() {
|
|
357
|
+
let text = "# Conventions\nsome_other_key: 30\n";
|
|
358
|
+
assert!(matches!(
|
|
359
|
+
parse_staleness_days(text),
|
|
360
|
+
StalenessThreshold::NotConfigured
|
|
361
|
+
));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
#[test]
|
|
365
|
+
fn parse_key_with_leading_whitespace_on_line_is_found() {
|
|
366
|
+
// The key may appear under a heading with indentation stripped.
|
|
367
|
+
let text = " backlog_staleness_days: 45\n";
|
|
368
|
+
assert!(matches!(
|
|
369
|
+
parse_staleness_days(text),
|
|
370
|
+
StalenessThreshold::Days(45)
|
|
371
|
+
));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
#[test]
|
|
375
|
+
fn parse_key_zero_returns_days_zero() {
|
|
376
|
+
let text = "backlog_staleness_days: 0\n";
|
|
377
|
+
assert!(matches!(
|
|
378
|
+
parse_staleness_days(text),
|
|
379
|
+
StalenessThreshold::Days(0)
|
|
380
|
+
));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#[test]
|
|
384
|
+
fn parse_key_non_integer_value_skips() {
|
|
385
|
+
let text = "backlog_staleness_days: auto\n";
|
|
386
|
+
assert!(matches!(
|
|
387
|
+
parse_staleness_days(text),
|
|
388
|
+
StalenessThreshold::NotConfigured
|
|
389
|
+
));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ── parse_date ────────────────────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
#[test]
|
|
395
|
+
fn parse_date_valid_returns_some() {
|
|
396
|
+
assert!(parse_date("2026-01-01").is_some());
|
|
397
|
+
assert!(parse_date("2000-02-29").is_some()); // valid leap year
|
|
398
|
+
assert!(parse_date("1970-01-01").is_some());
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#[test]
|
|
402
|
+
fn parse_date_invalid_format_returns_none() {
|
|
403
|
+
assert!(parse_date("26-01-01").is_none()); // too short
|
|
404
|
+
assert!(parse_date("2026/01/01").is_none()); // wrong separator
|
|
405
|
+
assert!(parse_date("2026-1-1").is_none()); // no zero-padding
|
|
406
|
+
assert!(parse_date("").is_none());
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
#[test]
|
|
410
|
+
fn parse_date_impossible_calendar_dates_return_none() {
|
|
411
|
+
// Day exceeds the month's real length — must fail closed, not be fed
|
|
412
|
+
// into staleness math as if it were a real date.
|
|
413
|
+
assert!(parse_date("2026-02-31").is_none()); // Feb never has 31
|
|
414
|
+
assert!(parse_date("2026-02-29").is_none()); // 2026 is not a leap year
|
|
415
|
+
assert!(parse_date("2026-04-31").is_none()); // April has 30
|
|
416
|
+
assert!(parse_date("2026-06-31").is_none()); // June has 30
|
|
417
|
+
assert!(parse_date("2026-01-32").is_none()); // no month has 32
|
|
418
|
+
assert!(parse_date("2026-01-00").is_none()); // day 0 invalid
|
|
419
|
+
assert!(parse_date("2026-00-10").is_none()); // month 0 invalid
|
|
420
|
+
assert!(parse_date("2026-13-10").is_none()); // month 13 invalid
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
#[test]
|
|
424
|
+
fn parse_date_leap_year_boundaries() {
|
|
425
|
+
assert!(parse_date("2000-02-29").is_some()); // divisible by 400 → leap
|
|
426
|
+
assert!(parse_date("2024-02-29").is_some()); // divisible by 4 → leap
|
|
427
|
+
assert!(parse_date("1900-02-29").is_none()); // divisible by 100, not 400 → not leap
|
|
428
|
+
assert!(parse_date("2026-02-28").is_some()); // last valid non-leap Feb day
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
#[test]
|
|
432
|
+
fn days_in_month_is_leap_aware() {
|
|
433
|
+
assert_eq!(days_in_month(2026, 2), 28);
|
|
434
|
+
assert_eq!(days_in_month(2024, 2), 29);
|
|
435
|
+
assert_eq!(days_in_month(1900, 2), 28);
|
|
436
|
+
assert_eq!(days_in_month(2000, 2), 29);
|
|
437
|
+
assert_eq!(days_in_month(2026, 4), 30);
|
|
438
|
+
assert_eq!(days_in_month(2026, 12), 31);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
#[test]
|
|
442
|
+
fn parse_date_monotonically_increasing() {
|
|
443
|
+
let d1 = parse_date("2026-01-01").unwrap();
|
|
444
|
+
let d2 = parse_date("2026-01-02").unwrap();
|
|
445
|
+
let d3 = parse_date("2026-02-01").unwrap();
|
|
446
|
+
let d4 = parse_date("2027-01-01").unwrap();
|
|
447
|
+
assert!(d1 < d2, "next day should be later");
|
|
448
|
+
assert!(d2 < d3, "next month should be later");
|
|
449
|
+
assert!(d3 < d4, "next year should be later");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
#[test]
|
|
453
|
+
fn parse_date_exactly_365_day_difference() {
|
|
454
|
+
// 2026 is not a leap year; 2025-01-01 to 2026-01-01 = 365 days.
|
|
455
|
+
let d_start = parse_date("2025-01-01").unwrap();
|
|
456
|
+
let d_end = parse_date("2026-01-01").unwrap();
|
|
457
|
+
assert_eq!(d_end - d_start, 365, "365 days in a non-leap year");
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── is_stale ─────────────────────────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
#[test]
|
|
463
|
+
fn item_older_than_threshold_is_stale() {
|
|
464
|
+
// today = 2026-06-15 (day N), item created 2025-01-01 (> 90 days ago)
|
|
465
|
+
let today = parse_date("2026-06-15").unwrap();
|
|
466
|
+
let item = make_backlog_item("old", Some("2025-01-01"), None);
|
|
467
|
+
assert!(is_stale(&item, 90, today));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
#[test]
|
|
471
|
+
fn item_newer_than_threshold_is_not_stale() {
|
|
472
|
+
let today = parse_date("2026-06-15").unwrap();
|
|
473
|
+
// created yesterday
|
|
474
|
+
let item = make_backlog_item("fresh", Some("2026-06-14"), None);
|
|
475
|
+
assert!(!is_stale(&item, 90, today));
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
#[test]
|
|
479
|
+
fn item_exactly_at_threshold_is_not_stale() {
|
|
480
|
+
// "more than N days" — exact boundary is not stale.
|
|
481
|
+
let today = parse_date("2026-04-15").unwrap();
|
|
482
|
+
// 90 days before 2026-04-15 is 2026-01-15
|
|
483
|
+
let item = make_backlog_item("boundary", Some("2026-01-15"), None);
|
|
484
|
+
let diff = today - parse_date("2026-01-15").unwrap();
|
|
485
|
+
// diff must equal exactly the threshold for this to be the boundary case.
|
|
486
|
+
assert_eq!(diff, 90);
|
|
487
|
+
// Not stale: "more than" 90, not "90 or more".
|
|
488
|
+
assert!(!is_stale(&item, 90, today));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
#[test]
|
|
492
|
+
fn item_one_day_past_threshold_is_stale() {
|
|
493
|
+
let today = parse_date("2026-04-16").unwrap();
|
|
494
|
+
// 91 days before 2026-04-16 is 2026-01-15
|
|
495
|
+
let item = make_backlog_item("just-over", Some("2026-01-15"), None);
|
|
496
|
+
let diff = today - parse_date("2026-01-15").unwrap();
|
|
497
|
+
assert_eq!(diff, 91);
|
|
498
|
+
assert!(is_stale(&item, 90, today));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
#[test]
|
|
502
|
+
fn updated_takes_precedence_over_created() {
|
|
503
|
+
// created is old, updated is recent → not stale
|
|
504
|
+
let today = parse_date("2026-06-15").unwrap();
|
|
505
|
+
let item = make_backlog_item("updated-recently", Some("2020-01-01"), Some("2026-06-14"));
|
|
506
|
+
assert!(!is_stale(&item, 90, today));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
#[test]
|
|
510
|
+
fn updated_old_created_recent_is_judged_by_updated() {
|
|
511
|
+
// updated is the deciding date; if updated is old, stale regardless of created.
|
|
512
|
+
let today = parse_date("2026-06-15").unwrap();
|
|
513
|
+
let item = make_backlog_item("stale-updated", Some("2026-06-14"), Some("2020-01-01"));
|
|
514
|
+
assert!(is_stale(&item, 90, today));
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
#[test]
|
|
518
|
+
fn dateless_item_is_stale() {
|
|
519
|
+
// Both created and updated are None → treated as infinitely stale.
|
|
520
|
+
let today = parse_date("2026-06-15").unwrap();
|
|
521
|
+
let item = make_backlog_item("dateless", None, None);
|
|
522
|
+
assert!(is_stale(&item, 90, today));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ── apply_stale ───────────────────────────────────────────────────────────
|
|
526
|
+
|
|
527
|
+
#[test]
|
|
528
|
+
fn apply_stale_excludes_non_backlog_items() {
|
|
529
|
+
let today = parse_date("2026-06-15").unwrap();
|
|
530
|
+
let active = make_active_item("active-old", Some("2020-01-01"));
|
|
531
|
+
let backlog_old = make_backlog_item("backlog-old", Some("2020-01-01"), None);
|
|
532
|
+
|
|
533
|
+
let items: Vec<&Item> = vec![&active, &backlog_old];
|
|
534
|
+
let stale = apply_stale(items, 90, today);
|
|
535
|
+
|
|
536
|
+
let ids: Vec<&str> = stale.iter().map(|i| i.id.as_str()).collect();
|
|
537
|
+
assert!(!ids.contains(&"active-old"), "active items must be excluded");
|
|
538
|
+
assert!(ids.contains(&"backlog-old"), "old backlog item must be included");
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
#[test]
|
|
542
|
+
fn apply_stale_excludes_fresh_backlog_items() {
|
|
543
|
+
let today = parse_date("2026-06-15").unwrap();
|
|
544
|
+
let fresh = make_backlog_item("fresh", Some("2026-06-14"), None);
|
|
545
|
+
let old = make_backlog_item("old", Some("2020-01-01"), None);
|
|
546
|
+
|
|
547
|
+
let items: Vec<&Item> = vec![&fresh, &old];
|
|
548
|
+
let stale = apply_stale(items, 90, today);
|
|
549
|
+
|
|
550
|
+
let ids: Vec<&str> = stale.iter().map(|i| i.id.as_str()).collect();
|
|
551
|
+
assert!(ids.contains(&"old"));
|
|
552
|
+
assert!(!ids.contains(&"fresh"));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
#[test]
|
|
556
|
+
fn apply_stale_includes_dateless_backlog_item() {
|
|
557
|
+
let today = parse_date("2026-06-15").unwrap();
|
|
558
|
+
let dateless = make_backlog_item("dateless", None, None);
|
|
559
|
+
let items: Vec<&Item> = vec![&dateless];
|
|
560
|
+
let stale = apply_stale(items, 90, today);
|
|
561
|
+
assert_eq!(stale.len(), 1);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ── today_as_days ─────────────────────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
#[test]
|
|
567
|
+
fn today_as_days_returns_plausible_value() {
|
|
568
|
+
// Sanity check: today should be after 2020-01-01 and before 2100-01-01.
|
|
569
|
+
let today = today_as_days();
|
|
570
|
+
let lower = parse_date("2020-01-01").unwrap();
|
|
571
|
+
let upper = parse_date("2100-01-01").unwrap();
|
|
572
|
+
assert!(
|
|
573
|
+
today >= lower,
|
|
574
|
+
"today ({today}) should be after 2020-01-01 ({lower})"
|
|
575
|
+
);
|
|
576
|
+
assert!(
|
|
577
|
+
today < upper,
|
|
578
|
+
"today ({today}) should be before 2100-01-01 ({upper})"
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { installDomGlobals, loadBoardModule, makeItem } from "./harness.mjs";
|
|
5
|
+
|
|
6
|
+
test("dependency model reports cycles and missing dependency stubs", async () => {
|
|
7
|
+
installDomGlobals();
|
|
8
|
+
const { buildDependencyModel } = await loadBoardModule("dependency.js");
|
|
9
|
+
const model = buildDependencyModel([
|
|
10
|
+
makeItem({ id: "story-a", depends_on: ["story-c"] }),
|
|
11
|
+
makeItem({ id: "story-b", depends_on: ["story-a"] }),
|
|
12
|
+
makeItem({ id: "story-c", depends_on: ["story-b", "missing-dep"], unmet_deps: ["missing-dep"] }),
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
assert.deepEqual(new Set(model.cycleIds), new Set(["story-a", "story-b", "story-c"]));
|
|
16
|
+
assert.equal(model.nodes.has("external:missing-dep"), true);
|
|
17
|
+
assert.equal(model.nodes.get("external:missing-dep").external, true);
|
|
18
|
+
assert.equal(
|
|
19
|
+
model.edges.some((edge) => edge.from === "external:missing-dep" && edge.to === "story-c" && edge.unmet),
|
|
20
|
+
true,
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("dependency model represents filtered dependencies as unmet external stubs", async () => {
|
|
25
|
+
installDomGlobals();
|
|
26
|
+
const { buildDependencyModel } = await loadBoardModule("dependency.js");
|
|
27
|
+
const model = buildDependencyModel([
|
|
28
|
+
makeItem({ id: "visible-story", depends_on: ["filtered-story"], unmet_deps: ["filtered-story"] }),
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
assert.equal(model.nodes.has("external:filtered-story"), true);
|
|
32
|
+
assert.equal(model.nodes.get("external:filtered-story").external, true);
|
|
33
|
+
assert.equal(
|
|
34
|
+
model.edges.some((edge) => (
|
|
35
|
+
edge.from === "external:filtered-story"
|
|
36
|
+
&& edge.to === "visible-story"
|
|
37
|
+
&& edge.external
|
|
38
|
+
&& edge.unmet
|
|
39
|
+
)),
|
|
40
|
+
true,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("table comparators are deterministic, stage-aware, and tolerate missing updated", async () => {
|
|
45
|
+
installDomGlobals();
|
|
46
|
+
const { compareRows, sortedItems } = await loadBoardModule("table.js");
|
|
47
|
+
const snapshot = {
|
|
48
|
+
items: [
|
|
49
|
+
makeItem({ id: "draft", stage: "drafting" }),
|
|
50
|
+
makeItem({ id: "done", stage: "done" }),
|
|
51
|
+
makeItem({ id: "empty-updated", updated: "" }),
|
|
52
|
+
makeItem({ id: "dated", updated: "2026-06-01" }),
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
assert.equal(compareRows(snapshot.items[0], snapshot.items[1], "stage", snapshot) < 0, true);
|
|
57
|
+
assert.equal(compareRows(snapshot.items[2], snapshot.items[3], "updated", snapshot) < 0, true);
|
|
58
|
+
|
|
59
|
+
const duplicateA = makeItem({ id: "same", stage: "done", updated: "2026-06-01" });
|
|
60
|
+
const duplicateB = makeItem({ id: "same", stage: "done", updated: "2026-06-01" });
|
|
61
|
+
const sorted = sortedItems([duplicateA, duplicateB], { items: [duplicateA, duplicateB] });
|
|
62
|
+
assert.equal(sorted[0], duplicateA);
|
|
63
|
+
assert.equal(sorted[1], duplicateB);
|
|
64
|
+
});
|