HR-ATS-Portal/tools/check_evidence_citations.py

260 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Verify every `path:line` evidence citation in docs/architecture against the repository.
The architecture package's central claim is "we verified this by direct inspection", so a
citation that lands on the wrong line is not a cosmetic defect: it is the one thing a
reviewer spot-checks. This gate makes a stale anchor a failed build instead of a
credibility problem discovered by the reader.
Two levels of checking:
1. EVERY citation — the cited file exists and every cited line number is within the
file. Catches citations to deleted files and off-the-end line numbers.
2. CURATED anchors — for the load-bearing anchors listed in ANCHORS below, the cited
line must still match an expected pattern. Catches citation rot: the source moved
and the anchors went stale.
3. CLAIM rules — a sentence making a specific claim must not cite an anchor that
belongs to a different claim. This is the level that catches the defect this script
was written for. Levels 1 and 2 cannot see it: `js/data.js:126` is a real line and
really does hold `salary: int(90,190)*1000`, so citing it for the *ATS score* passes
both — while being exactly wrong, because the score is at `:123`. Several adjacent
lines in `js/data.js` each anchor a different argument in this package, so a
one-line slip silently reattributes a claim.
Lines that intentionally discuss a citation correction will name both the wrong and the
right anchor and would trip level 3. Wrap those in:
<!-- citation-check: ignore-start -->
...prose about the old and new anchors...
<!-- citation-check: ignore-end -->
stdlib only, no dependencies, no build step. Run from anywhere:
python3 tools/check_evidence_citations.py
Exit status 0 = clean, 1 = at least one bad citation.
"""
from __future__ import annotations
import os
import re
import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DOCS = os.path.join(REPO, 'docs', 'architecture')
# Only extensions that exist in this repository today. Citations to planned files
# (.sql migrations, .tsx components, .yml workflows) are intentionally not matched —
# they cannot be verified and are design intent, not evidence.
CITATION = re.compile(
r'(?P<path>(?:[\w.-]+/)*[\w.-]+\.(?:js|html|css|py))'
r':(?P<spec>\d+(?:\s*[-,]\s*\d+)*)'
)
# path:line -> regex the cited line must match. Keep the reason in the comment.
ANCHORS: dict[str, str] = {
# --- the candidate object literal: four adjacent lines, four different claims ---
'js/data.js:117': r'candidates\.push\(\{',
'js/data.js:118': r"id: 'CAN-' \+ \(5001 \+ i\)", # CAN- reference code
'js/data.js:120': r'jobId: job\.id', # single jobId on candidate
'js/data.js:121': r'experience: int\(1, 14\)', # experience in whole years
'js/data.js:122': r'location: pick\(locations\), stage, status: stage', # stage as string
# The most-quoted fact in the package, and the recruiter scalar, share this line.
'js/data.js:123': r'aiScore: int\(52, 98\).*recruiter: job\.recruiter',
'js/data.js:126': r'salary: int\(90, 190\) \* 1000', # money as bare integer
# --- the job object literal ---
'js/data.js:94': r'jobs\.push\(\{',
'js/data.js:95': r"id: 'JOB-' \+ \(1001 \+ i\)", # JOB- reference code
'js/data.js:96': r'recruiter: rec\.name, recruiterId: rec\.id', # recruiter scalar on job
'js/data.js:99': r'salaryMin: int\(80, 160\) \* 1000', # job salary range
'js/data.js:100': r'skills: pickN\(skillsPool, int\(4, 7\)\)', # requirements as flat array
# --- the inbox / raw-intake object literal ---
'js/data.js:297': r'inbox\.push\(\{',
'js/data.js:298': r"id: 'APP-' \+ \(30001 \+ i\)", # APP- reference code
'js/data.js:300': r'resumeStatus: rs, atsScore: int\(48, 97\)',
# --- other load-bearing anchors ---
'js/data.js:10': r'function rand\(\)', # seeded LCG
'js/data.js:237': r"new Date\('2026-07-09'\)", # hardcoded today
'js/data.js:492': r'window\.DB = \{',
'js/data.js:504': r"money: n => '\$'", # hardcoded currency symbol
'js/data.js:510': r'getRecruiterByName', # name string as identity
'js/candidates.js:18': r"new Date\('2026-07-09'\)", # client-side relevance blend
'js/candidates.js:68': r'\$\{UI\.ava|render: c =>', # unescaped interpolation sink
'js/candidates.js:121': r'onclick="Candidates\.openProfi', # inline handler with interpolated id
'js/offers.js:129': r'\+f\.base <= 0', # offer validation is > 0 only
'js/rbac.js:78': r'r\.matrix\[mod\]\[pi\] = !r\.matrix\[mod\]\[pi\]', # matrix gates nothing
'js/ui.js:251': r'window\.UI = \{', # primitive export list
'js/charts.js:339': r'window\.Charts = \{', # chart engine export list
'js/charts.js:9': r'getComputedStyle', # charts read CSS custom properties
'index.html:264': r'<script src="js/data\.js">', # first script tag
'index.html:285': r'<script src="js/app\.js">', # last script tag
}
# (label, claim pattern, {forbidden anchor: what that anchor actually is})
# Read as: "if a line makes this claim, it must not cite these anchors".
CLAIM_RULES: list[tuple[str, str, dict[str, str]]] = [
(
'ATS/AI score is a random integer',
r'aiScore|int\(52,\s*98\)',
{
'js/data.js:126': "the candidate's `salary`; the score is at :123",
'js/data.js:122': '`location`/`stage`/`status`; the score is at :123',
},
),
(
'money is a bare integer with no currency',
r'salary: int\(90|bare integer|no currency field',
{
'js/data.js:123': '`aiScore`/`recruiter`; candidate salary is at :126',
'js/data.js:100': '`education`/`skills`; the job salary range is at :99',
},
),
(
'JOB- reference code shape',
r"JOB-1001|'JOB-'",
{'js/data.js:94': '`jobs.push({`; the id line is :95'},
),
(
'APP- reference code shape',
r"APP-30001|'APP-'",
{'js/data.js:297': '`inbox.push({`; the id line is :298'},
),
(
'CAN- reference code shape',
r"CAN-5001|'CAN-'",
{'js/data.js:117': '`candidates.push({`; the id line is :118'},
),
(
'candidate experience stored as whole years',
r'integer years|whole years|total_experience_months',
{'js/data.js:122': '`location`/`stage`/`status`; experience is at :121'},
),
(
'recruiter assignment is a single scalar',
r'recruiterId|single scalar|scalar recruiter',
{
'js/data.js:99': 'the job `salaryMin`/`salaryMax`; job recruiter is at :96',
'js/data.js:124': '`applied`/`education`; candidate recruiter is at :123',
},
),
]
# How far back from a citation to read for the claim it supports. These citations are
# written as "«claim» (`anchor`)", so the supporting clause sits immediately to the left.
CLAUSE_WINDOW = 130
IGNORE_START = re.compile(r'<!--\s*citation-check:\s*ignore-start\s*-->')
IGNORE_END = re.compile(r'<!--\s*citation-check:\s*ignore-end\s*-->')
def lines_of(spec: str) -> list[int]:
"""'117-127' -> [117, 127]; '54,237' -> [54, 237]; '126' -> [126]."""
return [int(n) for n in re.findall(r'\d+', spec)]
def main() -> int:
if not os.path.isdir(DOCS):
print(f'no docs directory at {DOCS}', file=sys.stderr)
return 1
cache: dict[str, list[str] | None] = {}
def source(path: str) -> list[str] | None:
if path not in cache:
full = os.path.join(REPO, path)
try:
with open(full, encoding='utf-8') as fh:
cache[path] = fh.read().split('\n')
except OSError:
cache[path] = None
return cache[path]
docs = []
for root, _dirs, names in os.walk(DOCS):
docs.extend(os.path.join(root, n) for n in sorted(names) if n.endswith('.md'))
problems: list[str] = []
checked = anchored = claim_checked = 0
for doc in sorted(docs):
rel_doc = os.path.relpath(doc, REPO)
ignoring = False
with open(doc, encoding='utf-8') as fh:
for lineno, text in enumerate(fh, 1):
if IGNORE_START.search(text):
ignoring = True
continue
if IGNORE_END.search(text):
ignoring = False
continue
# level 3 — claim must not cite another claim's anchor.
# Scoped to the clause immediately preceding each citation, because a
# single line often carries several claims each with its own anchor.
if not ignoring:
for m in CITATION.finditer(text):
clause = text[max(0, m.start() - CLAUSE_WINDOW):m.start()]
cited = {f"{m.group('path')}:{n}"
for n in lines_of(m.group('spec'))}
for label, claim, forbidden in CLAIM_RULES:
if not re.search(claim, clause):
continue
claim_checked += 1
for anchor, what in forbidden.items():
if anchor in cited:
problems.append(
f'{rel_doc}:{lineno}: claims "{label}" but cites '
f'`{anchor}`, which is {what}'
)
for m in CITATION.finditer(text):
path, spec = m.group('path'), m.group('spec')
src = source(path)
where = f'{rel_doc}:{lineno}'
if src is None:
problems.append(
f'{where}: cites `{path}:{spec}` but {path} does not exist'
)
continue
for n in lines_of(spec):
checked += 1
if not 1 <= n <= len(src):
problems.append(
f'{where}: cites `{path}:{n}` but {path} has '
f'{len(src)} lines'
)
continue
want = ANCHORS.get(f'{path}:{n}')
if want is None:
continue
anchored += 1
if not re.search(want, src[n - 1]):
problems.append(
f'{where}: cites `{path}:{n}` but that line no longer '
f'matches /{want}/\n'
f' line {n} is: {src[n - 1].strip()[:100]}'
)
print(
f'{len(docs)} documents, {checked} cited line numbers checked, '
f'{anchored} against curated anchors ({len(ANCHORS)} declared), '
f'{claim_checked} claim/anchor pairings checked '
f'({len(CLAIM_RULES)} rules)'
)
if problems:
print(f'\n{len(problems)} bad citation(s):\n', file=sys.stderr)
for p in problems:
print(f' {p}', file=sys.stderr)
print(
'\nFix the citation, or update ANCHORS in this script if the source moved '
'deliberately.',
file=sys.stderr,
)
return 1
print('all evidence citations resolve to the lines they claim')
return 0
if __name__ == '__main__':
sys.exit(main())