instruction-drift 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Offline repository instruction drift checks."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,118 @@
1
+ """Deterministic, offline comparison of lexical claims with disk contents."""
2
+ from dataclasses import asdict, dataclass
3
+ from fnmatch import fnmatchcase
4
+ import os
5
+ from pathlib import Path
6
+ import re
7
+ from .extract import extract
8
+
9
+ DEFAULT_FILES = ('AGENTS.md', 'CLAUDE.md', 'MUSE.md', 'CONTRIBUTING.md')
10
+ SKIP = {'node_modules', 'dist', '__pycache__', 'venv'}
11
+
12
+ @dataclass(frozen=True)
13
+ class Finding:
14
+ file: str
15
+ line: int
16
+ kind: str
17
+ claim: str
18
+ severity: str
19
+ detail: str
20
+
21
+
22
+ def imports(root, excluded):
23
+ identifiers = set()
24
+ for directory, dirs, files in os.walk(root):
25
+ dirs[:] = sorted(d for d in dirs if not d.startswith('.') and d not in SKIP
26
+ and not (Path(directory) / d).is_symlink())
27
+ for name in sorted(files):
28
+ path = Path(directory) / name
29
+ if path.resolve() in excluded or path.suffix not in ('.py', '.js', '.ts'):
30
+ continue
31
+ try:
32
+ content = path.read_text(encoding='utf-8')
33
+ except (OSError, UnicodeError):
34
+ continue
35
+ if path.suffix == '.py':
36
+ for match in re.finditer(r'^\s*(?:from\s+([\w.]+)\s+import\b|import\s+([^\n;#]+))', content, re.M):
37
+ if match.group(1):
38
+ identifiers.add(match.group(1))
39
+ else:
40
+ identifiers.update(part.strip().split()[0] for part in
41
+ match.group(2).split(',') if part.strip())
42
+ else:
43
+ identifiers.update(re.findall(r'''(?:require\(\s*|\bfrom\s*)['"]([^'"]+)['"]''', content))
44
+ return identifiers
45
+
46
+
47
+ def audit(repo='.', files=None, ignore=(), fail_on='high'):
48
+ root = Path(repo).resolve()
49
+ names = list(DEFAULT_FILES if files is None else files)
50
+ report = dict(files_read=[], findings=[], ignored=0, parse_errors=[], truncated=False)
51
+ if not root.is_dir():
52
+ report['parse_errors'].append({'file': str(repo), 'detail': 'Repository is not a directory'})
53
+ excluded = {(root / name).resolve() for name in names}
54
+ used = imports(root, excluded) if root.is_dir() else set()
55
+ targets = None
56
+ seen = set()
57
+ threshold_hit = False
58
+ for name in names if root.is_dir() else []:
59
+ path = root / name
60
+ if files is None and not path.exists():
61
+ continue
62
+ try:
63
+ content = path.read_text(encoding='utf-8')
64
+ except (OSError, UnicodeError) as error:
65
+ report['parse_errors'].append({'file': name, 'detail': str(error)})
66
+ continue
67
+ report['files_read'].append(name)
68
+ for item in extract(content):
69
+ key = (name, item.line, item.kind, item.claim)
70
+ if key in seen:
71
+ continue
72
+ seen.add(key)
73
+ if any(fnmatchcase(item.claim, pattern) for pattern in ignore):
74
+ report['ignored'] += 1
75
+ continue
76
+ severity, detail = 'HIGH', ''
77
+ if item.kind == 'prohibition-contradiction':
78
+ if item.claim not in used:
79
+ continue
80
+ severity = 'MEDIUM'
81
+ detail = 'Identifier appears in source imports; verify manually.'
82
+ elif item.claim.startswith('make '):
83
+ makefile = root / 'Makefile'
84
+ if not makefile.exists():
85
+ continue
86
+ if targets is None:
87
+ try:
88
+ targets = set(re.findall(r'^([A-Za-z0-9_.-]+)\s*:(?!=)', makefile.read_text(encoding='utf-8'), re.M))
89
+ except (OSError, UnicodeError) as error:
90
+ report['parse_errors'].append({'file': 'Makefile', 'detail': str(error)})
91
+ targets = set()
92
+ if item.claim[5:] in targets:
93
+ continue
94
+ detail = 'Target not found in root Makefile.'
95
+ else:
96
+ candidate = root / item.claim
97
+ try:
98
+ candidate.resolve().relative_to(root)
99
+ except ValueError:
100
+ continue
101
+ exists = candidate.is_dir() if item.claim.endswith('/') else candidate.exists()
102
+ if exists:
103
+ continue
104
+ detail = 'Referenced path does not exist' + (' as a directory.' if item.claim.endswith('/') else '.')
105
+ threshold_hit = threshold_hit or severity == 'HIGH' or fail_on == 'medium'
106
+ if len(report['findings']) < 500:
107
+ report['findings'].append(asdict(Finding(name, item.line, item.kind, item.claim, severity, detail)))
108
+ else:
109
+ report['truncated'] = True
110
+ findings = report['findings']
111
+ report['totals'] = {'kind': {k: sum(f['kind'] == k for f in findings) for k in
112
+ ('referenced-file', 'script-in-command', 'prohibition-contradiction')},
113
+ 'severity': {s: sum(f['severity'] == s for f in findings) for s in ('HIGH', 'MEDIUM')}}
114
+ failed = report['parse_errors'] or threshold_hit
115
+ report['exit_code'] = 2 if failed else (1 if findings else 0)
116
+ report['verdict'] = {0: 'PASS', 1: 'WARN', 2: 'FAIL'}[report['exit_code']]
117
+ report['parse_error_count'] = len(report['parse_errors'])
118
+ return report
@@ -0,0 +1,33 @@
1
+ """Command-line interface; commands mentioned in instructions are never run."""
2
+ import argparse
3
+ import json
4
+ from . import __version__
5
+ from .audit import audit
6
+
7
+
8
+ def main(argv=None):
9
+ parser = argparse.ArgumentParser(prog='instruction-drift', description='Check repository instruction claims offline.')
10
+ parser.add_argument('--version', action='version', version='%(prog)s ' + __version__)
11
+ sub = parser.add_subparsers(dest='command', required=True)
12
+ check = sub.add_parser('audit', help='Audit instruction claims against repository files')
13
+ check.add_argument('repo', nargs='?', default='.')
14
+ check.add_argument('--files', nargs='+', action='extend')
15
+ check.add_argument('--ignore', nargs='+', action='extend', default=[])
16
+ check.add_argument('--json', action='store_true')
17
+ check.add_argument('--fail-on', choices=('high', 'medium'), default='high')
18
+ args = parser.parse_args(argv)
19
+ report = audit(args.repo, args.files, args.ignore, args.fail_on)
20
+ if args.json:
21
+ print(json.dumps(report, indent=2))
22
+ else:
23
+ print('Instruction files read: ' + (', '.join(report['files_read']) or 'none (no instruction files read)'))
24
+ for finding in report['findings']:
25
+ print('{file}:{line}: {severity} {kind}: {claim} — {detail}'.format(**finding))
26
+ for error in report['parse_errors']:
27
+ print('{file}: parse error: {detail}'.format(**error))
28
+ print('Totals: ' + json.dumps(report['totals'], sort_keys=True))
29
+ print('Ignored: {}; parse errors: {}'.format(report['ignored'], report['parse_error_count']))
30
+ if report['truncated']:
31
+ print('Report truncated at 500 findings.')
32
+ print(report['verdict'])
33
+ return report['exit_code']
@@ -0,0 +1,52 @@
1
+ """Small, deliberately lexical instruction-claim extractors."""
2
+ from dataclasses import dataclass
3
+ import re
4
+
5
+ EXTENSIONS = ('.py', '.js', '.ts', '.md', '.json', '.yaml', '.yml',
6
+ '.toml', '.sh', '.go', '.rs', '.txt')
7
+ ROOTS = ('src/', 'tests/', 'docs/', 'examples/', 'scripts/', 'bin/')
8
+ TOKEN = re.compile(r"[^\s`\"'<>|;()]+")
9
+ BACKTICK = re.compile(r'`([^`]+)`')
10
+ PROHIBITION = re.compile(
11
+ r'\b(?:do not use|never use|must not use|avoid using)\s+`?([\w@][\w.\-@]*)',
12
+ re.IGNORECASE)
13
+
14
+ @dataclass(frozen=True)
15
+ class Claim:
16
+ line: int
17
+ kind: str
18
+ claim: str
19
+
20
+
21
+ def path_token(token):
22
+ """Return a normalized path claim, or None for unsupported tokens."""
23
+ token = token.lstrip(',:!?[]{}').rstrip('.,:!?[]{}')
24
+ if '*' in token or '://' in token:
25
+ return None
26
+ bare = token[2:] if token.startswith('./') else token
27
+ if '/' in bare and (bare.endswith(EXTENSIONS) or bare.startswith(ROOTS)):
28
+ return token
29
+ return None
30
+
31
+
32
+ def extract(text):
33
+ """Extract claims in line order; command paths have a single kind."""
34
+ result = []
35
+ for number, line in enumerate(text.splitlines(), 1):
36
+ spans = list(BACKTICK.finditer(line))
37
+ for match in TOKEN.finditer(line):
38
+ path = path_token(match.group())
39
+ if path:
40
+ command = any(s.start(1) <= match.start() < s.end(1)
41
+ and (len(s.group(1).split()) > 1 or s.group(1).startswith('./'))
42
+ for s in spans)
43
+ result.append(Claim(number, 'script-in-command' if command else
44
+ 'referenced-file', path))
45
+ for span in spans:
46
+ words = span.group(1).split()
47
+ if len(words) >= 2 and words[0] == 'make' and not words[1].startswith('-'):
48
+ result.append(Claim(number, 'script-in-command', 'make ' + words[1]))
49
+ for match in PROHIBITION.finditer(line):
50
+ result.append(Claim(number, 'prohibition-contradiction',
51
+ match.group(1).rstrip('.')))
52
+ return list(dict.fromkeys(result))
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: instruction-drift
3
+ Version: 0.1.0
4
+ Summary: Offline heuristic checks for stale repository instructions
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Dynamic: license-file
10
+
11
+ # instruction-drift
12
+
13
+ An offline, deterministic, stdlib-only Python CLI that checks concrete instruction
14
+ claims against repository files. A finding means **check this claim**. It does
15
+ not establish that an instruction is wrong. No daemon, LLM, network, or keys.
16
+
17
+ Requires Python 3.9+. Install a locally built wheel with `pip install
18
+ /path/to/instruction_drift-0.1.0-py3-none-any.whl`.
19
+
20
+ ```sh
21
+ instruction-drift audit .
22
+ instruction-drift audit examples/sample-repo --json
23
+ instruction-drift audit . --files AGENTS.md CONTRIBUTING.md --ignore 'docs/*'
24
+ instruction-drift audit . --fail-on medium
25
+ instruction-drift --version
26
+ instruction-drift --help
27
+ ```
28
+
29
+ The repository defaults to the current directory. Default instruction files,
30
+ in order, are root AGENTS.md, CLAUDE.md, MUSE.md, and CONTRIBUTING.md. Missing
31
+ default files are skipped; an empty repository passes with a note. `--files`
32
+ replaces that list with paths relative to the repository. Missing explicitly
33
+ requested files, unreadable files, and invalid UTF-8 produce parse errors.
34
+ `--files` and `--ignore` accept multiple arguments and can be repeated.
35
+
36
+ Checks are heuristic:
37
+
38
+ - **referenced-file (HIGH):** missing path tokens containing `/` and ending in
39
+ .py, .js, .ts, .md, .json, .yaml, .yml, .toml, .sh, .go, .rs, or .txt;
40
+ also directory references rooted under src/, tests/, docs/, examples/,
41
+ scripts/, or bin/. Trailing `/` requires a directory. Paths resolve relative
42
+ to the repository root. URLs, tokens containing `*`, and paths resolving
43
+ outside the repository are skipped.
44
+ - **script-in-command (HIGH):** missing path tokens in backticked commands;
45
+ missing `make target` targets when a root Makefile exists. Simple literal
46
+ target definitions are recognized; Makefile variables and comments are not
47
+ definitions. A command path is reported once rather than also as a reference.
48
+ - **prohibition-contradiction (MEDIUM):** “do not use”, “never use”, “must not
49
+ use”, or “avoid using” followed by an identifier also found in a Python
50
+ import/from import or JS/TS require/from import. Verify manually: exceptions
51
+ and test fixtures may be intentional. Instruction files are excluded from
52
+ source search. Hidden directories, .git, node_modules, dist, __pycache__,
53
+ .venv, and venv are skipped, as are symlinked directories.
54
+
55
+ The report includes files read, file:line locations, kinds, claims, severities,
56
+ details, totals by kind and severity, ignored count, parse-error count, and a
57
+ PASS/WARN/FAIL verdict. `--json` exposes the same information as valid JSON.
58
+ Claims matching `--ignore` glob patterns are excluded and counted before
59
+ checking their validity. Findings are deduplicated by file, line, kind, and
60
+ claim. At most 500 findings are retained in instruction-file and line order;
61
+ additional findings set the truncation flag. Totals describe retained findings. Failure thresholds still apply to findings
62
+ beyond the display cap.
63
+
64
+ Exit codes:
65
+
66
+ | Code | Meaning |
67
+ | --- | --- |
68
+ | 0 | PASS: no findings and no parse errors |
69
+ | 1 | WARN: findings exist, but none reach the failure threshold |
70
+ | 2 | FAIL: a finding reaches the threshold, or any parse error occurs |
71
+
72
+ The default threshold is HIGH; `--fail-on medium` also fails MEDIUM findings.
73
+ For example, the synthetic sample repo cites a renamed script and moved test
74
+ directory, and prohibits an identifier imported in src/app.py. Auditing it
75
+ returns two HIGH findings, one MEDIUM finding, and exit code 2. Its illustrative
76
+ import does not require an installed dependency because commands are never run.
77
+
78
+ ## Limitations
79
+
80
+ This is pattern matching, not semantic judgement. Paraphrased claims are not
81
+ checked. A MEDIUM contradiction needs human review. Commands are not executed;
82
+ only path tokens and simple Makefile target names are checked. Dynamic imports,
83
+ complex shell syntax, generated paths, multiline imports, Makefile includes and
84
+ computed targets, and language-specific resolution are not interpreted.
85
+ Lexical import patterns may match comments or strings and may miss real imports.
86
+ Source files that cannot be decoded or read are skipped; this can hide import
87
+ matches. Instruction-file read failures always produce parse errors. A truncated
88
+ report is incomplete. Filesystem changes during a scan can affect its results.
89
+
90
+ ## Differentiation
91
+
92
+ - **startup-trust-guard** audits TRUST: can an attacker plant or rewrite the
93
+ instruction file? instruction-drift audits TRUTH: is what it says still true
94
+ about the on-disk repository?
95
+ - **rules-doctor** audits rule-file health and structure. This tool cross-checks
96
+ instruction claims against the on-disk repo.
97
+ - **edit-guard** blocks edits made on stale reads. This tool finds stale
98
+ instructions that can cause those edits.
99
+ - **context-mode / heavy runtimes:** this is an offline stdlib CLI with no
100
+ daemon, no LLM, and no network.
101
+
102
+ ## Development
103
+
104
+ ```sh
105
+ PYTHONPATH=src python3 -m unittest discover -s tests -v
106
+ python3 -m build
107
+ ```
108
+
109
+ Building requires setuptools, wheel, and the build frontend already installed
110
+ for offline use. Runtime and tests use only the Python standard library.
@@ -0,0 +1,11 @@
1
+ instruction_drift/__init__.py,sha256=U81StD9LNclhgLJ9bgtdeFUzA4XVRYGOmqgLTpxL3S0,74
2
+ instruction_drift/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ instruction_drift/audit.py,sha256=QYPE3oOOA0NTXto2cdOvoJJ3hxLMrmIulc1bV_v5Vkk,5316
4
+ instruction_drift/cli.py,sha256=4LsT_OHgZjO3fB4ddp_Upj6h95QA7BKAB0TXtRVCJTA,1745
5
+ instruction_drift/extract.py,sha256=TgMFTf_rQk_AQziMb47Yjwv0zJ-6yjgxnu1a8jqKLJQ,2105
6
+ instruction_drift-0.1.0.dist-info/licenses/LICENSE,sha256=BHibCVQHNQhBYTj-25q7SphGxQHnM5-dmo37W0YLcm8,1087
7
+ instruction_drift-0.1.0.dist-info/METADATA,sha256=FBT_t2D87L80K4EuwvgAJ5IpWexU8kzqch9rmjzFBiM,5338
8
+ instruction_drift-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ instruction_drift-0.1.0.dist-info/entry_points.txt,sha256=21cQWcFwc_6LI_eRPGTqjBD2lRcw2zvFdMVOuODEy2I,65
10
+ instruction_drift-0.1.0.dist-info/top_level.txt,sha256=Am-FIfrhTWa7BaRGUmYqhcgYx8SQa1FoGBsaa-4sAZs,18
11
+ instruction_drift-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ instruction-drift = instruction_drift.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 instruction-drift contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ instruction_drift