Setting the file. One moment.
Docs Drift · Docs Sync Audit · github/awesome-copilot · Skills Docs
ContentsBack to the top of the page def render
— line 409
This file
Number 139.1
Position 1 of 1
Type Python
Size 21 KB
Lines 474 scripts/ docs_drift.py
Python · 474 lines · 21 KB
paths backticked paths, against the filesystem (opt-in, --check-paths)
17 env vars names documented in docs or .env.example, against names actually
18 read by the code, in both directions
19 staleness a doc untouched for far longer than the code it describes
20
21 It does not judge prose. Wording, tone, completeness and accuracy of explanation
22 are the reviewing agent's job; this exists so the agent does not spend forty tool
23 calls confirming whether a path exists.
24
25 Backticked-path checking is opt-in. On real repositories most such references are
26 ambiguous -- a path the doc is telling you to create, or one an archived report
27 described accurately at the time -- and reporting them buries the findings that
28 are unambiguous. Markdown links are always checked, because a link is a promise
29 to resolve.
30
31 Values are never read out of environment files. Only the names to the left of `=`
32 are used, because the right-hand side is a credential by design.
33 """
34
35 from __future__ import annotations
36
37 import argparse
38 import json
39 import re
40 import shutil
41 import subprocess
42 import sys
43 from pathlib import Path
44
45 if hasattr (sys.stdout, "reconfigure" ):
46 sys.stdout.reconfigure( encoding = "utf-8" , errors = "replace" )
47
48 GIT_TIMEOUT = 30
49 MAX_READ = 2_000_000
50 STALE_DAYS = 120
51
52 SKIP_DIRS = {
53 ".git" , "node_modules" , "vendor" , "venv" , ".venv" , "dist" , "build" , "target" ,
54 "__pycache__" , ".next" , "coverage" , ".terraform" , "site-packages" ,
55 }
56 DOC_EXTS = { ".md" , ".mdx" , ".rst" , ".txt" }
57 CODE_EXTS = {
58 ".py" , ".js" , ".jsx" , ".ts" , ".tsx" , ".mjs" , ".cjs" , ".vue" , ".svelte" ,
59 ".go" , ".rs" , ".rb" , ".php" , ".java" , ".kt" , ".swift" , ".cs" , ".ex" , ".exs" , ".sh" ,
60 }
61
62 FENCE = re.compile( r " ^ ```" )
63 # Commands worth checking. Anything else in a fenced block is left alone.
64 CMD_NPM = re.compile( r " \b(?: npm | pnpm | yarn | bun )\s + run \s + ([ A-Za-z0-9:_.- ] + ) " )
65 CMD_MAKE = re.compile( r " \b make \s + ([ A-Za-z0-9_.- ] + ) " )
66 CMD_SCRIPT = re.compile( r " (?:^ | \s)( \. / [ A-Za-z0-9_./- ] +| (?: python3 ?| node | bash | sh | ruby )\s + ([ A-Za-z0-9_./- ] + \. [ A-Za-z0-9 ] + )) " )
67
68 MD_LINK = re.compile( r "! ? \[ [ ^ \] ] * \]\( ([ ^) \s] + ) " )
69 BACKTICK = re.compile( r "` ([ ^` \n ] + ) `" )
70
71 # Placeholder shapes that are not meant to resolve.
72 PLACEHOLDER = re.compile(
73 r " [ <>{}$* ] | ^ \. {3}| \. {3} $ | (^ | / )( path/to | your [ -_ ] | my [ -_ ] | example | foo | bar | baz | placeholder ) " ,
74 re.I)
75
76 ENV_IN_CODE = [
77 re.compile( r "process \. env \. ([ A-Z ][ A-Z0-9_ ] * ) " ),
78 re.compile( r """process \. env \[ \s * [ '" ]([ A-Z ][ A-Z0-9_ ] * )[ '" ] """ ),
79 re.compile( r """os \. environ (?: \. get ) ? \[ ? \( ? \s * [ '" ]([ A-Z ][ A-Z0-9_ ] * )[ '" ] """ ),
80 re.compile( r """os \. getenv \( \s * [ '" ]([ A-Z ][ A-Z0-9_ ] * )[ '" ] """ ),
81 re.compile( r """getenv \( \s * [ '" ]([ A-Z ][ A-Z0-9_ ] * )[ '" ] """ ),
82 re.compile( r """ENV \[ \s * [ '" ]([ A-Z ][ A-Z0-9_ ] * )[ '" ] """ ),
83 re.compile( r """Deno \. env \. get \( \s * [ '" ]([ A-Z ][ A-Z0-9_ ] * )[ '" ] """ ),
84 ]
85 ENV_NAME = re.compile( r " \b([ A-Z ][ A-Z0-9_ ] {2,} )\b " )
86
87 # Import forms across the languages handled above. Four alternatives, so findall
88 # returns tuples and the caller takes the first non-empty group.
89 IMPORT_SPEC = re.compile(
90 r """ (?: from | import )\s + [ '" ]([ ^'" ] + )[ '" ] """
91 r """ | require \( \s * [ '" ]([ ^'" ] + )[ '" ]\s * \) """
92 r """ | ^\s * from \s + ([ A-Za-z0-9_. ] + )\s + import"""
93 r """ | ^\s * import \s + ([ A-Za-z0-9_. ] + ) """ ,
94 re.M)
95
96 warnings: list[ str ] = []
97
98
99 def run_git (args: list[ str ], cwd: Path) -> str | None :
100 git = shutil.which( "git" )
101 if git is None :
102 return None
103 try :
104 p = subprocess.run([git, * args], cwd = str (cwd), text = True , timeout = GIT_TIMEOUT ,
105 stdout = subprocess. PIPE , stderr = subprocess. PIPE ,
106 encoding = "utf-8" , errors = "replace" )
107 except ( OSError , subprocess.SubprocessError) as exc:
108 warnings.append( f "git { ' ' .join(args[: 2 ]) } failed: { exc } " )
109 return None
110 return p.stdout if p.returncode == 0 else None
111
112
113 def list_files (repo: Path) -> list[ str ]:
114 out = run_git([ "ls-files" , "--cached" , "--other" , "--exclude-standard" ], repo)
115 if out is not None and out.strip():
116 return sorted (x.strip() for x in out.splitlines() if x.strip())
117 warnings.append( "git unavailable or empty index; walking the filesystem instead" )
118 files = []
119 for p in repo.rglob( "*" ):
120 if p.is_file() and not any (part in SKIP_DIRS for part in p.parts):
121 files.append(p.relative_to(repo).as_posix())
122 return sorted (files)
123
124
125 def read (path: Path) -> str :
126 try :
127 if path.stat().st_size > MAX_READ :
128 return ""
129 return path.read_text( encoding = "utf-8" , errors = "replace" )
130 except OSError :
131 return ""
132
133
134 def available_commands (repo: Path, files: list[ str ]) -> tuple[dict[ str , set[ str ]], set[ str ]]:
135 """Real npm scripts and make targets, keyed by the directory that declares them."""
136 npm: dict[ str , set[ str ]] = {}
137 make: set[ str ] = set ()
138 for rel in files:
139 base = Path(rel).name
140 prefix = str (Path(rel).parent).replace( " \\ " , "/" )
141 if base == "package.json" :
142 try :
143 data = json.loads(read(repo / rel))
144 except ValueError :
145 continue
146 if isinstance (data.get( "scripts" ), dict ):
147 npm.setdefault(prefix, set ()).update(data[ "scripts" ].keys())
148 elif base == "Makefile" :
149 targets = re.findall( r " ^([ A-Za-z0-9 ][ A-Za-z0-9_.- ] * ) : (?! = ) " , read(repo / rel), re.M)
150 make.update(targets)
151 return npm, make
152
153
154 def fenced_blocks (text: str ) -> list[tuple[ int , str ]]:
155 """Yield (line_number, line) for lines inside fenced code blocks."""
156 out: list[tuple[ int , str ]] = []
157 inside = False
158 for i, line in enumerate (text.splitlines(), start = 1 ):
159 if FENCE .match(line.strip()):
160 inside = not inside
161 continue
162 if inside:
163 out.append((i, line))
164 return out
165
166
167 def env_names_from_code (repo: Path, files: list[ str ]) -> dict[ str , list[ str ]]:
168 """Env var names the code reads, mapped to every location that reads them."""
169 found: dict[ str , list[ str ]] = {}
170 for rel in files:
171 if Path(rel).suffix not in CODE_EXTS or any (p in SKIP_DIRS for p in Path(rel).parts):
172 continue
173 text = read(repo / rel)
174 if not text or "env" not in text.lower():
175 continue
176 for i, line in enumerate (text.splitlines(), start = 1 ):
177 for pattern in ENV_IN_CODE :
178 for name in pattern.findall(line):
179 found.setdefault(name, []).append( f " { rel } : { i } " )
180 return found
181
182
183 def unreferenced_modules (repo: Path, files: list[ str ]) -> set[ str ]:
184 """Code files that nothing imports, and that are not plausible entrypoints.
185
186 A documented setting read only inside such a file is configuration that cannot
187 take effect, which reads as working config in the docs. Deliberately
188 conservative: basename matching, and anything entrypoint-shaped is excluded, so
189 it under-reports rather than accusing live code of being dead.
190 """
191 code = [f for f in files
192 if Path(f).suffix in CODE_EXTS and not any (p in SKIP_DIRS for p in Path(f).parts)]
193 entrypoint = re.compile(
194 r " (^ | / )( server | main | index | app | cli | __init__ | __main__ | conftest | setup | wsgi | asgi ) \. [ A-Za-z ] + $ " ,
195 re.I)
196 imported: set[ str ] = set ()
197 for rel in code:
198 text = read(repo / rel)
199 if not text:
200 continue
201 for groups in IMPORT_SPEC .findall(text):
202 ref = next ((g for g in groups if g), "" ).strip()
203 if not ref:
204 continue
205 imported.add(Path(ref).name.lower())
206 imported.add(Path(ref).stem.lower())
207 for part in re.split( r " [ ./ \: ] " , ref):
208 if part:
209 imported.add(part.lower())
210 out = set ()
211 for rel in code:
212 if entrypoint.search(rel):
213 continue
214 stem = Path(rel).stem.lower()
215 if stem not in imported and Path(rel).name.lower() not in imported:
216 out.add(rel)
217 return out
218
219
220 def env_names_documented (repo: Path, files: list[ str ]) -> dict[ str , str ]:
221 """Env var names named in docs or declared in an env sample file.
222
223 Only the key to the left of `=` is ever read from an env file. The value is a
224 credential by design and is never touched.
225 """
226 documented: dict[ str , str ] = {}
227 for rel in files:
228 base = Path(rel).name
229 is_env_sample = base.startswith( ".env" )
230 if not is_env_sample and Path(rel).suffix not in DOC_EXTS :
231 continue
232 text = read(repo / rel)
233 if not text:
234 continue
235 for i, line in enumerate (text.splitlines(), start = 1 ):
236 if is_env_sample:
237 stripped = line.strip()
238 if not stripped or stripped.startswith( "#" ) or "=" not in stripped:
239 continue
240 key = stripped.split( "=" , 1 )[ 0 ].strip().lstrip( "export " ).strip()
241 if ENV_NAME .fullmatch(key or "" ):
242 documented.setdefault(key, f " { rel } : { i } " )
243 else :
244 # In prose, a backticked all-caps token is weak evidence: `SKILL.md`
245 # and `README` are not configuration. Require an underscore, which is
246 # what actually distinguishes API_TOKEN from a shouted word, and skip
247 # anything that looks like a filename.
248 for chunk in BACKTICK .findall(line):
249 if "." in chunk or "/" in chunk:
250 continue
251 for name in ENV_NAME .findall(chunk):
252 if "_" not in name:
253 continue
254 documented.setdefault(name, f " { rel } : { i } " )
255 return documented
256
257
258 def newest_commit_epoch (repo: Path, pathspec: str ) -> int | None :
259 out = run_git([ "log" , "-1" , "--format= %a t" , "--" , pathspec], repo)
260 if not out or not out.strip().isdigit():
261 return None
262 return int (out.strip())
263
264
265 def build (repo: Path, files: list[ str ], check_paths: bool = False ) -> dict :
266 findings: list[ dict ] = []
267
268 def add (kind: str , severity: str , doc: str , line: int | None , detail: str ,
269 source: str | None = None ) -> None :
270 findings.append({ "kind" : kind, "severity" : severity, "doc" : doc, "line" : line,
271 "detail" : detail, "source" : source})
272
273 file_set = set (files)
274 npm_scripts, make_targets = available_commands(repo, files)
275 all_npm = set ().union( * npm_scripts.values()) if npm_scripts else set ()
276 docs = [f for f in files
277 if Path(f).suffix in DOC_EXTS and not any (p in SKIP_DIRS for p in Path(f).parts)]
278
279 for doc in docs:
280 text = read(repo / doc)
281 if not text:
282 continue
283
284 # 1. commands
285 for lineno, line in fenced_blocks(text):
286 for script in set ( CMD_NPM .findall(line)):
287 if not all_npm:
288 continue
289 if script not in all_npm:
290 near = ", " .join( sorted (s for s in all_npm if s.startswith(script.split( ":" )[ 0 ]))[: 4 ])
291 hint = f " Closest existing: { near } ." if near else ""
292 add( "missing-script" , "high" , doc, lineno,
293 f "documents ` { script } `, which is not a script in any package.json. { hint } " ,
294 source = "package.json" )
295 for target in set ( CMD_MAKE .findall(line)):
296 if make_targets and target not in make_targets and target not in ( "-j" , "all" ):
297 add( "missing-make-target" , "high" , doc, lineno,
298 f "documents `make { target } `, which is not a target in the Makefile." ,
299 source = "Makefile" )
300 for whole, inner in CMD_SCRIPT .findall(line):
301 candidate = (inner or whole).lstrip( "./" )
302 if not candidate or PLACEHOLDER .search(candidate):
303 continue
304 if candidate.endswith( "/" ) or any (part in SKIP_DIRS
305 for part in Path(candidate).parts):
306 continue
307 if candidate not in file_set and not (repo / candidate).exists():
308 add( "missing-script-file" , "high" , doc, lineno,
309 f "documents running ` { candidate } `, which does not exist." )
310
311 # 2 and 3. links and backticked paths
312 for i, line in enumerate (text.splitlines(), start = 1 ):
313 for target in MD_LINK .findall(line):
314 t = target.split( "#" )[ 0 ].strip()
315 if not t or t.startswith(( "http://" , "https://" , "mailto:" , "#" , "tel:" , "data:" )):
316 continue
317 if PLACEHOLDER .search(t):
318 continue
319 if not (repo / Path(doc).parent / t).exists():
320 add( "broken-link" , "high" , doc, i,
321 f "relative link ` { t } ` does not resolve." )
322 for chunk in ( BACKTICK .findall(line) if check_paths else []):
323 c = chunk.strip()
324 # Only treat it as a path claim when it looks like one.
325 if "/" not in c or " " in c or PLACEHOLDER .search(c):
326 continue
327 # A leading slash means a slash-command or an absolute path, e.g.
328 # `/security-review`. Neither is a claim about this repository.
329 if c.startswith( "/" ):
330 continue
331 if not re.match( r " ^[ A-Za-z0-9._/- ] + $ " , c) or c.endswith( "/" ):
332 continue
333 # Require a real file extension. Extensionless slashed tokens are
334 # ambiguous by nature -- `origin/staging` is a git ref, `src/utils/billing`
335 # is an illustrative example, `@scope/pkg` is a package. Accusing those
336 # of being broken paths produced far more noise than signal.
337 if not re.match( r " ^ \. [ A-Za-z0-9 ] {1,5} $ " , Path(c).suffix):
338 continue
339 if c in file_set or (repo / c).exists():
340 continue
341 # Docs routinely write paths relative to their own directory.
342 if (repo / Path(doc).parent / c).exists():
343 continue
344 # A directory prefix that exists is close enough not to report.
345 if any (f.startswith(c.rstrip( "/" ) + "/" ) for f in file_set):
346 continue
347 # Docs also reference a shape that repeats, e.g. `agents/openai.yaml`
348 # when the real files are skills/<name>/agents/openai.yaml. If it is
349 # the tail of a real path, the claim is true enough.
350 tail = "/" + c
351 if any (f.endswith(tail) for f in file_set):
352 continue
353 add( "missing-path" , "medium" , doc, i,
354 f "references ` { c } `, which does not exist in the repository." )
355
356 # 4. env vars, both directions
357 in_code = env_names_from_code(repo, files)
358 in_docs = env_names_documented(repo, files)
359 dead = unreferenced_modules(repo, files)
360 for name, where in sorted (in_docs.items()):
361 readers = in_code.get(name, [])
362 doc_path, _, doc_line = where.rpartition( ":" )
363 if not readers:
364 add( "documented-unused-env" , "medium" , doc_path, int (doc_line),
365 f "` { name } ` is documented but nothing in the code reads it. "
366 "Either it is dead configuration or the docs promise a knob that does not exist." ,
367 source = "no reader found" )
368 elif all (r.rsplit( ":" , 1 )[ 0 ] in dead for r in readers):
369 where_read = ", " .join(readers[: 3 ])
370 add( "documented-env-in-unreferenced-module" , "medium" , doc_path, int (doc_line),
371 f "` { name } ` is read only in a module nothing imports, so the documented setting "
372 "cannot take effect. The docs describe a working knob that does nothing." ,
373 source = where_read)
374 for name, readers in sorted (in_code.items()):
375 if name not in in_docs:
376 add( "undocumented-env" , "medium" , "(docs)" , None ,
377 f "` { name } ` is read by the code but is not documented anywhere, "
378 "and is not in an env sample file." ,
379 source = readers[ 0 ])
380
381 # 5. staleness
382 code_dirs = { str (Path(f).parent).replace( " \\ " , "/" ) for f in files
383 if Path(f).suffix in CODE_EXTS }
384 newest_code = max ((e for e in (newest_commit_epoch(repo, d) for d in list (code_dirs)[: 40 ])
385 if e), default = None )
386 if newest_code:
387 for doc in docs:
388 doc_epoch = newest_commit_epoch(repo, doc)
389 if not doc_epoch:
390 continue
391 days = (newest_code - doc_epoch) / 86400
392 if days > STALE_DAYS :
393 add( "stale-doc" , "low" , doc, None ,
394 f "last changed { int (days) } days before the most recent code change. "
395 "Not wrong by itself, but worth reading against current behavior." )
396
397 order = { "high" : 0 , "medium" : 1 , "low" : 2 }
398 findings.sort( key =lambda f: (order.get(f[ "severity" ], 3 ), f[ "doc" ], f[ "line" ] or 0 ))
399 return {
400 "repo" : str (repo),
401 "totals" : { "docs_checked" : len (docs), "findings" : len (findings),
402 "npm_scripts_found" : len (all_npm), "make_targets_found" : len (make_targets),
403 "env_names_in_code" : len (in_code), "env_names_documented" : len (in_docs)},
404 "findings" : findings,
405 "warnings" : warnings,
406 }
407
408
409 def render (d: dict , top: int ) -> str :
410 t = d[ "totals" ]
411 L = [ "# Documentation Drift Check" , "" , f "Repo: { d[ 'repo' ] } " ,
412 f "Docs checked: { t[ 'docs_checked' ] } Findings: { t[ 'findings' ] } " ,
413 f "Known npm scripts: { t[ 'npm_scripts_found' ] } make targets: { t[ 'make_targets_found' ] } " ,
414 f "Env names in code: { t[ 'env_names_in_code' ] } documented: { t[ 'env_names_documented' ] } " , "" ]
415
416 if not d[ "findings" ]:
417 L.append( "No machine-verifiable drift found. Prose accuracy is still unchecked." )
418 else :
419 shown = d[ "findings" ][:top]
420 if len (d[ "findings" ]) > top:
421 L.append( f "TRUNCATED: showing { top } of { len (d[ 'findings' ]) } findings" )
422 L.append( "" )
423 for f in shown:
424 loc = f " { f[ 'doc' ] } : { f[ 'line' ] } " if f.get( "line" ) else f[ "doc" ]
425 L.append( f "- [ { f[ 'severity' ] } ] { f[ 'kind' ] } -- { loc } " )
426 L.append( f " { f[ 'detail' ] } " )
427 if f.get( "source" ):
428 L.append( f " source of truth: { f[ 'source' ] } " )
429 L.append( "" )
430 if d[ "warnings" ]:
431 L.append( "## Warnings" )
432 L.extend( f "- { w } " for w in d[ "warnings" ])
433 L.append( "" )
434 L.append( "Checks only claims with a definite answer. Wording, completeness and whether an" )
435 L.append( "explanation is actually correct are not checked here. Confirm each finding by" )
436 L.append( "opening both the doc and the source before reporting it." )
437 return " \n " .join(L)
438
439
440 def main () -> int :
441 ap = argparse.ArgumentParser( description = "Check documentation claims against the repo. Read-only." )
442 ap.add_argument( "--repo" , default = "." , help = "Path inside the repository." )
443 ap.add_argument( "--format" , choices = [ "text" , "json" ], default = "text" , help = "Output format." )
444 ap.add_argument( "--top" , type = int , default = 30 , help = "Findings to show. Default 30." )
445 ap.add_argument( "--check-paths" , action = "store_true" ,
446 help = ( "Also check backticked paths against the filesystem. Off by default: on "
447 "real repos most such references are ambiguous -- a path a doc tells you "
448 "to create, or one an archived report described at the time -- and the "
449 "noise buries the unambiguous findings. Markdown links are always checked." ))
450 ap.add_argument( "--no-git-root" , action = "store_true" ,
451 help = "Treat --repo literally instead of expanding to the enclosing git repository root." )
452 args = ap.parse_args()
453
454 repo = Path(args.repo).resolve()
455 if not repo.is_dir():
456 print ( f "error: not a directory: { repo } " , file = sys.stderr)
457 return 2
458 if not args.no_git_root:
459 root = run_git([ "rev-parse" , "--show-toplevel" ], repo)
460 if root and root.strip():
461 repo = Path(root.strip()).resolve()
462
463 files = list_files(repo)
464 if not files:
465 print ( f "error: no files found under { repo } " , file = sys.stderr)
466 return 2
467
468 data = build(repo, files, check_paths = args.check_paths)
469 print (json.dumps(data, indent = 2 ) if args.format == "json" else render(data, args.top))
470 return 0
471
472
473 if __name__ == "__main__" :
474 raise SystemExit (main())