#!/bin/sh
# Reads the docblocks of the files named and reports two things a reader
# cannot make sense of: a description that only says the name again, and a
# word that is not English and has not been explained where it is used.
#
# sh devlog/devscripts/check_docwords.sh <file> [<file> ...]
#
# A word is taken as explained where the same docblock spells it out, so
# "RIFF (the way an AVI is built out of named chunks)" passes while a bare
# "RIFF" does not. The list of English words comes from the english-words
# package, with the words this codebase uses added below.
# A file may carry docblocks written long before this check existed. Those
# have to be brought up by reading and rewriting each one, which is work
# for its own round: rewriting them by pattern produces prose that is not
# English and, worse, drops facts a developer needs, such as the name of a
# file. Naming a file in DOCBLOCKS_AWAITING_REWRITE says that its old
# docblocks are waiting on that work. The faults are still printed, so
# nothing is hidden, and any file not named still fails.
python3 - "$@" <<'PYEOF'
import os
import re
import sys
# A patch touching one method of a ten thousand line file cannot be
# asked to rewrite every docblock in it. Where LINES names the lines a
# patch added for a file, only the docblocks covering those lines are
# reported, which is the documentation the patch passes. With LINES
# unset every docblock in the file is checked, which is what a new file
# gets.
try:
from english_words import get_english_words_set
KNOWN = get_english_words_set(['web2'], lower=True)
except Exception:
KNOWN = set()
# words this codebase uses that a general word list does not carry
# Words the web2 list does not carry but that a reader of this codebase
# meets constantly: how a program is put together, what a standards body
# writes, and a few that are simply newer than the list.
KNOWN |= {"autoload", "autoloader", "specified", "offline", "airgapped",
"subtree", "subtrees", "sidebar", "sidebars", "busiest", "resave",
"posix", "rfc", "etc", "gmail", "domainkey", "atto", "aya",
"localconfig", "namespace", "namespaces", "middleware", "changelog",
"timestamp", "timestamps", "whitespace", "lookup", "lookups",
"hostname", "hostnames", "filename", "filenames", "username",
"usernames", "config", "configs", "inline", "inlined", "sitemap",
"sitemaps", "favicon", "unicode", "endianness", "checksum",
"checksums", "throughput", "backoff", "prefetch", "prefetched"}
# The command line tools that ship with Yioop, which a docblock names the
# way a reader would type them.
KNOWN |= {"tokentool", "arctool", "codetool", "configuretool", "createdb",
"groupwikitool", "avtool", "exportpublichelpdb", "queueserver",
"fetcher", "mediaupdater", "yioopbar"}
KNOWN |= {"macroblock", "macroblocks", "quantizer", "quantizers",
"keyframe", "keyframes", "codec", "codecs", "thumbnail", "thumbnails",
"webm", "webp", "mp4", "avi", "ogg", "matroska", "quicktime", "theora",
"yioop", "php", "gd", "url", "utf", "docblock", "runtime",
"filesource", "param", "var", "boolean", "int", "bool", "str",
"unescape", "wiki", "seekquarry", "pollett", "gnu", "gpl", "html",
"unreadable", "unwritten", "reflow", "subclasses", "subclass",
"deblocking", "dequantize", "dequantized", "dequantization",
"downsampled", "upsampled", "interlaced", "chroma", "luma",
"bitstream", "bitstreams", "demuxer", "demuxers", "muxed", "api",
"byte", "bytes", "largest", "smallest", "cropping", "cropped",
"nearest", "widest", "sixteen", "thirty", "forty", "fifty", "sixty",
"hundred", "thousand", "keyframe", "onto", "afresh", "whichever",
"themselves", "itself", "anything", "everything", "something",
"nothing", "somewhere", "elsewhere", "otherwise", "rather",
"besides", "beside", "within", "without", "toward", "towards",
"throughout", "meanwhile", "afterwards", "beforehand", "pixel",
"pixels", "payload", "payloads", "offset", "offsets", "parse",
"parsed", "parses", "decoder", "decoders", "encoder", "encoders",
"container", "containers", "boundary", "boundaries", "iterate",
"iterated", "lookup", "metadata", "timestamp", "timestamps",
"subsampling", "subsampled", "sync", "synced", "coordinate",
"coordinates", "dropped", "emitted", "flipped", "identified",
"inferred", "initialization", "multiplied", "scanned", "skipped",
"supplied", "timeline", "timescale", "descriptor", "descriptors",
"gigabyte", "gigabytes", "undecodable", "rounded", "wrapped",
"gathered", "grouped", "nudged", "shrunk", "spelled", "spelt",
"internet", "simplest", "gdimage", "mpeg", "itu", "destruct",
"construct", "base64", "partway", "overlapping", "halvings",
"halving", "loudness", "quieter", "louder", "playback", "resample",
"resampled", "resampling", "waveform", "waveforms", "m4a", "webm",
"opus", "aac", "celt", "wav", "began", "stretches", "stretch",
"database", "databases", "whitespace", "numeric", "mapping",
"mappings", "hangs", "uncompresses", "symlink", "symlinks",
"inheritdoc", "etcetera", "thumbs", "thumb",
"earlier", "later", "further", "fewer", "deeper", "longer",
"shorter", "faster", "slower", "higher", "lower", "likelier",
"closest", "loudest", "quietest", "smallest", "largest",
"quieter", "wider", "narrower", "coarser", "finer", "nearer"}
# A name the codebase declares is a fair thing to write in a docblock,
# and the file being checked is not the only place such a name is
# declared: a model names the job that calls it, a controller names a
# helper. So the names are gathered from the whole of src, once, and
# kept in a file beside the checks rather than walked again each run.
def names_declared_in_source():
kept = os.environ.get("NAMES_FILE",
"/home/claude/declared_names.txt")
root = os.environ.get("SOURCE_DIR", "src")
if os.path.exists(kept) and os.path.getsize(kept) > 0:
return set(open(kept, encoding="utf-8").read().split())
found = set()
for here, folders, files in os.walk(root):
for one in files:
if not one.endswith(".php"):
continue
try:
held = open(os.path.join(here, one),
encoding="utf-8", errors="ignore").read()
except OSError:
continue
for name in re.findall(r'(?:class|trait|interface|function|'
r'const)\s+(\w+)', held):
found.add(name.lower())
for piece in re.sub(r'(?<=[a-z0-9])(?=[A-Z])', ' ',
name).split():
found.add(piece.lower())
for name in re.findall(r'\$(\w+)', held):
for piece in name.split("_"):
found.add(piece.lower())
for name in re.findall(r'"([A-Z][A-Z0-9_]{3,})"', held):
found.add(name.lower())
for name in re.findall(r'\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)'
r'\b', held):
found.add(name.lower())
try:
open(kept, "w", encoding="utf-8").write("\n".join(sorted(found)))
except OSError:
pass
return found
AWAITING = set(os.environ.get("DOCBLOCKS_AWAITING_REWRITE", "").split())
KNOWN |= names_declared_in_source()
for path in sys.argv[1:]:
try:
whole = open(path, encoding="utf-8").read()
except OSError:
continue
# a constant this codebase declares may be named in a docblock, so
# long as the docblock goes on to say what the constant is for
for name in re.findall(r'\bconst\s+(\w+)', whole):
KNOWN.add(name.lower())
for name in re.findall(r'(?:class|trait|function)\s+(\w+)', whole):
KNOWN.add(name.lower())
for piece in re.sub(r'(?<=[a-z0-9])(?=[A-Z])', ' ', name).split():
KNOWN.add(piece.lower())
for name in re.findall(r'\$(\w+)', whole):
for piece in name.split("_"):
KNOWN.add(piece.lower())
# Code that came in from a separate project keeps its own house style,
# since its master copy lives elsewhere and every change here has to be
# carried back by hand. Pass ALL=1 to check those files too.
def from_another_project(path):
return "/atto_servers/" in path and not os.environ.get("ALL")
def lines_wanted():
named = os.environ.get("LINES", "").strip()
if named == "":
return None
return set(int(one) for one in named.split(",") if one.strip())
WANTED = lines_wanted()
def covers_a_wanted_line(text, block, line_at):
if WANTED is None:
return True
ends_at = line_at + text[block.start():block.end()].count("\n")
for one in WANTED:
if line_at <= one <= ends_at + 1:
return True
return False
fails = 0
for path in sys.argv[1:]:
waiting = path in AWAITING
if from_another_project(path):
continue
try:
text = open(path, encoding="utf-8").read()
except OSError:
continue
for block in re.finditer(r'/\*\*((?:[^*]|\*(?!/))*)\*/'
r'\s*\n\s*((?:abstract |final |public |private |protected |static |'
r'readonly )*(?:function|class|trait|const)?[^\n]*)', text):
words = " ".join(one.strip().lstrip("*").strip()
for one in block.group(1).split("\n"))
follows = block.group(2)
if "SeekQuarry/Yioop" in words:
continue
# a name inside quotation marks is a value, not a property: a
# setting whose value reads $anon is not a property called anon,
# and reading it as one asked for a docblock about nothing
without_strings = re.sub(r'"[^"]*"|\'[^\']*\'', '""', follows)
named = re.search(r'(?:function|class|trait|const)\s+(\w+)|'
r'\$(\w+)', without_strings)
line_at = text[:block.start()].count("\n") + 1
if not covers_a_wanted_line(text, block, line_at):
continue
if named:
name = named.group(1) or named.group(2)
plain = re.sub(r'(?<=[a-z])(?=[A-Z])', ' ',
name.replace("_", " ")).lower()
said = re.sub(r'@\w+[^\n]*', '', words)
said = re.sub(r'[^a-z ]', ' ', said.lower())
said = " ".join(said.split())
without = said.replace(plain, "").strip()
if plain and plain in said and len(without.split()) < 6:
print(f"{path}:{line_at}: says the name again ({name})")
if not waiting:
fails = 1
# a blank line among the tags, and a tag with no words after it
block_lines = block.group(1).split("\n")
for index, one in enumerate(block_lines):
said = one.strip().lstrip("*").strip()
if said != "" or index == 0 or index == len(block_lines) - 1:
continue
before = block_lines[index - 1].strip().lstrip("*").strip()
after = block_lines[index + 1].strip().lstrip("*").strip()
if before.startswith("@") and (after.startswith("@") or
after == ""):
print(f"{path}:{line_at}: a blank line among the tags")
if not waiting:
fails = 1
break
for one in block_lines:
said = one.strip().lstrip("*").strip()
# a var tag carries a type; the words sit above it, so it
# is only reported where the block says nothing at all
tag = re.match(r'^@(param|return)\s+\S+'
r'(?:\s+\$\w+)?\s*$', said)
if tag:
print(f"{path}:{line_at}: a tag with no words ({said})")
if not waiting:
fails = 1
break
# a docblock names what it describes in its first sentence, so a
# reader knows at once whose behavior is being set out
first = ""
for one in block_lines:
words = one.strip().lstrip("*").strip()
if words and not words.startswith("@"):
first = words
break
if "@filesource" in words:
continue
# a constant's docblock says what the value is for; the naming
# rule is for the methods and properties a reader follows
# A constant's docblock says what the value is for; the naming
# rule is for the methods and properties a reader follows. A
# constant may be given its value by nsdefine and its kin, and
# the value handed over may hold a variable, which is not what
# the docblock is about.
is_constant = re.search(r'\bconst\s+\w+', follows) is not None or \
re.search(r'\b(?:ns)?(?:cond)?define\s*\(', follows) is not None
if named and first and not is_constant:
name = named.group(1) or named.group(2)
opening = first.split(".")[0]
if name not in opening and name.lower() not in opening.lower():
print(f"{path}:{line_at}: the first sentence does not "
f"name {name}")
if not waiting:
fails = 1
prose = [one.strip().lstrip("*").strip()
for one in block_lines]
prose = [one for one in prose if one and not one.startswith("@")]
# a docblock that says it inherits its words has them, in the
# method it overrides
inherits = "@inheritdoc" in words or "@inheritDoc" in words
# a docblock marked to be passed over is not documentation and
# says so; the tool that reads these blocks skips it, and so
# does this check
ignored = "@ignore" in words
if not prose and not inherits and not ignored:
print(f"{path}:{line_at}: the docblock says nothing")
if not waiting:
fails = 1
for shouted in re.findall(r'\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b',
words):
if re.search(re.escape(shouted) + r'\s*\(', words):
continue
if shouted.lower() in KNOWN:
continue
# a constant the codebase declares may name itself
if re.search(r'\bconst\s+' + re.escape(shouted) + r'\b',
text):
continue
print(f"{path}:{line_at}: a name from the standard, not "
f"words ({shouted})")
if not waiting:
fails = 1
break
for word in re.findall(r'[A-Za-z][A-Za-z0-9_]{2,}', words):
# a name the file declares passes whole, and so do the
# pieces of it, since splitting a name is not a fault
if word.lower() in KNOWN:
continue
if "_" in word:
bare = word.replace("_", " ")
else:
bare = word
for piece in bare.split():
low = piece.lower()
# a word list holds root words, so the plain endings
# are taken off before looking one up
roots = {low}
for ending, instead in [("s", ""), ("es", ""),
("ies", "y"), ("ed", ""), ("ed", "e"), ("ing", ""),
("ing", "e"), ("er", ""), ("er", "e"), ("est", ""),
("ly", ""), ("d", "")]:
if low.endswith(ending):
roots.add(low[:-len(ending)] + instead)
if roots & KNOWN:
continue
if re.match(r'^h\d+$|^vp\d$|^avc\w*$|^hevc$|'
r'^\d+x\d+$', low):
continue
# a word spelled out in the same docblock passes
if re.search(re.escape(piece) + r'\s*\(', words):
continue
print(f"{path}:{line_at}: not an English word ({piece})")
if not waiting:
if not waiting:
fails = 1
break
# Two docblocks standing one after another with nothing between them.
# That shape comes from putting a new method above an existing one and
# taking its docblock with it, which leaves the older docblock describing
# whatever now follows and reads as though a method were deleted.
for path in sys.argv[1:]:
if not path.endswith(".php"):
continue
try:
lines = open(path, encoding="utf-8", errors="replace").read(
).split("\n")
except IOError:
continue
at = 0
while at < len(lines):
if lines[at].strip() != "/**":
at += 1
continue
walk = at + 1
while walk < len(lines) and lines[walk].strip() != "*/":
walk += 1
if walk + 1 < len(lines) and lines[walk + 1].strip() == "/**":
print(" " + path + ":" + str(walk + 2) +
" a docblock follows a docblock, with no method between")
fails = 1
at = walk + 1
# A comment inside a function body that only restates the line under it.
# The tell is that the comment's content words are already in the code
# beneath it: a reader learns nothing from the prose that the statement
# does not say. Six such comments went out across one run of work before
# Chris named them.
CODE_WORDS = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
for path in sys.argv[1:]:
if not (path.endswith(".php") or path.endswith(".js") or
path.endswith(".css")):
continue
try:
lines = open(path, encoding="utf-8", errors="replace").read(
).split("\n")
except IOError:
continue
at = 0
while at < len(lines):
stripped = lines[at].strip()
if not stripped.startswith("/*") or stripped.startswith("/**"):
at += 1
continue
block = []
walk = at
while walk < len(lines) and "*/" not in lines[walk]:
block.append(lines[walk])
walk += 1
if walk < len(lines):
block.append(lines[walk])
after = []
look = walk + 1
while look < len(lines) and len(after) < 4:
if lines[look].strip():
after.append(lines[look])
look += 1
prose = " ".join(block).replace("/*", " ").replace("*/", " ")
said = set(w.lower() for w in CODE_WORDS.findall(prose)
if len(w) > 3)
code = " ".join(after)
names = set()
for w in CODE_WORDS.findall(code):
for part in re.split(r"[_]|(?<=[a-z])(?=[A-Z])", w):
if len(part) > 3:
names.add(part.lower())
if said and names:
shared = said & names
if len(shared) >= 3 and len(shared) >= len(said) * 0.5:
print(" " + path + ":" + str(at + 1) +
" a comment repeating the code under it (" +
", ".join(sorted(shared)[:4]) + ")")
fails = 1
at = walk + 1
if fails:
print("check: the words above are not English or say nothing new")
elif AWAITING:
print("check: the faults above are in files named as waiting on a "
"hand rewrite: " + " ".join(sorted(AWAITING)))
else:
print("check: nothing found in " + str(len(sys.argv) - 1) + " file(s)")
sys.exit(0)
PYEOF