@grifhinz/logics-manager 2.2.0 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -1
- package/VERSION +1 -1
- package/clients/README.md +9 -0
- package/clients/shared-web/media/css/board.css +658 -0
- package/clients/shared-web/media/css/details.css +457 -0
- package/clients/shared-web/media/css/layout.css +123 -0
- package/clients/shared-web/media/css/toolbar.css +576 -0
- package/clients/shared-web/media/harnessApi.js +324 -0
- package/clients/shared-web/media/hostApi.js +213 -0
- package/clients/shared-web/media/hostApiContract.js +55 -0
- package/clients/shared-web/media/icon.png +0 -0
- package/clients/shared-web/media/layoutController.js +246 -0
- package/clients/shared-web/media/logics.svg +7 -0
- package/clients/shared-web/media/logicsModel.js +910 -0
- package/clients/shared-web/media/main.css +112 -0
- package/clients/shared-web/media/main.js +3 -0
- package/clients/shared-web/media/mainApp.js +1005 -0
- package/clients/shared-web/media/mainCore.js +604 -0
- package/clients/shared-web/media/mainInteractionHandlers.js +324 -0
- package/clients/shared-web/media/mainInteractions.js +378 -0
- package/clients/shared-web/media/renderBoard.js +3 -0
- package/clients/shared-web/media/renderBoardApp.js +1339 -0
- package/clients/shared-web/media/renderDetails.js +685 -0
- package/clients/shared-web/media/renderMarkdown.js +449 -0
- package/clients/shared-web/media/toolsPanelLayout.js +172 -0
- package/clients/shared-web/media/uiStatus.js +54 -0
- package/clients/shared-web/media/webviewChrome.js +405 -0
- package/clients/shared-web/media/webviewPersistence.js +116 -0
- package/clients/shared-web/media/webviewSelectors.js +491 -0
- package/clients/viewer/README.md +5 -0
- package/clients/viewer/browser-host.js +847 -0
- package/clients/viewer/index.html +237 -0
- package/clients/viewer/viewer.css +433 -0
- package/logics_manager/assist.py +9 -142
- package/logics_manager/assist_handoff.py +132 -0
- package/logics_manager/assist_surface.py +38 -0
- package/logics_manager/cli.py +78 -5
- package/logics_manager/flow.py +126 -24
- package/logics_manager/flow_evidence.py +63 -0
- package/logics_manager/update_check.py +138 -0
- package/logics_manager/viewer.py +533 -0
- package/package.json +12 -6
- package/pyproject.toml +1 -1
package/logics_manager/flow.py
CHANGED
|
@@ -9,6 +9,9 @@ from pathlib import Path
|
|
|
9
9
|
|
|
10
10
|
from .audit import audit_payload
|
|
11
11
|
from .cli_output import print_payload
|
|
12
|
+
from .flow_evidence import has_ac_proof as _has_ac_proof
|
|
13
|
+
from .flow_evidence import has_validation_evidence as _has_validation_evidence
|
|
14
|
+
from .flow_evidence import structured_validation_line as _structured_validation_line
|
|
12
15
|
from .index import index_payload
|
|
13
16
|
from .lint import expected_workflow_mermaid_signature, lint_payload
|
|
14
17
|
from .path_utils import ensure_relative_to
|
|
@@ -974,19 +977,6 @@ def _section_has_checked_checkbox(text: str, heading: str) -> bool:
|
|
|
974
977
|
return any("- [x]" in line.lower() for line in _section_lines(text.splitlines(), heading))
|
|
975
978
|
|
|
976
979
|
|
|
977
|
-
def _has_validation_evidence(text: str) -> bool:
|
|
978
|
-
for line in _section_lines(text.splitlines(), "Validation"):
|
|
979
|
-
stripped = line.strip()
|
|
980
|
-
if not stripped.startswith("- "):
|
|
981
|
-
continue
|
|
982
|
-
value = stripped[2:].strip().lower()
|
|
983
|
-
if not value or value.startswith("run `") or value.startswith("run the "):
|
|
984
|
-
continue
|
|
985
|
-
if any(marker in value for marker in ("pass", "ok", "validated", "verification", "regression")):
|
|
986
|
-
return True
|
|
987
|
-
return False
|
|
988
|
-
|
|
989
|
-
|
|
990
980
|
def _request_ac_ids(text: str) -> list[str]:
|
|
991
981
|
ids: list[str] = []
|
|
992
982
|
for line in _section_lines(text.splitlines(), "Acceptance criteria"):
|
|
@@ -996,11 +986,6 @@ def _request_ac_ids(text: str) -> list[str]:
|
|
|
996
986
|
return ids
|
|
997
987
|
|
|
998
988
|
|
|
999
|
-
def _has_ac_proof(text: str, ac_id: str) -> bool:
|
|
1000
|
-
upper = text.upper()
|
|
1001
|
-
return ac_id.upper() in upper and "proof:" in text.lower()
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
989
|
def _first_product_path(repo_root: Path, product_ref: str) -> Path | None:
|
|
1005
990
|
path = repo_root / "logics" / "product" / f"{product_ref}.md"
|
|
1006
991
|
return path if path.is_file() else None
|
|
@@ -1254,7 +1239,16 @@ def _request_ac_entries(request_path: Path) -> list[tuple[str, str]]:
|
|
|
1254
1239
|
return entries
|
|
1255
1240
|
|
|
1256
1241
|
|
|
1257
|
-
def
|
|
1242
|
+
def _ac_traceability_entry(ac_id: str, target: str, text: str, proof: str | None, proof_source: str | None) -> str:
|
|
1243
|
+
if proof and proof.strip():
|
|
1244
|
+
rendered = f"request-{ac_id} -> {target}. Proof: {proof.strip()}"
|
|
1245
|
+
if proof_source and proof_source.strip():
|
|
1246
|
+
rendered += f" Source: `{proof_source.strip()}`"
|
|
1247
|
+
return rendered
|
|
1248
|
+
return f"request-{ac_id} -> {target}. Evidence needed: {text}"
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def repair_ac_traceability_payload(repo_root: Path, source: str, *, dry_run: bool, proof: str | None = None, proof_source: str | None = None) -> dict[str, object]:
|
|
1258
1252
|
request_path = _resolve_workflow_source(repo_root, DOC_KINDS["request"], source)
|
|
1259
1253
|
request_ref = request_path.stem
|
|
1260
1254
|
ac_entries = _request_ac_entries(request_path)
|
|
@@ -1268,7 +1262,7 @@ def repair_ac_traceability_payload(repo_root: Path, source: str, *, dry_run: boo
|
|
|
1268
1262
|
for item_path in linked_items:
|
|
1269
1263
|
item_before = item_path.read_text(encoding="utf-8")
|
|
1270
1264
|
item_missing = [
|
|
1271
|
-
|
|
1265
|
+
_ac_traceability_entry(ac_id, "This backlog slice", text, proof, proof_source)
|
|
1272
1266
|
for ac_id, text in ac_entries
|
|
1273
1267
|
if not _has_ac_proof(item_before, ac_id)
|
|
1274
1268
|
]
|
|
@@ -1285,7 +1279,7 @@ def repair_ac_traceability_payload(repo_root: Path, source: str, *, dry_run: boo
|
|
|
1285
1279
|
for task_path in sorted(linked_task_paths):
|
|
1286
1280
|
task_before = task_path.read_text(encoding="utf-8")
|
|
1287
1281
|
task_missing = [
|
|
1288
|
-
|
|
1282
|
+
_ac_traceability_entry(ac_id, "This task", text, proof, proof_source)
|
|
1289
1283
|
for ac_id, text in ac_entries
|
|
1290
1284
|
if not _has_ac_proof(task_before, ac_id)
|
|
1291
1285
|
]
|
|
@@ -1296,6 +1290,8 @@ def repair_ac_traceability_payload(repo_root: Path, source: str, *, dry_run: boo
|
|
|
1296
1290
|
"command": "repair",
|
|
1297
1291
|
"kind": "ac-traceability",
|
|
1298
1292
|
"source": request_path.relative_to(repo_root).as_posix(),
|
|
1293
|
+
"proof_recorded": bool(proof and proof.strip()),
|
|
1294
|
+
"proof_source": proof_source.strip() if proof_source and proof_source.strip() else None,
|
|
1299
1295
|
"changed_files": sorted(path.as_posix() for path in changed_paths),
|
|
1300
1296
|
"dry_run": dry_run,
|
|
1301
1297
|
}
|
|
@@ -1423,7 +1419,7 @@ def _build_native_request_doc(repo_root: Path, planned_ref: str, title: str, arg
|
|
|
1423
1419
|
references = [
|
|
1424
1420
|
"`logics_manager/flow.py`",
|
|
1425
1421
|
"`logics_manager/assist.py`",
|
|
1426
|
-
"`
|
|
1422
|
+
"`tests/python/test_logics_manager_cli.py`",
|
|
1427
1423
|
]
|
|
1428
1424
|
content = "\n".join(
|
|
1429
1425
|
[
|
|
@@ -1862,6 +1858,7 @@ def _build_native_product_brief(
|
|
|
1862
1858
|
related_backlog = f"`{backlog_ref}`" if backlog_ref else "(none yet)"
|
|
1863
1859
|
related_task = f"`{task_ref}`" if task_ref else "(none yet)"
|
|
1864
1860
|
related_architecture = ", ".join(f"`{item}`" for item in architecture_refs) if architecture_refs else "(none yet)"
|
|
1861
|
+
signature_slug = _slugify(title) or "product-brief"
|
|
1865
1862
|
content = "\n".join(
|
|
1866
1863
|
[
|
|
1867
1864
|
f"## {ref} - {title}",
|
|
@@ -1876,6 +1873,15 @@ def _build_native_product_brief(
|
|
|
1876
1873
|
"# Overview",
|
|
1877
1874
|
f"Logics should keep a single, predictable product surface for {title.lower()}.",
|
|
1878
1875
|
"",
|
|
1876
|
+
"```mermaid",
|
|
1877
|
+
"%% logics-kind: product",
|
|
1878
|
+
f"%% logics-signature: product|{signature_slug}|generated",
|
|
1879
|
+
"flowchart TD",
|
|
1880
|
+
" Need[Product need] --> Scope[Scope and guardrails]",
|
|
1881
|
+
" Scope --> Decisions[Key decisions]",
|
|
1882
|
+
" Decisions --> Signals[Success signals]",
|
|
1883
|
+
"```",
|
|
1884
|
+
"",
|
|
1879
1885
|
"# Goals",
|
|
1880
1886
|
"- Keep the operator experience bounded and easy to reason about.",
|
|
1881
1887
|
"- Preserve the CLI as the canonical workflow entrypoint.",
|
|
@@ -2235,24 +2241,30 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2235
2241
|
|
|
2236
2242
|
repair_gates = repair_sub.add_parser("gates", help="Check task and linked request gate checkboxes.")
|
|
2237
2243
|
repair_gates.add_argument("source")
|
|
2244
|
+
repair_gates.add_argument("--verify-closeout")
|
|
2238
2245
|
repair_gates.add_argument("--format", choices=("text", "json"), default="text")
|
|
2239
2246
|
repair_gates.add_argument("--dry-run", action="store_true")
|
|
2240
2247
|
repair_gates.set_defaults(func=cmd_repair_gates)
|
|
2241
2248
|
|
|
2242
2249
|
repair_ac = repair_sub.add_parser("ac-traceability", help="Add missing AC traceability entries.")
|
|
2243
2250
|
repair_ac.add_argument("source")
|
|
2251
|
+
repair_ac.add_argument("--proof")
|
|
2252
|
+
repair_ac.add_argument("--proof-source")
|
|
2253
|
+
repair_ac.add_argument("--verify-closeout")
|
|
2244
2254
|
repair_ac.add_argument("--format", choices=("text", "json"), default="text")
|
|
2245
2255
|
repair_ac.add_argument("--dry-run", action="store_true")
|
|
2246
2256
|
repair_ac.set_defaults(func=cmd_repair_ac_traceability)
|
|
2247
2257
|
|
|
2248
2258
|
repair_links = repair_sub.add_parser("links", help="Repair linked backlog/product references for a task.")
|
|
2249
2259
|
repair_links.add_argument("source")
|
|
2260
|
+
repair_links.add_argument("--verify-closeout")
|
|
2250
2261
|
repair_links.add_argument("--format", choices=("text", "json"), default="text")
|
|
2251
2262
|
repair_links.add_argument("--dry-run", action="store_true")
|
|
2252
2263
|
repair_links.set_defaults(func=cmd_repair_links)
|
|
2253
2264
|
|
|
2254
2265
|
repair_mermaid = repair_sub.add_parser("mermaid", help="Insert or refresh workflow Mermaid signatures.")
|
|
2255
2266
|
repair_mermaid.add_argument("--refs", nargs="+", required=True)
|
|
2267
|
+
repair_mermaid.add_argument("--verify-closeout")
|
|
2256
2268
|
repair_mermaid.add_argument("--format", choices=("text", "json"), default="text")
|
|
2257
2269
|
repair_mermaid.add_argument("--dry-run", action="store_true")
|
|
2258
2270
|
repair_mermaid.set_defaults(func=cmd_repair_mermaid)
|
|
@@ -2260,6 +2272,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2260
2272
|
closeout_parser = sub.add_parser("closeout", help="Append validation, repair deterministic gaps, finish, and optionally validate/index.")
|
|
2261
2273
|
closeout_parser.add_argument("source")
|
|
2262
2274
|
closeout_parser.add_argument("--validation", action="append", default=[])
|
|
2275
|
+
closeout_parser.add_argument("--validation-command")
|
|
2276
|
+
closeout_parser.add_argument("--validation-result", default="passed")
|
|
2277
|
+
closeout_parser.add_argument("--validation-note")
|
|
2263
2278
|
closeout_parser.add_argument("--index", action="store_true")
|
|
2264
2279
|
closeout_parser.add_argument("--lint", action="store_true")
|
|
2265
2280
|
closeout_parser.add_argument("--audit", action="store_true")
|
|
@@ -2601,30 +2616,73 @@ def _print_repair_payload(payload: dict[str, object], output_format: str) -> Non
|
|
|
2601
2616
|
print(f"- {rel_path}")
|
|
2602
2617
|
|
|
2603
2618
|
|
|
2619
|
+
REPAIR_VERIFY_CODES = {
|
|
2620
|
+
"gates": {"task_gate_unchecked", "task_missing_done_gate", "request_dor_unchecked"},
|
|
2621
|
+
"ac-traceability": {"ac_missing_item_traceability", "ac_missing_task_traceability"},
|
|
2622
|
+
"links": {"backlog_missing_task_link", "companion_link_missing"},
|
|
2623
|
+
"mermaid": {"mermaid_signature_stale"},
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
|
|
2627
|
+
def _repair_verify_snapshot(repo_root: Path, source: str | None, dry_run: bool) -> dict[str, str]:
|
|
2628
|
+
if dry_run or not source:
|
|
2629
|
+
return {}
|
|
2630
|
+
preflight = validate_closeout_payload(repo_root, source)
|
|
2631
|
+
return _snapshot_existing_files(repo_root, list(preflight.get("related_paths", [])))
|
|
2632
|
+
|
|
2633
|
+
|
|
2634
|
+
def _finalize_repair_verify(repo_root: Path, payload: dict[str, object], source: str | None, snapshot: dict[str, str]) -> dict[str, object]:
|
|
2635
|
+
if not source or payload.get("dry_run"):
|
|
2636
|
+
return payload
|
|
2637
|
+
preflight = validate_closeout_payload(repo_root, source)
|
|
2638
|
+
payload["preflight"] = preflight
|
|
2639
|
+
relevant_codes = REPAIR_VERIFY_CODES.get(str(payload.get("kind")), set())
|
|
2640
|
+
remaining_relevant = [issue for issue in preflight.get("issues", []) if issue.get("code") in relevant_codes]
|
|
2641
|
+
payload["rolled_back"] = False
|
|
2642
|
+
if remaining_relevant and snapshot:
|
|
2643
|
+
payload["attempted_changed_files"] = payload.get("changed_files", [])
|
|
2644
|
+
_restore_file_snapshot(repo_root, snapshot)
|
|
2645
|
+
payload["changed_files"] = []
|
|
2646
|
+
payload["rolled_back"] = True
|
|
2647
|
+
payload["rollback_reason"] = "repair verification left relevant closeout issues"
|
|
2648
|
+
payload["remaining_relevant_issues"] = remaining_relevant
|
|
2649
|
+
return payload
|
|
2650
|
+
|
|
2651
|
+
|
|
2604
2652
|
def cmd_repair_gates(args: argparse.Namespace) -> dict[str, object]:
|
|
2605
2653
|
repo_root = _find_repo_root(Path.cwd())
|
|
2654
|
+
verify_source = args.verify_closeout or args.source
|
|
2655
|
+
snapshot = _repair_verify_snapshot(repo_root, verify_source, args.dry_run)
|
|
2606
2656
|
payload = repair_gates_payload(repo_root, args.source, dry_run=args.dry_run)
|
|
2657
|
+
payload = _finalize_repair_verify(repo_root, payload, verify_source, snapshot)
|
|
2607
2658
|
_print_repair_payload(payload, args.format)
|
|
2608
2659
|
return payload
|
|
2609
2660
|
|
|
2610
2661
|
|
|
2611
2662
|
def cmd_repair_ac_traceability(args: argparse.Namespace) -> dict[str, object]:
|
|
2612
2663
|
repo_root = _find_repo_root(Path.cwd())
|
|
2613
|
-
|
|
2664
|
+
snapshot = _repair_verify_snapshot(repo_root, args.verify_closeout, args.dry_run)
|
|
2665
|
+
payload = repair_ac_traceability_payload(repo_root, args.source, dry_run=args.dry_run, proof=args.proof, proof_source=args.proof_source)
|
|
2666
|
+
payload = _finalize_repair_verify(repo_root, payload, args.verify_closeout, snapshot)
|
|
2614
2667
|
_print_repair_payload(payload, args.format)
|
|
2615
2668
|
return payload
|
|
2616
2669
|
|
|
2617
2670
|
|
|
2618
2671
|
def cmd_repair_links(args: argparse.Namespace) -> dict[str, object]:
|
|
2619
2672
|
repo_root = _find_repo_root(Path.cwd())
|
|
2673
|
+
verify_source = args.verify_closeout or args.source
|
|
2674
|
+
snapshot = _repair_verify_snapshot(repo_root, verify_source, args.dry_run)
|
|
2620
2675
|
payload = repair_links_payload(repo_root, args.source, dry_run=args.dry_run)
|
|
2676
|
+
payload = _finalize_repair_verify(repo_root, payload, verify_source, snapshot)
|
|
2621
2677
|
_print_repair_payload(payload, args.format)
|
|
2622
2678
|
return payload
|
|
2623
2679
|
|
|
2624
2680
|
|
|
2625
2681
|
def cmd_repair_mermaid(args: argparse.Namespace) -> dict[str, object]:
|
|
2626
2682
|
repo_root = _find_repo_root(Path.cwd())
|
|
2683
|
+
snapshot = _repair_verify_snapshot(repo_root, args.verify_closeout, args.dry_run)
|
|
2627
2684
|
payload = repair_mermaid_payload(repo_root, args.refs, dry_run=args.dry_run)
|
|
2685
|
+
payload = _finalize_repair_verify(repo_root, payload, args.verify_closeout, snapshot)
|
|
2628
2686
|
_print_repair_payload(payload, args.format)
|
|
2629
2687
|
return payload
|
|
2630
2688
|
|
|
@@ -2642,11 +2700,43 @@ def _closeout_refs(repo_root: Path, task_path: Path) -> list[str]:
|
|
|
2642
2700
|
return sorted(refs)
|
|
2643
2701
|
|
|
2644
2702
|
|
|
2645
|
-
def
|
|
2703
|
+
def _snapshot_existing_files(repo_root: Path, rel_paths: list[str]) -> dict[str, str]:
|
|
2704
|
+
snapshot: dict[str, str] = {}
|
|
2705
|
+
for rel_path in rel_paths:
|
|
2706
|
+
path = repo_root / rel_path
|
|
2707
|
+
if path.is_file():
|
|
2708
|
+
snapshot[rel_path] = path.read_text(encoding="utf-8")
|
|
2709
|
+
return snapshot
|
|
2710
|
+
|
|
2711
|
+
|
|
2712
|
+
def _restore_file_snapshot(repo_root: Path, snapshot: dict[str, str]) -> None:
|
|
2713
|
+
for rel_path, content in snapshot.items():
|
|
2714
|
+
path = repo_root / rel_path
|
|
2715
|
+
path.write_text(content, encoding="utf-8")
|
|
2716
|
+
|
|
2717
|
+
|
|
2718
|
+
def closeout_payload(
|
|
2719
|
+
repo_root: Path,
|
|
2720
|
+
source: str,
|
|
2721
|
+
*,
|
|
2722
|
+
validations: list[str],
|
|
2723
|
+
run_index: bool,
|
|
2724
|
+
run_lint: bool,
|
|
2725
|
+
run_audit: bool,
|
|
2726
|
+
dry_run: bool,
|
|
2727
|
+
validation_command: str | None = None,
|
|
2728
|
+
validation_result: str = "passed",
|
|
2729
|
+
validation_note: str | None = None,
|
|
2730
|
+
) -> dict[str, object]:
|
|
2646
2731
|
task_path = _resolve_workflow_source(repo_root, DOC_KINDS["task"], source)
|
|
2647
2732
|
task_ref = task_path.stem
|
|
2648
2733
|
changed_files: set[str] = set()
|
|
2649
2734
|
steps: list[dict[str, object]] = []
|
|
2735
|
+
initial_preflight = validate_closeout_payload(repo_root, task_ref)
|
|
2736
|
+
rollback_snapshot = {} if dry_run else _snapshot_existing_files(repo_root, list(initial_preflight.get("related_paths", [])))
|
|
2737
|
+
|
|
2738
|
+
if validation_command and validation_command.strip():
|
|
2739
|
+
validations = [*validations, _structured_validation_line(validation_command, validation_result, validation_note)]
|
|
2650
2740
|
|
|
2651
2741
|
for validation in validations:
|
|
2652
2742
|
if validation and validation.strip():
|
|
@@ -2678,12 +2768,20 @@ def closeout_payload(repo_root: Path, source: str, *, validations: list[str], ru
|
|
|
2678
2768
|
|
|
2679
2769
|
preflight = validate_closeout_payload(repo_root, task_ref)
|
|
2680
2770
|
if preflight["issues"]:
|
|
2771
|
+
rolled_back = False
|
|
2772
|
+
attempted_changed_files = sorted(changed_files)
|
|
2773
|
+
if rollback_snapshot:
|
|
2774
|
+
_restore_file_snapshot(repo_root, rollback_snapshot)
|
|
2775
|
+
changed_files.clear()
|
|
2776
|
+
rolled_back = True
|
|
2681
2777
|
return {
|
|
2682
2778
|
"command": "closeout",
|
|
2683
2779
|
"ok": False,
|
|
2684
2780
|
"source": task_path.relative_to(repo_root).as_posix(),
|
|
2685
2781
|
"changed_files": sorted(changed_files),
|
|
2782
|
+
"attempted_changed_files": attempted_changed_files,
|
|
2686
2783
|
"preflight": preflight,
|
|
2784
|
+
"rolled_back": rolled_back,
|
|
2687
2785
|
"steps": steps,
|
|
2688
2786
|
"dry_run": dry_run,
|
|
2689
2787
|
}
|
|
@@ -2736,6 +2834,7 @@ def closeout_payload(repo_root: Path, source: str, *, validations: list[str], ru
|
|
|
2736
2834
|
"lint": lint_result,
|
|
2737
2835
|
"audit": audit_result,
|
|
2738
2836
|
"steps": steps,
|
|
2837
|
+
"rolled_back": False,
|
|
2739
2838
|
"dry_run": dry_run,
|
|
2740
2839
|
}
|
|
2741
2840
|
|
|
@@ -2746,6 +2845,9 @@ def cmd_closeout(args: argparse.Namespace) -> dict[str, object]:
|
|
|
2746
2845
|
repo_root,
|
|
2747
2846
|
args.source,
|
|
2748
2847
|
validations=args.validation or [],
|
|
2848
|
+
validation_command=args.validation_command,
|
|
2849
|
+
validation_result=args.validation_result,
|
|
2850
|
+
validation_note=args.validation_note,
|
|
2749
2851
|
run_index=args.index,
|
|
2750
2852
|
run_lint=args.lint,
|
|
2751
2853
|
run_audit=args.audit,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import date
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def section_lines(lines: list[str], heading: str) -> list[str]:
|
|
8
|
+
start_idx = None
|
|
9
|
+
target = heading.strip().lower()
|
|
10
|
+
for idx, line in enumerate(lines):
|
|
11
|
+
if line.startswith("# ") and line[2:].strip().lower() == target:
|
|
12
|
+
start_idx = idx + 1
|
|
13
|
+
break
|
|
14
|
+
if start_idx is None:
|
|
15
|
+
return []
|
|
16
|
+
out: list[str] = []
|
|
17
|
+
for idx in range(start_idx, len(lines)):
|
|
18
|
+
line = lines[idx]
|
|
19
|
+
if line.startswith("# "):
|
|
20
|
+
break
|
|
21
|
+
out.append(line)
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def has_validation_evidence(text: str) -> bool:
|
|
26
|
+
concrete_ok_context = ("lint", "audit", "test", "pytest", "npm", "ci", "coverage", "smoke", "package")
|
|
27
|
+
invalid_markers = ("...", "todo", "tbd", "pending", "needs ", "need ", "not ok", "failed", "failure", "failing")
|
|
28
|
+
for line in section_lines(text.splitlines(), "Validation"):
|
|
29
|
+
stripped = line.strip()
|
|
30
|
+
if not stripped.startswith("- "):
|
|
31
|
+
continue
|
|
32
|
+
value = stripped[2:].strip().lower()
|
|
33
|
+
if not value or value.startswith("run `") or value.startswith("run the "):
|
|
34
|
+
continue
|
|
35
|
+
if any(marker in value for marker in invalid_markers):
|
|
36
|
+
continue
|
|
37
|
+
if "command:" in value and "result:" in value and ("date:" in value or "session:" in value):
|
|
38
|
+
result_match = re.search(r"\bresult:\s*([^|,;]+)", value)
|
|
39
|
+
result = result_match.group(1).strip() if result_match else ""
|
|
40
|
+
if result in {"pass", "passed", "ok", "success", "succeeded"}:
|
|
41
|
+
return True
|
|
42
|
+
if any(marker in value for marker in ("pass", "validated", "verified", "verification", "regression")):
|
|
43
|
+
return True
|
|
44
|
+
if "ok" in value and any(marker in value for marker in concrete_ok_context):
|
|
45
|
+
return True
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def has_ac_proof(text: str, ac_id: str) -> bool:
|
|
50
|
+
upper = text.upper()
|
|
51
|
+
return ac_id.upper() in upper and "proof:" in text.lower()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def structured_validation_line(command: str, result: str, note: str | None) -> str:
|
|
55
|
+
normalized_result = result.strip().lower() or "passed"
|
|
56
|
+
parts = [
|
|
57
|
+
f"command: `{command.strip()}`",
|
|
58
|
+
f"result: {normalized_result}",
|
|
59
|
+
f"date: {date.today().isoformat()}",
|
|
60
|
+
]
|
|
61
|
+
if note and note.strip():
|
|
62
|
+
parts.append(f"note: {note.strip()}")
|
|
63
|
+
return " | ".join(parts)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
from urllib.error import URLError
|
|
10
|
+
from urllib.request import urlopen
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
NPM_LATEST_URL = "https://registry.npmjs.org/@grifhinz%2Flogics-manager/latest"
|
|
14
|
+
DISABLE_ENV = "LOGICS_MANAGER_NO_UPDATE_CHECK"
|
|
15
|
+
CHECK_INTERVAL_SECONDS = 24 * 60 * 60
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class UpdateInfo:
|
|
20
|
+
current_version: str
|
|
21
|
+
latest_version: str | None
|
|
22
|
+
update_available: bool
|
|
23
|
+
checked_at: int | None
|
|
24
|
+
update_command: str
|
|
25
|
+
source: str
|
|
26
|
+
|
|
27
|
+
def to_payload(self) -> dict[str, Any]:
|
|
28
|
+
return {
|
|
29
|
+
"currentVersion": self.current_version,
|
|
30
|
+
"latestVersion": self.latest_version,
|
|
31
|
+
"updateAvailable": self.update_available,
|
|
32
|
+
"checkedAt": self.checked_at,
|
|
33
|
+
"updateCommand": self.update_command,
|
|
34
|
+
"source": self.source,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_version(value: str | None) -> tuple[int, int, int, str]:
|
|
39
|
+
raw = (value or "").strip().lstrip("v")
|
|
40
|
+
parts = raw.split(".", 3)
|
|
41
|
+
numeric: list[int] = []
|
|
42
|
+
suffix = ""
|
|
43
|
+
for index, part in enumerate(parts[:3]):
|
|
44
|
+
digits = ""
|
|
45
|
+
rest = ""
|
|
46
|
+
for char in part:
|
|
47
|
+
if char.isdigit() and not rest:
|
|
48
|
+
digits += char
|
|
49
|
+
else:
|
|
50
|
+
rest += char
|
|
51
|
+
numeric.append(int(digits or "0"))
|
|
52
|
+
if rest:
|
|
53
|
+
suffix = ".".join([rest, *parts[index + 1 :]])
|
|
54
|
+
break
|
|
55
|
+
while len(numeric) < 3:
|
|
56
|
+
numeric.append(0)
|
|
57
|
+
return numeric[0], numeric[1], numeric[2], suffix
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_newer_version(latest: str | None, current: str | None) -> bool:
|
|
61
|
+
latest_tuple = _parse_version(latest)
|
|
62
|
+
current_tuple = _parse_version(current)
|
|
63
|
+
return latest_tuple[:3] > current_tuple[:3]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def update_cache_path() -> Path:
|
|
67
|
+
override = os.environ.get("LOGICS_MANAGER_UPDATE_CACHE")
|
|
68
|
+
if override:
|
|
69
|
+
return Path(override)
|
|
70
|
+
cache_root = os.environ.get("XDG_CACHE_HOME")
|
|
71
|
+
if cache_root:
|
|
72
|
+
return Path(cache_root) / "logics-manager" / "update-check.json"
|
|
73
|
+
return Path.home() / ".cache" / "logics-manager" / "update-check.json"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _read_cache(path: Path, now: int) -> dict[str, Any] | None:
|
|
77
|
+
try:
|
|
78
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
79
|
+
except (OSError, json.JSONDecodeError):
|
|
80
|
+
return None
|
|
81
|
+
checked_at = int(payload.get("checked_at") or 0)
|
|
82
|
+
if checked_at <= 0 or now - checked_at > CHECK_INTERVAL_SECONDS:
|
|
83
|
+
return None
|
|
84
|
+
return payload if isinstance(payload, dict) else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _write_cache(path: Path, payload: dict[str, Any]) -> None:
|
|
88
|
+
try:
|
|
89
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
path.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8")
|
|
91
|
+
except OSError:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def fetch_latest_npm_version(*, timeout: float = 0.75, opener: Callable[..., Any] = urlopen) -> str | None:
|
|
96
|
+
try:
|
|
97
|
+
with opener(NPM_LATEST_URL, timeout=timeout) as response:
|
|
98
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
99
|
+
except (OSError, URLError, TimeoutError, json.JSONDecodeError, ValueError):
|
|
100
|
+
return None
|
|
101
|
+
version = payload.get("version") if isinstance(payload, dict) else None
|
|
102
|
+
return version.strip() if isinstance(version, str) and version.strip() else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_update_info(
|
|
106
|
+
current_version: str,
|
|
107
|
+
*,
|
|
108
|
+
cache_path: Path | None = None,
|
|
109
|
+
now: int | None = None,
|
|
110
|
+
fetch_latest: Callable[[], str | None] | None = None,
|
|
111
|
+
) -> UpdateInfo:
|
|
112
|
+
now_value = int(time.time() if now is None else now)
|
|
113
|
+
path = cache_path or update_cache_path()
|
|
114
|
+
cached = _read_cache(path, now_value)
|
|
115
|
+
latest = str(cached.get("latest_version") or "") if cached else ""
|
|
116
|
+
if not latest:
|
|
117
|
+
latest = (fetch_latest or fetch_latest_npm_version)() or ""
|
|
118
|
+
_write_cache(path, {"checked_at": now_value, "latest_version": latest})
|
|
119
|
+
return UpdateInfo(
|
|
120
|
+
current_version=current_version,
|
|
121
|
+
latest_version=latest or None,
|
|
122
|
+
update_available=is_newer_version(latest, current_version),
|
|
123
|
+
checked_at=now_value,
|
|
124
|
+
update_command="logics-manager self-update",
|
|
125
|
+
source="npm",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_update_notice(current_version: str) -> str | None:
|
|
130
|
+
if os.environ.get(DISABLE_ENV):
|
|
131
|
+
return None
|
|
132
|
+
info = get_update_info(current_version)
|
|
133
|
+
if not info.update_available or not info.latest_version:
|
|
134
|
+
return None
|
|
135
|
+
return (
|
|
136
|
+
f"logics-manager {info.latest_version} is available "
|
|
137
|
+
f"(current {info.current_version}). Run `{info.update_command}` to update."
|
|
138
|
+
)
|