dotscope 0.1.0__py3-none-any.whl

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.
Files changed (114) hide show
  1. dotscope/.scope +63 -0
  2. dotscope/__init__.py +3 -0
  3. dotscope/absorber.py +390 -0
  4. dotscope/assertions.py +128 -0
  5. dotscope/ast_analyzer.py +2 -0
  6. dotscope/backtest.py +2 -0
  7. dotscope/bench.py +141 -0
  8. dotscope/budget.py +3 -0
  9. dotscope/cache.py +2 -0
  10. dotscope/check/__init__.py +1 -0
  11. dotscope/check/acknowledge.py +2 -0
  12. dotscope/check/checker.py +3 -0
  13. dotscope/check/checks/__init__.py +1 -0
  14. dotscope/check/checks/antipattern.py +2 -0
  15. dotscope/check/checks/boundary.py +2 -0
  16. dotscope/check/checks/contracts.py +3 -0
  17. dotscope/check/checks/direction.py +2 -0
  18. dotscope/check/checks/intent.py +2 -0
  19. dotscope/check/checks/stability.py +2 -0
  20. dotscope/check/constraints.py +2 -0
  21. dotscope/check/models.py +15 -0
  22. dotscope/cli.py +1447 -0
  23. dotscope/composer.py +147 -0
  24. dotscope/constants.py +45 -0
  25. dotscope/context.py +60 -0
  26. dotscope/counterfactual.py +180 -0
  27. dotscope/debug.py +220 -0
  28. dotscope/discovery.py +104 -0
  29. dotscope/formatter.py +157 -0
  30. dotscope/graph.py +3 -0
  31. dotscope/health.py +212 -0
  32. dotscope/help.py +204 -0
  33. dotscope/history.py +6 -0
  34. dotscope/hooks.py +2 -0
  35. dotscope/ingest.py +858 -0
  36. dotscope/intent.py +618 -0
  37. dotscope/lessons.py +223 -0
  38. dotscope/matcher.py +104 -0
  39. dotscope/mcp_server.py +1081 -0
  40. dotscope/models/.scope +45 -0
  41. dotscope/models/__init__.py +7 -0
  42. dotscope/models/core.py +288 -0
  43. dotscope/models/history.py +73 -0
  44. dotscope/models/intent.py +213 -0
  45. dotscope/models/passes.py +58 -0
  46. dotscope/models/state.py +250 -0
  47. dotscope/models.py +9 -0
  48. dotscope/near_miss.py +3 -0
  49. dotscope/onboarding.py +2 -0
  50. dotscope/parser.py +387 -0
  51. dotscope/passes/.scope +105 -0
  52. dotscope/passes/__init__.py +1 -0
  53. dotscope/passes/ast_analyzer.py +508 -0
  54. dotscope/passes/backtest.py +198 -0
  55. dotscope/passes/budget_allocator.py +164 -0
  56. dotscope/passes/convention_compliance.py +40 -0
  57. dotscope/passes/convention_discovery.py +247 -0
  58. dotscope/passes/convention_parser.py +223 -0
  59. dotscope/passes/graph_builder.py +299 -0
  60. dotscope/passes/history_miner.py +336 -0
  61. dotscope/passes/incremental.py +149 -0
  62. dotscope/passes/lang/__init__.py +38 -0
  63. dotscope/passes/lang/_base.py +20 -0
  64. dotscope/passes/lang/_treesitter.py +93 -0
  65. dotscope/passes/lang/go.py +333 -0
  66. dotscope/passes/lang/javascript.py +348 -0
  67. dotscope/passes/lazy.py +152 -0
  68. dotscope/passes/semantic_diff.py +160 -0
  69. dotscope/passes/sentinel/__init__.py +1 -0
  70. dotscope/passes/sentinel/acknowledge.py +222 -0
  71. dotscope/passes/sentinel/checker.py +383 -0
  72. dotscope/passes/sentinel/checks/__init__.py +1 -0
  73. dotscope/passes/sentinel/checks/antipattern.py +84 -0
  74. dotscope/passes/sentinel/checks/boundary.py +46 -0
  75. dotscope/passes/sentinel/checks/contracts.py +148 -0
  76. dotscope/passes/sentinel/checks/convention.py +54 -0
  77. dotscope/passes/sentinel/checks/direction.py +71 -0
  78. dotscope/passes/sentinel/checks/intent.py +207 -0
  79. dotscope/passes/sentinel/checks/stability.py +66 -0
  80. dotscope/passes/sentinel/checks/voice.py +108 -0
  81. dotscope/passes/sentinel/constraints.py +472 -0
  82. dotscope/passes/sentinel/line_filter.py +88 -0
  83. dotscope/passes/sentinel/models.py +15 -0
  84. dotscope/passes/virtual.py +239 -0
  85. dotscope/passes/voice.py +162 -0
  86. dotscope/passes/voice_defaults.py +28 -0
  87. dotscope/passes/voice_discovery.py +245 -0
  88. dotscope/paths.py +32 -0
  89. dotscope/progress.py +44 -0
  90. dotscope/regression.py +147 -0
  91. dotscope/resolver.py +203 -0
  92. dotscope/scanner.py +246 -0
  93. dotscope/sessions.py +2 -0
  94. dotscope/storage/.scope +64 -0
  95. dotscope/storage/__init__.py +1 -0
  96. dotscope/storage/cache.py +114 -0
  97. dotscope/storage/claude_hooks.py +119 -0
  98. dotscope/storage/git_hooks.py +277 -0
  99. dotscope/storage/incremental_state.py +61 -0
  100. dotscope/storage/mcp_config.py +98 -0
  101. dotscope/storage/near_miss.py +183 -0
  102. dotscope/storage/onboarding.py +150 -0
  103. dotscope/storage/session_manager.py +195 -0
  104. dotscope/storage/timing.py +84 -0
  105. dotscope/timing.py +2 -0
  106. dotscope/tokens.py +53 -0
  107. dotscope/utility.py +123 -0
  108. dotscope/virtual.py +3 -0
  109. dotscope/visibility.py +664 -0
  110. dotscope-0.1.0.dist-info/METADATA +50 -0
  111. dotscope-0.1.0.dist-info/RECORD +114 -0
  112. dotscope-0.1.0.dist-info/WHEEL +4 -0
  113. dotscope-0.1.0.dist-info/entry_points.txt +3 -0
  114. dotscope-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,152 @@
1
+ """Lazy ingest: generate a single scope on demand when resolve can't find one.
2
+
3
+ When an agent (or CLI) resolves a module that hasn't been ingested yet,
4
+ this module builds a minimal scope from a partial graph and filtered history.
5
+ Full ingest fills in transitive dependencies and cross-module contracts later.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from ..absorber import absorb_docs
14
+ from ..context import parse_context
15
+ from ..history import HistoryAnalysis, analyze_history
16
+ from ..models.core import ScopeConfig
17
+ from ..models.passes import PlannedScope
18
+ from ..parser import serialize_scope
19
+ from ..progress import ProgressEmitter
20
+ from ..tokens import estimate_scope_tokens
21
+
22
+
23
+ def lazy_ingest_module(
24
+ root: str,
25
+ module_name: str,
26
+ quiet: bool = True,
27
+ ) -> Optional[ScopeConfig]:
28
+ """Ingest a single module on demand.
29
+
30
+ Returns the ScopeConfig if successful, None if module dir doesn't exist.
31
+ """
32
+ module_name = module_name.rstrip("/")
33
+ module_path = os.path.join(root, module_name)
34
+ if not os.path.isdir(module_path):
35
+ return None
36
+
37
+ progress = ProgressEmitter(quiet=quiet)
38
+
39
+ # Collect module files
40
+ from .graph_builder import _collect_source_files, build_partial_graph
41
+ all_files = _collect_source_files(root)
42
+ module_files = [
43
+ (rel, lang) for rel, lang in all_files
44
+ if rel.startswith(module_name + "/") or rel.startswith(module_name + os.sep)
45
+ ]
46
+ if not module_files:
47
+ return None
48
+
49
+ # Partial graph: module files + one level of imports
50
+ progress.start(f"lazy ingest {module_name}/ (graph)")
51
+ graph = build_partial_graph(root, module_files)
52
+ progress.finish(f"{len(graph.files)} files")
53
+
54
+ # Filtered history: recent commits touching this module
55
+ progress.start(f"lazy ingest {module_name}/ (history)")
56
+ history = HistoryAnalysis()
57
+ try:
58
+ history = analyze_history(
59
+ root, max_commits=50, paths=[module_name + "/"],
60
+ )
61
+ except Exception:
62
+ pass
63
+ progress.finish(f"{history.commits_analyzed} commits")
64
+
65
+ # Synthesize one scope
66
+ scope_path = os.path.join(root, module_name, ".scope")
67
+ includes = [module_name + "/"]
68
+
69
+ # Add cross-module imports from the partial graph
70
+ for path, node in graph.files.items():
71
+ if path.startswith(module_name + "/"):
72
+ for imp in node.imports:
73
+ if not imp.startswith(module_name + "/") and imp not in includes:
74
+ includes.append(imp)
75
+
76
+ # Context from history
77
+ context_parts = []
78
+ if history.implicit_contracts:
79
+ context_parts.append("## Implicit Contracts (from git history)")
80
+ for ic in history.implicit_contracts[:5]:
81
+ context_parts.append(f"- {ic.description}")
82
+
83
+ # Stability
84
+ stability_lines = []
85
+ for rel, _ in module_files:
86
+ fh = history.file_histories.get(rel)
87
+ if fh and fh.stability and fh.commit_count >= 3:
88
+ stability_lines.append(
89
+ f"- {os.path.basename(rel)}: {fh.stability} ({fh.commit_count} commits)"
90
+ )
91
+ if stability_lines:
92
+ context_parts.append("## Stability")
93
+ context_parts.extend(stability_lines[:10])
94
+
95
+ if not context_parts:
96
+ context_parts.append(
97
+ f"{module_name} module — {len(module_files)} files."
98
+ )
99
+
100
+ context = parse_context("\n".join(context_parts))
101
+
102
+ # Tags
103
+ tags = [module_name.lower()]
104
+
105
+ # Token estimate
106
+ full_paths = [os.path.join(root, rel) for rel, _ in module_files]
107
+ token_est = estimate_scope_tokens(full_paths)
108
+
109
+ config = ScopeConfig(
110
+ path=scope_path,
111
+ description=f"{module_name} module ({len(module_files)} files)",
112
+ includes=includes,
113
+ excludes=[f"{module_name}/__pycache__/", "*.pyc"],
114
+ context=context,
115
+ related=[],
116
+ owners=[],
117
+ tags=tags,
118
+ tokens_estimate=token_est,
119
+ )
120
+
121
+ # Write scope file
122
+ os.makedirs(os.path.dirname(scope_path), exist_ok=True)
123
+ content = serialize_scope(config)
124
+ with open(scope_path, "w", encoding="utf-8") as f:
125
+ f.write(content)
126
+
127
+ # Update .scopes index
128
+ from ..ingest import append_to_index
129
+ planned = PlannedScope(
130
+ directory=module_name,
131
+ config=config,
132
+ confidence=0.5,
133
+ signals=["lazy: on-demand ingest"],
134
+ )
135
+ try:
136
+ append_to_index(root, planned)
137
+ except Exception:
138
+ pass # Index update is best-effort
139
+
140
+ # Mark that a full re-ingest is needed
141
+ dot_dir = os.path.join(root, ".dotscope")
142
+ os.makedirs(dot_dir, exist_ok=True)
143
+ marker = os.path.join(dot_dir, "needs_full_ingest")
144
+ Path(marker).touch()
145
+
146
+ if not quiet:
147
+ print(
148
+ f"dotscope: {module_name}/ scoped on demand",
149
+ file=sys.stderr,
150
+ )
151
+
152
+ return config
@@ -0,0 +1,160 @@
1
+ """Semantic diff: translate git diff into convention-level structural changes."""
2
+
3
+ import os
4
+ import subprocess
5
+ from typing import Dict, List, Optional
6
+
7
+ from ..models import ConventionNode, ConventionRule, FileAnalysis, SemanticDiffReport
8
+ from .convention_parser import parse_conventions
9
+
10
+
11
+ def semantic_diff(
12
+ diff_text: str,
13
+ repo_root: str,
14
+ conventions: List[ConventionRule],
15
+ ) -> SemanticDiffReport:
16
+ """Translate a git diff into convention-level changes.
17
+
18
+ Parses the AST at two points in time:
19
+ 1. HEAD commit (using `git show HEAD:<file>` for each modified file)
20
+ 2. Working directory (current files on disk)
21
+
22
+ Compares the ConventionNode graphs to determine structural changes.
23
+ """
24
+ modified_files = _extract_modified_files(diff_text)
25
+ if not modified_files or not conventions:
26
+ return SemanticDiffReport()
27
+
28
+ # Parse conventions at HEAD
29
+ head_ast = {}
30
+ for filepath in modified_files:
31
+ source = _git_show_head(repo_root, filepath)
32
+ if source:
33
+ analysis = _parse_source(source, filepath)
34
+ if analysis:
35
+ head_ast[filepath] = analysis
36
+
37
+ nodes_before = parse_conventions(head_ast, conventions)
38
+
39
+ # Parse conventions at working directory
40
+ working_ast = {}
41
+ for filepath in modified_files:
42
+ full_path = os.path.join(repo_root, filepath)
43
+ if os.path.exists(full_path):
44
+ try:
45
+ with open(full_path, "r", encoding="utf-8") as f:
46
+ source = f.read()
47
+ analysis = _parse_source(source, filepath)
48
+ if analysis:
49
+ working_ast[filepath] = analysis
50
+ except (IOError, UnicodeDecodeError):
51
+ pass
52
+
53
+ nodes_after = parse_conventions(working_ast, conventions)
54
+
55
+ before_map = {(n.file_path, n.name): n for n in nodes_before}
56
+ after_map = {(n.file_path, n.name): n for n in nodes_after}
57
+
58
+ added = []
59
+ removed = []
60
+ modified = []
61
+
62
+ for key, node in after_map.items():
63
+ if key not in before_map:
64
+ added.append(node)
65
+ elif before_map[key].violations != node.violations:
66
+ modified.append((before_map[key], node))
67
+
68
+ for key, node in before_map.items():
69
+ if key not in after_map:
70
+ removed.append(node)
71
+
72
+ all_upheld = all(not n.violations for n in after_map.values())
73
+
74
+ return SemanticDiffReport(
75
+ added=added,
76
+ removed=removed,
77
+ modified=modified,
78
+ all_conventions_upheld=all_upheld,
79
+ )
80
+
81
+
82
+ def format_semantic_diff(report: SemanticDiffReport) -> str:
83
+ """Format a SemanticDiffReport for terminal output."""
84
+ lines = ["Semantic Diff:"]
85
+
86
+ for node in report.added:
87
+ lines.append(f" [ADDED] {node.name}: {node.file_path}")
88
+ for node in report.removed:
89
+ lines.append(f" [REMOVED] {node.name}: {node.file_path}")
90
+ for before, after in report.modified:
91
+ lines.append(f" [MODIFIED] {after.name}: {after.file_path}")
92
+ for v in after.violations:
93
+ lines.append(f" ! {v}")
94
+ for dep in report.dependency_changes:
95
+ lines.append(f" [MODIFIED] Dependency: {dep}")
96
+
97
+ lines.append("")
98
+ if report.all_conventions_upheld:
99
+ lines.append(" Conventions: All upheld")
100
+ else:
101
+ violation_count = sum(
102
+ len(n.violations) for n in
103
+ [node for _, node in report.modified] +
104
+ report.added
105
+ )
106
+ lines.append(f" Conventions: {violation_count} violation(s)")
107
+
108
+ if report.counterfactual:
109
+ lines.append("")
110
+ lines.append(f" {report.counterfactual}")
111
+
112
+ return "\n".join(lines)
113
+
114
+
115
+ def _extract_modified_files(diff_text: str) -> List[str]:
116
+ """Extract file paths from unified diff."""
117
+ files = []
118
+ for line in diff_text.splitlines():
119
+ if line.startswith("diff --git"):
120
+ parts = line.split(" b/", 1)
121
+ if len(parts) > 1:
122
+ filepath = parts[1]
123
+ if filepath not in files:
124
+ files.append(filepath)
125
+ return files
126
+
127
+
128
+ def _git_show_head(repo_root: str, filepath: str) -> Optional[str]:
129
+ """Get file content at HEAD."""
130
+ try:
131
+ result = subprocess.run(
132
+ ["git", "show", f"HEAD:{filepath}"],
133
+ cwd=repo_root, capture_output=True, text=True, timeout=5,
134
+ )
135
+ if result.returncode == 0:
136
+ return result.stdout
137
+ except (subprocess.TimeoutExpired, FileNotFoundError):
138
+ pass
139
+ return None
140
+
141
+
142
+ def _parse_source(source: str, filepath: str) -> Optional[FileAnalysis]:
143
+ """Parse source code into FileAnalysis."""
144
+ try:
145
+ from .ast_analyzer import analyze_file
146
+ import tempfile
147
+ # Write to temp file for analyze_file (it reads from disk)
148
+ ext = os.path.splitext(filepath)[1]
149
+ lang = {".py": "python", ".js": "javascript", ".ts": "typescript", ".go": "go"}.get(ext)
150
+ if not lang:
151
+ return None
152
+ with tempfile.NamedTemporaryFile(mode="w", suffix=ext, delete=False, encoding="utf-8") as tf:
153
+ tf.write(source)
154
+ tf.flush()
155
+ try:
156
+ return analyze_file(tf.name, lang)
157
+ finally:
158
+ os.unlink(tf.name)
159
+ except Exception:
160
+ return None
@@ -0,0 +1 @@
1
+ """Architectural enforcement for dotscope."""
@@ -0,0 +1,222 @@
1
+ """Acknowledgment recording and confidence decay."""
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from typing import Dict, List, Optional
7
+
8
+ # Defaults (overridable via .dotscope/config.yaml)
9
+ DECAY_RATE = 0.1
10
+ DECAY_THRESHOLD = 3
11
+ DECAY_WINDOW_DAYS = 30
12
+ MIN_CONFIDENCE = 0.3
13
+
14
+
15
+ def record_acknowledgment(
16
+ repo_root: str,
17
+ ack_id: str,
18
+ reason: str,
19
+ session_id: Optional[str] = None,
20
+ ) -> dict:
21
+ """Record an acknowledgment in .dotscope/acknowledgments.jsonl."""
22
+ dot_dir = os.path.join(repo_root, ".dotscope")
23
+ os.makedirs(dot_dir, exist_ok=True)
24
+
25
+ entry = {
26
+ "id": ack_id,
27
+ "reason": reason,
28
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
29
+ "session_id": session_id or "",
30
+ }
31
+
32
+ path = os.path.join(dot_dir, "acknowledgments.jsonl")
33
+ with open(path, "a", encoding="utf-8") as f:
34
+ f.write(json.dumps(entry) + "\n")
35
+
36
+ return entry
37
+
38
+
39
+ def load_acknowledgments(repo_root: str) -> List[dict]:
40
+ """Load all acknowledgments."""
41
+ path = os.path.join(repo_root, ".dotscope", "acknowledgments.jsonl")
42
+ if not os.path.exists(path):
43
+ return []
44
+
45
+ entries = []
46
+ with open(path, "r", encoding="utf-8") as f:
47
+ for line in f:
48
+ line = line.strip()
49
+ if line:
50
+ try:
51
+ entries.append(json.loads(line))
52
+ except json.JSONDecodeError:
53
+ continue
54
+ return entries
55
+
56
+
57
+ def is_acknowledged(repo_root: str, ack_id: str) -> bool:
58
+ """Check if a hold has been acknowledged.
59
+
60
+ Returns True if previously acknowledged AND confidence has not
61
+ decayed below the HOLD threshold (0.5). Constraints acknowledged
62
+ 3+ times within 30 days lose confidence — below 0.5 they become
63
+ NOTEs instead of HOLDs, so this returns False (not "acknowledged"
64
+ as a HOLD pass-through).
65
+ """
66
+ acks = load_acknowledgments(repo_root)
67
+ if not any(a["id"] == ack_id for a in acks):
68
+ return False
69
+
70
+ # Apply decay: if confidence drops below 0.5, the constraint
71
+ # should remain active (as a NOTE), not be silently passed
72
+ confidence = compute_decayed_confidence(1.0, ack_id, acks)
73
+ return confidence >= 0.5
74
+
75
+
76
+ def compute_decayed_confidence(
77
+ base_confidence: float,
78
+ ack_id: str,
79
+ acknowledgments: List[dict],
80
+ ) -> float:
81
+ """Apply confidence decay based on repeated acknowledgments.
82
+
83
+ After DECAY_THRESHOLD acknowledgments within DECAY_WINDOW_DAYS,
84
+ each additional acknowledgment drops confidence by DECAY_RATE.
85
+ Never drops below MIN_CONFIDENCE.
86
+ """
87
+ now = time.time()
88
+ window_seconds = DECAY_WINDOW_DAYS * 86400
89
+
90
+ recent = [
91
+ a for a in acknowledgments
92
+ if a.get("id") == ack_id and _parse_timestamp(a.get("timestamp", "")) > now - window_seconds
93
+ ]
94
+
95
+ count = len(recent)
96
+ if count <= DECAY_THRESHOLD:
97
+ return base_confidence
98
+
99
+ excess = count - DECAY_THRESHOLD
100
+ decayed = base_confidence - (excess * DECAY_RATE)
101
+ return max(decayed, MIN_CONFIDENCE)
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Gap 3: NUDGE escalation — repeated nudges become guards
106
+ # ---------------------------------------------------------------------------
107
+
108
+ ESCALATION_THRESHOLD = 3 # nudge fires this many times → escalate to GUARD
109
+ ESCALATION_WINDOW_DAYS = 30
110
+
111
+
112
+ def record_nudge_occurrence(repo_root: str, check_id: str) -> None:
113
+ """Track that a nudge fired. Used for escalation tracking."""
114
+ if not check_id:
115
+ return
116
+ dot_dir = os.path.join(repo_root, ".dotscope")
117
+ os.makedirs(dot_dir, exist_ok=True)
118
+
119
+ entry = {
120
+ "id": check_id,
121
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
122
+ }
123
+
124
+ path = os.path.join(dot_dir, "nudge_occurrences.jsonl")
125
+ with open(path, "a", encoding="utf-8") as f:
126
+ f.write(json.dumps(entry) + "\n")
127
+
128
+
129
+ def record_nudge_resolution(repo_root: str, check_id: str) -> None:
130
+ """Record that a nudge's underlying issue was fixed.
131
+
132
+ Resets the escalation counter so the nudge doesn't instantly
133
+ re-escalate if the issue recurs later.
134
+ """
135
+ if not check_id:
136
+ return
137
+ dot_dir = os.path.join(repo_root, ".dotscope")
138
+ os.makedirs(dot_dir, exist_ok=True)
139
+
140
+ entry = {
141
+ "id": check_id,
142
+ "resolved": True,
143
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
144
+ }
145
+
146
+ path = os.path.join(dot_dir, "nudge_occurrences.jsonl")
147
+ with open(path, "a", encoding="utf-8") as f:
148
+ f.write(json.dumps(entry) + "\n")
149
+
150
+
151
+ def is_escalated(repo_root: str, check_id: str) -> bool:
152
+ """Has this nudge been ignored enough times to escalate to GUARD?
153
+
154
+ 3+ occurrences within 30 days *since the last resolution* means
155
+ the agent is ignoring the guidance. Escalate.
156
+
157
+ If the issue was resolved (nudge stopped firing) and then recurred,
158
+ the counter restarts from the resolution point.
159
+ """
160
+ if not check_id:
161
+ return False
162
+ path = os.path.join(repo_root, ".dotscope", "nudge_occurrences.jsonl")
163
+ if not os.path.exists(path):
164
+ return False
165
+
166
+ now = time.time()
167
+ window = ESCALATION_WINDOW_DAYS * 86400
168
+
169
+ # Find the last resolution timestamp for this check
170
+ last_resolved = 0.0
171
+ count = 0
172
+
173
+ try:
174
+ with open(path, "r", encoding="utf-8") as f:
175
+ for line in f:
176
+ line = line.strip()
177
+ if not line:
178
+ continue
179
+ try:
180
+ entry = json.loads(line)
181
+ if entry.get("id") != check_id:
182
+ continue
183
+ ts = _parse_timestamp(entry.get("timestamp", ""))
184
+ if entry.get("resolved"):
185
+ last_resolved = max(last_resolved, ts)
186
+ except json.JSONDecodeError:
187
+ continue
188
+ except IOError:
189
+ return False
190
+
191
+ # Count occurrences after last resolution and within window
192
+ try:
193
+ with open(path, "r", encoding="utf-8") as f:
194
+ for line in f:
195
+ line = line.strip()
196
+ if not line:
197
+ continue
198
+ try:
199
+ entry = json.loads(line)
200
+ if entry.get("id") != check_id:
201
+ continue
202
+ if entry.get("resolved"):
203
+ continue
204
+ ts = _parse_timestamp(entry.get("timestamp", ""))
205
+ if ts > last_resolved and ts > now - window:
206
+ count += 1
207
+ except json.JSONDecodeError:
208
+ continue
209
+ except IOError:
210
+ return False
211
+
212
+ return count >= ESCALATION_THRESHOLD
213
+
214
+
215
+ def _parse_timestamp(ts: str) -> float:
216
+ """Parse ISO timestamp to epoch seconds."""
217
+ try:
218
+ from datetime import datetime
219
+ dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
220
+ return dt.timestamp()
221
+ except (ValueError, AttributeError):
222
+ return 0.0