diff --git a/crates/liyi/src/markers.rs.liyi.jsonc b/crates/liyi/src/markers.rs.liyi.jsonc index f9acb3a..f0962e4 100644 --- a/crates/liyi/src/markers.rs.liyi.jsonc +++ b/crates/liyi/src/markers.rs.liyi.jsonc @@ -43,7 +43,7 @@ "source_anchor": "const ALIAS_TABLE: &[(&str, &str)] = &[", "related": { "marker-normalization": "sha256:f1786541ac9be4533a36924a80c578a749024a4980fbc323417c2d5640ed6306", - "quine-escape-in-source": null + "quine-escape-in-source": "sha256:b929750d0d3c9ce24a6f814d7a1db15f2fc03983efa2647cfcf6a7462d2c8b81" } }, { @@ -125,7 +125,7 @@ "source_anchor": "fn is_fence_delimiter(line: &str) -> bool {", "confidence": 0.95, "related": { - "markdown-fenced-block-skip": null + "markdown-fenced-block-skip": "sha256:0bdd7d1ad747e2a826adfe2eca5652c7a9f382e1089ff12dd2c9b8d88f3920b7" } }, { @@ -164,8 +164,8 @@ "source_hash": "sha256:882dc8ffe044cfd565123f41aa19ad94b6ec641f829f96a3c7b02565beb43de3", "source_anchor": "pub fn scan_markers(content: &str) -> Vec {", "related": { - "markdown-fenced-block-skip": null, - "quine-escape-in-source": null + "markdown-fenced-block-skip": "sha256:0bdd7d1ad747e2a826adfe2eca5652c7a9f382e1089ff12dd2c9b8d88f3920b7", + "quine-escape-in-source": "sha256:b929750d0d3c9ce24a6f814d7a1db15f2fc03983efa2647cfcf6a7462d2c8b81" } } ] diff --git a/crates/liyi/src/prompt.rs b/crates/liyi/src/prompt.rs index 87577a8..84e2f50 100644 --- a/crates/liyi/src/prompt.rs +++ b/crates/liyi/src/prompt.rs @@ -56,14 +56,19 @@ const TMPL_STALE_SPEC_FIXABLE: &str = "Run {fix_command} to refresh the \ reviewed spec, unset \"reviewed\"."; const TMPL_SHIFTED_SPAN: &str = "Run `liyi check --fix` to auto-correct the \ - span for \"{item}\" in {expected_sidecar} from [{old_start}, \ - {old_end}] to [{new_start}, {new_end}]."; + span for \"{item}\" in {expected_sidecar} from {old_span} \ + to {new_span}."; const TMPL_UNREVIEWED_SPEC: &str = "Verify that the intent for \"{item}\" \ in {expected_sidecar} matches the source at {source_file}:{source_line}, \ then run `liyi approve {expected_sidecar} --item {item}` or set \ \"reviewed\": true in the sidecar."; +const TMPL_REQ_CHANGED: &str = "Requirement \"{requirement}\" has changed. \ + Run `liyi approve {expected_sidecar} --item {item}` to update the \ + stored hash for the related edge, or manually set \"related\": \ + {{\"{requirement}\": null}} in the sidecar to trigger re-approval."; + /// Metadata for each diagnostic type: `(type_name, template, untrusted_fields)`. struct GroupMeta { type_name: &'static str, @@ -233,6 +238,30 @@ pub fn build_prompt_output( item, )); } + DiagnosticKind::ReqChanged { requirement } => { + let Some(source_line) = d.span_start else { + continue; + }; + let item_name = &d.item_or_req; + let mut item = serde_json::json!({ + "item": item_name, + "requirement": requirement, + "source_file": source_rel, + "source_line": source_line, + "expected_sidecar": expected_sidecar, + }); + if let Some(text) = &d.intent { + item["intent_text"] = serde_json::Value::String(text.clone()); + } + raw.push(( + GroupMeta { + type_name: "req_changed", + template: TMPL_REQ_CHANGED, + untrusted_fields: &["item", "requirement", "intent_text"], + }, + item, + )); + } _ => {} } } diff --git a/crates/liyi/src/prompt.rs.liyi.jsonc b/crates/liyi/src/prompt.rs.liyi.jsonc index c703802..bcaa12b 100644 --- a/crates/liyi/src/prompt.rs.liyi.jsonc +++ b/crates/liyi/src/prompt.rs.liyi.jsonc @@ -32,8 +32,8 @@ "reviewed": true, "intent": "=trivial", "source_span": [ - 68, - 72 + 73, + 77 ], "tree_path": "struct.GroupMeta", "source_hash": "sha256:949b255735ad374171b0742fb9981747e0c5adb8cabf1ec4fca07078696579f2", @@ -42,25 +42,25 @@ { "item": "build_prompt_output", "reviewed": true, - "intent": "Convert a diagnostic slice into PromptOutput by filtering to actionable kinds (coverage gaps plus Stale, Shifted, and Unreviewed), skipping fixed diagnostics, and building (GroupMeta, serde_json::Value) pairs. Each pair captures the diagnostic type, its template, untrusted field names, and a JSON object of per-item data. A final group_items() pass groups pairs by (type_name, template pointer) preserving insertion order. Error-only diagnostics remain excluded from groups but still influence exit_code.", + "intent": "Convert a diagnostic slice into PromptOutput by filtering to actionable kinds (coverage gaps plus Stale, Shifted, Unreviewed, and ReqChanged), skipping fixed diagnostics, and building (GroupMeta, serde_json::Value) pairs. Each pair captures the diagnostic type, its template, untrusted field names, and a JSON object of per-item data. A final group_items() pass groups pairs by (type_name, template pointer) preserving insertion order. Error-only diagnostics remain excluded from groups but still influence exit_code.", "source_span": [ - 81, - 253 + 86, + 282 ], "tree_path": "fn.build_prompt_output", - "source_hash": "sha256:4b58abb88e8267fb636eae6727230d6f4c90fdaab97f0125c916e5a55985851e", + "source_hash": "sha256:496e2e08e42fff85a266aef7e978d4a53deb461a9b183cc680c620f7202ab014", "source_anchor": "pub fn build_prompt_output(", "related": { - "quine-escape-in-source": null + "quine-escape-in-source": "sha256:b929750d0d3c9ce24a6f814d7a1db15f2fc03983efa2647cfcf6a7462d2c8b81" } }, { "item": "group_items", "reviewed": true, - "intent": "Group raw (GroupMeta, serde_json::Value) pairs by (type_name, template) into PromptGroups, preserving insertion order. Uses linear scan since the number of distinct groups is small (≤ 7). Two items share a group when they have the same type_name and the same template pointer (ptr::eq).", + "intent": "Group raw (GroupMeta, serde_json::Value) pairs by (type_name, template) into PromptGroups, preserving insertion order. Uses linear scan since the number of distinct groups is small (≤ 8). Two items share a group when they have the same type_name and the same template pointer (ptr::eq).", "source_span": [ - 256, - 281 + 285, + 310 ], "tree_path": "fn.group_items", "source_hash": "sha256:62e8c05770642c453f9bbfe2e62d1981f3b8b6542d9cf9018a98feeae47988cb", diff --git a/crates/liyi/tests/golden.rs b/crates/liyi/tests/golden.rs index 1a50aeb..f1794d2 100644 --- a/crates/liyi/tests/golden.rs +++ b/crates/liyi/tests/golden.rs @@ -808,6 +808,34 @@ fn prompt_stale_unreviewed_spec_is_fixable() { assert_eq!(item["fix_command"], "liyi check --fix"); } +#[test] +fn prompt_req_changed() { + let (_tmp, root) = fixture_in_tmp("req_changed"); + let flags = CheckFlags { + fail_on_stale: false, + fail_on_unreviewed: false, + fail_on_req_changed: false, + fail_on_untracked: false, + }; + + let (diagnostics, exit_code) = run_check(&root, &[], false, false, &flags); + let output = liyi::prompt::build_prompt_output(&diagnostics, exit_code, &root); + + let group = output + .groups + .iter() + .find(|g| g.prompt_type == "req_changed") + .expect("expected req_changed group"); + + assert!(group.template.contains("liyi approve")); + assert_eq!(group.count, group.items.len()); + + let item = &group.items[0]; + assert_eq!(item["item"], "add"); + assert_eq!(item["requirement"], "no-overflow"); + assert_eq!(item["source_line"], 2); +} + // --------------------------------------------------------------------------- // =trivial sidecar sentinel tests // --------------------------------------------------------------------------- diff --git a/docs/prompt-mode-design.md b/docs/prompt-mode-design.md index d5a5691..8e8d7df 100644 --- a/docs/prompt-mode-design.md +++ b/docs/prompt-mode-design.md @@ -198,6 +198,7 @@ Option B is cleaner — keeps formatting concerns out of the core check logic. - `DiagnosticKind::Stale` → `stale_spec` (two templates: fixable vs manual) - `DiagnosticKind::Shifted` → `shifted_span` - `DiagnosticKind::Unreviewed` → `unreviewed_spec` +- `DiagnosticKind::ReqChanged` → `req_changed` A final `group_items()` pass groups them by `(type_name, template pointer)`, preserving insertion order. Error-class diagnostics (`ParseError`, `OrphanedSource`, etc.) are excluded from groups but still affect `exit_code`. @@ -209,7 +210,7 @@ A final `group_items()` pass groups them by `(type_name, template pointer)`, pre 1. `liyi check --prompt` on a fixture with gaps produces valid JSON matching `schema/prompt.schema.json`. 2. `liyi check --prompt` on a clean repo produces `{"groups": [], "exit_code": 0, ...}`. -3. The JSON includes groups for all six diagnostic types: `missing_requirement_spec`, `missing_related_edge`, `req_no_related`, `stale_spec`, `shifted_span`, and `unreviewed_spec`. +3. The JSON includes groups for all seven diagnostic types: `missing_requirement_spec`, `missing_related_edge`, `req_no_related`, `stale_spec`, `shifted_span`, `unreviewed_spec`, and `req_changed`. 4. `--prompt` will be mutually exclusive with `--json` (when implemented). 5. Exit code behavior is identical to default mode (respects all `--fail-on-*` flags). 6. When error-class diagnostics are present, `exit_code` is `2` even if `groups` is empty. @@ -232,7 +233,7 @@ A final `group_items()` pass groups them by `(type_name, template pointer)`, pre ## Resolved Questions -1. **Should `--prompt` include non-coverage diagnostics?** Yes — all actionable diagnostic types (Untracked, MissingRelatedEdge, ReqNoRelated, Stale, Shifted, Unreviewed) are included as groups. Error-class diagnostics are excluded but affect `exit_code`. +1. **Should `--prompt` include non-coverage diagnostics?** Yes — all actionable diagnostic types (Untracked, MissingRelatedEdge, ReqNoRelated, Stale, Shifted, Unreviewed, ReqChanged) are included as groups. Error-class diagnostics are excluded but affect `exit_code`. 2. **Should we include the actual requirement text in `missing_requirement_spec`?** Yes. The cognitive load inversion principle argues for including all context the tool already has, so agents need not re-read files. Added as optional `requirement_text` field. diff --git a/schema/prompt.schema.json b/schema/prompt.schema.json index 5d69bb1..a47fe37 100644 --- a/schema/prompt.schema.json +++ b/schema/prompt.schema.json @@ -51,7 +51,8 @@ "req_no_related", "stale_spec", "shifted_span", - "unreviewed_spec" + "unreviewed_spec", + "req_changed" ], "description": "Diagnostic type shared by all items in this group." }, @@ -103,9 +104,45 @@ { "if": { "properties": { "type": { "const": "unreviewed_spec" } }, "required": ["type"] }, "then": { "properties": { "items": { "items": { "$ref": "#/$defs/unreviewedSpecItem" } } } } + }, + { + "if": { "properties": { "type": { "const": "req_changed" } }, "required": ["type"] }, + "then": { "properties": { "items": { "items": { "$ref": "#/$defs/reqChangedItem" } } } } } ] }, + "reqChangedItem": { + "type": "object", + "required": ["item", "requirement", "source_file", "source_line", "expected_sidecar"], + "additionalProperties": false, + "properties": { + "item": { + "type": "string", + "description": "Name of the item whose related edge has a stale requirement hash (untrusted)." + }, + "requirement": { + "type": "string", + "description": "Name of the requirement that changed (untrusted)." + }, + "source_file": { + "type": "string", + "description": "Repo-relative path to the source file containing the item." + }, + "source_line": { + "type": "integer", + "minimum": 1, + "description": "1-indexed start line of the item in the source file." + }, + "expected_sidecar": { + "type": "string", + "description": "Repo-relative path to the sidecar file containing the itemSpec with the stale related edge." + }, + "intent_text": { + "type": "string", + "description": "Current intent text recorded in the sidecar, when available (untrusted)." + } + } + }, "missingRequirementSpecItem": { "type": "object", "required": ["requirement", "source_file", "annotation_line", "expected_sidecar"],