Skip to content

Commit df0a7ef

Browse files
committed
fix(index): re-derive task IDs when the derivation rules change
The index now records the ID-derivation version it was built under and re-derives every file on a mismatch, since a rules change moves every unpinned ID while leaving content hashes identical. check-index compares stored IDs against freshly derived ones, lint names the cause, and the new lash migrate-ids rewrites references left dangling. Fixes #54
1 parent 6c4ca86 commit df0a7ef

25 files changed

Lines changed: 3047 additions & 18 deletions

‎CHANGELOG.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,38 @@ While the major version is 0, minor version bumps may contain breaking changes.
88

99
## [Unreleased]
1010

11+
### Added
12+
13+
- `lash migrate-ids` rewrites `@depends-on` references left dangling by a
14+
task-ID derivation change. It reports by default and writes only with
15+
`--write`; `--forget` discards the pending renames for a project that would
16+
rather repair by hand. Only whole references on `@depends-on:` lines are
17+
rewritten — prose mentioning an old ID is left alone, and so is the
18+
unqualified `old-id` form, since a bare token can name a file as readily as
19+
a task.
20+
21+
### Fixed
22+
23+
- Stale task IDs no longer survive `lash index`. A task's ID is derived from
24+
its title and is not written to the Markdown unless pinned with `@id:`, so a
25+
release that changes the derivation rules moves every unpinned ID while every
26+
content hash stays identical — and incremental indexing, which keys off those
27+
hashes, never re-derives. A file nobody had edited kept serving IDs from
28+
rules no longer in force: `lash show` printed the stored ID, `lash lint`
29+
derived a different one and refused to resolve it, and `lash check-index`
30+
called the index in sync throughout. The index now records the derivation
31+
version it was built under and re-derives every file when that does not
32+
match, so an upgrade repairs itself on the next `lash index`. The IDs that
33+
moved are reported, and recorded for `lash migrate-ids` — the re-derive is
34+
the only moment both spellings exist.
35+
- `lash check-index` compares stored task IDs against freshly derived ones
36+
instead of only comparing content hashes, which by construction cannot see a
37+
change in how IDs are derived from unchanged content.
38+
- `lash lint` now says when an unresolved reference points at a task ID that a
39+
derivation change moved, rather than at a task that is missing. Without it
40+
the error reads as a false positive: the ID it names is exactly the one
41+
`lash show` prints back.
42+
1143
## [0.3.1] - 2026-08-11
1244

1345
Two fixes to the root cause the 0.3.0 sweep left standing. The parsed model

‎crates/lash-agent/src/content.rs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ lash format [PATH...] # Normalize formatting
123123
# Indexing
124124
lash index # Update SQLite index after changes
125125
lash check-index # Verify database consistency
126+
lash migrate-ids # Show references left dangling by an ID rule change
127+
lash migrate-ids --write # Rewrite those references
126128
127129
# Dependencies & Links
128130
lash graph # Show dependency graph (ascii)
@@ -196,6 +198,22 @@ lash index --force # Force full reindex
196198
lash check-index # Verify consistency
197199
```
198200
201+
### Task IDs That Moved
202+
203+
A task without an explicit `@id:` gets its ID derived from its title, so a
204+
release that changes the derivation rules moves every such ID. `lash index`
205+
detects this, re-derives the stored IDs, and records what each one used to be.
206+
References written against the old IDs stop resolving at that moment:
207+
208+
```bash
209+
lash migrate-ids # Show what changed and which references it affects
210+
lash migrate-ids --write # Rewrite the references, then re-index
211+
```
212+
213+
`lash check-index` reports stored IDs that no longer match the current rules,
214+
and `lash lint` says when an unresolved reference is one of these rather than a
215+
typo. Pin an ID with `@id:` to keep it stable across future changes.
216+
199217
### Broken References
200218
201219
If `@depends-on` or `@doc` references are broken:
@@ -340,6 +358,7 @@ pub const TOP_LEVEL_SUBCOMMANDS: &[&str] = &[
340358
"init",
341359
"lint",
342360
"list",
361+
"migrate-ids",
343362
"playground",
344363
"search",
345364
"show",

‎crates/lash-cli/src/cli.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,18 @@ pub enum Commands {
203203
diff: bool,
204204
},
205205

206+
/// Rewrite references to task IDs that a derivation change moved
207+
#[command()]
208+
MigrateIds {
209+
/// Rewrite the references (without this, only reports what would change)
210+
#[arg(long)]
211+
write: bool,
212+
213+
/// Discard the pending renames without rewriting anything
214+
#[arg(long, conflicts_with = "write")]
215+
forget: bool,
216+
},
217+
206218
/// List tasks matching specified criteria
207219
List {
208220
/// Filter by task ID (supports fuzzy matching)

‎crates/lash-cli/src/commands/check_index.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ pub fn execute(args: CheckIndexArgs) -> Result<i32> {
9595
verifier_config = verifier_config.with_paths(absolute_paths);
9696
}
9797

98+
// Re-deriving task IDs has to use the project's own parser settings, or
99+
// the IDs compared against are not the ones this project would get.
100+
let parser_config = lash_types::LashConfig::from_root(&project_root).unwrap_or_default();
101+
verifier_config = verifier_config.with_parser_config(parser_config);
102+
98103
let verifier = IndexVerifier::new(&conn, verifier_config);
99104

100105
// Run verification
@@ -159,6 +164,7 @@ fn output_json_report(report: &lash_db::VerificationReport) -> Result<()> {
159164
"hash_mismatches": report.count_by_kind(lash_db::IssueKind::HashMismatch),
160165
"orphaned_tasks": report.count_by_kind(lash_db::IssueKind::OrphanedTasks),
161166
"orphaned_dependencies": report.count_by_kind(lash_db::IssueKind::OrphanedDependencies),
167+
"stale_task_ids": report.count_by_kind(lash_db::IssueKind::StaleTaskIds),
162168
}),
163169
});
164170

@@ -224,6 +230,11 @@ fn output_text_report(
224230
report.count_by_kind(lash_db::IssueKind::OrphanedDependencies),
225231
theme,
226232
);
233+
print_issue_count_if_any(
234+
"Stale task IDs (derived under older ID rules)",
235+
report.count_by_kind(lash_db::IssueKind::StaleTaskIds),
236+
theme,
237+
);
227238

228239
// Detailed issue list if requested
229240
if show_diff {

‎crates/lash-cli/src/commands/index.rs‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,13 @@ fn output_json_report(report: &lash_db::IndexReport, error_reporter: &ErrorRepor
239239
"files_deleted": report.files_deleted,
240240
"files_unchanged": report.files_unchanged,
241241
"has_changes": report.has_changes,
242+
"id_derivation_rebuild": report.id_derivation_rebuild,
243+
"id_renames": report.id_renames.iter().map(|r| json!({
244+
"file": r.file_path.display().to_string(),
245+
"old_id": r.old_full_id(),
246+
"new_id": r.new_full_id(),
247+
"title": r.title,
248+
})).collect::<Vec<_>>(),
242249
"errors": {
243250
"count": summary.error_count,
244251
"files_affected": summary.files_affected.len(),
@@ -253,6 +260,12 @@ fn output_json_report(report: &lash_db::IndexReport, error_reporter: &ErrorRepor
253260
Ok(())
254261
}
255262

263+
/// How many renamed IDs `lash index` spells out before summarising the rest
264+
///
265+
/// Past a handful the list stops being readable in a terminal, and
266+
/// `lash migrate-ids` shows the whole thing anyway.
267+
const MAX_LISTED_RENAMES: usize = 10;
268+
256269
/// Output indexing report as human-readable text
257270
fn output_text_report(
258271
report: &lash_db::IndexReport,
@@ -308,6 +321,46 @@ fn output_text_report(
308321
println!(" Unchanged: {}", report.files_unchanged);
309322
}
310323

324+
// A re-derive that actually moved IDs is not a routine reindex: every
325+
// reference written against one of the old IDs stopped resolving in this
326+
// same moment. Say so here rather than letting `lash lint` be the first
327+
// thing that mentions it, several commands later, without the context.
328+
//
329+
// Keyed on renames rather than on the re-derive itself, because a rule
330+
// change need not affect any title in a given project, and warning about
331+
// a repair that changed nothing would train people to ignore it.
332+
if !report.id_renames.is_empty() {
333+
println!();
334+
let notice = format!(
335+
"{} task ID{} changed: this index was built under older ID rules.",
336+
report.id_renames.len(),
337+
if report.id_renames.len() == 1 {
338+
""
339+
} else {
340+
"s"
341+
}
342+
);
343+
if let Some(t) = theme {
344+
println!("{}", t.style_warning(&notice));
345+
} else {
346+
println!("{notice}");
347+
}
348+
for rename in report.id_renames.iter().take(MAX_LISTED_RENAMES) {
349+
println!(" {} → {}", rename.old_full_id(), rename.new_full_id());
350+
}
351+
if report.id_renames.len() > MAX_LISTED_RENAMES {
352+
println!(
353+
" … and {} more",
354+
report.id_renames.len() - MAX_LISTED_RENAMES
355+
);
356+
}
357+
println!();
358+
println!("Stored IDs now match what lash derives today. References written");
359+
println!("against the old IDs will not resolve until they are updated:");
360+
println!(" lash migrate-ids # show what would change");
361+
println!(" lash migrate-ids --write # rewrite the references");
362+
}
363+
311364
// Print error summary
312365
let summary = error_reporter.summary();
313366
if summary.error_count > 0 {
@@ -433,6 +486,8 @@ mod tests {
433486
error: "Parse error".to_string(),
434487
}],
435488
has_changes: true,
489+
id_derivation_rebuild: false,
490+
id_renames: vec![],
436491
profile: None,
437492
};
438493

@@ -1023,6 +1078,8 @@ mod tests {
10231078
error: "Unexpected token".to_string(),
10241079
}],
10251080
has_changes: false,
1081+
id_derivation_rebuild: false,
1082+
id_renames: vec![],
10261083
profile: None,
10271084
};
10281085

@@ -1528,6 +1585,8 @@ mod tests {
15281585
files_skipped: 0,
15291586
errors: vec![],
15301587
has_changes: false,
1588+
id_derivation_rebuild: false,
1589+
id_renames: vec![],
15311590
profile: None,
15321591
};
15331592
output_text_report(&report_zero, false, &reporter, None);
@@ -1546,9 +1605,72 @@ mod tests {
15461605
files_skipped: 0,
15471606
errors: vec![],
15481607
has_changes: added > 0 || updated > 0 || deleted > 0,
1608+
id_derivation_rebuild: false,
1609+
id_renames: vec![],
15491610
profile: None,
15501611
};
15511612
output_text_report(&r, false, &reporter, None);
15521613
}
15531614
}
1615+
1616+
/// Build a report carrying `count` renamed IDs.
1617+
fn report_with_renames(count: usize) -> lash_db::IndexReport {
1618+
let id_renames = (0..count)
1619+
.map(|i| lash_db::TaskIdRename {
1620+
file_path: PathBuf::from("tasks.md"),
1621+
file_id: "tasks".to_string(),
1622+
old_local_id: format!("old-{i}"),
1623+
new_local_id: format!("new-{i}"),
1624+
title: format!("Task {i}"),
1625+
})
1626+
.collect();
1627+
1628+
lash_db::IndexReport {
1629+
files_processed: 1,
1630+
files_added: 0,
1631+
files_updated: 1,
1632+
files_deleted: 0,
1633+
files_unchanged: 0,
1634+
files_skipped: 0,
1635+
errors: vec![],
1636+
has_changes: true,
1637+
id_derivation_rebuild: true,
1638+
id_renames,
1639+
profile: None,
1640+
}
1641+
}
1642+
1643+
#[test]
1644+
fn test_json_report_carries_the_renamed_ids() {
1645+
// Machine consumers need the mapping, not just the count: it is the
1646+
// only record of what a reference used to mean.
1647+
let report = report_with_renames(2);
1648+
let reporter = ErrorReporter::new(ErrorReporterConfig {
1649+
verbosity: Verbosity::Normal,
1650+
output_format: OutputFormat::JsonPretty,
1651+
display_mode: ErrorDisplayMode::Batch,
1652+
theme: None,
1653+
show_summary: false,
1654+
});
1655+
1656+
assert!(output_json_report(&report, &reporter).is_ok());
1657+
}
1658+
1659+
#[test]
1660+
fn test_a_long_rename_list_is_truncated_not_dropped() {
1661+
// Silently printing only the first ten would read as "that was all".
1662+
let report = report_with_renames(MAX_LISTED_RENAMES + 3);
1663+
assert!(report.id_renames.len() > MAX_LISTED_RENAMES);
1664+
1665+
// Exercised for panics and for the truncation arithmetic; the text
1666+
// itself is asserted end-to-end in the integration tests.
1667+
output_text_report(&report, false, &text_reporter(), None);
1668+
}
1669+
1670+
#[test]
1671+
fn test_report_with_no_renames_is_the_quiet_path() {
1672+
let report = report_with_renames(0);
1673+
assert!(report.id_renames.is_empty());
1674+
output_text_report(&report, false, &text_reporter(), None);
1675+
}
15541676
}

‎crates/lash-cli/src/commands/lint.rs‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,11 +240,21 @@ pub fn execute(args: LintArgs) -> anyhow::Result<i32> {
240240
}
241241

242242
// Convert LintDiagnostic to Diagnostic for output
243-
let diagnostics: Vec<Diagnostic> = filtered_diagnostics
243+
let mut diagnostics: Vec<Diagnostic> = filtered_diagnostics
244244
.iter()
245245
.map(lint_diagnostic_to_diagnostic)
246246
.collect();
247247

248+
// An unresolved reference whose target the index still recognises is not
249+
// an ordinary broken link; it is the signature of a task-ID derivation
250+
// change. Saying so here saves the reader from concluding lint is wrong,
251+
// which is the natural reading when `lash show` prints the very ID lint
252+
// just rejected (GitHub issue #54).
253+
let id_drift = project_root.as_deref().map_or_else(
254+
crate::commands::migrate_ids::IdDriftNotice::default,
255+
|root| crate::commands::migrate_ids::annotate_id_drift(&mut diagnostics, root),
256+
);
257+
248258
// Output results
249259
if args.json {
250260
// JSON output to stdout
@@ -258,6 +268,12 @@ pub fn execute(args: LintArgs) -> anyhow::Result<i32> {
258268
args.verbosity,
259269
args.suggest,
260270
)?;
271+
272+
// The per-diagnostic help only surfaces under `-v`. This note is not
273+
// routine help — it is the reason the errors above are not the typos
274+
// they look like — so it goes after the summary, once, at normal
275+
// verbosity.
276+
print_id_drift_notice(id_drift, theme.as_ref());
261277
}
262278

263279
// Determine exit code based on errors
@@ -266,6 +282,37 @@ pub fn execute(args: LintArgs) -> anyhow::Result<i32> {
266282
Ok(if has_errors { 2 } else { 0 })
267283
}
268284

285+
/// Print the ID-drift footer, if any references were explained by one
286+
///
287+
/// Kept out of `print_summary` because it is not a count of anything — it
288+
/// reframes errors already printed, and only appears when there is something
289+
/// to reframe.
290+
fn print_id_drift_notice(
291+
notice: crate::commands::migrate_ids::IdDriftNotice,
292+
theme: Option<&CliTheme>,
293+
) {
294+
let Some(advice) = notice.advice() else {
295+
return;
296+
};
297+
298+
// The references above were correct when they were written; the rules
299+
// moved underneath them. Saying which of the two it is turns a list of
300+
// apparent typos into one known problem with one known fix.
301+
let explained = notice.pending_migration + notice.stale_index;
302+
let headline = format!(
303+
"{explained} of these reference(s) point at task IDs that a derivation change \
304+
moved, not at tasks that are missing."
305+
);
306+
307+
println!();
308+
if let Some(t) = theme {
309+
println!("{}", t.style_warning(&headline));
310+
} else {
311+
println!("{headline}");
312+
}
313+
println!(" {advice}");
314+
}
315+
269316
/// Output diagnostics in JSON format to stdout
270317
fn output_json_diagnostics(
271318
diagnostics: &[Diagnostic],

0 commit comments

Comments
 (0)