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).
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
22- Keep detector logic independent of OC Chat runtime imports.
34- Public checkers accept repository-relative paths for matching.
35- Absolute filesystem paths are normalized only through ``check_paths`` with
37- Detectors must not edit source or depend on live OC Chat runtime objects.
38- Findings include remediation text from the rule registry.
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)
51from __future__
import annotations
56from collections.abc
import Iterable, Iterator, Mapping
57from dataclasses
import dataclass
58from pathlib
import Path
59from types
import MappingProxyType
61from oct.core.diagnostics
import _dbg
64_dbg(
"OCT-LINTER",
"module",
"loaded", 4)
67@dataclass(frozen=True)
69 """Metadata for one portable invariant rule."""
78@dataclass(frozen=True)
80 """One invariant finding."""
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"
95RULES: Mapping[str, RuleMetadata] = MappingProxyType({
97 rule_id=
"prompt_no_raw_tool_schema_dump",
98 purpose=
"Prevent raw tool schema JSON from entering system-prompt construction.",
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."
104 detector_kind=
"AST with anchored same-scope alias tracking",
105 spec_reference=_PROMPT_REFS,
108 rule_id=
"prompt_model_setter_chokepoint",
109 purpose=
"Require runtime model switches to pass through ChatSession.set_model.",
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."
115 detector_kind=
"AST assignment checker",
116 spec_reference=_PROMPT_REFS,
119 rule_id=
"prompt_no_literal_mode_branch",
120 purpose=
"Keep prompt composition mode behavior declarative.",
122 "Modes are declarative profiles. Add or modify profile data and "
123 "shared resolver logic instead of hard-coding mode-specific branches."
125 detector_kind=
"AST branch-condition checker",
126 spec_reference=_PROMPT_REFS,
129 rule_id=
"prompt_no_retired_composition_symbols",
130 purpose=
"Prevent retired prompt-composition API names from reappearing.",
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."
137 detector_kind=
"Token checker with one explicit alias exception",
138 spec_reference=_PROMPT_REFS,
141 rule_id=
"prompt_cache_ctx_fields_distinct",
142 purpose=
"Keep cache-hit ratio and context saturation separate.",
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 "
149 detector_kind=
"AST call keyword checker",
150 spec_reference=_PROMPT_REFS,
160_MODE_LITERALS = {
"standard",
"option-c"}
162 "build_system_prompt_with_tools",
163 "ensure_system_includes_tool_schemas",
168 """Return whether *path* is in the root-anchored runtime source scope."""
171 if not parts
or not parts[-1].endswith(
".py"):
173 if any(part
in _EXCLUDED_PARTS
for part
in parts):
175 if parts == (
"oc_chat",
"main.py"):
177 if len(parts) >= 3
and parts[0] ==
"oc_chat" and parts[1] ==
"core":
179 if len(parts) >= 2
and parts[0]
in {
"providers",
"oc_ui"}:
185 """Check one repository-relative source path and return invariant findings."""
192 findings: list[Finding] = []
199 return sorted(findings, key=
lambda item: (item.path, item.line, item.col, item.rule_id))
203 """OCT linter adapter: collapse :func:`check_source` findings to the
204 ``(passed, message)`` contract the per-file linter dispatch expects.
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.
215 f
"{f.rule_id} @ {f.line}:{f.col} - {f.message}" for f
in findings
217 return False, message
221 """Yield existing Python files in OC Chat's runtime source scope."""
223 root = root.resolve()
225 root /
"oc_chat" /
"core",
226 root /
"oc_chat" /
"main.py",
230 seen: set[Path] = set()
231 for candidate
in candidates:
232 if candidate.is_file():
234 elif candidate.is_dir():
235 paths = sorted(candidate.rglob(
"*.py"))
239 resolved = path.resolve()
244 relative = resolved.relative_to(root)
251def check_paths(paths: Iterable[Path], *, root: Path) -> list[Finding]:
252 """Check filesystem paths by converting them to repository-relative paths."""
254 root = root.resolve()
255 findings: list[Finding] = []
257 resolved = path.resolve()
259 relative = resolved.relative_to(root)
264 text = resolved.read_text(encoding=
"utf-8", errors=
"replace")
266 return sorted(findings, key=
lambda item: (item.path, item.line, item.col, item.rule_id))
270 value = str(path).replace(
"\\",
"/")
271 while value.startswith(
"./"):
273 return value.strip(
"/")
278 return tuple(part
for part
in normalized.split(
"/")
if part
and part !=
".")
283 return ast.parse(text)
288def _finding(rule_id: str, path: str, line: int, col: int, message: str) -> Finding:
289 metadata = RULES[rule_id]
296 remediation=metadata.remediation,
304 findings: list[Finding] = []
310 isinstance(node, ast.Call)
313 and isinstance(node.args[0], ast.Name)
314 and node.args[0].id
in aliases
317 "prompt_no_raw_tool_schema_dump",
321 "Raw json.dumps(tool_schemas) or same-scope alias in prompt construction.",
328 for node
in ast.walk(tree):
329 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
334 body = getattr(scope,
"body", [])
341 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
343 for child
in ast.iter_child_nodes(node):
348 aliases = {
"tool_schemas"}
355 if value_name
in aliases:
356 for name
in target_names:
357 if name
not in aliases:
364 if isinstance(node, ast.Assign):
367 for target
in node.targets
368 if isinstance(target, ast.Name)
370 if isinstance(node, ast.AnnAssign)
and isinstance(node.target, ast.Name):
371 return [node.target.id]
376 value: ast.AST |
None
377 if isinstance(node, ast.Assign):
379 elif isinstance(node, ast.AnnAssign):
383 return value.id
if isinstance(value, ast.Name)
else None
388 isinstance(func, ast.Attribute)
389 and func.attr ==
"dumps"
390 and isinstance(func.value, ast.Name)
391 and func.value.id ==
"json"
398 return visitor.findings
410 self.generic_visit(node)
415 self.generic_visit(node)
420 self.generic_visit(node)
424 for target
in node.targets:
426 self.generic_visit(node)
430 self.generic_visit(node)
434 self.generic_visit(node)
442 "prompt_model_setter_chokepoint",
444 getattr(target,
"lineno", 1),
445 getattr(target,
"col_offset", 0) + 1,
446 "Raw session.model/self.model assignment outside ChatSession.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"
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"}
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)):
478 for candidate
in ast.walk(test):
481 "prompt_no_literal_mode_branch",
484 candidate.col_offset + 1,
485 "Literal mode branch on standard/option-c.",
492 for operator, right
in zip(node.ops, node.comparators):
493 if isinstance(operator, (ast.Eq, ast.NotEq)):
505 (isinstance(node, ast.Name)
and node.id ==
"mode")
506 or (isinstance(node, ast.Attribute)
and node.attr ==
"mode")
511 return isinstance(node, ast.Constant)
and node.value
in _MODE_LITERALS
517 tree: ast.Module |
None,
520 findings: list[Finding] = []
522 tokens = tokenize.generate_tokens(io.StringIO(text).readline)
524 if token.type != tokenize.NAME:
526 line, col = token.start
527 if token.string
in _RETIRED_NAMES:
529 "prompt_no_retired_composition_symbols",
533 f
"Retired prompt composition symbol {token.string!r}.",
535 elif token.string ==
"_DEFAULT_SYSTEM" and (line, col)
not in allowed_default_system:
537 "prompt_no_retired_composition_symbols",
542 "Unauthorized _DEFAULT_SYSTEM name token.",
544 except tokenize.TokenError:
551 tree: ast.Module |
None,
552) -> set[tuple[int, int]]:
553 if path !=
"oc_chat/core/session.py" or tree
is None:
555 allowed: set[tuple[int, int]] = set()
556 for node
in ast.walk(tree):
557 if not isinstance(node, ast.Assign):
560 isinstance(node.value, ast.Name)
561 and node.value.id ==
"CANONICAL_POLICY"
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))
571 findings: list[Finding] = []
572 for node
in ast.walk(tree):
576 keyword.arg: keyword.value
577 for keyword
in node.keywords
578 if keyword.arg
in {
"cache_hit_pct",
"context_used_pct"}
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:
584 if isinstance(cache_value, ast.Constant)
and isinstance(context_value, ast.Constant):
586 if ast.dump(cache_value, include_attributes=
False) != ast.dump(
588 include_attributes=
False,
592 "prompt_cache_ctx_fields_distinct",
596 "cache_hit_pct and context_used_pct share the same computation.",
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")
None visit_Assign(self, ast.Assign node)
None __init__(self, str path)
None _check_target(self, ast.AST target, ast.AST|None value)
None visit_AugAssign(self, ast.AugAssign node)
None visit_AsyncFunctionDef(self, ast.AsyncFunctionDef node)
bool _is_allowed_chokepoint(self, ast.AST target, ast.AST|None value)
None visit_AnnAssign(self, ast.AnnAssign node)
None visit_FunctionDef(self, ast.FunctionDef node)
None visit_ClassDef(self, ast.ClassDef node)
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)
bool _is_model_write_target(ast.AST target)
tuple[str,...] _path_parts(str|Path path)
list[Finding] _check_model_setter_chokepoint(str path, ast.Module tree)
bool _is_json_dumps(ast.AST func)
str|None _assignment_value_name(ast.AST node)
bool is_runtime_path(str|Path path)
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 _is_show_turn_summary_call(ast.Call node)
bool _is_mode_expr(ast.AST node)
bool _compare_has_mode_literal(ast.Compare node)
ast.Module|None _parse_source(str text)
list[Finding] _check_raw_tool_schema_dump(str path, ast.Module tree)
Iterator[ast.AST] _iter_scopes(ast.Module tree)
bool _is_mode_literal(ast.AST node)
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)
str _normalize_repo_path(str|Path path)
list[Finding] check_source(str|Path path, str text)