lyingdocs 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.
lyingdocs/tools.py ADDED
@@ -0,0 +1,423 @@
1
+ """Tool implementations and OpenAI function schemas for DocentAgent."""
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from .codex import run_codex_task
9
+ from .workspace import CATEGORIES, SEVERITIES, Workspace
10
+
11
+ logger = logging.getLogger("lyingdocs")
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Tool Schemas (OpenAI function-calling format)
16
+ # ---------------------------------------------------------------------------
17
+
18
+ TOOL_SCHEMAS = [
19
+ {
20
+ "type": "function",
21
+ "function": {
22
+ "name": "list_docs",
23
+ "description": (
24
+ "List documentation files and subdirectories under a given path. "
25
+ "Returns file names, sizes, and types. Use to explore the doc tree."
26
+ ),
27
+ "parameters": {
28
+ "type": "object",
29
+ "properties": {
30
+ "directory": {
31
+ "type": "string",
32
+ "description": "Relative path within the doc root. Use '.' for the root.",
33
+ "default": ".",
34
+ }
35
+ },
36
+ "required": [],
37
+ },
38
+ },
39
+ },
40
+ {
41
+ "type": "function",
42
+ "function": {
43
+ "name": "read_doc",
44
+ "description": (
45
+ "Read a documentation file's content. Supports optional line ranges "
46
+ "for large files. Returns content with line numbers."
47
+ ),
48
+ "parameters": {
49
+ "type": "object",
50
+ "properties": {
51
+ "path": {
52
+ "type": "string",
53
+ "description": "Relative path to the doc file within doc root.",
54
+ },
55
+ "start_line": {
56
+ "type": "integer",
57
+ "description": "Start reading from this line (1-indexed). Default: 1.",
58
+ "default": 1,
59
+ },
60
+ "end_line": {
61
+ "type": "integer",
62
+ "description": "Stop reading at this line (inclusive). Default: read all.",
63
+ },
64
+ },
65
+ "required": ["path"],
66
+ },
67
+ },
68
+ },
69
+ {
70
+ "type": "function",
71
+ "function": {
72
+ "name": "search_docs",
73
+ "description": (
74
+ "Search across documentation files for a pattern (regex or substring). "
75
+ "Returns matching lines with file paths and line numbers. Max 50 results."
76
+ ),
77
+ "parameters": {
78
+ "type": "object",
79
+ "properties": {
80
+ "pattern": {
81
+ "type": "string",
82
+ "description": "Search pattern (regex supported).",
83
+ },
84
+ "glob": {
85
+ "type": "string",
86
+ "description": "Glob pattern to filter files. Default: '*.md'.",
87
+ "default": "*.md",
88
+ },
89
+ },
90
+ "required": ["pattern"],
91
+ },
92
+ },
93
+ },
94
+ {
95
+ "type": "function",
96
+ "function": {
97
+ "name": "dispatch_codex",
98
+ "description": (
99
+ "Dispatch an atomic code analysis task to Codex CLI. The task should be "
100
+ "specific and targeted — reference concrete doc claims and what to verify "
101
+ "in the codebase. Each dispatch uses one unit of your budget."
102
+ ),
103
+ "parameters": {
104
+ "type": "object",
105
+ "properties": {
106
+ "task_description": {
107
+ "type": "string",
108
+ "description": (
109
+ "Specific audit question. Must reference concrete doc claims "
110
+ "and what to verify in code. Example: 'The docs at "
111
+ "docs/api/auth.md:45-60 claim OAuth2 PKCE is used for "
112
+ "all public clients. Verify the auth middleware implements "
113
+ "PKCE validation.'"
114
+ ),
115
+ },
116
+ "focus_paths": {
117
+ "type": "array",
118
+ "items": {"type": "string"},
119
+ "description": (
120
+ "Optional list of code paths to prioritize (relative to code root)."
121
+ ),
122
+ },
123
+ },
124
+ "required": ["task_description"],
125
+ },
126
+ },
127
+ },
128
+ {
129
+ "type": "function",
130
+ "function": {
131
+ "name": "record_finding",
132
+ "description": (
133
+ "Record a documentation-code misalignment finding. Call this after "
134
+ "analyzing Codex output to save confirmed findings."
135
+ ),
136
+ "parameters": {
137
+ "type": "object",
138
+ "properties": {
139
+ "category": {
140
+ "type": "string",
141
+ "enum": list(CATEGORIES),
142
+ "description": (
143
+ "LogicMismatch: code contradicts doc. "
144
+ "PhantomSpec: doc describes something absent from code. "
145
+ "ShadowLogic: important code logic not documented. "
146
+ "HardcodedDrift: important params hardcoded."
147
+ ),
148
+ },
149
+ "title": {
150
+ "type": "string",
151
+ "description": "Short descriptive title for the finding.",
152
+ },
153
+ "doc_ref": {
154
+ "type": "string",
155
+ "description": "Doc file path + line/section reference.",
156
+ },
157
+ "code_ref": {
158
+ "type": "string",
159
+ "description": "Code file path + line number (from Codex output).",
160
+ },
161
+ "description": {
162
+ "type": "string",
163
+ "description": "Detailed explanation of the misalignment.",
164
+ },
165
+ "severity": {
166
+ "type": "string",
167
+ "enum": list(SEVERITIES),
168
+ "description": "Impact severity: high, medium, or low.",
169
+ },
170
+ },
171
+ "required": [
172
+ "category", "title", "doc_ref", "code_ref",
173
+ "description", "severity",
174
+ ],
175
+ },
176
+ },
177
+ },
178
+ {
179
+ "type": "function",
180
+ "function": {
181
+ "name": "get_progress",
182
+ "description": (
183
+ "Check current audit progress: sections completed, findings by category, "
184
+ "Codex dispatch budget remaining."
185
+ ),
186
+ "parameters": {"type": "object", "properties": {}, "required": []},
187
+ },
188
+ },
189
+ {
190
+ "type": "function",
191
+ "function": {
192
+ "name": "mark_section_complete",
193
+ "description": "Mark a documentation section/file as fully audited.",
194
+ "parameters": {
195
+ "type": "object",
196
+ "properties": {
197
+ "section_path": {
198
+ "type": "string",
199
+ "description": "Path to the doc section/file that has been audited.",
200
+ },
201
+ "notes": {
202
+ "type": "string",
203
+ "description": "Optional notes about what was found or not found.",
204
+ "default": "",
205
+ },
206
+ },
207
+ "required": ["section_path"],
208
+ },
209
+ },
210
+ },
211
+ {
212
+ "type": "function",
213
+ "function": {
214
+ "name": "finalize_report",
215
+ "description": (
216
+ "Signal that all auditing is complete. Triggers final report generation. "
217
+ "Call this when you have audited all high-priority sections or the "
218
+ "dispatch budget is running low."
219
+ ),
220
+ "parameters": {"type": "object", "properties": {}, "required": []},
221
+ },
222
+ },
223
+ ]
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Tool Executor
228
+ # ---------------------------------------------------------------------------
229
+
230
+
231
+ class ToolExecutor:
232
+ """Executes tool calls from the agent and returns results."""
233
+
234
+ def __init__(
235
+ self,
236
+ doc_root: Path,
237
+ code_path: Path,
238
+ output_dir: Path,
239
+ workspace: Workspace,
240
+ config: dict,
241
+ codex_bin: str | None = None,
242
+ ):
243
+ self.doc_root = doc_root.resolve()
244
+ self.code_path = code_path
245
+ self.output_dir = output_dir
246
+ self.workspace = workspace
247
+ self.config = config
248
+ self.codex_bin = codex_bin
249
+
250
+ def execute(self, tool_name: str, arguments: dict) -> str:
251
+ """Dispatch a tool call and return the result string."""
252
+ handler = getattr(self, f"_tool_{tool_name}", None)
253
+ if handler is None:
254
+ return f"[ERROR] Unknown tool: {tool_name}"
255
+ try:
256
+ return handler(**arguments)
257
+ except Exception as e:
258
+ logger.error("Tool %s failed: %s", tool_name, e)
259
+ return f"[ERROR] {tool_name} failed: {e}"
260
+
261
+ # -- Tool implementations --
262
+
263
+ def _tool_list_docs(self, directory: str = ".") -> str:
264
+ target = (self.doc_root / directory).resolve()
265
+ if not str(target).startswith(str(self.doc_root)):
266
+ return "[ERROR] Path outside doc root."
267
+ if not target.is_dir():
268
+ return f"[ERROR] Not a directory: {directory}"
269
+
270
+ lines = [f"Contents of {directory}/:\n"]
271
+
272
+ # Directories first
273
+ dirs = sorted(
274
+ p for p in target.iterdir()
275
+ if p.is_dir() and not p.name.startswith(".")
276
+ )
277
+ for d in dirs:
278
+ file_count = sum(1 for _ in d.rglob("*") if _.is_file())
279
+ lines.append(f" 📁 {d.name}/ ({file_count} files)")
280
+
281
+ # Then files
282
+ files = sorted(p for p in target.iterdir() if p.is_file())
283
+ for f in files:
284
+ size = f.stat().st_size
285
+ lines.append(f" 📄 {f.name} ({_human_size(size)})")
286
+
287
+ return "\n".join(lines)
288
+
289
+ def _tool_read_doc(
290
+ self, path: str, start_line: int = 1, end_line: int | None = None
291
+ ) -> str:
292
+ target = (self.doc_root / path).resolve()
293
+ if not str(target).startswith(str(self.doc_root)):
294
+ return "[ERROR] Path outside doc root."
295
+ if not target.is_file():
296
+ return f"[ERROR] File not found: {path}"
297
+
298
+ content = target.read_text(encoding="utf-8", errors="replace")
299
+ all_lines = content.splitlines()
300
+
301
+ start = max(0, start_line - 1)
302
+ end = end_line if end_line else len(all_lines)
303
+ selected = all_lines[start:end]
304
+
305
+ numbered = [
306
+ f"{i + start + 1:4d} | {line}" for i, line in enumerate(selected)
307
+ ]
308
+
309
+ header = f"File: {path} (lines {start + 1}-{start + len(selected)}/{len(all_lines)})\n"
310
+ return header + "\n".join(numbered)
311
+
312
+ def _tool_search_docs(self, pattern: str, glob: str = "*.md") -> str:
313
+ results = []
314
+ try:
315
+ regex = re.compile(pattern, re.IGNORECASE)
316
+ except re.error:
317
+ # Fall back to literal search
318
+ regex = re.compile(re.escape(pattern), re.IGNORECASE)
319
+
320
+ for path in sorted(self.doc_root.rglob(glob)):
321
+ if not path.is_file():
322
+ continue
323
+ # Skip hidden/non-doc dirs
324
+ rel = path.relative_to(self.doc_root)
325
+ if any(p.startswith(".") for p in rel.parts):
326
+ continue
327
+
328
+ try:
329
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
330
+ except Exception:
331
+ continue
332
+
333
+ for i, line in enumerate(lines, 1):
334
+ if regex.search(line):
335
+ results.append(f"{rel}:{i}: {line.strip()}")
336
+ if len(results) >= 50:
337
+ results.append("... (truncated at 50 results)")
338
+ return "\n".join(results)
339
+
340
+ if not results:
341
+ return f"No matches found for pattern: {pattern}"
342
+ return "\n".join(results)
343
+
344
+ def _tool_dispatch_codex(
345
+ self, task_description: str, focus_paths: list[str] | None = None
346
+ ) -> str:
347
+ if not self.config.get("codex_enabled", True) or not self.codex_bin:
348
+ return (
349
+ "[UNAVAILABLE] Codex CLI is not available. "
350
+ "Install via 'npm install -g @openai/codex' or enable it in config. "
351
+ "Continue auditing using documentation analysis only."
352
+ )
353
+
354
+ if self.workspace.is_budget_exhausted():
355
+ return (
356
+ "[ERROR] Codex dispatch budget exhausted. "
357
+ "You should finalize the report with the findings collected so far."
358
+ )
359
+
360
+ self.workspace.increment_dispatch()
361
+ task_id = f"{self.workspace.codex_dispatch_count:03d}"
362
+
363
+ result = run_codex_task(
364
+ self.config,
365
+ task_description,
366
+ self.code_path,
367
+ self.output_dir,
368
+ task_id,
369
+ focus_paths,
370
+ codex_bin=self.codex_bin,
371
+ )
372
+
373
+ remaining = self.workspace.dispatches_remaining()
374
+ footer = f"\n\n[Codex dispatches remaining: {remaining}/{self.workspace.max_dispatches}]"
375
+ return result + footer
376
+
377
+ def _tool_record_finding(
378
+ self,
379
+ category: str,
380
+ title: str,
381
+ doc_ref: str,
382
+ code_ref: str,
383
+ description: str,
384
+ severity: str,
385
+ ) -> str:
386
+ finding = self.workspace.add_finding(
387
+ category=category,
388
+ title=title,
389
+ doc_ref=doc_ref,
390
+ code_ref=code_ref,
391
+ description=description,
392
+ severity=severity,
393
+ )
394
+ return (
395
+ f"Finding recorded: [{finding.category}] {finding.title} "
396
+ f"(id: {finding.id}, severity: {finding.severity})"
397
+ )
398
+
399
+ def _tool_get_progress(self) -> str:
400
+ return self.workspace.get_progress_summary()
401
+
402
+ def _tool_mark_section_complete(
403
+ self, section_path: str, notes: str = ""
404
+ ) -> str:
405
+ self.workspace.mark_section_complete(section_path, notes)
406
+ return f"Section marked complete: {section_path}"
407
+
408
+ def _tool_finalize_report(self) -> str:
409
+ self.workspace.finalize()
410
+ return (
411
+ "Report finalization triggered. "
412
+ f"Total findings: {len(self.workspace.findings)}. "
413
+ "The final report will be generated now."
414
+ )
415
+
416
+
417
+ def _human_size(size: int) -> str:
418
+ if size < 1024:
419
+ return f"{size}B"
420
+ elif size < 1024 * 1024:
421
+ return f"{size / 1024:.1f}KB"
422
+ else:
423
+ return f"{size / (1024 * 1024):.1f}MB"
lyingdocs/workspace.py ADDED
@@ -0,0 +1,170 @@
1
+ """Workspace state: findings, progress tracking, and persistence."""
2
+
3
+ import json
4
+ import logging
5
+ import threading
6
+ import uuid
7
+ from dataclasses import asdict, dataclass, field
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ logger = logging.getLogger("lyingdocs")
12
+
13
+ CATEGORIES = ("LogicMismatch", "PhantomSpec", "ShadowLogic", "HardcodedDrift")
14
+ SEVERITIES = ("high", "medium", "low")
15
+
16
+
17
+ @dataclass
18
+ class Finding:
19
+ id: str
20
+ category: str
21
+ title: str
22
+ doc_ref: str
23
+ code_ref: str
24
+ description: str
25
+ severity: str
26
+ timestamp: str
27
+
28
+
29
+ class Workspace:
30
+ """Manages audit state: findings, completed sections, and dispatch budget."""
31
+
32
+ def __init__(self, output_dir: Path, max_dispatches: int = 20):
33
+ self.output_dir = output_dir
34
+ self.max_dispatches = max_dispatches
35
+ self.findings: list[Finding] = []
36
+ self.completed_sections: set[str] = set()
37
+ self.codex_dispatch_count: int = 0
38
+ self._finalized: bool = False
39
+ self._lock = threading.Lock()
40
+
41
+ self.output_dir.mkdir(parents=True, exist_ok=True)
42
+
43
+ def add_finding(
44
+ self,
45
+ category: str,
46
+ title: str,
47
+ doc_ref: str,
48
+ code_ref: str,
49
+ description: str,
50
+ severity: str,
51
+ ) -> Finding:
52
+ """Record a new misalignment finding."""
53
+ if category not in CATEGORIES:
54
+ raise ValueError(
55
+ f"Invalid category '{category}'. Must be one of: {CATEGORIES}"
56
+ )
57
+ if severity not in SEVERITIES:
58
+ raise ValueError(
59
+ f"Invalid severity '{severity}'. Must be one of: {SEVERITIES}"
60
+ )
61
+
62
+ finding = Finding(
63
+ id=str(uuid.uuid4())[:8],
64
+ category=category,
65
+ title=title,
66
+ doc_ref=doc_ref,
67
+ code_ref=code_ref,
68
+ description=description,
69
+ severity=severity,
70
+ timestamp=datetime.now(timezone.utc).isoformat(),
71
+ )
72
+ with self._lock:
73
+ self.findings.append(finding)
74
+
75
+ # Append to JSONL for crash recovery
76
+ with open(self.output_dir / "findings.jsonl", "a", encoding="utf-8") as f:
77
+ f.write(json.dumps(asdict(finding)) + "\n")
78
+
79
+ logger.info(
80
+ " Finding recorded: [%s] %s (%s)", category, title, severity
81
+ )
82
+ return finding
83
+
84
+ def mark_section_complete(self, section_path: str, notes: str = "") -> None:
85
+ with self._lock:
86
+ self.completed_sections.add(section_path)
87
+ logger.info(" Section completed: %s", section_path)
88
+
89
+ def increment_dispatch(self) -> None:
90
+ with self._lock:
91
+ self.codex_dispatch_count += 1
92
+
93
+ def dispatches_remaining(self) -> int:
94
+ with self._lock:
95
+ return max(0, self.max_dispatches - self.codex_dispatch_count)
96
+
97
+ def finalize(self) -> None:
98
+ with self._lock:
99
+ self._finalized = True
100
+
101
+ def is_complete(self) -> bool:
102
+ with self._lock:
103
+ return self._finalized
104
+
105
+ def is_budget_exhausted(self) -> bool:
106
+ with self._lock:
107
+ return self.codex_dispatch_count >= self.max_dispatches
108
+
109
+ def get_progress_summary(self) -> str:
110
+ """Return a text summary of current audit progress."""
111
+ by_cat = {c: [] for c in CATEGORIES}
112
+ for f in self.findings:
113
+ by_cat[f.category].append(f)
114
+
115
+ lines = [
116
+ "## Audit Progress",
117
+ f"Codex dispatches: {self.codex_dispatch_count}/{self.max_dispatches}",
118
+ f"Sections completed: {len(self.completed_sections)}",
119
+ f"Total findings: {len(self.findings)}",
120
+ "",
121
+ "### Findings by Category",
122
+ ]
123
+ for cat in CATEGORIES:
124
+ count = len(by_cat[cat])
125
+ if count:
126
+ lines.append(f" {cat}: {count}")
127
+ for f in by_cat[cat]:
128
+ lines.append(f" - [{f.severity}] {f.title}")
129
+ else:
130
+ lines.append(f" {cat}: 0")
131
+
132
+ if self.completed_sections:
133
+ lines.append("\n### Completed Sections")
134
+ for s in sorted(self.completed_sections):
135
+ lines.append(f" - {s}")
136
+
137
+ if self.is_budget_exhausted():
138
+ lines.append("\n⚠️ Codex dispatch budget exhausted.")
139
+
140
+ return "\n".join(lines)
141
+
142
+ def save_state(self) -> None:
143
+ """Persist workspace state to JSON for resume capability."""
144
+ state = {
145
+ "findings": [asdict(f) for f in self.findings],
146
+ "completed_sections": sorted(self.completed_sections),
147
+ "codex_dispatch_count": self.codex_dispatch_count,
148
+ "finalized": self._finalized,
149
+ }
150
+ path = self.output_dir / "workspace_state.json"
151
+ path.write_text(json.dumps(state, indent=2), encoding="utf-8")
152
+
153
+ def load_state(self) -> bool:
154
+ """Load workspace state from checkpoint. Returns True if loaded."""
155
+ path = self.output_dir / "workspace_state.json"
156
+ if not path.exists():
157
+ return False
158
+
159
+ state = json.loads(path.read_text(encoding="utf-8"))
160
+ self.findings = [Finding(**f) for f in state.get("findings", [])]
161
+ self.completed_sections = set(state.get("completed_sections", []))
162
+ self.codex_dispatch_count = state.get("codex_dispatch_count", 0)
163
+ self._finalized = state.get("finalized", False)
164
+ logger.info(
165
+ " Resumed workspace: %d findings, %d sections, %d dispatches",
166
+ len(self.findings),
167
+ len(self.completed_sections),
168
+ self.codex_dispatch_count,
169
+ )
170
+ return True