oct
Loading...
Searching...
No Matches
prompt_invariant_rules.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/linter/prompt_invariant_rules.py
4
5"""
6Purpose
7-------
8OC Chat prompt-invariant structural rules, lifted into the OCT linter from
9``oc_chat/invariants/prompt_invariant_rules.py`` (Plan 140) at the Plan 151
10monorepo migration. Provides local structural enforcement for the Plan 135
11prompt invariants. The module exposes portable rule metadata, runtime path
12scoping, and source checkers that the OCT strict profile hosts as the
13``prompt_invariants`` rule (replacing the OC Chat pytest wrapper).
14
15Responsibilities
16----------------
17- Define rule metadata and agent-legible remediation text.
18- Keep runtime path scope centralized and repository-relative (scoped to
19 the OC Chat runtime source tree; a no-op for every other project).
20- Check a single source string or a set of filesystem paths for invariant
21 violations.
22- Keep detector logic independent of OC Chat runtime imports.
23
24Diagnostics
25-----------
26Domain: OCT-LINTER
27Levels:
28 L2 - lifecycle events
29 L3 - semantic details
30 L4 - deep tracing
31
32Contracts
33---------
34- Public checkers accept repository-relative paths for matching.
35- Absolute filesystem paths are normalized only through ``check_paths`` with
36 an explicit root.
37- Detectors must not edit source or depend on live OC Chat runtime objects.
38- Findings include remediation text from the rule registry.
39
40Dependencies
41------------
42- ast (parses the source and walks it for the structural detectors)
43- collections.abc (Iterable, Iterator, Mapping - type hints for paths and the rule table)
44- dataclasses (dataclass - the frozen RuleMetadata and Finding records)
45- io (StringIO - feeds the source text to tokenize)
46- pathlib (Path - filesystem paths for the runtime scope walk)
47- tokenize (NAME tokens for the retired composition symbol check)
48- types (MappingProxyType - the read-only RULES table)
49- oct.core.diagnostics (structured debug logging via _dbg)
50"""
51from __future__ import annotations
52
53import ast
54import io
55import tokenize
56from collections.abc import Iterable, Iterator, Mapping
57from dataclasses import dataclass
58from pathlib import Path
59from types import MappingProxyType
60
61from oct.core.diagnostics import _dbg
62
63
64_dbg("OCT-LINTER", "module", "loaded", 4)
65
66
67@dataclass(frozen=True)
69 """Metadata for one portable invariant rule."""
70
71 rule_id: str
72 purpose: str
73 remediation: str
74 detector_kind: str
75 spec_reference: str
76
77
78@dataclass(frozen=True)
79class Finding:
80 """One invariant finding."""
81
82 rule_id: str
83 path: str
84 line: int
85 col: int
86 message: str
87 remediation: str
88
89
90_PROMPT_REFS = (
91 "oc_chat/plans/135-prompt-redesign-agnostic-core.md section 4.7; "
92 "oc_chat/docs/architecture/inner-loop/inner-loop.md section 8"
93)
94
95RULES: Mapping[str, RuleMetadata] = MappingProxyType({
96 "prompt_no_raw_tool_schema_dump": RuleMetadata(
97 rule_id="prompt_no_raw_tool_schema_dump",
98 purpose="Prevent raw tool schema JSON from entering system-prompt construction.",
99 remediation=(
100 "Full schemas belong in the API tools parameter. messages[0] should "
101 "contain only canonical policy plus compact tool map/reference so "
102 "provider cache prefixes stay stable and portable."
103 ),
104 detector_kind="AST with anchored same-scope alias tracking",
105 spec_reference=_PROMPT_REFS,
106 ),
107 "prompt_model_setter_chokepoint": RuleMetadata(
108 rule_id="prompt_model_setter_chokepoint",
109 purpose="Require runtime model switches to pass through ChatSession.set_model.",
110 remediation=(
111 "Raw writes skip the refresh contract and can leave cached context, "
112 "prompt metadata, and presenter context-window gauges stale. Use "
113 "session.set_model(new_model) for runtime switches."
114 ),
115 detector_kind="AST assignment checker",
116 spec_reference=_PROMPT_REFS,
117 ),
118 "prompt_no_literal_mode_branch": RuleMetadata(
119 rule_id="prompt_no_literal_mode_branch",
120 purpose="Keep prompt composition mode behavior declarative.",
121 remediation=(
122 "Modes are declarative profiles. Add or modify profile data and "
123 "shared resolver logic instead of hard-coding mode-specific branches."
124 ),
125 detector_kind="AST branch-condition checker",
126 spec_reference=_PROMPT_REFS,
127 ),
128 "prompt_no_retired_composition_symbols": RuleMetadata(
129 rule_id="prompt_no_retired_composition_symbols",
130 purpose="Prevent retired prompt-composition API names from reappearing.",
131 remediation=(
132 # OCT-LINT: disable=no_hardcoded_secrets reason="Rule IDs, mode literals, and remediation strings are invariant specifications, not secrets"
133 "Use build_system_prompt, ensure_system_includes_tool_reference, "
134 "and CANONICAL_POLICY. The retired names invite schema re-embedding "
135 "and stale prompt ownership."
136 ),
137 detector_kind="Token checker with one explicit alias exception",
138 spec_reference=_PROMPT_REFS,
139 ),
140 "prompt_cache_ctx_fields_distinct": RuleMetadata(
141 rule_id="prompt_cache_ctx_fields_distinct",
142 purpose="Keep cache-hit ratio and context saturation separate.",
143 remediation=(
144 "cache-hit is prompt-cache efficiency; ctx is context-window "
145 "saturation from acc.max_prompt_in_turn / get_max_context_tokens(model). "
146 "They answer different operational questions and must not share a "
147 "computation."
148 ),
149 detector_kind="AST call keyword checker",
150 spec_reference=_PROMPT_REFS,
151 ),
152})
153
154_EXCLUDED_PARTS = {
155 "__pycache__",
156 "docs",
157 "plans",
158 "tests",
159}
160_MODE_LITERALS = {"standard", "option-c"}
161_RETIRED_NAMES = {
162 "build_system_prompt_with_tools",
163 "ensure_system_includes_tool_schemas",
164}
165
166
167def is_runtime_path(path: str | Path) -> bool:
168 """Return whether *path* is in the root-anchored runtime source scope."""
169
170 parts = _path_parts(path)
171 if not parts or not parts[-1].endswith(".py"):
172 return False
173 if any(part in _EXCLUDED_PARTS for part in parts):
174 return False
175 if parts == ("oc_chat", "main.py"):
176 return True
177 if len(parts) >= 3 and parts[0] == "oc_chat" and parts[1] == "core":
178 return True
179 if len(parts) >= 2 and parts[0] in {"providers", "oc_ui"}:
180 return True
181 return False
182
183
184def check_source(path: str | Path, text: str) -> list[Finding]:
185 """Check one repository-relative source path and return invariant findings."""
186
187 normalized = _normalize_repo_path(path)
188 if not is_runtime_path(normalized):
189 return []
190
191 tree = _parse_source(text)
192 findings: list[Finding] = []
193 if tree is not None:
194 findings.extend(_check_raw_tool_schema_dump(normalized, tree))
195 findings.extend(_check_model_setter_chokepoint(normalized, tree))
196 findings.extend(_check_literal_mode_branch(normalized, tree))
197 findings.extend(_check_cache_ctx_fields_distinct(normalized, tree))
198 findings.extend(_check_retired_composition_symbols(normalized, text, tree))
199 return sorted(findings, key=lambda item: (item.path, item.line, item.col, item.rule_id))
200
201
202def check_prompt_invariants(path: str | Path, text: str) -> tuple[bool, str]:
203 """OCT linter adapter: collapse :func:`check_source` findings to the
204 ``(passed, message)`` contract the per-file linter dispatch expects.
205
206 Returns ``(True, "")`` when the source is clean or out of the OC Chat
207 runtime scope (a no-op for every non-OC-Chat project), else
208 ``(False, "<one line per finding>")`` naming each violated rule with its
209 location and remediation-bearing message.
210 """
211 findings = check_source(path, text)
212 if not findings:
213 return True, ""
214 message = "; ".join(
215 f"{f.rule_id} @ {f.line}:{f.col} - {f.message}" for f in findings
216 )
217 return False, message
218
219
220def iter_runtime_paths(root: Path) -> Iterator[Path]:
221 """Yield existing Python files in OC Chat's runtime source scope."""
222
223 root = root.resolve()
224 candidates = [
225 root / "oc_chat" / "core",
226 root / "oc_chat" / "main.py",
227 root / "providers",
228 root / "oc_ui",
229 ]
230 seen: set[Path] = set()
231 for candidate in candidates:
232 if candidate.is_file():
233 paths = [candidate]
234 elif candidate.is_dir():
235 paths = sorted(candidate.rglob("*.py"))
236 else:
237 paths = []
238 for path in paths:
239 resolved = path.resolve()
240 if resolved in seen:
241 continue
242 seen.add(resolved)
243 try:
244 relative = resolved.relative_to(root)
245 except ValueError:
246 continue
247 if is_runtime_path(relative):
248 yield resolved
249
250
251def check_paths(paths: Iterable[Path], *, root: Path) -> list[Finding]:
252 """Check filesystem paths by converting them to repository-relative paths."""
253
254 root = root.resolve()
255 findings: list[Finding] = []
256 for path in paths:
257 resolved = path.resolve()
258 try:
259 relative = resolved.relative_to(root)
260 except ValueError:
261 continue
262 if not is_runtime_path(relative):
263 continue
264 text = resolved.read_text(encoding="utf-8", errors="replace")
265 findings.extend(check_source(relative, text))
266 return sorted(findings, key=lambda item: (item.path, item.line, item.col, item.rule_id))
267
268
269def _normalize_repo_path(path: str | Path) -> str:
270 value = str(path).replace("\\", "/")
271 while value.startswith("./"):
272 value = value[2:]
273 return value.strip("/")
274
275
276def _path_parts(path: str | Path) -> tuple[str, ...]:
277 normalized = _normalize_repo_path(path)
278 return tuple(part for part in normalized.split("/") if part and part != ".")
279
280
281def _parse_source(text: str) -> ast.Module | None:
282 try:
283 return ast.parse(text)
284 except SyntaxError:
285 return None
286
287
288def _finding(rule_id: str, path: str, line: int, col: int, message: str) -> Finding:
289 metadata = RULES[rule_id]
290 return Finding(
291 rule_id=rule_id,
292 path=path,
293 line=line,
294 col=col,
295 message=message,
296 remediation=metadata.remediation,
297 )
298
299
300def _check_raw_tool_schema_dump(path: str, tree: ast.Module) -> list[Finding]:
301 if _path_parts(path)[0] == "providers":
302 return []
303
304 findings: list[Finding] = []
305 for scope in _iter_scopes(tree):
306 nodes = list(_iter_scope_nodes(scope))
307 aliases = _tool_schema_aliases(nodes)
308 for node in nodes:
309 if (
310 isinstance(node, ast.Call)
311 and _is_json_dumps(node.func)
312 and node.args
313 and isinstance(node.args[0], ast.Name)
314 and node.args[0].id in aliases
315 ):
316 findings.append(_finding(
317 "prompt_no_raw_tool_schema_dump",
318 path,
319 node.lineno,
320 node.col_offset + 1,
321 "Raw json.dumps(tool_schemas) or same-scope alias in prompt construction.",
322 ))
323 return findings
324
325
326def _iter_scopes(tree: ast.Module) -> Iterator[ast.AST]:
327 yield tree
328 for node in ast.walk(tree):
329 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
330 yield node
331
332
333def _iter_scope_nodes(scope: ast.AST) -> Iterator[ast.AST]:
334 body = getattr(scope, "body", [])
335 for child in body:
336 yield from _iter_scope_child_nodes(child)
337
338
339def _iter_scope_child_nodes(node: ast.AST) -> Iterator[ast.AST]:
340 yield node
341 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
342 return
343 for child in ast.iter_child_nodes(node):
344 yield from _iter_scope_child_nodes(child)
345
346
347def _tool_schema_aliases(nodes: list[ast.AST]) -> set[str]:
348 aliases = {"tool_schemas"}
349 changed = True
350 while changed:
351 changed = False
352 for node in nodes:
353 target_names = _assignment_target_names(node)
354 value_name = _assignment_value_name(node)
355 if value_name in aliases:
356 for name in target_names:
357 if name not in aliases:
358 aliases.add(name)
359 changed = True
360 return aliases
361
362
363def _assignment_target_names(node: ast.AST) -> list[str]:
364 if isinstance(node, ast.Assign):
365 return [
366 target.id
367 for target in node.targets
368 if isinstance(target, ast.Name)
369 ]
370 if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
371 return [node.target.id]
372 return []
373
374
375def _assignment_value_name(node: ast.AST) -> str | None:
376 value: ast.AST | None
377 if isinstance(node, ast.Assign):
378 value = node.value
379 elif isinstance(node, ast.AnnAssign):
380 value = node.value
381 else:
382 value = None
383 return value.id if isinstance(value, ast.Name) else None
384
385
386def _is_json_dumps(func: ast.AST) -> bool:
387 return (
388 isinstance(func, ast.Attribute)
389 and func.attr == "dumps"
390 and isinstance(func.value, ast.Name)
391 and func.value.id == "json"
392 )
393
394
395def _check_model_setter_chokepoint(path: str, tree: ast.Module) -> list[Finding]:
396 visitor = _ModelAssignmentVisitor(path)
397 visitor.visit(tree)
398 return visitor.findings
399
400
401class _ModelAssignmentVisitor(ast.NodeVisitor):
402 def __init__(self, path: str) -> None:
403 self.path = path
404 self.class_stack: list[str] = []
405 self.function_stack: list[str] = []
406 self.findings: list[Finding] = []
407
408 def visit_ClassDef(self, node: ast.ClassDef) -> None:
409 self.class_stack.append(node.name)
410 self.generic_visit(node)
411 self.class_stack.pop()
412
413 def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
414 self.function_stack.append(node.name)
415 self.generic_visit(node)
416 self.function_stack.pop()
417
418 def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
419 self.function_stack.append(node.name)
420 self.generic_visit(node)
421 self.function_stack.pop()
422
423 def visit_Assign(self, node: ast.Assign) -> None:
424 for target in node.targets:
425 self._check_target(target, node.value)
426 self.generic_visit(node)
427
428 def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
429 self._check_target(node.target, node.value)
430 self.generic_visit(node)
431
432 def visit_AugAssign(self, node: ast.AugAssign) -> None:
433 self._check_target(node.target, node.value)
434 self.generic_visit(node)
435
436 def _check_target(self, target: ast.AST, value: ast.AST | None) -> None:
437 if not _is_model_write_target(target):
438 return
439 if self._is_allowed_chokepoint(target, value):
440 return
441 self.findings.append(_finding(
442 "prompt_model_setter_chokepoint",
443 self.path,
444 getattr(target, "lineno", 1),
445 getattr(target, "col_offset", 0) + 1,
446 "Raw session.model/self.model assignment outside ChatSession.set_model.",
447 ))
448
449 def _is_allowed_chokepoint(self, target: ast.AST, value: ast.AST | None) -> bool:
450 return (
451 self.class_stack[-1:] == ["ChatSession"]
452 and self.function_stack[-1:] == ["set_model"]
453 and isinstance(target, ast.Attribute)
454 and isinstance(target.value, ast.Name)
455 and target.value.id == "self"
456 and isinstance(value, ast.Name)
457 and value.id == "new_model"
458 )
459
460
461def _is_model_write_target(target: ast.AST) -> bool:
462 return (
463 isinstance(target, ast.Attribute)
464 and target.attr == "model"
465 and isinstance(target.value, ast.Name)
466 and target.value.id in {"self", "session"}
467 )
468
469
470def _check_literal_mode_branch(path: str, tree: ast.Module) -> list[Finding]:
471 findings: list[Finding] = []
472 for node in ast.walk(tree):
473 test: ast.AST | None = None
474 if isinstance(node, (ast.If, ast.IfExp, ast.While)):
475 test = node.test
476 if test is None:
477 continue
478 for candidate in ast.walk(test):
479 if isinstance(candidate, ast.Compare) and _compare_has_mode_literal(candidate):
480 findings.append(_finding(
481 "prompt_no_literal_mode_branch",
482 path,
483 candidate.lineno,
484 candidate.col_offset + 1,
485 "Literal mode branch on standard/option-c.",
486 ))
487 return findings
488
489
490def _compare_has_mode_literal(node: ast.Compare) -> bool:
491 left = node.left
492 for operator, right in zip(node.ops, node.comparators):
493 if isinstance(operator, (ast.Eq, ast.NotEq)):
494 if (
495 (_is_mode_expr(left) and _is_mode_literal(right))
496 or (_is_mode_literal(left) and _is_mode_expr(right))
497 ):
498 return True
499 left = right
500 return False
501
502
503def _is_mode_expr(node: ast.AST) -> bool:
504 return (
505 (isinstance(node, ast.Name) and node.id == "mode")
506 or (isinstance(node, ast.Attribute) and node.attr == "mode")
507 )
508
509
510def _is_mode_literal(node: ast.AST) -> bool:
511 return isinstance(node, ast.Constant) and node.value in _MODE_LITERALS
512
513
515 path: str,
516 text: str,
517 tree: ast.Module | None,
518) -> list[Finding]:
519 allowed_default_system = _allowed_default_system_positions(path, tree)
520 findings: list[Finding] = []
521 try:
522 tokens = tokenize.generate_tokens(io.StringIO(text).readline)
523 for token in tokens:
524 if token.type != tokenize.NAME:
525 continue
526 line, col = token.start
527 if token.string in _RETIRED_NAMES:
528 findings.append(_finding(
529 "prompt_no_retired_composition_symbols",
530 path,
531 line,
532 col + 1,
533 f"Retired prompt composition symbol {token.string!r}.",
534 ))
535 elif token.string == "_DEFAULT_SYSTEM" and (line, col) not in allowed_default_system:
536 findings.append(_finding(
537 "prompt_no_retired_composition_symbols",
538 path,
539 line,
540 col + 1,
541 # OCT-LINT: disable=no_hardcoded_secrets reason="Rule IDs, mode literals, and remediation strings are invariant specifications, not secrets"
542 "Unauthorized _DEFAULT_SYSTEM name token.",
543 ))
544 except tokenize.TokenError:
545 return findings
546 return findings
547
548
550 path: str,
551 tree: ast.Module | None,
552) -> set[tuple[int, int]]:
553 if path != "oc_chat/core/session.py" or tree is None:
554 return set()
555 allowed: set[tuple[int, int]] = set()
556 for node in ast.walk(tree):
557 if not isinstance(node, ast.Assign):
558 continue
559 if not (
560 isinstance(node.value, ast.Name)
561 and node.value.id == "CANONICAL_POLICY"
562 ):
563 continue
564 for target in node.targets:
565 if isinstance(target, ast.Name) and target.id == "_DEFAULT_SYSTEM":
566 allowed.add((target.lineno, target.col_offset))
567 return allowed
568
569
570def _check_cache_ctx_fields_distinct(path: str, tree: ast.Module) -> list[Finding]:
571 findings: list[Finding] = []
572 for node in ast.walk(tree):
573 if not isinstance(node, ast.Call) or not _is_show_turn_summary_call(node):
574 continue
575 keyword_values = {
576 keyword.arg: keyword.value
577 for keyword in node.keywords
578 if keyword.arg in {"cache_hit_pct", "context_used_pct"}
579 }
580 cache_value = keyword_values.get("cache_hit_pct")
581 context_value = keyword_values.get("context_used_pct")
582 if cache_value is None or context_value is None:
583 continue
584 if isinstance(cache_value, ast.Constant) and isinstance(context_value, ast.Constant):
585 continue
586 if ast.dump(cache_value, include_attributes=False) != ast.dump(
587 context_value,
588 include_attributes=False,
589 ):
590 continue
591 findings.append(_finding(
592 "prompt_cache_ctx_fields_distinct",
593 path,
594 node.lineno,
595 node.col_offset + 1,
596 "cache_hit_pct and context_used_pct share the same computation.",
597 ))
598 return findings
599
600
601def _is_show_turn_summary_call(node: ast.Call) -> bool:
602 return (
603 (isinstance(node.func, ast.Attribute) and node.func.attr == "show_turn_summary")
604 or (isinstance(node.func, ast.Name) and node.func.id == "show_turn_summary")
605 )
None _check_target(self, ast.AST target, ast.AST|None value)
bool _is_allowed_chokepoint(self, ast.AST target, ast.AST|None value)
set[str] _tool_schema_aliases(list[ast.AST] nodes)
list[Finding] check_paths(Iterable[Path] paths, *, Path root)
Iterator[ast.AST] _iter_scope_child_nodes(ast.AST node)
list[Finding] _check_literal_mode_branch(str path, ast.Module tree)
tuple[str,...] _path_parts(str|Path path)
list[Finding] _check_model_setter_chokepoint(str path, ast.Module tree)
tuple[bool, str] check_prompt_invariants(str|Path path, str text)
list[str] _assignment_target_names(ast.AST node)
Iterator[Path] iter_runtime_paths(Path root)
bool _compare_has_mode_literal(ast.Compare node)
list[Finding] _check_raw_tool_schema_dump(str path, ast.Module tree)
Iterator[ast.AST] _iter_scopes(ast.Module tree)
set[tuple[int, int]] _allowed_default_system_positions(str path, ast.Module|None tree)
Finding _finding(str rule_id, str path, int line, int col, str message)
Iterator[ast.AST] _iter_scope_nodes(ast.AST scope)
list[Finding] _check_cache_ctx_fields_distinct(str path, ast.Module tree)
list[Finding] _check_retired_composition_symbols(str path, str text, ast.Module|None tree)
list[Finding] check_source(str|Path path, str text)