171 """Validate the structure of a loaded ``.octrc.json`` dict.
173 Returns a list of human-readable error strings. Empty list means the
174 config is structurally acceptable. Unknown keys are tolerated (forward
175 compat); only known keys are type-checked.
177 errors: list[str] = []
178 if not isinstance(cfg, dict):
179 return [f
"top-level must be an object, got {type(cfg).__name__}"]
181 linter_cfg = cfg.get(
"linter")
182 if linter_cfg
is not None and not isinstance(linter_cfg, dict):
183 errors.append(f
"'linter' must be an object, got {type(linter_cfg).__name__}")
186 profile = linter_cfg.get(
"profile")
if isinstance(linter_cfg, dict)
else None
187 if profile
is not None:
188 if not isinstance(profile, str):
189 errors.append(f
"'linter.profile' must be a string, got {type(profile).__name__}")
190 elif profile
not in _PROFILES:
191 valid =
", ".join(p
for p
in _PROFILES
if not p.startswith(
"_"))
192 errors.append(f
"'linter.profile' unknown value '{profile}' (valid: {valid})")
194 if isinstance(linter_cfg, dict):
195 for key
in (
"exclude_dirs_add",
"exclude_dirs_remove",
"wildcard_exclude_add"):
196 val = linter_cfg.get(key)
199 if not isinstance(val, list):
200 errors.append(f
"'linter.{key}' must be a list, got {type(val).__name__}")
202 for i, item
in enumerate(val):
203 if not isinstance(item, str):
204 errors.append(f
"'linter.{key}[{i}]' must be a string, got {type(item).__name__}")
206 rules = linter_cfg.get(
"rules")
207 if rules
is not None:
208 if not isinstance(rules, dict):
209 errors.append(f
"'linter.rules' must be an object, got {type(rules).__name__}")
211 for rule_name, rule_val
in rules.items():
212 if not isinstance(rule_val, bool):
214 f
"'linter.rules.{rule_name}' must be a bool, got {type(rule_val).__name__}"
220 formatter_cfg = cfg.get(
"formatter")
221 if formatter_cfg
is not None:
222 if not isinstance(formatter_cfg, dict):
224 f
"'formatter' must be an object, got {type(formatter_cfg).__name__}"
227 max_backups = formatter_cfg.get(
"max_backups_per_file")
228 if max_backups
is not None:
229 if not isinstance(max_backups, int)
or isinstance(max_backups, bool):
231 "'formatter.max_backups_per_file' must be an int, "
232 f
"got {type(max_backups).__name__}"
234 elif max_backups < 0:
236 "'formatter.max_backups_per_file' must be >= 0, "
241 git_cfg = cfg.get(
"git")
242 if git_cfg
is not None:
243 if not isinstance(git_cfg, dict):
244 errors.append(f
"'git' must be an object, got {type(git_cfg).__name__}")
246 _GIT_VALID_PROFILES = (
"proto",
"compact",
"strict",
"auto")
248 pb = git_cfg.get(
"protected_branches")
250 if not isinstance(pb, list):
251 errors.append(f
"'git.protected_branches' must be a list, got {type(pb).__name__}")
253 for i, item
in enumerate(pb):
254 if not isinstance(item, str):
255 errors.append(f
"'git.protected_branches[{i}]' must be a string, got {type(item).__name__}")
257 bn = git_cfg.get(
"branch_naming")
259 if not isinstance(bn, dict):
260 errors.append(f
"'git.branch_naming' must be an object, got {type(bn).__name__}")
262 bn_enabled = bn.get(
"enabled")
263 if bn_enabled
is not None and not isinstance(bn_enabled, bool):
264 errors.append(f
"'git.branch_naming.enabled' must be a bool, got {type(bn_enabled).__name__}")
265 bn_patterns = bn.get(
"patterns")
266 if bn_patterns
is not None:
267 if not isinstance(bn_patterns, list):
268 errors.append(f
"'git.branch_naming.patterns' must be a list, got {type(bn_patterns).__name__}")
270 for i, pat
in enumerate(bn_patterns):
271 if not isinstance(pat, str):
272 errors.append(f
"'git.branch_naming.patterns[{i}]' must be a string, got {type(pat).__name__}")
274 pcp = git_cfg.get(
"pre_commit_profile")
276 if not isinstance(pcp, str):
277 errors.append(f
"'git.pre_commit_profile' must be a string, got {type(pcp).__name__}")
278 elif pcp
not in _GIT_VALID_PROFILES:
279 errors.append(f
"'git.pre_commit_profile' unknown value '{pcp}' (valid: {', '.join(_GIT_VALID_PROFILES)})")
282 "conventional_commits",
"require_changelog_update",
283 "auto_branch_on_protected",
"add_post_check",
285 val = git_cfg.get(bool_key)
286 if val
is not None and not isinstance(val, bool):
287 errors.append(f
"'git.{bool_key}' must be a bool, got {type(val).__name__}")
289 sds = git_cfg.get(
"status_default_scope")
291 if not isinstance(sds, str):
293 f
"'git.status_default_scope' must be a string, "
294 f
"got {type(sds).__name__}"
296 elif sds
not in (
"project",
"repo",
"auto"):
298 f
"'git.status_default_scope' unknown value "
299 f
"'{sds}' (valid: project, repo, auto)"
302 oum = git_cfg.get(
"observe_unstaged_mutation")
304 if isinstance(oum, bool):
306 elif isinstance(oum, str):
307 if oum
not in (
"off",
"warn",
"block"):
309 f
"'git.observe_unstaged_mutation' unknown "
310 f
"value '{oum}' (valid: off, warn, block)"
314 f
"'git.observe_unstaged_mutation' must be a "
315 f
"bool or string, got {type(oum).__name__}"
318 ids = git_cfg.get(
"ignore_default_scope")
320 if not isinstance(ids, str):
322 f
"'git.ignore_default_scope' must be a string, "
323 f
"got {type(ids).__name__}"
325 elif ids
not in (
"workspace",
"project",
"nearest"):
327 f
"'git.ignore_default_scope' unknown value "
328 f
"'{ids}' (valid: workspace, project, nearest)"
331 for num_key
in (
"large_file_warn_mb",
"timeout",
"long_timeout"):
332 val = git_cfg.get(num_key)
334 if not isinstance(val, (int, float))
or isinstance(val, bool):
335 errors.append(f
"'git.{num_key}' must be a number, got {type(val).__name__}")
337 errors.append(f
"'git.{num_key}' must be > 0, got {val}")
340 scanner_cfg = cfg.get(
"secret_scanner")
341 if scanner_cfg
is not None:
342 if not isinstance(scanner_cfg, dict):
343 errors.append(f
"'secret_scanner' must be an object, got {type(scanner_cfg).__name__}")
345 et = scanner_cfg.get(
"code_entropy_threshold")
347 if not isinstance(et, (int, float))
or isinstance(et, bool):
349 f
"'secret_scanner.code_entropy_threshold' must be a number, got {type(et).__name__}"
353 f
"'secret_scanner.code_entropy_threshold' must be >= 0, got {et}"
355 for key
in (
"docstring_entropy_threshold",
"comment_entropy_threshold"):
356 val = scanner_cfg.get(key)
358 if not isinstance(val, (int, float))
or isinstance(val, bool):
360 f
"'secret_scanner.{key}' must be a number, got {type(val).__name__}"
364 f
"'secret_scanner.{key}' must be >= 0, got {val}"
368 excl = scanner_cfg.get(
"entropy_exclusions")
370 if not isinstance(excl, list):
371 errors.append(
"'secret_scanner.entropy_exclusions' must be an array")
373 for i, entry
in enumerate(excl):
374 if not isinstance(entry, dict):
376 f
"'secret_scanner.entropy_exclusions[{i}]' must be an object"
379 ctx_val = entry.get(
"context")
380 if ctx_val
not in (
"namedtuple_field",
"test_directory",
"module_self_doc"):
382 f
"'secret_scanner.entropy_exclusions[{i}].context' "
383 f
"must be 'namedtuple_field', 'test_directory', or 'module_self_doc'"
385 if ctx_val ==
"namedtuple_field":
386 if not isinstance(entry.get(
"field_name_pattern"), str):
388 f
"'secret_scanner.entropy_exclusions[{i}].field_name_pattern' "
391 elif ctx_val
in (
"test_directory",
"module_self_doc"):
392 if not isinstance(entry.get(
"path_pattern"), str):
394 f
"'secret_scanner.entropy_exclusions[{i}].path_pattern' "
397 restrictions = entry.get(
"restrictions", {})
398 if not isinstance(restrictions, dict):
400 f
"'secret_scanner.entropy_exclusions[{i}].restrictions' "
870 tree: ast.Module |
None =
None,
871) -> list[tuple[str, int]]:
873 Return ``[(func_name, lineno), ...]`` for every function that calls
874 ``_dbg()`` without a prior ``func = "..."`` assignment.
876 Returns an empty list for trivial files (< 20 lines) or files with
877 syntax errors. This is the single source of truth for func-pattern
878 detection — used by :func:`check_func_pattern` (linter pass/fail)
879 and by :func:`oct.formatter.oct_format.fix_func_pattern` (auto-fix).
881 lines = text.splitlines()
886 tree, _ = safe_parse(text)
890 violations: list[tuple[str, int]] = []
893 node
for node
in ast.walk(tree)
894 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
897 for func_node
in func_nodes:
903 for stmt
in func_node.body:
904 if (isinstance(stmt, ast.Expr)
905 and isinstance(stmt.value, ast.Constant)
906 and isinstance(stmt.value.value, str)):
908 if (isinstance(stmt, ast.Assign)
909 and len(stmt.targets) == 1
910 and isinstance(stmt.targets[0], ast.Name)
911 and stmt.targets[0].id ==
"func"
912 and isinstance(stmt.value, ast.Constant)
913 and isinstance(stmt.value.value, str)):
917 if non_doc_seen >= 3:
922 if isinstance(node, ast.Call):
923 if (isinstance(node.func, ast.Name)
924 and node.func.id ==
"_dbg"):
926 if (isinstance(node.func, ast.Attribute)
927 and node.func.attr ==
"_dbg"):
930 if has_dbg_call
and not has_func_var:
931 violations.append((func_node.name, func_node.lineno))
1065 text: str, tree: ast.Module |
None =
None, filename: str =
""
1066) -> tuple[bool, str]:
1068 Flag ``_dbg_assert`` calls made inside security-sensitive contexts.
1070 ``_dbg_assert`` is a diagnostic aid — in production mode it is a no-op.
1071 Per AP-16 it must never be used for safety invariants (authentication,
1072 authorization, input validation, crypto, etc.). This check walks the AST
1073 looking for calls named ``_dbg_assert`` (bare or attribute access) and
1074 verifies that none of the enclosing functions, classes, or the module's
1075 filename stem contain a security-sensitive keyword.
1078 tree, _ = safe_parse(text)
1080 return True,
"syntax error; skipped"
1083 module_sensitive =
False
1085 stem = Path(filename).stem
1087 module_sensitive =
True
1089 violations: list[str] = []
1091 def _walk(node: ast.AST, scopes: list[str]) ->
None:
1092 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
1093 new_scopes = scopes + [node.name]
1094 for child
in ast.iter_child_nodes(node):
1095 _walk(child, new_scopes)
1098 if isinstance(node, ast.Call):
1101 if isinstance(func, ast.Name):
1103 elif isinstance(func, ast.Attribute):
1104 call_name = func.attr
1105 if call_name ==
"_dbg_assert":
1106 sensitive_scope =
None
1107 for scope
in scopes:
1109 sensitive_scope = scope
1111 if sensitive_scope
is None and module_sensitive:
1112 sensitive_scope = f
"module '{Path(filename).stem}'"
1113 if sensitive_scope
is not None:
1115 f
"L{node.lineno}: _dbg_assert used in security-sensitive "
1116 f
"context '{sensitive_scope}' — use a hard assertion (AP-16)"
1119 for child
in ast.iter_child_nodes(node):
1120 _walk(child, scopes)
1125 return False,
"; ".join(violations[:5])
1219 dry_run: bool =
False,
1221 """Lint one Python file and return a result dict with check outcomes.
1223 Thread-safe: logging uses ctx.log_lock internally.
1226 rel = to_project_relative(path, ctx.project_root)
1229 log(f
"--- {rel} ---", ctx)
1232 _file_hash: str |
None =
None
1233 _rules_hash: str |
None =
None
1234 if ctx.lint_cache
is not None and not fix_mode:
1236 _file_hash = content_hash(text)
1237 _rules_hash = rules_hash(active_rules)
1238 if not ctx.no_cache_read:
1239 cached = ctx.lint_cache.get(_file_hash, _rules_hash)
1240 if cached
is not None:
1241 log(
" (cached)", ctx)
1242 result = dict(cached.rule_results)
1243 result[
"json_detail"] = cached.json_detail
1244 result[
"fixed"] =
False
1245 result[
"rel"] = str(rel)
1251 _checks = (cached.json_detail
or {}).get(
"checks", {})
1253 result[
"compliant"] = all(
1255 for c
in _checks.values()
1256 if c.get(
"severity", _SB) == _SB
1263 result[
"compliant"] = all(
1264 v
for k, v
in result.items()
1265 if isinstance(v, bool)
and k !=
"fixed"
1270 tree, syntax_warnings_list = safe_parse(text)
1272 lines = text.splitlines()
1273 header_errors: list[str] = []
1274 doc_msg = diag_msg = dbg_msg = dbg_imp_msg = func_msg = secrets_msg =
""
1276 compact_mode = active_rules.get(
"_compact_mode",
False)
1279 waivers = parse_inline_waivers(text)
1280 waived_rules: set[str] = {w[
"rule"]
for w
in waivers.values()
if w[
"reason"]
is not None}
1281 waiver_warnings: list[str] = []
1282 for lineno, w
in waivers.items():
1283 if w[
"reason"]
is None:
1284 waiver_warnings.append(
1285 f
"Line {lineno}: waiver for '{w['rule']}' has no reason"
1289 "header":
False,
"docstring":
False,
"diagnostics_profile":
False,
1290 "dbg_usage":
False,
"dbg_import":
False,
"func_pattern":
False,
1291 "syntax_warnings":
False,
"tests_exist":
False,
"type_hints":
False,
1292 "no_hardcoded_secrets":
False,
1293 "fixed":
False,
"rel": str(rel),
1294 "waivers": [{
"line": ln, **w}
for ln, w
in waivers.items()],
1298 def _rule_active(rule: str) -> bool:
1299 return active_rules.get(rule,
True)
and rule
not in waived_rules
1301 if _rule_active(
"header"):
1303 if fix_mode
and not ok_header:
1305 result[
"fixed"] =
True
1306 result[
"header"] =
False
1307 report(
False, f
"Would fix header: {', '.join(header_errors)}", fix_mode=
True, ctx=ctx)
1310 log(
" Current header lines:", ctx)
1314 for i, hl
in enumerate(lines[:boundary], 1):
1315 log(f
" {i}: {hl}", ctx)
1316 log(
" Corrected header:", ctx)
1317 for i, hl
in enumerate(preview.splitlines() + [
""], 1):
1318 log(f
" {i}: {hl}", ctx)
1321 result[
"fixed"] =
True
1322 result[
"header"] =
True
1323 report(
False, f
"Header block corrected: {', '.join(header_errors)}", fix_mode=
True, ctx=ctx)
1325 result[
"header"] = ok_header
1326 report(ok_header, f
"Header block: {', '.join(header_errors) if header_errors else 'OK'}", ctx=ctx)
1329 result[
"header"] =
True
1331 if _rule_active(
"docstring"):
1333 result[
"docstring"] = ok_doc
1334 report(ok_doc, f
"Docstring structure: {doc_msg or 'OK'}", ctx=ctx)
1337 result[
"docstring"] =
True
1339 if _rule_active(
"dependencies_section"):
1341 result[
"dependencies_section"] = ok_deps
1342 report(ok_deps, f
"Dependencies section: {deps_msg or 'OK'}", ctx=ctx)
1345 result[
"dependencies_section"] =
True
1347 if _rule_active(
"diagnostics_profile"):
1349 result[
"diagnostics_profile"] = ok_diag
1350 report(ok_diag, f
"Diagnostics profile: {diag_msg or 'OK'}", ctx=ctx)
1353 result[
"diagnostics_profile"] =
True
1355 if _rule_active(
"dbg_usage"):
1357 result[
"dbg_usage"] = ok_dbg
1358 report(ok_dbg, f
"_dbg usage: {dbg_msg or 'OK'}", ctx=ctx)
1361 result[
"dbg_usage"] =
True
1363 if _rule_active(
"dbg_import"):
1365 result[
"dbg_import"] = ok_dbg_imp
1366 report(ok_dbg_imp, f
"_dbg import: {dbg_imp_msg or 'OK'}", ctx=ctx)
1369 result[
"dbg_import"] =
True
1371 if _rule_active(
"func_pattern"):
1373 result[
"func_pattern"] = ok_func
1374 report(ok_func, f
"func pattern: {func_msg or 'OK'}", ctx=ctx)
1377 result[
"func_pattern"] =
True
1380 result[
"syntax_warnings"] = ok_syntax
1381 report(ok_syntax, f
"Syntax warnings: {syntax_msg or 'OK'}", ctx=ctx)
1383 if _rule_active(
"type_hints"):
1384 ok_hints, hints_msg =
check_type_hints(text, tree=tree, compact_mode=compact_mode)
1385 result[
"type_hints"] = ok_hints
1386 report(ok_hints, f
"Type hints: {hints_msg or 'OK'}", ctx=ctx)
1389 result[
"type_hints"] =
True
1391 if _rule_active(
"no_hardcoded_secrets"):
1396 file_entropy = ctx.code_entropy_threshold
1397 for excl
in ctx.entropy_exclusions:
1398 if excl.get(
"context")
in (
"test_directory",
"module_self_doc"):
1399 pattern = excl.get(
"path_pattern",
"")
1400 restrictions = excl.get(
"restrictions", {})
1401 if pattern
and rel.replace(
"\\",
"/").startswith(pattern):
1402 if restrictions.get(
"exempt_entropy_only"):
1405 ok_secrets, secrets_msg = check_no_hardcoded_secrets(
1406 text, tree=tree, mode=ctx.secret_match_mode,
1407 code_entropy_threshold=file_entropy,
1408 docstring_entropy_threshold=ctx.docstring_entropy_threshold,
1409 comment_entropy_threshold=ctx.comment_entropy_threshold,
1410 excluded_field_patterns=ctx.excluded_field_patterns,
1412 result[
"no_hardcoded_secrets"] = ok_secrets
1413 report(ok_secrets, f
"Hardcoded secrets: {secrets_msg or 'OK'}", ctx=ctx)
1416 result[
"no_hardcoded_secrets"] =
True
1418 if _rule_active(
"dbg_assert_safety"):
1420 text, tree=tree, filename=str(path)
1422 result[
"dbg_assert_safety"] = ok_dbg_assert
1423 report(ok_dbg_assert, f
"dbg_assert safety: {dbg_assert_msg or 'OK'}", ctx=ctx)
1425 ok_dbg_assert =
True
1426 result[
"dbg_assert_safety"] =
True
1428 if _rule_active(
"tests_exist"):
1430 result[
"tests_exist"] = ok_test
1431 report(ok_test,
"Regression test exists", ctx=ctx)
1434 result[
"tests_exist"] =
True
1437 for ww
in waiver_warnings:
1438 report(
False, f
"Waiver warning: {ww}", ctx=ctx)
1444 "header": ok_header
or fix_mode,
1445 "docstring": ok_doc,
1446 "dependencies_section": result.get(
"dependencies_section",
True),
1447 "diagnostics_profile": ok_diag,
1448 "dbg_usage": ok_dbg,
1449 "dbg_import": ok_dbg_imp,
1450 "func_pattern": ok_func,
1451 "syntax_warnings": ok_syntax,
1452 "type_hints": ok_hints,
1453 "no_hardcoded_secrets": ok_secrets,
1454 "dbg_assert_safety": ok_dbg_assert,
1455 "tests_exist": ok_test,
1457 _check_severities = {
1458 rule: get_severity(rule, ctx.profile_name)
1459 for rule
in _check_results
1463 result[
"compliant"] = all(
1465 for rule, passed
in _check_results.items()
1466 if _check_severities[rule] == SEVERITY_BLOCKING
1469 def _check_block(passed: bool, message: str, rule: str) -> dict:
1473 "severity": _check_severities[rule],
1477 result[
"json_detail"] = {
1480 "header": _check_block(
1481 ok_header
or (fix_mode
and not ok_header),
1482 ", ".join(header_errors)
if header_errors
else "OK",
1485 "docstring": _check_block(ok_doc, doc_msg
or "OK",
"docstring"),
1486 "dependencies_section": _check_block(
1487 result.get(
"dependencies_section",
True),
1488 "OK" if result.get(
"dependencies_section",
True)
else "Missing or malformed Dependencies section",
1489 "dependencies_section",
1491 "diagnostics_profile": _check_block(ok_diag, diag_msg
or "OK",
"diagnostics_profile"),
1492 "dbg_usage": _check_block(ok_dbg, dbg_msg
or "OK",
"dbg_usage"),
1493 "dbg_import": _check_block(ok_dbg_imp, dbg_imp_msg
or "OK",
"dbg_import"),
1494 "func_pattern": _check_block(ok_func, func_msg
or "OK",
"func_pattern"),
1495 "syntax_warnings": _check_block(ok_syntax, syntax_msg
or "OK",
"syntax_warnings"),
1496 "type_hints": _check_block(ok_hints, hints_msg
if not ok_hints
else "OK",
"type_hints"),
1497 "no_hardcoded_secrets": _check_block(ok_secrets, secrets_msg
if not ok_secrets
else "OK",
"no_hardcoded_secrets"),
1498 "dbg_assert_safety": _check_block(ok_dbg_assert, dbg_assert_msg
if not ok_dbg_assert
else "OK",
"dbg_assert_safety"),
1499 "tests_exist": _check_block(ok_test,
"OK" if ok_test
else "No test found",
"tests_exist"),
1501 "compliant": result[
"compliant"],
1502 "waivers": result[
"waivers"],
1506 if ctx.lint_cache
is not None and not fix_mode
and _file_hash
and _rules_hash:
1508 from datetime
import datetime
as _dt
1509 from oct
import __version__
as _ver
1510 cache_entry = LintCacheEntry(
1511 content_hash=_file_hash,
1513 k: v
for k, v
in result.items()
1514 if k
not in (
"fixed",
"rel",
"json_detail",
"waivers",
"compliant")
1516 json_detail=result[
"json_detail"],
1518 timestamp=_dt.now().isoformat(),
1520 ctx.lint_cache.put(_file_hash, _rules_hash, cache_entry)
1530def run_linter(project_root: Path, argv: list[str] |
None =
None) -> int:
1532 Run the Option C linter on the given project root.
1537 The detected Option C project root directory.
1539 Optional list of command-line arguments (e.g. ["--fix-headers"]).
1540 If None, ``argparse`` will use ``sys.argv``.
1545 Exit code: 0 if all checks passed, 1 if any failures detected.
1547 parser = argparse.ArgumentParser(add_help=
False)
1548 parser.add_argument(
"--help", action=
"store_true")
1549 parser.add_argument(
"--fix-headers", action=
"store_true")
1550 parser.add_argument(
"--dry-run", action=
"store_true",
1551 help=
"Preview changes without modifying files")
1552 parser.add_argument(
1554 action=
"store_true",
1555 help=
"Emit machine-readable JSON to stdout instead of colorized terminal output",
1557 parser.add_argument(
1561 help=
"Parallel workers for file processing (default: 1)",
1563 parser.add_argument(
1564 "--changed", action=
"store_true",
1565 help=
"Lint only git-modified Python files",
1567 parser.add_argument(
1571 help=
"Lint profile: proto, compact, strict (overrides .octrc.json)",
1573 parser.add_argument(
1576 choices=(
"substring",
"word"),
1577 default=
"substring",
1579 "Secret-name matcher (OI-529): 'substring' (default, legacy) or "
1580 "'word' (segment-aware; avoids false positives like 'compass')."
1583 parser.add_argument(
1584 "--code-entropy-threshold",
1587 dest=
"entropy_threshold",
1588 help=
"Shannon entropy threshold for code string literals (default: 4.5)",
1590 parser.add_argument(
1592 action=
"store_true",
1593 help=
"Disable entropy-based secret detection",
1595 parser.add_argument(
1597 action=
"store_true",
1598 help=
"Bypass lint cache reads (still writes updated cache)",
1600 parser.add_argument(
1602 action=
"store_true",
1603 help=
"Save current violations as the baseline",
1605 parser.add_argument(
1606 "--against-baseline",
1607 action=
"store_true",
1608 help=
"Report only new/resolved violations vs saved baseline",
1610 parser.add_argument(
1614 help=
"Show documentation for a lint rule (rationale, pattern, spec ref)",
1616 parser.add_argument(
1617 "targets", nargs=
"*", default=
None,
1618 help=
"Files or directories to lint (default: entire project)",
1620 args = parser.parse_args(argv)
1623 print(
"Option C Linter")
1625 print(
" oct lint [--fix-headers] [--dry-run] [--json] [--jobs N] [--changed] [--profile P] [--secret-match MODE] [PATH ...]")
1626 print(
" oct lint --explain RULE [--json]")
1629 print(
" --help Show this help message")
1630 print(
" --fix-headers Automatically fix shebang, encoding, and path headers")
1631 print(
" --dry-run Preview changes without modifying files")
1632 print(
" --json Emit machine-readable JSON to stdout")
1633 print(
" --jobs N, -j N Parallel workers for file processing (default: 1)")
1634 print(
" --changed Lint only git-modified Python files")
1635 print(
" --profile P Lint profile: proto, compact, strict (overrides .octrc.json)")
1636 print(
" --secret-match MODE Secret-name matcher: substring (default) | word (segment-aware)")
1637 print(
" --code-entropy-threshold N Shannon entropy threshold for code strings (default: 4.5)")
1638 print(
" --no-entropy Disable entropy-based secret detection")
1639 print(
" --no-cache Bypass lint cache reads (still writes)")
1640 print(
" --baseline Save current violations as the baseline")
1641 print(
" --against-baseline Report only new/resolved vs saved baseline")
1642 print(
" --explain RULE Show rule documentation (rationale, pattern, spec ref)")
1643 print(
" PATH ... Files or directories to lint (default: entire project)")
1649 info = get_rule_info(args.explain)
1651 available =
", ".join(r.name
for r
in list_rules())
1652 print(f
"Error: unknown rule '{args.explain}'", file=sys.stderr)
1653 print(f
"Available rules: {available}", file=sys.stderr)
1656 from dataclasses
import asdict
1657 json.dump(asdict(info), sys.stdout, indent=2)
1660 print(f
"Rule: {info.name}")
1661 print(f
"Label: {info.label}")
1662 print(f
"Fixable: {'yes' if info.fixable else 'no'}")
1663 print(f
"Profiles: {', '.join(info.profiles)}")
1664 print(f
"Spec: {info.spec_reference}")
1667 print(f
" {info.rationale}")
1669 print(
"Correct pattern:")
1670 for line
in info.correct_pattern.splitlines():
1673 print(
"Common mistake:")
1674 print(f
" {info.common_mistake}")
1678 if args.baseline
and args.against_baseline:
1679 print(
"Error: --baseline and --against-baseline are mutually exclusive.",
1684 from oct.core.compat
import check_oc_diagnostics_compat
1685 compat_msg = check_oc_diagnostics_compat()
1687 print(compat_msg, file=sys.stderr)
1689 root = project_root.resolve()
1690 _oc_diag = root /
"oc_diagnostics"
1693 project_name=root.name,
1694 diagnostics_dir=_oc_diag
if _oc_diag.exists()
else root /
"diagnostics",
1695 tests_dir=root /
"tests",
1696 docs_dir=root /
"docs",
1697 secret_match_mode=args.secret_match,
1700 if ctx.diagnostics_dir.name ==
"diagnostics":
1702 "Warning: 'diagnostics/' is deprecated and will be removed in v1.0.0. "
1703 "Rename to 'oc_diagnostics/'.",
1715 from oct.core.octrc
import load_octrc
1716 active_exclude_dirs = set(EXCLUDE_DIRS)
1717 active_wildcard_exclude = list(WILDCARD_EXCLUDE_DIRS)
1718 active_rules = dict(_DEFAULT_RULES)
1719 octrc, octrc_errors = load_octrc(root)
1721 msg =
"; ".join(octrc_errors)
1723 print(f
"Error: {msg}", file=sys.stderr)
1725 print(f
"Warning: {msg}; using defaults.", file=sys.stderr)
1731 msg = f
".octrc.json schema errors: {'; '.join(schema_errors)}"
1733 print(f
"Error: {msg}", file=sys.stderr)
1735 print(f
"Warning: {msg}", file=sys.stderr)
1737 linter_cfg = octrc.get(
"linter", {})
1738 if not isinstance(linter_cfg, dict):
1740 for key
in (
"exclude_dirs_add",
"exclude_dirs_remove",
"wildcard_exclude_add"):
1741 val = linter_cfg.get(key)
1742 if val
is not None and not isinstance(val, list):
1743 linter_cfg[key] = []
1744 if not isinstance(linter_cfg.get(
"profile",
"default"), str):
1745 linter_cfg[
"profile"] =
"default"
1746 if linter_cfg.get(
"rules")
is not None and not isinstance(linter_cfg[
"rules"], dict):
1747 linter_cfg[
"rules"] = {}
1748 for d
in linter_cfg.get(
"exclude_dirs_add", []):
1749 if isinstance(d, str):
1750 active_exclude_dirs.add(d)
1751 for d
in linter_cfg.get(
"exclude_dirs_remove", []):
1752 if isinstance(d, str):
1753 active_exclude_dirs.discard(d)
1754 for d
in linter_cfg.get(
"wildcard_exclude_add", []):
1755 if isinstance(d, str)
and d
not in active_wildcard_exclude:
1756 active_wildcard_exclude.append(d)
1758 profile_name = linter_cfg.get(
"profile",
"default")
1759 active_rules = dict(_PROFILES.get(profile_name, _DEFAULT_RULES))
1760 rule_overrides = linter_cfg.get(
"rules", {})
or {}
1761 active_rules.update({k: v
for k, v
in rule_overrides.items()
if isinstance(v, bool)})
1762 ctx.profile_name = profile_name
1765 scanner_cfg = octrc.get(
"secret_scanner", {})
1766 if isinstance(scanner_cfg, dict):
1767 octrc_threshold = scanner_cfg.get(
"code_entropy_threshold")
1768 if isinstance(octrc_threshold, (int, float))
and not isinstance(octrc_threshold, bool):
1769 ctx.code_entropy_threshold = float(octrc_threshold)
1770 for key
in (
"docstring_entropy_threshold",
"comment_entropy_threshold"):
1771 val = scanner_cfg.get(key)
1772 if isinstance(val, (int, float))
and not isinstance(val, bool):
1773 setattr(ctx, key, float(val))
1776 exclusions = scanner_cfg.get(
"entropy_exclusions", [])
1777 if isinstance(exclusions, list):
1778 ctx.entropy_exclusions = exclusions
1779 ctx.excluded_field_patterns = [
1780 e[
"field_name_pattern"]
for e
in exclusions
1781 if e.get(
"context") ==
"namedtuple_field"
1782 and isinstance(e.get(
"field_name_pattern"), str)
1787 cli_profile = args.profile.lower()
1788 if cli_profile
in _PROFILES:
1789 active_rules = dict(_PROFILES[cli_profile])
1790 ctx.profile_name = cli_profile
1793 f
"Warning: unknown profile '{args.profile}'; "
1794 f
"valid: {', '.join(p for p in _PROFILES if not p.startswith('_'))}",
1800 ctx.code_entropy_threshold = 0.0
1801 elif args.entropy_threshold
is not None:
1802 ctx.code_entropy_threshold = args.entropy_threshold
1805 from oct.core.option_c_dir
import option_c_dir
1806 from oct
import __version__
1808 if not args.fix_headers:
1810 ctx.lint_cache = LintCache(cache_dir /
"lint-cache.json", __version__)
1811 ctx.no_cache_read = args.no_cache
1815 log_dir = root /
"logs"
1816 log_dir.mkdir(exist_ok=
True)
1818 timestamp = datetime.now().strftime(
"%Y%m%d-%H%M%S")
1819 log_file = log_dir / f
"oct_lint_output-{timestamp}.txt"
1820 ctx.log_handle = log_file.open(
"w", encoding=
"utf-8")
1821 ctx.log_path = log_file
1822 except OSError
as exc:
1823 print(f
"Warning: Could not open lint log file: {exc}", file=sys.stderr)
1824 ctx.log_handle =
None
1826 fix_mode = args.fix_headers
1827 dry_run = args.dry_run
1828 json_mode = args.json
1830 if fix_mode
and dry_run
and not json_mode:
1831 print(
"Dry-run mode: showing what --fix-headers would change. "
1832 "Fix details will be written to the log file.",
1837 log(f
"Project root: {ctx.project_root}", ctx)
1838 log(f
"Project name: {ctx.project_name}", ctx)
1842 ctx.docs_dir /
"ARCHITECTURE.md",
1843 ctx.tests_dir /
"run_tests.py",
1844 ctx.tests_dir /
"Tests_README.md",
1845 ctx.diagnostics_dir /
"debug_config.json",
1850 for req
in required_docs:
1851 report(req.exists(), f
"Required file: {req}", fix_mode=
False, ctx=ctx)
1858 jobs = max(1, args.jobs)
1859 if fix_mode
and jobs > 1:
1862 print(
"Note: --jobs forced to 1 when --fix-headers is active.", file=sys.stderr)
1867 print(
"Note: --changed overrides explicit PATH targets.", file=sys.stderr)
1871 p
for p
in python_files
1872 if p.is_file()
and not any(
1873 is_excluded_dir(Path(part), active_exclude_dirs, active_wildcard_exclude)
1874 for part
in p.relative_to(ctx.project_root).parts
1877 if not python_files:
1879 print(
"No modified Python files found.", file=sys.stderr)
1883 for t
in args.targets:
1884 p = Path(t).resolve()
1885 if p.is_file()
and p.suffix ==
".py":
1886 python_files.append(p)
1888 python_files.extend(
1892 print(f
"Warning: Not a Python file or directory: {t}", file=sys.stderr)
1893 python_files = list(dict.fromkeys(python_files))
1894 if not python_files:
1895 print(
"No matching Python files found.", file=sys.stderr)
1898 python_files = list(
find_python_files(ctx.project_root, active_exclude_dirs, active_wildcard_exclude))
1899 total_files = len(python_files)
1903 results = [
_lint_single_file(p, ctx, active_rules, fix_mode, dry_run)
for p
in python_files]
1905 with concurrent.futures.ThreadPoolExecutor(max_workers=jobs)
as pool:
1906 futures = [pool.submit(_lint_single_file, p, ctx, active_rules, fix_mode, dry_run)
for p
in python_files]
1907 results = [f.result()
for f
in futures]
1910 pass_header = sum(1
for r
in results
if r[
"header"])
1911 pass_docstring = sum(1
for r
in results
if r[
"docstring"])
1912 pass_diag = sum(1
for r
in results
if r[
"diagnostics_profile"])
1913 pass_dbg = sum(1
for r
in results
if r[
"dbg_usage"])
1914 pass_dbg_import = sum(1
for r
in results
if r[
"dbg_import"])
1915 pass_func = sum(1
for r
in results
if r[
"func_pattern"])
1916 pass_syntax = sum(1
for r
in results
if r[
"syntax_warnings"])
1917 pass_hints = sum(1
for r
in results
if r[
"type_hints"])
1918 pass_tests = sum(1
for r
in results
if r[
"tests_exist"])
1919 fully_compliant = sum(1
for r
in results
if r[
"compliant"])
1920 fixed_files = sum(1
for r
in results
if r[
"fixed"])
1922 file_results: list[dict] = []
1924 file_results = [r[
"json_detail"]
for r
in results]
1928 if fully_compliant == total_files:
1929 log(
"All checks passed. Project conforms to Option C.", ctx)
1931 log(
"Some checks failed. See above for details.", ctx)
1936 def pct(n: int) -> str:
1937 return f
"{(n / total_files * 100):.1f}%" if total_files
else "0%"
1939 log(f
"Number of files checked:\t{total_files}", ctx)
1940 log(f
"[Header block]:\t\t{pass_header} PASS ({pct(pass_header)})", ctx)
1941 log(f
"[Docstring structure]:\t\t{pass_docstring} PASS ({pct(pass_docstring)})", ctx)
1942 log(f
"[Diagnostics profile]:\t\t{pass_diag} PASS ({pct(pass_diag)})", ctx)
1943 log(f
"[_dbg usage]:\t\t\t{pass_dbg} PASS ({pct(pass_dbg)})", ctx)
1944 log(f
"[_dbg import]:\t\t\t{pass_dbg_import} PASS ({pct(pass_dbg_import)})", ctx)
1945 log(f
"[func pattern]:\t\t\t{pass_func} PASS ({pct(pass_func)})", ctx)
1946 log(f
"[Syntax warnings]:\t\t{pass_syntax} PASS ({pct(pass_syntax)})", ctx)
1947 log(f
"[Type hints]:\t\t\t{pass_hints} PASS ({pct(pass_hints)})", ctx)
1948 log(f
"[Regression test exists]:\t{pass_tests} PASS ({pct(pass_tests)})", ctx)
1949 log(f
"[Compliant files]:\t\t{fully_compliant} PASS ({pct(fully_compliant)})", ctx)
1952 label =
"Would fix" if dry_run
else "Files fixed"
1953 log(f
"[{label}]:\t\t{fixed_files}", ctx)
1955 if ctx.log_handle
is not None:
1956 ctx.log_handle.close()
1957 ctx.log_handle =
None
1960 if ctx.lint_cache
is not None:
1962 ctx.lint_cache.save()
1967 baseline_info: dict |
None =
None
1970 baseline_path = cache_dir /
"lint-baseline.json"
1971 profile_name = args.profile
or "strict"
1972 count = save_baseline(results, baseline_path, profile_name)
1973 baseline_info = {
"action":
"saved",
"count": count,
"path": str(baseline_path)}
1975 print(f
"\nBaseline saved: {count} violation(s) -> {baseline_path}",
1978 if args.against_baseline:
1980 baseline_path = cache_dir /
"lint-baseline.json"
1981 baseline = load_baseline(baseline_path)
1982 if baseline
is None:
1983 print(
"Error: no baseline found. Run 'oct lint --baseline' first.",
1986 new_violations, resolved = diff_against_baseline(results, baseline)
1988 "action":
"compared",
1989 "new_violations": [{
"path": v[0],
"rule": v[1],
"message": v[2]}
for v
in new_violations],
1990 "resolved_violations": [{
"path": v[0],
"rule": v[1],
"message": v[2]}
for v
in resolved],
1991 "new_count": len(new_violations),
1992 "resolved_count": len(resolved),
1993 "baselined_count": len(baseline.entries),
1997 print(f
"\n{RED}New violations ({len(new_violations)}):{RESET}", file=sys.stderr)
1998 for path_str, rule, msg
in new_violations:
1999 print(f
" {path_str}: [{rule}] {msg}", file=sys.stderr)
2001 print(f
"\n{GREEN}Resolved ({len(resolved)}):{RESET}", file=sys.stderr)
2002 for path_str, rule, msg
in resolved:
2003 print(f
" {path_str}: [{rule}] {msg}", file=sys.stderr)
2004 if not new_violations:
2005 print(f
"\n{GREEN}No new violations vs baseline.{RESET}", file=sys.stderr)
2010 "project": ctx.project_name,
2011 "oct_version": __version__,
2012 "timestamp": datetime.now().isoformat(),
2014 "total_files": total_files,
2015 "fully_compliant": fully_compliant,
2016 "pass_header": pass_header,
2017 "pass_docstring": pass_docstring,
2018 "pass_diagnostics_profile": pass_diag,
2019 "pass_dbg": pass_dbg,
2020 "pass_dbg_import": pass_dbg_import,
2021 "pass_func": pass_func,
2022 "pass_syntax": pass_syntax,
2023 "pass_type_hints": pass_hints,
2024 "pass_tests": pass_tests,
2026 "files": file_results,
2028 if baseline_info
is not None:
2029 output[
"baseline"] = baseline_info
2030 json.dump(output, sys.stdout, indent=2)
2032 if args.against_baseline
and baseline_info:
2033 return 1
if baseline_info.get(
"new_count", 0) > 0
else 0
2034 return 0
if fully_compliant == total_files
else 1
2037 print(f
"{CYAN}Option C Linter Summary{RESET}")
2038 print(f
"Files checked: {total_files}")
2040 def color(n: int) -> str:
2041 return GREEN
if n == total_files
else RED
2043 print(f
"[Header block]: {color(pass_header)}{pass_header}{RESET} PASS ({pct(pass_header)})")
2044 print(f
"[Docstring structure]: {color(pass_docstring)}{pass_docstring}{RESET} PASS ({pct(pass_docstring)})")
2045 print(f
"[Diagnostics profile]: {color(pass_diag)}{pass_diag}{RESET} PASS ({pct(pass_diag)})")
2046 print(f
"[_dbg usage]: {color(pass_dbg)}{pass_dbg}{RESET} PASS ({pct(pass_dbg)})")
2047 print(f
"[_dbg import]: {color(pass_dbg_import)}{pass_dbg_import}{RESET} PASS ({pct(pass_dbg_import)})")
2048 print(f
"[func pattern]: {color(pass_func)}{pass_func}{RESET} PASS ({pct(pass_func)})")
2049 print(f
"[Syntax warnings]: {color(pass_syntax)}{pass_syntax}{RESET} PASS ({pct(pass_syntax)})")
2050 print(f
"[Type hints]: {color(pass_hints)}{pass_hints}{RESET} PASS ({pct(pass_hints)})")
2051 print(f
"[Regression test exists]: {color(pass_tests)}{pass_tests}{RESET} PASS ({pct(pass_tests)})")
2052 print(f
"[Compliant files]: {color(fully_compliant)}{fully_compliant}{RESET} PASS ({pct(fully_compliant)})")
2055 label =
"Would fix" if dry_run
else "Files fixed"
2056 print(f
"[{label}]: {GREEN}{fixed_files}{RESET}")
2057 if dry_run
and fixed_files > 0
and ctx.log_path:
2058 print(f
"\nDry-run fix details written to: {ctx.log_path}")
2060 if args.against_baseline
and baseline_info:
2061 return 1
if baseline_info.get(
"new_count", 0) > 0
else 0
2062 return 0
if fully_compliant == total_files
else 1