cd094b6dfb
Five findings from the second review round, and the first is this PR's own standard applied to its own P1. **The two fixes improvise-dj1l names had no discriminating test.** Reverting `has_key` to unification, or dropping `canon_key` from the duplicate gate, left kb-test at 27 passed. Case 11 was carried by `check_total` refusing with a DIFFERENT message, and case 10 by the other half of the fix. Both now assert which refusal fired (`said()`), plus two cases aimed at each half: a rm key with a variable in a non-final position must be refused as ABSENT rather than matching everything, and a keyed FACT whose key holds a variable must ADD without excluding the siblings it would unify with. Each proved by reverting exactly one fix and watching exactly that case fail. **Keying a rule on its whole clause made `replace` on an edited rule silently become `add`** -- both arms live, the stale one still firing, and `check_total` cannot see it because `NO + NF` is correct arithmetic for an add. Reachable on the 15 `kb_error/1` arms and the `kb_text/2` rules, so editing the check improvise-2f97 adds is the first thing to hit it. `check_rule_replace` refuses and names the route (rm, then add); the negative arm -- replace of a rule present verbatim -- is tested, or the refusal would just be "replace never works on rules". **The `decision(` arm was dead code.** `cited_name` took the first field, and the repo spells `decision(<ticket>, <name>)`, whose ticket id fails `name_shaped/1` on the hyphen -- so that marker could never fire for a real citation. A second `cited_name` clause takes the second field. All three markers are now exercised. Minor, from the same round: `_JS_TEST` no longer accepts `test.only`, which disables every other test in its file, with a case asserting a heading cannot be satisfied by one; and `node-test-test.sh`'s last case no longer duplicates an earlier one. Filed rather than fixed: improvise-w8ih, extracting `web/assemble.sh`'s gate so its own absence is visible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
853 lines
28 KiB
Python
Executable File
853 lines
28 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Fail if any .rs file carries more than two consecutive comment lines.
|
|
|
|
The rule (user, 2026-09-20): reasoning lives in context/ prose, not in code.
|
|
A comment run is at most two lines, optionally naming a context/ file for the
|
|
rest. See context/code/ for where the prose went, and improvise-9c8c for why.
|
|
|
|
Counts `///`, `//!` and `//` alike. Attributes are not comments and are
|
|
ignored.
|
|
|
|
A ``` fence SUSPENDS the count rather than ending the run: the fenced body is
|
|
exempt (a doctest is a running test, not prose) but the prose on either side
|
|
belongs to ONE run and is summed. Two lines above a fence and two below is a
|
|
run of four, which is over the limit -- and that is deliberate, because a
|
|
reader meets it as one uninterrupted comment block. See --list-doctests.
|
|
|
|
Note rustdoc executes a bare ``` or ```rust fence and does NOT execute
|
|
```text, ```ignore or ```no_run. Only a runnable fence earns the exemption;
|
|
the rest is prose wearing a fence and the cap applies to it.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
LIMIT = 2
|
|
|
|
|
|
_RAW_STR = re.compile(r'(?:b|c)?r(#*)"')
|
|
_CHAR_LIT = re.compile(r"'(?:\\.|[^'\\])'")
|
|
_DOC_ATTR = re.compile(r"#!?\[\s*doc\s*=")
|
|
|
|
|
|
def scan(lines):
|
|
"""Classify each line, respecting Rust strings and NESTED block comments.
|
|
|
|
Returns per line: has_code, has_comment, the `//` body, the CODE-ONLY text
|
|
(no string or comment content), whether comment content leads the line, and
|
|
whether the line began inside a block comment. See context/code/tooling.md.
|
|
"""
|
|
out = []
|
|
in_block = 0
|
|
in_raw = None
|
|
in_str = False
|
|
for raw in lines:
|
|
i, n = 0, len(raw)
|
|
started_in_block = in_block > 0
|
|
has_code = False
|
|
has_comment = started_in_block
|
|
body = None
|
|
code = []
|
|
leads = None
|
|
while i < n:
|
|
if in_block:
|
|
has_comment = True
|
|
leads = True if leads is None else leads
|
|
if raw.startswith("/*", i):
|
|
in_block += 1
|
|
i += 2
|
|
elif raw.startswith("*/", i):
|
|
in_block -= 1
|
|
i += 2
|
|
else:
|
|
i += 1
|
|
continue
|
|
if in_raw is not None:
|
|
closer = '"' + "#" * in_raw
|
|
if raw.startswith(closer, i):
|
|
in_raw = None
|
|
i += len(closer)
|
|
else:
|
|
i += 1
|
|
has_code = True
|
|
leads = False if leads is None else leads
|
|
continue
|
|
if in_str:
|
|
has_code = True
|
|
leads = False if leads is None else leads
|
|
if raw[i] == "\\":
|
|
i += 2
|
|
continue
|
|
if raw[i] == '"':
|
|
in_str = False
|
|
i += 1
|
|
continue
|
|
if raw[i].isspace():
|
|
i += 1
|
|
continue
|
|
if raw.startswith("//", i):
|
|
has_comment = True
|
|
leads = True if leads is None else leads
|
|
if not has_code:
|
|
body = raw[i:]
|
|
break
|
|
if raw.startswith("/*", i):
|
|
has_comment = True
|
|
leads = True if leads is None else leads
|
|
in_block = 1
|
|
i += 2
|
|
continue
|
|
m = _RAW_STR.match(raw, i)
|
|
if m:
|
|
in_raw = len(m.group(1))
|
|
has_code = True
|
|
leads = False if leads is None else leads
|
|
code.append('"')
|
|
i = m.end()
|
|
continue
|
|
m = _CHAR_LIT.match(raw, i)
|
|
if m:
|
|
has_code = True
|
|
leads = False if leads is None else leads
|
|
i = m.end()
|
|
continue
|
|
if raw[i] == '"':
|
|
in_str = True
|
|
has_code = True
|
|
leads = False if leads is None else leads
|
|
i += 1
|
|
continue
|
|
has_code = True
|
|
leads = False if leads is None else leads
|
|
code.append(raw[i])
|
|
i += 1
|
|
out.append((has_code, has_comment, body, "".join(code), bool(leads), started_in_block))
|
|
return out
|
|
|
|
|
|
def _kind(body):
|
|
if body is None:
|
|
return "//"
|
|
if body.startswith("//!"):
|
|
return "//!"
|
|
return "///" if body.startswith("///") else "//"
|
|
|
|
|
|
def comment_runs(lines):
|
|
"""Yield (start_line_number, length) for each run, skipping runnable doctests.
|
|
|
|
Every question here is asked of the SCANNER, never of the raw line: an
|
|
attribute's bracket depth is counted over code only, because `#[cfg(feature
|
|
= "[")]` otherwise exempted the whole file. See context/code/tooling.md.
|
|
"""
|
|
scanned = scan(lines)
|
|
|
|
run_start, run_len, run_kind = None, 0, None
|
|
in_fence = fence_exempt = False
|
|
attr_depth = 0
|
|
attr_inner = False
|
|
inner_pending = False
|
|
|
|
def add(n, kind):
|
|
nonlocal run_start, run_len, run_kind
|
|
if run_start is None:
|
|
run_start, run_len, run_kind = n, 0, kind
|
|
run_len += 1
|
|
|
|
for n, (has_code, has_comment, body, code, leads, in_block_at_start) in enumerate(scanned, 1):
|
|
# A line that CLOSES a block comment carries comment text even when code
|
|
# follows it; a line that opens one after code does not.
|
|
is_comment = has_comment and (not has_code or (in_block_at_start and leads))
|
|
code_s = code.strip()
|
|
|
|
if attr_depth > 0:
|
|
attr_depth += code.count("[") - code.count("]")
|
|
if is_comment:
|
|
add(n, _kind(body))
|
|
if attr_depth <= 0:
|
|
attr_depth = 0
|
|
inner_pending = inner_pending or attr_inner
|
|
continue
|
|
|
|
if not is_comment and (code_s.startswith("#[") or code_s.startswith("#![")):
|
|
# `#[doc = "..."]` and `#![doc = "..."]` ARE comments; `#[doc(hidden)]`
|
|
# is not, which an unanchored `#[doc` prefix could not tell apart.
|
|
if _DOC_ATTR.match(code_s):
|
|
add(n, "//!" if code_s.startswith("#![") else "///")
|
|
continue
|
|
inner = code_s.startswith("#![")
|
|
depth = code.count("[") - code.count("]")
|
|
if depth > 0:
|
|
attr_depth, attr_inner = depth, inner
|
|
continue
|
|
inner_pending = inner_pending or inner
|
|
continue
|
|
|
|
if is_comment:
|
|
kind = _kind(body)
|
|
# An inner attribute ends a run only where the DOC KIND changes: a
|
|
# crate doc and an item doc are two blocks, `//!` either side is one.
|
|
if inner_pending:
|
|
inner_pending = False
|
|
if run_kind is not None and kind != run_kind and run_start is not None and run_len > 0:
|
|
yield run_start, run_len
|
|
run_start, run_len, run_kind = None, 0, None
|
|
doc = kind in ("///", "//!")
|
|
text = body.lstrip("/!").strip() if body is not None else ""
|
|
if doc and text.startswith("```"):
|
|
if in_fence:
|
|
exempt_here = fence_exempt
|
|
in_fence = fence_exempt = False
|
|
else:
|
|
info = text[3:].strip().lower()
|
|
in_fence = True
|
|
fence_exempt = info in ("", "rust") or info.startswith("rust,")
|
|
exempt_here = fence_exempt
|
|
if exempt_here:
|
|
continue
|
|
add(n, kind)
|
|
continue
|
|
if doc and in_fence and fence_exempt:
|
|
continue
|
|
add(n, kind)
|
|
else:
|
|
in_fence = fence_exempt = False
|
|
inner_pending = False
|
|
if run_start is not None and run_len > 0:
|
|
yield run_start, run_len
|
|
run_start, run_len, run_kind = None, 0, None
|
|
if run_start is not None and run_len > 0:
|
|
yield run_start, run_len
|
|
|
|
|
|
def rust_files(roots):
|
|
for root in roots:
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
dirnames[:] = [d for d in dirnames if d != "target"]
|
|
for f in filenames:
|
|
if f.endswith(".rs"):
|
|
yield os.path.join(dirpath, f)
|
|
|
|
|
|
_TEST_ATTRS = ("test", "tokio::test", "async_std::test", "wasm_bindgen_test")
|
|
|
|
|
|
def _is_test_attribute(line):
|
|
"""True only for an attribute that actually marks a test.
|
|
|
|
Matched by exact path, never by substring: `#[cfg(test)]` contains "test"
|
|
and marks a compilation condition, not a test.
|
|
"""
|
|
body = line.strip()
|
|
if not body.startswith("#["):
|
|
return False
|
|
body = body[2:].rstrip()
|
|
body = body[:-1] if body.endswith("]") else body
|
|
return body.strip().strip(":") in _TEST_ATTRS
|
|
|
|
|
|
EVIDENCE_DIR = os.path.join("context", "test-evidence")
|
|
EVIDENCE_CITE = "test-evidence"
|
|
JS_ROOTS = [os.path.join("harness", "tests"), os.path.join("web", "tests")]
|
|
|
|
_RUST_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
_QUOTED_NAME = re.compile(r'^"(.+)"$')
|
|
# Only a RUNNABLE LEAF test. `.skip`, `.todo`, `.describe` and `.only` are all
|
|
# excluded: the first three name no executed assertion, and `.only` disables
|
|
# every OTHER test in its file, so a heading satisfied by one is a heading
|
|
# satisfied by a file that stopped running (review, PR #69).
|
|
_JS_TEST = re.compile(
|
|
r"""(?:^|[^\w.])(?:test|it)\s*\(\s*(['"`])(.+?)\1"""
|
|
)
|
|
|
|
|
|
def evidence_claims(text):
|
|
"""Yield (kind, name, lineno) for every heading in an evidence file.
|
|
|
|
`### ` and `#### ` alike, because the browser entries have to use `####`
|
|
and were invisible to a check that collected only `### ` (improvise-5ray).
|
|
A heading is a CLAIM when it is shaped like a test name -- a bare
|
|
identifier (Rust) or a quoted string (JS). Anything else is prose about
|
|
the section: `Helper - x`, `Note - y`, a file's own scope. That shape rule
|
|
is the whole discrimination, so see context/code/tooling.md before loosening
|
|
it: a heading that is neither shape names nothing and is checked by nobody.
|
|
"""
|
|
for n, raw in enumerate(text.split("\n"), 1):
|
|
s = raw.strip()
|
|
if not (s.startswith("### ") or s.startswith("#### ")):
|
|
continue
|
|
body = s.lstrip("#").strip().strip("`").strip()
|
|
m = _QUOTED_NAME.match(body)
|
|
if m:
|
|
yield "js", m.group(1), n
|
|
elif _RUST_NAME.match(body):
|
|
yield "rust", body, n
|
|
|
|
|
|
def rust_tests(text):
|
|
"""(tests defined here, tests whose doc comment cites the evidence dir).
|
|
|
|
A heading must name a TEST, not merely a function. Accepting any `fn` let a
|
|
`###` naming a fixture or a helper pass, so an evidence file could report
|
|
itself satisfied while naming nothing that runs (found on PR #39).
|
|
"""
|
|
defined, citing = set(), set()
|
|
annotated = False
|
|
doc = []
|
|
# A `proptest!` block accepts its bodies as tests -- but only INSIDE the
|
|
# block. A bare flag was never cleared, so the first proptest! in a file
|
|
# made every later fn, helpers included, count as a test: a silent
|
|
# success in the gate itself (found by review on PR #40). Track the
|
|
# block's braces and leave when they balance.
|
|
proptest_depth = None
|
|
for raw in text.split("\n"):
|
|
s = raw.strip()
|
|
if proptest_depth is not None:
|
|
proptest_depth += raw.count("{") - raw.count("}")
|
|
if proptest_depth <= 0:
|
|
proptest_depth = None
|
|
elif s.startswith("proptest!"):
|
|
proptest_depth = raw.count("{") - raw.count("}")
|
|
if proptest_depth <= 0:
|
|
proptest_depth = None if "{" in raw else 0
|
|
in_proptest = proptest_depth is not None
|
|
if s.startswith("//"):
|
|
doc.append(s)
|
|
continue
|
|
# ANCHOR the attribute. `"test" in s` also matched `#[cfg(test)]`,
|
|
# so every cfg-gated fixture cleared a gate written to reject
|
|
# exactly those -- the third instance of the unanchored-substring
|
|
# class in this campaign, after the bd audit's exclusion list.
|
|
if s.startswith("#[") and _is_test_attribute(s):
|
|
annotated = True
|
|
continue
|
|
if s.startswith("fn ") or s.startswith("pub fn ") or s.startswith("async fn "):
|
|
if annotated or in_proptest:
|
|
name = s.split("fn ", 1)[1].split("(")[0].split("<")[0].strip()
|
|
defined.add(name)
|
|
if any(EVIDENCE_CITE in d for d in doc):
|
|
citing.add(name)
|
|
annotated = False
|
|
doc = []
|
|
elif s and not s.startswith("#["):
|
|
annotated = False
|
|
doc = []
|
|
return defined, citing
|
|
|
|
|
|
def js_tests(text):
|
|
"""(tests defined here, tests whose preceding comment cites the evidence dir).
|
|
|
|
The JS half exists because `defined` held only Rust `fn` names, so a
|
|
`####` naming a Playwright or node test could never have resolved even if
|
|
the heading had been collected -- the convention that kept the checker
|
|
quiet was the same one that made it blind (improvise-5ray).
|
|
"""
|
|
defined, citing = set(), set()
|
|
doc = []
|
|
for raw in text.split("\n"):
|
|
s = raw.strip()
|
|
m = _JS_TEST.search(raw)
|
|
if m:
|
|
defined.add(m.group(2))
|
|
if any(EVIDENCE_CITE in d for d in doc):
|
|
citing.add(m.group(2))
|
|
doc = []
|
|
continue
|
|
if s.startswith("//") or s.startswith("*") or s.startswith("/*"):
|
|
doc.append(s)
|
|
elif s:
|
|
doc = []
|
|
return defined, citing
|
|
|
|
|
|
def js_files(roots):
|
|
for root in roots:
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
dirnames[:] = [d for d in dirnames if d != "node_modules"]
|
|
for f in filenames:
|
|
if f.endswith(".spec.js") or f.endswith(".test.js"):
|
|
yield os.path.join(dirpath, f)
|
|
|
|
|
|
def evidence_verdict(claims, defined, citing):
|
|
"""(orphans, unstated) -- the two directions, keyed by ("rust"|"js", name).
|
|
|
|
`orphans` is evidence naming a test nobody can find. `unstated` is the
|
|
reverse obligation (improvise-lyyc): a test that POINTS AT the evidence
|
|
file must appear in it, or a reader following the pointer arrives nowhere.
|
|
Only that narrower obligation, because most tests have no evidence and
|
|
should not be forced to. Note what neither direction can see: an entry can
|
|
exist and still describe its subject backwards, which is discipline rather
|
|
than a gate.
|
|
"""
|
|
orphans = {k: v for k, v in claims.items() if k[1] not in defined[k[0]]}
|
|
unstated = {}
|
|
for kind, names in citing.items():
|
|
for name in names:
|
|
if (kind, name) not in claims:
|
|
unstated.setdefault((kind, name), []).extend(sorted(names[name]))
|
|
return orphans, unstated
|
|
|
|
|
|
def check_evidence(roots):
|
|
"""Both directions over context/test-evidence/. See evidence_verdict."""
|
|
if not os.path.isdir(EVIDENCE_DIR):
|
|
print("no context/test-evidence/ yet: nothing to check")
|
|
return 0
|
|
|
|
claims = {}
|
|
for dirpath, _, filenames in os.walk(EVIDENCE_DIR):
|
|
for f in sorted(filenames):
|
|
if not f.endswith(".md"):
|
|
continue
|
|
path = os.path.join(dirpath, f)
|
|
text = open(path, encoding="utf8", errors="replace").read()
|
|
for kind, name, n in evidence_claims(text):
|
|
claims.setdefault((kind, name), []).append(f"{path}:{n}")
|
|
|
|
defined = {"rust": set(), "js": set()}
|
|
citing = {"rust": {}, "js": {}}
|
|
for path in rust_files(roots):
|
|
d, c = rust_tests(open(path, encoding="utf8", errors="replace").read())
|
|
defined["rust"] |= d
|
|
for name in c:
|
|
citing["rust"].setdefault(name, []).append(path)
|
|
for path in js_files(JS_ROOTS):
|
|
d, c = js_tests(open(path, encoding="utf8", errors="replace").read())
|
|
defined["js"] |= d
|
|
for name in c:
|
|
citing["js"].setdefault(name, []).append(path)
|
|
|
|
orphans, unstated = evidence_verdict(claims, defined, citing)
|
|
if not orphans and not unstated:
|
|
print(f"test-evidence: {len(claims)} entries, all name a test that exists; "
|
|
f"{sum(len(v) for v in citing.values())} tests cite the file and all have one")
|
|
return 0
|
|
if orphans:
|
|
print(f"{len(orphans)} test-evidence entries name no test:\n", file=sys.stderr)
|
|
for (kind, name), where in sorted(orphans.items()):
|
|
print(f" [{kind}] {name} ({', '.join(where)})", file=sys.stderr)
|
|
if unstated:
|
|
print(f"\n{len(unstated)} tests cite {EVIDENCE_DIR} and have no entry there:\n",
|
|
file=sys.stderr)
|
|
for (kind, name), where in sorted(unstated.items()):
|
|
print(f" [{kind}] {name} ({', '.join(where)})", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
SELF_TEST_CASES = [
|
|
("a plain run of three", 3, """
|
|
// one
|
|
// two
|
|
// three
|
|
fn f() {}
|
|
"""),
|
|
("a block comment of six", 6, """
|
|
/* one
|
|
two
|
|
three
|
|
four
|
|
five
|
|
*/
|
|
fn f() {}
|
|
"""),
|
|
("a rustdoc block comment of six", 6, """
|
|
/** one
|
|
* two
|
|
* three
|
|
* four
|
|
* five
|
|
*/
|
|
fn f() {}
|
|
"""),
|
|
("an unclosed fence must not exempt the rest of the file", 6, """
|
|
/// opens a fence and never closes it
|
|
/// ```
|
|
/// let x = 1;
|
|
fn a() {}
|
|
|
|
/// one
|
|
/// two
|
|
/// three
|
|
/// four
|
|
/// five
|
|
/// six
|
|
fn b() {}
|
|
"""),
|
|
("an attribute must not split a run", 4, """
|
|
/// one
|
|
/// two
|
|
#[inline]
|
|
/// three
|
|
/// four
|
|
fn c() {}
|
|
"""),
|
|
("a non-runnable fence is prose and counts", 4, """
|
|
/// one
|
|
/// ```text
|
|
/// two
|
|
/// ```
|
|
fn d() {}
|
|
"""),
|
|
("a fence in a PLAIN // comment earns no exemption", 7, """
|
|
// ```
|
|
// one
|
|
// two
|
|
// three
|
|
// four
|
|
// five
|
|
// ```
|
|
fn f() {}
|
|
"""),
|
|
("a block comment opened after code on the same line", 5, """
|
|
fn f() {} /* one
|
|
two
|
|
three
|
|
four
|
|
five
|
|
*/
|
|
"""),
|
|
("nested block comments do not close early", 7, """
|
|
/* /* */
|
|
one
|
|
two
|
|
three
|
|
four
|
|
five
|
|
*/
|
|
fn f() {}
|
|
"""),
|
|
("a multi-line attribute does not split a run", 4, """
|
|
/// one
|
|
/// two
|
|
#[cfg(all(
|
|
unix,
|
|
target_pointer_width = "64"
|
|
))]
|
|
/// three
|
|
/// four
|
|
fn f() {}
|
|
"""),
|
|
("an attribute inside a fence does not reopen it", 5, """
|
|
/// one
|
|
/// ```
|
|
/// let x = 1;
|
|
#[inline]
|
|
/// ```
|
|
/// hidden A
|
|
/// hidden B
|
|
/// hidden C
|
|
/// hidden D
|
|
fn g() {}
|
|
"""),
|
|
("#[doc = ...] is the one attribute that is a comment", 3, """
|
|
#[doc = "one"]
|
|
#[doc = "two"]
|
|
#[doc = "three"]
|
|
fn f() {}
|
|
"""),
|
|
("#![doc = ...] is the inner spelling of the same thing", 5, """
|
|
#![doc = "one"]
|
|
#![doc = "two"]
|
|
#![doc = "three"]
|
|
#![doc = "four"]
|
|
#![doc = "five"]
|
|
fn f() {}
|
|
"""),
|
|
("an attribute holding a bracket in a string exempts nothing", 6, """
|
|
#[cfg(feature = "[")]
|
|
fn g() {}
|
|
// a
|
|
// b
|
|
// c
|
|
// d
|
|
// e
|
|
// f
|
|
fn f() {}
|
|
"""),
|
|
("an inner attribute between two //! blocks does not split them", 4, """
|
|
//! one
|
|
//! two
|
|
#![allow(clippy::needless_return)]
|
|
//! three
|
|
//! four
|
|
fn f() {}
|
|
"""),
|
|
("a block comment closing on a line with code still counts", 3, """
|
|
/* one
|
|
two
|
|
three */ fn f() {}
|
|
"""),
|
|
("a char literal holding a quote does not open a string", 3, """
|
|
pub const C: char = '"';
|
|
// one
|
|
// two
|
|
// three
|
|
fn f() {}
|
|
"""),
|
|
]
|
|
|
|
SELF_TEST_CLEAN = [
|
|
("two lines is the limit", """
|
|
// one
|
|
// two
|
|
fn f() {}
|
|
"""),
|
|
("a runnable doctest is exempt", """
|
|
/// one
|
|
/// ```
|
|
/// let x = 1;
|
|
/// assert_eq!(x, 1);
|
|
/// ```
|
|
/// two
|
|
fn f() {}
|
|
"""),
|
|
("a closed fence does not leak into a later comment", """
|
|
/// one
|
|
/// ```
|
|
/// let x = 1;
|
|
/// ```
|
|
fn a() {}
|
|
|
|
// two
|
|
// three
|
|
fn b() {}
|
|
"""),
|
|
("a block comment inside a raw string is not a comment", """
|
|
pub const S: &str = r#"x"y /* not a comment
|
|
one
|
|
two
|
|
three
|
|
four
|
|
"#;
|
|
fn a() {}
|
|
"""),
|
|
("// inside a raw string is not a comment", """
|
|
pub const S: &str = r#"x"y // not a comment
|
|
// nor this
|
|
// nor this
|
|
// nor this
|
|
"#;
|
|
fn a() {}
|
|
"""),
|
|
("a crate doc and an item doc are separated by the inner attribute", """
|
|
//! crate one
|
|
//! crate two
|
|
#![allow(clippy::needless_return)]
|
|
/// item one
|
|
/// item two
|
|
pub fn go() {}
|
|
"""),
|
|
("// inside a multi-line plain string is not a comment", """
|
|
pub const S: &str = "open \\
|
|
// one
|
|
// two
|
|
// three
|
|
close";
|
|
fn a() {}
|
|
"""),
|
|
("#[doc(hidden)] is an attribute, not a comment", """
|
|
/// one
|
|
/// two
|
|
#[doc(hidden)]
|
|
pub fn f() {}
|
|
"""),
|
|
("an attribute's brackets close, so later code still ends a run", """
|
|
/// one
|
|
/// two
|
|
#[cfg(all(
|
|
unix,
|
|
))]
|
|
fn f() {}
|
|
/// three
|
|
/// four
|
|
fn g() {}
|
|
"""),
|
|
]
|
|
|
|
|
|
# (name, evidence markdown, rust source, js source, orphans, unstated).
|
|
#
|
|
# The evidence checker had no self-test at all, which is how both halves of
|
|
# improvise-5ray and improvise-lyyc survived: the shape that silenced the
|
|
# checker was the shape it could not see. Each case below is an injection.
|
|
SELF_TEST_EVIDENCE = [
|
|
("a #### heading naming no test is an orphan",
|
|
'#### "a test that does not exist anywhere"\n',
|
|
"", "", ["a test that does not exist anywhere"], []),
|
|
|
|
("a #### heading naming a JS test that exists is satisfied",
|
|
'#### "a peer that cannot be reached is named"\n',
|
|
"", 'test("a peer that cannot be reached is named", async () => {});\n', [], []),
|
|
|
|
("a quoted heading can never be satisfied by a Rust fn of that name",
|
|
'#### "a_rust_name"\n',
|
|
"#[test]\nfn a_rust_name() {}\n", "", ["a_rust_name"], []),
|
|
|
|
("a prose #### heading claims nothing",
|
|
"#### Helper — `addressed`\n#### Note — the basis_check module\n"
|
|
"#### harness/tests/shell.spec.js — the file's own scope\n",
|
|
"", "", [], []),
|
|
|
|
("a ### naming a helper rather than a test is still an orphan",
|
|
"### a_helper\n", "fn a_helper() {}\n", "", ["a_helper"], []),
|
|
|
|
("a test that cites the evidence file and has no entry is caught",
|
|
"", "/// See `context/test-evidence/clients.md`.\n#[test]\nfn a_cited_test() {}\n",
|
|
"", [], ["a_cited_test"]),
|
|
|
|
("the same test with an entry is satisfied",
|
|
"### a_cited_test\n",
|
|
"/// See `context/test-evidence/clients.md`.\n#[test]\nfn a_cited_test() {}\n",
|
|
"", [], []),
|
|
|
|
("a NON-test fn citing the file is under no obligation",
|
|
"", "/// See `context/test-evidence/clients.md`.\nfn a_helper() {}\n", "", [], []),
|
|
|
|
("a citation belonging to the fn ABOVE does not attach to the next one",
|
|
"### cited\n",
|
|
"/// See `context/test-evidence/clients.md`.\n#[test]\nfn cited() {}\n"
|
|
"\n#[test]\nfn uncited() {}\n",
|
|
"", [], []),
|
|
|
|
("a heading cannot be satisfied by a skipped or todo test",
|
|
'#### "a skipped one"\n#### "a todo one"\n', "",
|
|
'test.skip("a skipped one", () => {});\ntest.todo("a todo one");\n',
|
|
["a skipped one", "a todo one"], []),
|
|
|
|
("a heading cannot be satisfied by a test.only, which disables its file",
|
|
'#### "the only one left running"\n', "",
|
|
'test.only("the only one left running", () => {});\n',
|
|
["the only one left running"], []),
|
|
|
|
("a heading cannot be satisfied by an empty suite's name",
|
|
'#### "a suite with nothing in it"\n', "",
|
|
'test.describe("a suite with nothing in it", () => {});\n',
|
|
["a suite with nothing in it"], []),
|
|
|
|
("a test nested in a suite still satisfies its heading",
|
|
'#### "a nested test"\n', "",
|
|
'test.describe("a group", () => {\n test("a nested test", () => {});\n});\n',
|
|
[], []),
|
|
|
|
("a JS test that cites the evidence file and has no entry is caught",
|
|
"", "",
|
|
'// See context/test-evidence/clients.md.\ntest("a cited spec", async () => {});\n',
|
|
[], ["a cited spec"]),
|
|
]
|
|
|
|
|
|
def _evidence_self_test():
|
|
failures = []
|
|
for name, md, rs, js, want_orphans, want_unstated in SELF_TEST_EVIDENCE:
|
|
claims = {}
|
|
for kind, cname, n in evidence_claims(md):
|
|
claims.setdefault((kind, cname), []).append(f"fixture:{n}")
|
|
rd, rc = rust_tests(rs)
|
|
jd, jc = js_tests(js)
|
|
defined = {"rust": rd, "js": jd}
|
|
citing = {"rust": {k: ["fixture.rs"] for k in rc},
|
|
"js": {k: ["fixture.js"] for k in jc}}
|
|
orphans, unstated = evidence_verdict(claims, defined, citing)
|
|
got_o = sorted(k[1] for k in orphans)
|
|
got_u = sorted(k[1] for k in unstated)
|
|
if got_o != sorted(want_orphans):
|
|
failures.append(f" ORPHANS: {name}: expected {sorted(want_orphans)}, saw {got_o}")
|
|
if got_u != sorted(want_unstated):
|
|
failures.append(f" UNSTATED: {name}: expected {sorted(want_unstated)}, saw {got_u}")
|
|
return failures
|
|
|
|
|
|
def self_test():
|
|
"""Check the checker against every bypass it has already been fixed for.
|
|
|
|
Ten silent successes have been found in this file, every one failing in the
|
|
direction that looks like passing. See context/code/tooling.md.
|
|
"""
|
|
failures = []
|
|
for name, expected, src in SELF_TEST_CASES:
|
|
runs = [n for n in comment_runs(src.split("\n")) if n[1] > LIMIT]
|
|
longest = max((n[1] for n in runs), default=0)
|
|
if longest != expected:
|
|
failures.append(f" MISSED: {name}: expected a run of {expected}, saw {longest}")
|
|
for name, src in SELF_TEST_CLEAN:
|
|
runs = [n for n in comment_runs(src.split("\n")) if n[1] > LIMIT]
|
|
if runs:
|
|
failures.append(f" FALSE POSITIVE: {name}: flagged {runs}")
|
|
failures += _evidence_self_test()
|
|
if failures:
|
|
print("checker self-test FAILED:", file=sys.stderr)
|
|
print("\n".join(failures), file=sys.stderr)
|
|
return 1
|
|
print(f"checker self-test: {len(SELF_TEST_CASES)} violations caught, "
|
|
f"{len(SELF_TEST_CLEAN)} legitimate shapes allowed, "
|
|
f"{len(SELF_TEST_EVIDENCE)} evidence verdicts")
|
|
return 0
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("roots", nargs="*",
|
|
default=["src", "crates", "tests", "examples", "harness"])
|
|
ap.add_argument("--limit", type=int, default=LIMIT)
|
|
ap.add_argument("--list-doctests", action="store_true",
|
|
help="list doc comments containing a ``` fence and exit")
|
|
ap.add_argument("--check-evidence", action="store_true",
|
|
help="every context/test-evidence heading must name a test that exists")
|
|
ap.add_argument("--self-test", action="store_true",
|
|
help="check the checker against the bypasses it has been fixed for")
|
|
args = ap.parse_args()
|
|
if args.self_test:
|
|
return self_test()
|
|
default_roots = ["src", "crates", "tests", "examples", "harness"]
|
|
requested = args.roots or default_roots
|
|
# A path that is not a directory must FAIL. Filtering it away made
|
|
# `check-comment-runs.py some/file.rs` walk nothing and print success --
|
|
# a filter that over-matches fails in the direction that looks like
|
|
# passing, which is the one direction a checker must not fail in.
|
|
roots = [r for r in requested if os.path.isdir(r)]
|
|
missing = [r for r in requested if not os.path.isdir(r)]
|
|
if missing and args.roots:
|
|
for r in missing:
|
|
kind = "not a directory" if os.path.exists(r) else "does not exist"
|
|
print(f"error: {r}: {kind} (pass directories, not files)", file=sys.stderr)
|
|
return 2
|
|
if not roots:
|
|
print("error: nothing to scan", file=sys.stderr)
|
|
return 2
|
|
|
|
if args.check_evidence:
|
|
return check_evidence(roots)
|
|
|
|
if args.list_doctests:
|
|
for path in sorted(rust_files(roots)):
|
|
for n, raw in enumerate(open(path, encoding="utf8", errors="replace"), 1):
|
|
s = raw.strip()
|
|
if s.startswith(("///", "//!")) and s.lstrip("/!").strip().startswith("```"):
|
|
print(f"{path}:{n}")
|
|
return 0
|
|
|
|
violations = []
|
|
for path in sorted(rust_files(roots)):
|
|
lines = open(path, encoding="utf8", errors="replace").read().split("\n")
|
|
for start, length in comment_runs(lines):
|
|
if length > args.limit:
|
|
violations.append((path, start, length))
|
|
|
|
if not violations:
|
|
print(f"comment runs: none longer than {args.limit} lines")
|
|
return 0
|
|
|
|
worst = sorted(violations, key=lambda v: -v[2])
|
|
print(f"{len(violations)} comment runs longer than {args.limit} lines:\n", file=sys.stderr)
|
|
for path, start, length in worst:
|
|
print(f" {path}:{start} {length} lines", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|