260 lines
12 KiB
Python
Executable File
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())
|