52d94303bc
ci / test (push) Has been cancelled
Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
1057 lines
42 KiB
Python
1057 lines
42 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""Compare the knowledge keys that are DECLARED against the ones that are READ.
|
|
|
|
python tools/knowledge_drift.py # findings, exit 1 if any
|
|
python tools/knowledge_drift.py --all # + every suppressed line
|
|
python tools/knowledge_drift.py --blind # only the blind-spot report
|
|
python tools/knowledge_drift.py --json # machine readable
|
|
|
|
WHY THIS EXISTS
|
|
---------------
|
|
The tool's behaviour is driven by layered YAML (knowledge.DEFAULTS ->
|
|
catalogue.yaml -> plant -> job -> project config) read through
|
|
`knowledge.get(know, 'dotted.path', default)`. Nothing anywhere compared the
|
|
two sides of that contract, and seven separate drifts were found in one day by
|
|
accident. Every one looked normal from the outside:
|
|
|
|
* a key written under the wrong parent -- parsed cleanly, did nothing
|
|
* a key documented in a job file with a careful comment, read by no code
|
|
* a value read by the AI prompt builder and by no check
|
|
* a value written, tested, and never wired to the check it was for
|
|
|
|
They are one missing mechanism, not seven bugs. A key that is declared and
|
|
never read is a documented promise the tool does not keep. A key that is read
|
|
and declared nowhere is a default nobody can see or override.
|
|
|
|
WHAT "READ" MEANS HERE, AND THE THING THAT MATTERS MOST
|
|
------------------------------------------------------
|
|
A reader is graded, not counted. `rails.left_rail_is_fixed` HAD a reader --
|
|
the AI prompt builder -- and was still drift, because no check consumed it, so
|
|
a plant that set it was silently ignored. `weight_three_way` had a reader in
|
|
the test suite. So every call site is put in a tier:
|
|
|
|
behaviour a check, a solver, an export -- the tool acts on it
|
|
surface the config screen, the config dump, the AI prompt. These
|
|
DISPLAY a value. A key only they read is settable, visible,
|
|
and inert.
|
|
test only the suite touches it
|
|
|
|
and a key whose best reader is `surface` or `test` is reported, separately,
|
|
with that word attached.
|
|
|
|
THE DISCIPLINE: "could not judge" MUST NOT READ AS "judged and found nothing"
|
|
----------------------------------------------------------------------------
|
|
A path built from a variable, a subtree fetched and walked by hand, a key
|
|
reached through a helper -- this detector cannot see all of those. Every run
|
|
therefore ends with a blind-spot report naming the call sites it could not
|
|
resolve and the subtrees it has deliberately stopped judging. A clean
|
|
findings list on top of a long blind-spot list is not a clean codebase, and
|
|
the output says so in those words.
|
|
|
|
CONSTRAINTS THIS RESPECTS
|
|
-------------------------
|
|
Host CPython 3.12. The code it reads -- the tree under CODE_ROOTS -- is
|
|
IronPython 2.7 and CPython, so THAT is only ever PARSED (`ast`), never
|
|
imported: importing `vibe` for real pulls in the Revit API and dies. YAML is
|
|
read with PyYAML, not `vibe.yamlkit` (a hand-rolled subset for design-spec
|
|
files that would silently parse a fraction of a knowledge file). The real
|
|
plant and job layers are gitignored: this runs, and is useful, when they are
|
|
absent, and it never prints a filename or a value from them -- only key
|
|
paths, under the generic labels 'plant layer' / 'job layer'.
|
|
|
|
One narrow exception to "never imports": `vibe.plantpath` and `vibe.joblocate`
|
|
have no Revit import at all -- see their own module docstrings -- so they are
|
|
loaded, via the same `sys.modules['vibe']` stub `tools/snapshot_cycle.py` and
|
|
`WillTools.extension/tests/conftest.py` use, purely to ask WHERE the plant and
|
|
job layers live on THIS machine (`_plant_root` / `_jobs_root` below). That is
|
|
resolution-rule reuse, not a parallel guess at the same question, and it is
|
|
the fix for the gap `machine-setup.md` in the private repo used to flag as
|
|
"a recommendation, not implemented": WILLTOOLS_KNOWLEDGE and WILLTOOLS_JOBS
|
|
now say where to look, the same as every other reader of these layers.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ast
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import types
|
|
|
|
try:
|
|
import yaml
|
|
except ImportError: # pragma: no cover
|
|
sys.stderr.write('PyYAML is required: pip install pyyaml\n')
|
|
raise SystemExit(2)
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
KNOWLEDGE_PY = os.path.join(ROOT, 'WillTools.extension', 'lib', 'vibe',
|
|
'knowledge.py')
|
|
CONFIGMODEL_PY = os.path.join(ROOT, 'WillTools.extension', 'lib', 'vibe',
|
|
'configmodel.py')
|
|
CATALOGUE = os.path.join(ROOT, 'knowledge', 'catalogue.yaml')
|
|
ALLOWLIST = os.path.join(ROOT, 'tools', 'knowledge_drift_allow.txt')
|
|
|
|
# `vibe.plantpath` / `vibe.joblocate` only -- see the docstring above. Neither
|
|
# imports the Revit API, so the stub is enough to make `from vibe import
|
|
# plantpath` resolve as an ordinary submodule instead of running
|
|
# `vibe/__init__.py` (which does, and dies on host CPython).
|
|
_VIBE_DIR = os.path.join(ROOT, 'WillTools.extension', 'lib', 'vibe')
|
|
if 'vibe' not in sys.modules:
|
|
_stub = types.ModuleType('vibe')
|
|
_stub.__path__ = [_VIBE_DIR]
|
|
_stub.__file__ = os.path.join(_VIBE_DIR, '__init__.py')
|
|
sys.modules['vibe'] = _stub
|
|
from vibe import joblocate # noqa: E402
|
|
from vibe import plantpath # noqa: E402
|
|
|
|
CODE_ROOTS = ('WillTools.extension', 'panellift', 'tools')
|
|
SKIP_DIRS = {'__pycache__', '.git', 'dist', '_archive', '_scratch',
|
|
'.pytest_cache', 'node_modules'}
|
|
|
|
# The name a module is bound to when it is the knowledge module.
|
|
KNOWLEDGE_ALIASES = {'K', 'knowledge', 'kn'}
|
|
|
|
CATEGORIES = ('unread', 'undeclared', 'defaults', 'surface-only', 'test-only')
|
|
|
|
|
|
# --------------------------------------------------------------- reader tiers
|
|
|
|
def tier_of(relpath):
|
|
"""behaviour / surface / test, from where the call site lives.
|
|
|
|
The split is the whole point of the tool, so it is stated as data here
|
|
rather than being inferred. `configdump`, `configmodel` and the Project
|
|
Config screen render settings. None of
|
|
them makes the tool DO anything, so a key only they read is a key a plant
|
|
can set and be ignored on -- which is exactly the shape of the
|
|
`rails.left_rail_is_fixed` drift.
|
|
"""
|
|
p = relpath.replace('\\', '/')
|
|
if '/tests/' in p or os.path.basename(p).startswith('test_') \
|
|
or os.path.basename(p) == 'conftest.py':
|
|
return 'test'
|
|
for mark in ('lib/vibe/configdump.py', 'lib/vibe/configmodel.py',
|
|
'ProjectConfig.pushbutton'):
|
|
if mark in p:
|
|
return 'surface'
|
|
return 'behaviour'
|
|
|
|
|
|
TIER_RANK = {'behaviour': 0, 'surface': 1, 'test': 2}
|
|
|
|
|
|
# ------------------------------------------------------------- path flattening
|
|
|
|
def flatten(node, prefix=''):
|
|
"""Every dotted path in a nested mapping, containers included.
|
|
|
|
Containers are emitted as well as leaves because a container IS a
|
|
read target here -- `strands.area_in2` is fetched whole and walked --
|
|
so the reader side and the declaration side have to speak about the
|
|
same set of names.
|
|
"""
|
|
out = []
|
|
if isinstance(node, dict):
|
|
for key, value in node.items():
|
|
if not isinstance(key, str):
|
|
continue
|
|
path = prefix + '.' + key if prefix else key
|
|
out.append(path)
|
|
out.extend(flatten(value, path))
|
|
return out
|
|
|
|
|
|
# ------------------------------------------------------------- declaration side
|
|
|
|
def _literal(node):
|
|
try:
|
|
return ast.literal_eval(node)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def declarations():
|
|
"""{path: set(source labels)} plus the raw trees, keyed by source.
|
|
|
|
Sources are labels, never paths: the plant and job layers are gitignored
|
|
and a colleague's clone has neither, so nothing that identifies a file --
|
|
or a client -- may reach the output.
|
|
"""
|
|
decls = {}
|
|
trees = {}
|
|
|
|
def add(label, tree):
|
|
if not isinstance(tree, dict):
|
|
return
|
|
trees.setdefault(label, {})
|
|
_deep_update(trees[label], tree)
|
|
for path in flatten(tree):
|
|
decls.setdefault(path, set()).add(label)
|
|
|
|
# 1. DEFAULTS / STARTER / PLANT_STARTER, parsed out of knowledge.py.
|
|
src = _read(KNOWLEDGE_PY)
|
|
if src is not None:
|
|
mod = ast.parse(src)
|
|
for stmt in mod.body:
|
|
if not isinstance(stmt, ast.Assign):
|
|
continue
|
|
names = [t.id for t in stmt.targets if isinstance(t, ast.Name)]
|
|
if 'DEFAULTS' in names:
|
|
add('knowledge.DEFAULTS', _literal(stmt.value))
|
|
elif 'STARTER' in names or 'PLANT_STARTER' in names:
|
|
text = _literal(stmt.value)
|
|
if isinstance(text, str):
|
|
add(names[0], _safe_yaml(text, names[0]))
|
|
|
|
# 2. The public catalogue.
|
|
if os.path.isfile(CATALOGUE):
|
|
add('catalogue.yaml', _safe_yaml(_read(CATALOGUE), 'catalogue.yaml'))
|
|
|
|
# 3. The private layers, if this checkout has them.
|
|
for label, files in (('plant layer', _plant_files()),
|
|
('job layer', _job_files())):
|
|
for path in files:
|
|
add(label, _unwrap_config(_safe_yaml(_read(path), label)))
|
|
|
|
return decls, trees
|
|
|
|
|
|
def _deep_update(base, override):
|
|
for key, value in (override or {}).items():
|
|
if isinstance(value, dict) and isinstance(base.get(key), dict):
|
|
_deep_update(base[key], value)
|
|
else:
|
|
base[key] = value
|
|
return base
|
|
|
|
|
|
def _unwrap_config(tree):
|
|
"""A project config file wraps its knowledge under `knowledge:`.
|
|
|
|
Mirrors `knowledge._merge_config_file`. The sibling sections describe the
|
|
configuration rather than being part of it, so merging the document whole
|
|
would plant junk branches and then report every one of them as unread.
|
|
"""
|
|
if isinstance(tree, dict) and isinstance(tree.get('knowledge'), dict) \
|
|
and ('project' in tree or 'output_locations' in tree):
|
|
return tree['knowledge']
|
|
return tree
|
|
|
|
|
|
def _plant_root():
|
|
"""Where this run looks for the plant layer(s).
|
|
|
|
WILLTOOLS_KNOWLEDGE wins when it is set, resolved the SAME way every
|
|
other reader resolves it -- `vibe.plantpath.plant_root()`, reused rather
|
|
than re-derived, so a machine whose env var points at a private clone
|
|
somewhere else on disk (the setup `machine-setup.md` in the private repo
|
|
documents) is walked from the folder the real tool would actually read.
|
|
|
|
Unset, this is `<repo>/knowledge/plant` -- NOT `plantpath`'s own
|
|
unset-fallback of `%APPDATA%/WillTools`, on purpose: that fallback names
|
|
a per-machine install folder that has nothing to do with auditing a
|
|
checkout and will not even exist on a CI runner. `<repo>/knowledge/plant`
|
|
is where a private clone has always lived when nobody has set the env
|
|
var, and is exactly what the hardcoded path here used to be.
|
|
"""
|
|
if os.environ.get(plantpath.PLANT_ENV):
|
|
return plantpath.plant_root()
|
|
return os.path.join(ROOT, 'knowledge', 'plant')
|
|
|
|
|
|
def _jobs_root():
|
|
"""Where this run looks for job layers -- WILLTOOLS_JOBS, or the checkout
|
|
default. Same reasoning as `_plant_root`, and the same env var every
|
|
other reader of the job layer (`designspec.root_dir`, `vibe.joblocate`)
|
|
honours; unlike the plant file, WILLTOOLS_JOBS already names a folder
|
|
directly, so there is no file-vs-folder resolution to reuse here."""
|
|
return os.environ.get(joblocate.JOBS_ENV) or os.path.join(
|
|
ROOT, 'knowledge', 'job')
|
|
|
|
|
|
def _plant_files():
|
|
"""Every plant knowledge file this run can see.
|
|
|
|
WALKED, not listed, because the plant layer is ONE FOLDER PER PLANT:
|
|
`<root>/<plant>/knowledge.yaml`, with the older single
|
|
`<root>/knowledge.yaml` still supported. Listing only the top directory
|
|
stopped finding anything the day the folders appeared, and the failure
|
|
was quiet in the worst way -- the run still succeeded, the layer count
|
|
silently dropped to four, every key declared only by a plant became
|
|
"never declared", and five allowlist entries were reported STALE. A
|
|
checker that stops checking and says it is fine is the thing this file
|
|
exists to prevent, so it is walked and the layer count is printed.
|
|
|
|
`.bak-*` snapshots are skipped for the same reason `_job_files()` skips
|
|
them: yesterday's file would resurrect a key that was removed on purpose.
|
|
"""
|
|
out = []
|
|
base = _plant_root()
|
|
if not os.path.isdir(base):
|
|
return out
|
|
for dirpath, dirnames, filenames in os.walk(base):
|
|
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
for name in sorted(filenames):
|
|
if name.endswith(('.yaml', '.yml')) and '.bak-' not in name:
|
|
out.append(os.path.join(dirpath, name))
|
|
return out
|
|
|
|
|
|
def _job_files():
|
|
"""Job knowledge and project-config files, from any job folder this run
|
|
can see -- see `_jobs_root` for where that is.
|
|
|
|
`.bak-*` snapshots are skipped: they are yesterday's file, and a key that
|
|
was removed on purpose would come back as a finding forever.
|
|
"""
|
|
out = []
|
|
base = _jobs_root()
|
|
if not os.path.isdir(base):
|
|
return out
|
|
for dirpath, dirnames, filenames in os.walk(base):
|
|
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
for name in sorted(filenames):
|
|
if name.endswith(('.yaml', '.yml')) and '.bak-' not in name:
|
|
out.append(os.path.join(dirpath, name))
|
|
return out
|
|
|
|
|
|
PARSE_NOTES = []
|
|
|
|
|
|
def _safe_yaml(text, label):
|
|
if not text:
|
|
return {}
|
|
try:
|
|
return yaml.safe_load(text) or {}
|
|
except Exception as exc:
|
|
PARSE_NOTES.append('%s did not parse as YAML (%s) -- its keys are '
|
|
'NOT in this run' % (label, exc.__class__.__name__))
|
|
return {}
|
|
|
|
|
|
def _read(path):
|
|
try:
|
|
with io.open(path, 'r', encoding='utf-8') as fh:
|
|
return fh.read()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# ------------------------------------------------------------------ reader side
|
|
|
|
class Reader(object):
|
|
__slots__ = ('path', 'default', 'file', 'line', 'tier', 'how', 'covering')
|
|
|
|
def __init__(self, path, default, file, line, how, covering=True):
|
|
self.path = path
|
|
self.default = default
|
|
self.file = file
|
|
self.line = line
|
|
self.tier = tier_of(file)
|
|
self.how = how
|
|
# A COVERING read fetches the node and everything under it -- a real
|
|
# knowledge.get -- so nothing below it can be called unread. A config
|
|
# row or a subtree caption names the node without fetching its
|
|
# children, so it must not silence them: `parameters` appearing on the
|
|
# config screen says nothing about whether `parameters.sheet.weight`
|
|
# is used by anything.
|
|
self.covering = covering
|
|
|
|
@property
|
|
def where(self):
|
|
return '%s:%d' % (self.file, self.line)
|
|
|
|
|
|
# One-level wrappers around knowledge.get whose path is a literal prefix plus a
|
|
# caller-supplied key. Resolving them by hand is a judgement call: the
|
|
# alternative is to treat the whole prefix as opaque, which would hide every
|
|
# nomenclature and parameter key from the checker. Each entry names the arg
|
|
# index that carries the tail.
|
|
HELPERS = {
|
|
'_nomen': ('nomenclature.', 1), # designspec._nomen
|
|
'param_names': ('parameters.', 1), # knowledge.param_names
|
|
}
|
|
|
|
# Tuples of role names that are fed to a wrapper in a loop rather than one at a
|
|
# time. knowledge.unmapped_roles() walks PARAMETER_ROLES and calls
|
|
# param_names on each, so every role in it is genuinely read -- but by a
|
|
# variable, which the AST scan cannot follow.
|
|
ROLE_TUPLES = {'PARAMETER_ROLES': 'parameters.', 'OPTIONAL_ROLES': 'parameters.'}
|
|
|
|
# Module-level literals in the SURFACE layer that name whole knowledge
|
|
# subtrees to display. `configdump.SECTION_ORDER` and `configmodel.SUBTREES`
|
|
# are how a branch reaches the config dump and the config screen, and without
|
|
# them a branch shown on both would be reported as read by nothing at all --
|
|
# which is a different, and wrong, statement about it. Every string in the
|
|
# literal is taken; the ones that are not declared paths are dropped.
|
|
SURFACE_TABLES = {
|
|
('lib/vibe/configmodel.py', 'SUBTREES'),
|
|
}
|
|
# `configdump.SECTION_ORDER` / `SECTION_NOTES` are deliberately NOT here.
|
|
# They order and caption whole branches; treating them as reads made every
|
|
# top-level branch look read and swallowed the real findings underneath.
|
|
# Display order is not consumption.
|
|
|
|
|
|
class Scan(ast.NodeVisitor):
|
|
def __init__(self, relpath):
|
|
self.rel = relpath
|
|
self.readers = []
|
|
self.blind = []
|
|
# knowledge.py calls its own `get` bare, with no module prefix, and
|
|
# that is where is_strand / catalogue_item / param_names live -- so
|
|
# skipping bare calls would report the families and lifters tables as
|
|
# unread when they are the most-read keys in the tool. Only inside
|
|
# that one file, where `get` cannot mean anything else.
|
|
self.bare_get = relpath.endswith('lib/vibe/knowledge.py')
|
|
self.fn_stack = []
|
|
self.locals_stack = []
|
|
|
|
def visit_FunctionDef(self, node):
|
|
self.fn_stack.append(node.name)
|
|
self.locals_stack.append(_local_strings(node))
|
|
self.generic_visit(node)
|
|
self.locals_stack.pop()
|
|
self.fn_stack.pop()
|
|
|
|
visit_AsyncFunctionDef = visit_FunctionDef
|
|
|
|
def _resolve_name(self, node):
|
|
"""A local variable holding one or two string literals, or None.
|
|
|
|
`liftexport._fast_box` picks between two literal paths with a ternary
|
|
and passes the result to `knowledge.get`. Refusing to follow that
|
|
would report both `handling.fast_lifter_box` keys as read only by the
|
|
config screen -- a false accusation, and the worst kind, because it
|
|
is indistinguishable from the true ones.
|
|
"""
|
|
if not isinstance(node, ast.Name):
|
|
return None
|
|
for scope in reversed(self.locals_stack):
|
|
if node.id in scope:
|
|
return scope[node.id]
|
|
return None
|
|
|
|
def visit_Call(self, node):
|
|
self._knowledge_get(node)
|
|
self._helper(node)
|
|
self._config_row(node)
|
|
self.generic_visit(node)
|
|
|
|
# knowledge.get(know, 'a.b', default) -------------------------------
|
|
def _knowledge_get(self, node):
|
|
fn = node.func
|
|
if isinstance(fn, ast.Attribute) and fn.attr == 'get':
|
|
base = fn.value
|
|
name = base.id if isinstance(base, ast.Name) else (
|
|
base.attr if isinstance(base, ast.Attribute) else None)
|
|
if name not in KNOWLEDGE_ALIASES:
|
|
return
|
|
elif self.bare_get and isinstance(fn, ast.Name) and fn.id == 'get':
|
|
pass
|
|
else:
|
|
return
|
|
if len(node.args) < 2:
|
|
return
|
|
arg = node.args[1]
|
|
default = (_unparse(node.args[2]) if len(node.args) > 2 else 'None')
|
|
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
self.readers.append(Reader(arg.value, default, self.rel,
|
|
node.lineno, 'literal'))
|
|
elif isinstance(arg, ast.BinOp) and isinstance(arg.op, ast.Add) \
|
|
and isinstance(arg.left, ast.Constant) \
|
|
and isinstance(arg.left.value, str):
|
|
if self.fn_stack and self.fn_stack[-1] in HELPERS:
|
|
# This IS the body of a wrapper whose callers are resolved
|
|
# above. Blinding its own prefix here would hide every
|
|
# nomenclature and parameter key behind the very helper that
|
|
# makes them visible.
|
|
return
|
|
self.blind.append((self.rel, node.lineno, 'concatenated path',
|
|
arg.left.value + '<var>'))
|
|
else:
|
|
resolved = self._resolve_name(arg)
|
|
if resolved:
|
|
for path in sorted(resolved):
|
|
self.readers.append(Reader(path, default, self.rel,
|
|
node.lineno, 'local literal'))
|
|
else:
|
|
self.blind.append((self.rel, node.lineno, 'non-literal path',
|
|
_unparse(arg)))
|
|
|
|
# _nomen(know, 'design_folder', ...) / param_names(know, 'sheet.fc') --
|
|
def _helper(self, node):
|
|
fn = node.func
|
|
name = fn.id if isinstance(fn, ast.Name) else (
|
|
fn.attr if isinstance(fn, ast.Attribute) else None)
|
|
if name not in HELPERS:
|
|
return
|
|
prefix, idx = HELPERS[name]
|
|
if len(node.args) <= idx:
|
|
return
|
|
arg = node.args[idx]
|
|
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
default = (_unparse(node.args[idx + 1])
|
|
if len(node.args) > idx + 1 else 'None')
|
|
self.readers.append(Reader(prefix + arg.value, default, self.rel,
|
|
node.lineno, 'helper ' + name))
|
|
else:
|
|
self.blind.append((self.rel, node.lineno,
|
|
'non-literal key into ' + name,
|
|
prefix + _unparse(arg)))
|
|
|
|
# configmodel._s('handling.rig_mode', ...) --------------------------
|
|
def _config_row(self, node):
|
|
fn = node.func
|
|
name = fn.id if isinstance(fn, ast.Name) else None
|
|
if name != '_s' or not node.args:
|
|
return
|
|
arg = node.args[0]
|
|
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
self.readers.append(Reader(arg.value, 'None', self.rel,
|
|
node.lineno, 'config row',
|
|
covering=False))
|
|
|
|
|
|
def _local_strings(fn):
|
|
"""{local name: set of string literals it can hold}, one level deep.
|
|
|
|
Only plain `x = 'a'` and `x = 'a' if c else 'b'`. Anything cleverer is
|
|
left unresolved on purpose: a wrong resolution is worse than an admitted
|
|
blind spot, because it turns into a confident finding about the wrong key.
|
|
"""
|
|
out = {}
|
|
for node in ast.walk(fn):
|
|
if not isinstance(node, ast.Assign) or len(node.targets) != 1:
|
|
continue
|
|
target = node.targets[0]
|
|
if not isinstance(target, ast.Name):
|
|
continue
|
|
value = node.value
|
|
got = set()
|
|
if isinstance(value, ast.Constant) and isinstance(value.value, str):
|
|
got.add(value.value)
|
|
elif isinstance(value, ast.IfExp):
|
|
for side in (value.body, value.orelse):
|
|
if isinstance(side, ast.Constant) and isinstance(side.value,
|
|
str):
|
|
got.add(side.value)
|
|
else:
|
|
got = set()
|
|
break
|
|
if got:
|
|
out.setdefault(target.id, set()).update(got)
|
|
else:
|
|
out[target.id] = set() # reassigned to something opaque
|
|
return {k: v for k, v in out.items() if v}
|
|
|
|
|
|
def _surface_tables(tree, rel):
|
|
out = []
|
|
for stmt in ast.walk(tree):
|
|
if not isinstance(stmt, ast.Assign):
|
|
continue
|
|
for target in stmt.targets:
|
|
if not isinstance(target, ast.Name):
|
|
continue
|
|
if not any(rel.endswith(f) and target.id == v
|
|
for f, v in SURFACE_TABLES):
|
|
continue
|
|
for text in _strings_in(_literal(stmt.value)):
|
|
out.append(Reader(text, 'None', rel, stmt.lineno,
|
|
'surface table ' + target.id,
|
|
covering=False))
|
|
return out
|
|
|
|
|
|
def _strings_in(value):
|
|
if isinstance(value, str):
|
|
return [value]
|
|
out = []
|
|
if isinstance(value, dict):
|
|
for key, val in value.items():
|
|
out.extend(_strings_in(key))
|
|
elif isinstance(value, (list, tuple, set)):
|
|
for item in value:
|
|
out.extend(_strings_in(item))
|
|
return out
|
|
|
|
|
|
def _unparse(node):
|
|
try:
|
|
return ast.unparse(node)
|
|
except Exception:
|
|
return '<?>'
|
|
|
|
|
|
def scan_code():
|
|
readers, blind, parse_fail = [], [], []
|
|
for root in CODE_ROOTS:
|
|
base = os.path.join(ROOT, root)
|
|
for dirpath, dirnames, filenames in os.walk(base):
|
|
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
for name in filenames:
|
|
if not name.endswith('.py'):
|
|
continue
|
|
full = os.path.join(dirpath, name)
|
|
rel = os.path.relpath(full, ROOT).replace('\\', '/')
|
|
text = _read(full)
|
|
if text is None:
|
|
continue
|
|
try:
|
|
tree = ast.parse(text)
|
|
except SyntaxError as exc:
|
|
# IronPython 2.7 print statements and the like. A file
|
|
# that will not parse is a file whose readers are INVISIBLE
|
|
# to this tool, so it is reported, never skipped quietly.
|
|
parse_fail.append((rel, 'line %s: %s' % (exc.lineno,
|
|
exc.msg)))
|
|
continue
|
|
scan = Scan(rel)
|
|
scan.visit(tree)
|
|
readers.extend(scan.readers)
|
|
blind.extend(scan.blind)
|
|
readers.extend(_role_tuples(tree, rel))
|
|
readers.extend(_surface_tables(tree, rel))
|
|
return readers, blind, parse_fail
|
|
|
|
|
|
def _role_tuples(tree, rel):
|
|
"""PARAMETER_ROLES = (...) -> a reader for each 'parameters.<role>'."""
|
|
out = []
|
|
for stmt in ast.walk(tree):
|
|
if not isinstance(stmt, ast.Assign):
|
|
continue
|
|
for target in stmt.targets:
|
|
if not isinstance(target, ast.Name) or target.id not in ROLE_TUPLES:
|
|
continue
|
|
prefix = ROLE_TUPLES[target.id]
|
|
for role in (_literal(stmt.value) or ()):
|
|
if isinstance(role, str):
|
|
out.append(Reader(prefix + role, 'None', rel,
|
|
stmt.lineno, 'role table'))
|
|
return out
|
|
|
|
|
|
# ------------------------------------------------------------------- allowlist
|
|
|
|
def load_allowlist():
|
|
"""`<category> <path> -- <reason>`, one per line.
|
|
|
|
An allowlist FILE rather than a YAML comment, chosen deliberately: two of
|
|
the three categories are about keys that exist only in Python or only in a
|
|
gitignored file, so a comment convention could not express them and half
|
|
the exceptions would have nowhere to live. One file, greppable, in the
|
|
repo, next to the tool that reads it. The reason is MANDATORY -- an
|
|
exception with no sentence is refused, because an unexplained suppression
|
|
is how a checker quietly stops checking -- and every run prints the
|
|
suppression count and any entry that no longer matches anything, so the
|
|
file cannot rot unseen.
|
|
"""
|
|
entries, errors = {}, []
|
|
text = _read(ALLOWLIST)
|
|
if text is None:
|
|
return entries, errors
|
|
for num, raw in enumerate(text.splitlines(), 1):
|
|
line = raw.strip()
|
|
if not line or line.startswith('#'):
|
|
continue
|
|
if ' -- ' not in line:
|
|
errors.append('%s:%d needs a reason: "<category> <path> -- why"'
|
|
% (os.path.basename(ALLOWLIST), num))
|
|
continue
|
|
head, reason = line.split(' -- ', 1)
|
|
bits = head.split()
|
|
if len(bits) != 2 or bits[0] not in CATEGORIES:
|
|
errors.append('%s:%d unreadable; category must be one of %s'
|
|
% (os.path.basename(ALLOWLIST), num,
|
|
', '.join(CATEGORIES)))
|
|
continue
|
|
if not reason.strip():
|
|
errors.append('%s:%d has an empty reason' %
|
|
(os.path.basename(ALLOWLIST), num))
|
|
continue
|
|
entries[(bits[0], bits[1])] = reason.strip()
|
|
return entries, errors
|
|
|
|
|
|
# ---------------------------------------------------------------------- analyse
|
|
|
|
def analyse():
|
|
decls, _trees = declarations()
|
|
readers, blind, parse_fail = scan_code()
|
|
allow, allow_errors = load_allowlist()
|
|
|
|
# A surface table is a list of branch names mixed with the help text
|
|
# beside them. Only the entries that are real declared paths are readers;
|
|
# the prose is dropped rather than reported as a hidden default.
|
|
readers = [r for r in readers
|
|
if not r.how.startswith('surface table') or r.path in decls]
|
|
|
|
by_path = {}
|
|
for r in readers:
|
|
by_path.setdefault(r.path, []).append(r)
|
|
|
|
read_paths = set(by_path)
|
|
covering_paths = {p for p, rs in by_path.items()
|
|
if any(r.covering for r in rs)}
|
|
# A path is covered when it, or any ancestor, is read by a COVERING read:
|
|
# fetching `strands.area_in2` whole and walking it reads every size in it.
|
|
covered = set()
|
|
for path in decls:
|
|
parts = path.split('.')
|
|
for i in range(1, len(parts) + 1):
|
|
head = '.'.join(parts[:i])
|
|
if head in covering_paths or (head == path and head in read_paths):
|
|
covered.add(path)
|
|
break
|
|
|
|
# Subtrees a dynamic path makes unjudgeable. `K.get(know, 'nomenclature.'
|
|
# + key)` means SOME nomenclature key is read and the tool cannot say
|
|
# which -- so the prefix is suppressed from findings and named in the
|
|
# blind-spot report with the number of keys it hides.
|
|
dyn_prefixes = sorted({b[3][:-len('<var>')].rstrip('.')
|
|
for b in blind if b[2] == 'concatenated path'})
|
|
|
|
def under_dyn(path):
|
|
return any(path == p or path.startswith(p + '.') for p in dyn_prefixes)
|
|
|
|
# ---- 1. declared, never read. Report the SHALLOWEST unread node: a
|
|
# 40-entry insert table nobody reads is one finding, not 200 lines.
|
|
tree = _paths_to_tree(sorted(decls))
|
|
unread = []
|
|
_collect_unread(tree, '', read_paths, covering_paths, unread)
|
|
unread = [p for p in unread if not under_dyn(p)]
|
|
dyn_hidden = sorted(p for p in decls
|
|
if under_dyn(p) and p not in covered)
|
|
|
|
# ---- 2. read, never declared.
|
|
undeclared = sorted(p for p in by_path
|
|
if p not in decls and not _is_prefix_of_decl(p, decls))
|
|
|
|
# ---- 3. same path, different fallbacks.
|
|
inconsistent = []
|
|
for path, rs in sorted(by_path.items()):
|
|
seen = {}
|
|
for r in rs:
|
|
# Two kinds of call site are excluded, both because they do not
|
|
# actually express an opinion about what "unset" means:
|
|
# * a config row or a subtree caption NAMES a key, it does not
|
|
# fetch it, so it has no fallback to disagree with;
|
|
# * a test that reads a value with no default is asserting what
|
|
# is there, not declaring what should happen when it is not.
|
|
# Counting either produced findings that were pure artefacts of
|
|
# this tool -- the fastest way to teach people to ignore it.
|
|
if not r.covering or r.tier == 'test':
|
|
continue
|
|
seen.setdefault(r.default, []).append(r)
|
|
if len(seen) > 1:
|
|
inconsistent.append((path, seen))
|
|
|
|
# ---- 4/5. graded readers.
|
|
surface_only, test_only = [], []
|
|
behaviour_paths = {p for p, rs in by_path.items()
|
|
if any(r.tier == 'behaviour' for r in rs)}
|
|
for path, rs in sorted(by_path.items()):
|
|
tiers = {r.tier for r in rs}
|
|
if 'behaviour' in tiers:
|
|
continue
|
|
# A BRANCH the config screen names, whose leaves a check does read,
|
|
# is not inert -- `parameters` is the example. Reporting the branch
|
|
# would be a second, wrong sentence about keys already judged one
|
|
# level down.
|
|
if any(b.startswith(path + '.') for b in behaviour_paths):
|
|
continue
|
|
(test_only if tiers == {'test'} else surface_only).append((path, rs))
|
|
|
|
findings = {
|
|
'unread': [(p, sorted(decls[p])) for p in unread],
|
|
'undeclared': [(p, sorted({r.where for r in by_path[p]}))
|
|
for p in undeclared],
|
|
'defaults': inconsistent,
|
|
'surface-only': surface_only,
|
|
'test-only': test_only,
|
|
}
|
|
|
|
suppressed = {}
|
|
for cat, rows in findings.items():
|
|
keep, gone = [], []
|
|
for row in rows:
|
|
if (cat, row[0]) in allow:
|
|
gone.append((row[0], allow[(cat, row[0])]))
|
|
else:
|
|
keep.append(row)
|
|
findings[cat] = keep
|
|
suppressed[cat] = gone
|
|
|
|
fired = {(c, r[0]) for c, rows in findings.items() for r in rows}
|
|
used = {(c, p) for c, rows in suppressed.items() for p, _why in rows}
|
|
stale = sorted(k for k in allow if k not in used and k not in fired)
|
|
|
|
return {
|
|
'findings': findings,
|
|
'suppressed': suppressed,
|
|
'stale_allow': stale,
|
|
'allow_errors': allow_errors,
|
|
'blind': blind,
|
|
'dyn_prefixes': dyn_prefixes,
|
|
'dyn_hidden': dyn_hidden,
|
|
'parse_fail': parse_fail,
|
|
'parse_notes': list(PARSE_NOTES),
|
|
'layers': sorted({lbl for s in decls.values() for lbl in s}),
|
|
'counts': {'declared': len(decls), 'read': len(by_path),
|
|
'readers': len(readers)},
|
|
}
|
|
|
|
|
|
def _is_prefix_of_decl(path, decls):
|
|
"""A read path that is a prefix of a declared one is declared enough.
|
|
|
|
Reading `catalogue.inserts` when only `catalogue.inserts.QLPD4T5WG...`
|
|
exists is not a hidden default; it is a table fetched whole.
|
|
"""
|
|
head = path + '.'
|
|
return any(d.startswith(head) for d in decls)
|
|
|
|
|
|
def _paths_to_tree(paths):
|
|
tree = {}
|
|
for path in paths:
|
|
node = tree
|
|
for part in path.split('.'):
|
|
node = node.setdefault(part, {})
|
|
return tree
|
|
|
|
|
|
def _collect_unread(node, prefix, read_paths, covering_paths, out):
|
|
for key in sorted(node):
|
|
path = prefix + '.' + key if prefix else key
|
|
if path in covering_paths:
|
|
continue # subtree fetched whole
|
|
kids = node[key]
|
|
named = path in read_paths # named, but not fetched whole
|
|
below = kids and _any_read_below(kids, path, read_paths)
|
|
if below:
|
|
_collect_unread(kids, path, read_paths, covering_paths, out)
|
|
elif named:
|
|
# The config screen names this branch and nothing under it is
|
|
# read. One finding about the branch (in surface-only) says the
|
|
# whole thing; listing every leaf here would say it again, worse.
|
|
continue
|
|
else:
|
|
out.append(path)
|
|
|
|
|
|
def _any_read_below(node, prefix, read_paths):
|
|
head = prefix + '.'
|
|
return any(p.startswith(head) for p in read_paths)
|
|
|
|
|
|
# ------------------------------------------------------------------- reporting
|
|
|
|
def _p(out, text=''):
|
|
out.write(text + '\n')
|
|
|
|
|
|
def report(res, out, show_all=False, blind_only=False):
|
|
c = res['counts']
|
|
_p(out, '=' * 72)
|
|
_p(out, 'KNOWLEDGE DRIFT')
|
|
_p(out, '=' * 72)
|
|
_p(out, '%d declared paths across %d layer(s): %s'
|
|
% (c['declared'], len(res['layers']), ', '.join(res['layers'])))
|
|
_p(out, '%d distinct paths read by %d call sites.'
|
|
% (c['read'], c['readers']))
|
|
private = [l for l in res['layers'] if l in ('plant layer', 'job layer')]
|
|
if not private:
|
|
_p(out, 'No private plant or job layer in this checkout -- only the '
|
|
'keys that ship in the repo were judged.')
|
|
_p(out)
|
|
|
|
total = 0
|
|
if not blind_only:
|
|
total = _findings(res, out)
|
|
|
|
_blind_report(res, out)
|
|
|
|
if not blind_only:
|
|
_p(out)
|
|
sup = sum(len(v) for v in res['suppressed'].values())
|
|
_p(out, '%d finding(s); %d suppressed by %s.'
|
|
% (total, sup, os.path.basename(ALLOWLIST)))
|
|
if show_all and sup:
|
|
for cat in CATEGORIES:
|
|
for path, why in res['suppressed'][cat]:
|
|
_p(out, ' [%s] %s -- %s' % (cat, path, why))
|
|
if res['stale_allow']:
|
|
_p(out, 'STALE allowlist entries (nothing matches them any more; '
|
|
'delete them):')
|
|
for cat, path in res['stale_allow']:
|
|
_p(out, ' %s %s' % (cat, path))
|
|
for err in res['allow_errors']:
|
|
_p(out, 'ALLOWLIST ERROR: ' + err)
|
|
return total
|
|
|
|
|
|
SECTIONS = (
|
|
('unread', 'DECLARED BUT NEVER READ',
|
|
'A key someone can set that no code looks at. The file says the tool '
|
|
'honours it; the tool does not.'),
|
|
('undeclared', 'READ BUT NEVER DECLARED',
|
|
'A hidden default: nothing in DEFAULTS, the starters, the catalogue or '
|
|
'a real layer names it, so nobody can discover or override it.'),
|
|
('defaults', 'READ WITH INCONSISTENT DEFAULTS',
|
|
'The same path fetched with different fallbacks. Two call sites '
|
|
'disagree about what "unset" means, and only one of them can be right.'),
|
|
('surface-only', 'READ ONLY BY THE CONFIG SCREEN OR THE PROMPT',
|
|
'Settable, displayed, and inert: no check, solver or export consumes '
|
|
'it. This is the shape rails.left_rail_is_fixed had.'),
|
|
('test-only', 'READ ONLY BY THE TEST SUITE',
|
|
'Written and tested but never wired to production code -- the shape '
|
|
'weight_three_way had.'),
|
|
)
|
|
|
|
|
|
def _findings(res, out):
|
|
total = 0
|
|
for cat, title, why in SECTIONS:
|
|
rows = res['findings'][cat]
|
|
total += len(rows)
|
|
_p(out, '-' * 72)
|
|
_p(out, '%s (%d)' % (title, len(rows)))
|
|
_p(out, ' ' + why)
|
|
_p(out)
|
|
if not rows:
|
|
_p(out, ' none')
|
|
_p(out)
|
|
continue
|
|
if cat == 'defaults':
|
|
for path, seen in rows:
|
|
_p(out, ' %s' % path)
|
|
for default, rs in sorted(seen.items()):
|
|
_p(out, ' default %-24s %s'
|
|
% (default, ', '.join(sorted(r.where for r in rs))))
|
|
elif cat in ('surface-only', 'test-only'):
|
|
for path, rs in rows:
|
|
_p(out, ' %-52s %s'
|
|
% (path, ', '.join(sorted(set(
|
|
'%s [%s]' % (r.where, r.tier) for r in rs)))))
|
|
else:
|
|
for path, where in rows:
|
|
_p(out, ' %-52s %s' % (path, ', '.join(where)))
|
|
_p(out)
|
|
return total
|
|
|
|
|
|
def _blind_report(res, out):
|
|
_p(out, '=' * 72)
|
|
_p(out, 'WHAT THIS TOOL COULD NOT SEE')
|
|
_p(out, '=' * 72)
|
|
_p(out, 'Read this before treating a short findings list as a clean '
|
|
'codebase.')
|
|
_p(out, '"Could not judge" is not "judged and found nothing".')
|
|
_p(out)
|
|
|
|
if res['dyn_prefixes']:
|
|
_p(out, 'Subtrees reached by a computed path -- SOME key under each '
|
|
'is read and')
|
|
_p(out, 'this tool cannot say which, so nothing under them is '
|
|
'reported:')
|
|
for prefix in res['dyn_prefixes']:
|
|
hidden = [p for p in res['dyn_hidden']
|
|
if p == prefix or p.startswith(prefix + '.')]
|
|
_p(out, ' %-28s hides %d declared key(s) from judgement'
|
|
% (prefix, len(hidden)))
|
|
_p(out)
|
|
|
|
other = [b for b in res['blind'] if b[2] != 'concatenated path']
|
|
if other:
|
|
_p(out, 'Call sites whose path is not a literal at all:')
|
|
for rel, line, kind, shown in sorted(other):
|
|
_p(out, ' %s:%d %s %s' % (rel, line, kind, shown))
|
|
_p(out)
|
|
|
|
if res['parse_fail']:
|
|
_p(out, 'Python files that would not parse on CPython 3.12 -- any '
|
|
'reader in them is INVISIBLE:')
|
|
for rel, msg in res['parse_fail']:
|
|
_p(out, ' %s (%s)' % (rel, msg))
|
|
_p(out)
|
|
|
|
for note in res['parse_notes']:
|
|
_p(out, 'NOTE: ' + note)
|
|
|
|
_p(out, 'Shapes this detector does not attempt at all:')
|
|
for line in (
|
|
'a subtree fetched once and then walked by hand -- the keys inside',
|
|
' it are read, but by dict access, not by a path this tool sees;',
|
|
'a path assembled from a variable at a call site listed above;',
|
|
'a wrapper other than _nomen / param_names / _s hiding a literal path;',
|
|
'anything read outside the .py files under %s;' % ', '.join(CODE_ROOTS),
|
|
'whether a reader that EXISTS actually changes an outcome -- the',
|
|
' tier is a proxy for that, not a proof.',
|
|
):
|
|
_p(out, (' ' + line) if line.startswith(' ') else (' - ' + line))
|
|
_p(out)
|
|
_p(out, 'And three kinds of drift that LOOK like this one but are NOT '
|
|
'judged here')
|
|
_p(out, 'at all, because what they are about is not a knowledge-layer key:')
|
|
for line in (
|
|
'a Revit shared parameter the model carries that no code reads',
|
|
' (TICKET_IS_REINFORCED was one);',
|
|
'a field in the panellift JSON job file, or in the reinforcing index',
|
|
' record, that nothing interprets -- those are dataclass and dict',
|
|
' schemas, not YAML layers;',
|
|
'a check that is registered and can never run. A dead check still',
|
|
' reads its keys, so every key it touches looks alive from here.',
|
|
):
|
|
_p(out, (' ' + line) if line.startswith(' ') else (' - ' + line))
|
|
_p(out)
|
|
|
|
|
|
def main(argv=None):
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument('--all', action='store_true',
|
|
help='also list every allowlist-suppressed finding')
|
|
ap.add_argument('--blind', action='store_true',
|
|
help='print only the blind-spot report')
|
|
ap.add_argument('--json', action='store_true', help='machine readable')
|
|
args = ap.parse_args(argv)
|
|
|
|
res = analyse()
|
|
if args.json:
|
|
payload = {
|
|
'counts': res['counts'],
|
|
'layers': res['layers'],
|
|
'findings': {
|
|
cat: [row[0] for row in res['findings'][cat]]
|
|
for cat in CATEGORIES},
|
|
'suppressed': {cat: [p for p, _ in res['suppressed'][cat]]
|
|
for cat in CATEGORIES},
|
|
'stale_allow': ['%s %s' % k for k in res['stale_allow']],
|
|
'allow_errors': res['allow_errors'],
|
|
'blind_dynamic_prefixes': res['dyn_prefixes'],
|
|
'blind_call_sites': ['%s:%d %s' % (b[0], b[1], b[2])
|
|
for b in res['blind']],
|
|
'unparseable_files': [f for f, _ in res['parse_fail']],
|
|
}
|
|
sys.stdout.write(json.dumps(payload, indent=2, sort_keys=True) + '\n')
|
|
total = sum(len(v) for v in payload['findings'].values())
|
|
else:
|
|
total = report(res, sys.stdout, show_all=args.all,
|
|
blind_only=args.blind)
|
|
if args.blind:
|
|
return 0
|
|
if res['allow_errors']:
|
|
return 2
|
|
return 1 if total else 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|