Skip to content
VLSI Mentor

Python Part II · Module 35 · Industry VLSI Automation Projects

A Filelist and Project Checker

The filelist is the one input every tool in the project reads, and nothing checks it. A compile failure tells you a file is missing; nothing tells you a file is listed twice, or that the module somebody added last week is in the tree and not in the list — which compiles cleanly and simulates the old behaviour. This chapter builds the checker, and two of its three jobs turn out to depend on facts about the filesystem that the obvious implementation gets wrong.

Advanced25 min readPythonPart IIAutomationFilelistsProject Hygiene

Part II · Module 35 · Chapter 35.7 · A Filelist and Project Checker

1. Three Questions About One File

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
CHECKS = [
    ("does every listed file exist?", "the compiler", "immediately",
     "a hard error"),
    ("is anything listed twice?", "nothing", "never",
     "a redefinition, or silence"),
    ("is anything in the tree missing from the list?", "nothing", "never",
     "the old behaviour, silently"),
]

print(f"{'the question':<46} {'who asks':<12} {'when':<12} symptom")
for q, who, when, sym in CHECKS:
    print(f"{q:<46} {who:<12} {when:<12} {sym}")

print()
print("questions the flow already answers:", 1)
print("questions nothing answers         :", 2)
print("and the worst symptom is          :", "the third — it compiles")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
the question                                   who asks     when         symptom
does every listed file exist?                  the compiler immediately  a hard error
is anything listed twice?                      nothing      never        a redefinition, or silence
is anything in the tree missing from the list? nothing      never        the old behaviour, silently

questions the flow already answers: 1
questions nothing answers         : 2
and the worst symptom is          : the third — it compiles

Only the first question is answered by anything today, and the third has the worst symptom. A module added to the tree and not to the filelist produces a design that compiles, elaborates and simulates — using whatever was there before — so the regression passes and the new logic is simply absent.

That is the shape Chapter 23.1's chip_a fixture had: three copies of one module in the tree and one in the filelist, with nothing reporting it.

2. The Annotation

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
PARTS = [
    ("files.f", "the filelist to check", "the subject"),
    ("--root rtl/", "what relative entries resolve against", "required — §4"),
    ("--strict", "orphans become errors, not warnings", "a policy switch"),
]

print(f"{'part':<16} {'meaning':<42} note")
for part, meaning, note in PARTS:
    print(f"{part:<16} {meaning:<42} {note}")

print()
MODES = [
    ("no --strict", "missing and duplicate are errors; orphans warn", 1),
    ("--strict", "orphans are errors too", 1),
    ("a clean project", "nothing reported", 0),
]
print(f"{'mode':<20} {'behaviour':<48} exit")
for mode, behaviour, code in MODES:
    print(f"{mode:<20} {behaviour:<48} {code}")

print()
print("why --strict is a flag and not the default:",
      "an orphan is sometimes deliberate — §8")
print("why --root has no default                :",
      "every default would be wrong somewhere — §4")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
part             meaning                                    note
files.f          the filelist to check                      the subject
--root rtl/      what relative entries resolve against      required — §4
--strict         orphans become errors, not warnings        a policy switch

mode                 behaviour                                        exit
no --strict          missing and duplicate are errors; orphans warn   1
--strict             orphans are errors too                           1
a clean project      nothing reported                                 0

why --strict is a flag and not the default: an orphan is sometimes deliberate — §8
why --root has no default                : every default would be wrong somewhere — §4

--root is required and --strict is optional, and the asymmetry is deliberate. Without a root the tool cannot answer any question correctly; without --strict it can still answer two of three, and the third is a policy decision rather than a fact.

3. The First Version

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from pathlib import Path

# a small project: three modules in the tree, two in the filelist
Path("proj/rtl").mkdir(parents=True, exist_ok=True)
for name in ("a.v", "b.v", "c.v"):
    (Path("proj/rtl") / name).write_text(
        f"module {name[0]}; endmodule\n", encoding="utf-8")
Path("proj/rtl/files.f").write_text("rtl/a.v\nrtl/b.v\n", encoding="utf-8")

# v1: read the lines and test each one
filelist = Path("proj/rtl/files.f")
entries = [l.strip() for l in filelist.read_text(encoding="utf-8").splitlines()
           if l.strip() and not l.strip().startswith("//")]

print("v1 — for each line, check that the path exists")
missing = []
for entry in entries:
    ok = Path(entry).exists()
    print(f"  {entry:<16} exists={ok}")
    if not ok:
        missing.append(entry)

print()
print("  entries :", len(entries))
print("  missing :", len(missing))
print()
print("every file is really there:", True)
print("v1 reports them missing   :", len(missing) == len(entries))
print("so v1 is wrong about      :", "all of them")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
v1 — for each line, check that the path exists
  rtl/a.v          exists=False
  rtl/b.v          exists=False

  entries : 2
  missing : 2

every file is really there: True
v1 reports them missing   : True
so v1 is wrong about      : all of them

Both files exist and v1 reports both missing. The tool is not subtly wrong on an edge case — it is wrong about every entry, and it is wrong for a reason Section 4 makes precise.

A v1 that happened to be run from the right directory would have reported everything fine, which is worse: it would have been adopted.

4. The Limitation: There Is No Implicit Root

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from pathlib import Path

Path("proj/rtl").mkdir(parents=True, exist_ok=True)
for name in ("a.v", "b.v", "c.v"):
    (Path("proj/rtl") / name).write_text("module m; endmodule\n",
                                         encoding="utf-8")
filelist = Path("proj/rtl/files.f")
filelist.write_text("rtl/a.v\nrtl/b.v\n", encoding="utf-8")

entries = [l.strip() for l in filelist.read_text(encoding="utf-8").splitlines()
           if l.strip()]

BASES = [
    ("the current directory", Path(".")),
    ("the filelist's own directory", filelist.parent),
    ("the filelist's parent's parent", filelist.parent.parent),
]

print(f"{'resolved against':<34} ", end="")
print(" ".join(f"{e:>10}" for e in entries))
for label, base in BASES:
    found = [(base / e).exists() for e in entries]
    print(f"{label:<34} " + " ".join(f"{str(f):>10}" for f in found))

print()
print("bases tried                 :", len(BASES))
print("bases that find both files  :",
      sum(1 for _, b in BASES if all((b / e).exists() for e in entries)))
print()
print("the filelist's own directory works:",
      all((filelist.parent / e).exists() for e in entries))
print("which is the base people assume   :", True)
print()
print("so a filelist alone is            :", "not interpretable")
print("and --root is                     :", "part of the input, not a flag")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
resolved against                      rtl/a.v    rtl/b.v
the current directory                   False      False
the filelist's own directory            False      False
the filelist's parent's parent           True       True

bases tried                 : 3
bases that find both files  : 1

the filelist's own directory works: False
which is the base people assume   : True

so a filelist alone is            : not interpretable
and --root is                     : part of the input, not a flag

Two of three bases find nothing, including the one everybody assumes. The entries say rtl/a.v and the filelist itself lives in rtl/, so resolving against the filelist's directory looks for proj/rtl/rtl/a.v.

That combination — entries relative to the project root, in a filelist stored inside a subdirectory — is the normal arrangement, and it means the two implicit bases a tool might choose are both wrong. The only correct behaviour is to take the root as an argument and refuse to guess.

5. The Architecture

Read, resolve, then three independent checks

Six blocks. Top row, left to right: the filelist text, including comments and nested include directives; a reader that strips comments and expands nested filelists into a flat ordered list of entries; and a resolver that joins each entry to the declared root and records both the written spelling and the resolved path. An arrow then runs downward from the resolver to the bottom row. Bottom row, right to left: three checks drawn as one block each — existence, duplication by file identity, and orphans found by walking the tree against an ignore list — feeding a report that names every problem and chooses one exit code.files.fcomments, -f includesRead and flattennested filelists tooResolve vs --rootkeep both spellings — §4Three checksexists · dup · orphanDeclared scopethe ignore list — §8Report, one codenames every problementriesresolved12
The three checks are deliberately separate passes over the same resolved list: existence is a fact, duplication needs file identity rather than string comparison, and orphans need a declared scope. Keeping them apart is what lets `--strict` change one of them without touching the others.

The resolver keeps both the written spelling and the resolved path, and that is not redundancy. Every message a user reads must quote the line as they wrote it — rtl/../rtl/a.v, not the tidy absolute form — or they cannot find it in the file; and every comparison must use the resolved form, because that is what the tool will actually open.

6. Reading the Filelist

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from pathlib import Path

Path("proj/rtl").mkdir(parents=True, exist_ok=True)
Path("proj/tb").mkdir(parents=True, exist_ok=True)
for p in ("rtl/a.v", "rtl/b.v", "rtl/c.v", "tb/tb_top.v"):
    (Path("proj") / p).write_text("module m; endmodule\n", encoding="utf-8")

Path("proj/rtl/files.f").write_text(
    "// the axi_fifo rtl filelist\n"
    "+incdir+rtl/include\n"
    "rtl/a.v\n"
    "\n"
    "rtl/b.v   // the second one\n"
    "-f tb/files.f\n", encoding="utf-8")
Path("proj/tb/files.f").write_text(
    "// testbench sources\n"
    "tb/tb_top.v\n", encoding="utf-8")


def read_filelist(path, root, _seen=None):
    """Flatten a filelist. Returns (entries, directives, includes)."""
    seen = _seen if _seen is not None else set()
    path = Path(path)
    if path.resolve() in seen:
        return [], [], [f"{path}: already included — cycle"]
    seen.add(path.resolve())
    entries, directives, problems = [], [], []
    for raw in path.read_text(encoding="utf-8").splitlines():
        line = raw.split("//", 1)[0].strip()
        if not line:
            continue
        if line.startswith("+"):
            directives.append(line)
            continue
        if line.startswith("-f"):
            nested = line.split(None, 1)[1]
            sub, subdir, subprob = read_filelist(Path(root) / nested, root,
                                                 seen)
            entries.extend(sub)
            directives.extend(subdir)
            problems.extend(subprob)
            continue
        entries.append(line)
    return entries, directives, problems


entries, directives, problems = read_filelist("proj/rtl/files.f", "proj")

print("entries   :", entries)
print("directives:", directives)
print("problems  :", problems or "none")
print()
print("comments stripped        :", not any("//" in e for e in entries))
print("blank lines dropped      :", "" not in entries)
print("trailing comment handled :", "rtl/b.v" in entries)
print("nested filelist expanded :", "tb/tb_top.v" in entries)
print("entry order preserved    :", entries == ["rtl/a.v", "rtl/b.v",
                                                "tb/tb_top.v"])
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
entries   : ['rtl/a.v', 'rtl/b.v', 'tb/tb_top.v']
directives: ['+incdir+rtl/include']
problems  : none

comments stripped        : True
blank lines dropped      : True
trailing comment handled : True
nested filelist expanded : True
entry order preserved    : True

Three source entries from two files, with the directive kept separately. +incdir+ is not a source and must not be existence-checked as one — it names a directory, and treating it as a file is how a checker produces a false positive on a perfectly good filelist.

The _seen set exists because a filelist can include itself, directly or through a chain, and the natural recursive reader then does not terminate. The cycle is reported rather than ignored, because a self-including filelist is always a mistake.

7. Duplicates: Strings, Paths and Files

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from pathlib import Path

Path("proj/rtl").mkdir(parents=True, exist_ok=True)
for name in ("a.v", "b.v"):
    (Path("proj/rtl") / name).write_text("module m; endmodule\n",
                                         encoding="utf-8")

ENTRIES = ["rtl/a.v", "./rtl/a.v", "rtl/../rtl/a.v", "rtl//a.v", "rtl/A.v",
           "rtl/b.v"]
ROOT = Path("proj")

print("six entries naming two real files")
print()
print(f"{'entry':<20} {'exists':<8} {'resolve() name':<16} file")
identity, order = {}, []
for e in ENTRIES:
    p = ROOT / e
    st = p.stat()
    ident = (st.st_dev, st.st_ino)
    if ident not in identity:
        identity[ident] = f"file#{len(identity) + 1}"
        order.append(ident)
    print(f"{e:<20} {str(p.exists()):<8} {p.resolve().name:<16} "
          f"{identity[ident]}")

by_string = len(set(ENTRIES))
by_resolve = len({(ROOT / e).resolve() for e in ENTRIES})
by_identity = len(identity)

print()
print("distinct by string            :", by_string)
print("distinct by resolve()         :", by_resolve)
print("distinct by file identity     :", by_identity)
print("real distinct files           :", 2)
print()
print("resolve() collapses . .. and //:", True)
print("resolve() collapses CASE       :", False)
print("this filesystem is case-insensitive:", True)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
six entries naming two real files

entry                exists   resolve() name   file
rtl/a.v              True     a.v              file#1
./rtl/a.v            True     a.v              file#1
rtl/../rtl/a.v       True     a.v              file#1
rtl//a.v             True     a.v              file#1
rtl/A.v              True     A.v              file#1
rtl/b.v              True     b.v              file#2

distinct by string            : 6
distinct by resolve()         : 3
distinct by file identity     : 2
real distinct files           : 2

resolve() collapses . .. and //: True
resolve() collapses CASE       : False
this filesystem is case-insensitive: True

String comparison says six, resolve() says three, file identity says two. Only the last is right, and the gap between three and two is the finding: this filesystem is case-insensitive, so rtl/A.v is rtl/a.v — exists() is True and the file opens — while resolve() preserves the case you asked for and therefore reports a different path.

The identifiers printed as file#1 and file#2 are first-seen surrogates. The real values come from st_dev and st_ino and are specific to this machine and this filesystem, so they are not published — Chapter 33.14 Section 3's rule applied to the checker's own output.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from pathlib import Path

Path("proj/rtl").mkdir(parents=True, exist_ok=True)
(Path("proj/rtl") / "a.v").write_text("module a; endmodule\n",
                                      encoding="utf-8")

ENTRIES = ["rtl/a.v", "rtl/A.v", "rtl/../rtl/a.v", "rtl/missing.v"]
ROOT = Path("proj")


def find_duplicates(entries, root):
    """Group by file identity; fall back to resolve() for missing files."""
    groups, order, problems = {}, [], []
    for entry in entries:
        p = root / entry
        if not p.exists():
            problems.append((entry, "missing"))
            continue
        st = p.stat()
        key = (st.st_dev, st.st_ino)
        if key not in groups:
            groups[key] = []
            order.append(key)
        groups[key].append(entry)
    dups = [groups[k] for k in order if len(groups[k]) > 1]
    return dups, problems


dups, problems = find_duplicates(ENTRIES, ROOT)

print("entries given  :", len(ENTRIES))
print("missing         :", [e for e, _ in problems])
print("duplicate groups:", len(dups))
for group in dups:
    print("   one file listed", len(group), "times as:", group)

print()
print("a missing file cannot be identity-checked:", True)
print("so it is reported as missing, not deduped:", True)
print("and the dedup never silently drops it    :", True)
print()
print("the group is reported with the SPELLINGS:",
      "so the user can find the lines")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
entries given  : 4
missing         : ['rtl/missing.v']
duplicate groups: 1
   one file listed 3 times as: ['rtl/a.v', 'rtl/A.v', 'rtl/../rtl/a.v']

a missing file cannot be identity-checked: True
so it is reported as missing, not deduped: True
and the dedup never silently drops it    : True

the group is reported with the SPELLINGS: so the user can find the lines

Three spellings of one file, grouped, and reported as the user wrote them. The ordering matters: existence is checked first, because a missing file has no st_ino to group by, and a dedup pass that called stat() on it would raise rather than report.

Reporting the spellings rather than the resolved path is what makes the message actionable. rtl/../rtl/a.v is findable with a text search; the absolute resolved form is not in the file at all.

8. Orphans Need a Declared Scope

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from pathlib import Path

for d in ("proj/rtl", "proj/rtl/include", "proj/tb", "proj/rtl/.old"):
    Path(d).mkdir(parents=True, exist_ok=True)
FILES = ["proj/rtl/a.v", "proj/rtl/b.v", "proj/rtl/c.v",
         "proj/rtl/scratch.v", "proj/rtl/.old/a_v1.v",
         "proj/rtl/include/defs.vh", "proj/tb/tb_top.v"]
for p in FILES:
    Path(p).write_text("module m; endmodule\n", encoding="utf-8")

LISTED = ["rtl/a.v", "rtl/b.v", "tb/tb_top.v"]
ROOT = Path("proj")
SOURCE_SUFFIXES = {".v", ".sv"}

IGNORE = [
    ("rtl/.old/**", "superseded versions, kept for reference", "amit",
     "2026-01-20"),
    ("rtl/scratch.v", "an experiment, not in any build", "priya",
     "2026-02-03"),
]


def walk_sources(root):
    return sorted(p.relative_to(root).as_posix()
                  for p in root.rglob("*")
                  if p.is_file() and p.suffix in SOURCE_SUFFIXES)


def ignored(rel, patterns):
    from fnmatch import fnmatch
    return any(fnmatch(rel, pat.replace("/**", "/*"))
               or rel.startswith(pat.replace("/**", "/"))
               for pat, *_ in patterns)


tree = walk_sources(ROOT)
listed_set = set(LISTED)
orphans = [r for r in tree if r not in listed_set]
unexplained = [r for r in orphans if not ignored(r, IGNORE)]

print("source files in the tree :", len(tree))
for r in tree:
    mark = ("listed" if r in listed_set
            else "ignored" if ignored(r, IGNORE) else "ORPHAN")
    print(f"  {r:<26} {mark}")

print()
print("listed       :", len(listed_set))
print("orphans      :", len(orphans))
print("of those, explained by the ignore list:", len(orphans) - len(unexplained))
print("unexplained orphans                   :", unexplained)
print()
print("the .vh include file is an orphan:", "rtl/include/defs.vh" in orphans)
print("is it a source?                  :", False)
print("so SOURCE_SUFFIXES excludes it   :",
      ".vh" not in SOURCE_SUFFIXES)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
source files in the tree : 6
  rtl/.old/a_v1.v            ignored
  rtl/a.v                    listed
  rtl/b.v                    listed
  rtl/c.v                    ORPHAN
  rtl/scratch.v              ignored
  tb/tb_top.v                listed

listed       : 3
orphans      : 3
of those, explained by the ignore list: 2
unexplained orphans                   : ['rtl/c.v']

the .vh include file is an orphan: False
is it a source?                  : False
so SOURCE_SUFFIXES excludes it   : True

Three orphans, two explained, one real. The one real orphan is rtl/c.v — a module in the tree that no build includes — and it is the finding the whole check exists for.

The other two are why --strict is a flag rather than the default. rtl/.old/ holds superseded versions somebody deliberately kept, and rtl/scratch.v is an experiment; reporting them every run trains people to ignore the output, which is the same dynamic Chapter 35.6 Section 8 described for a --check that always fails.

The ignore list carries the same four fields as Chapter 35.5's waiver file — pattern, reason, owner, date — for the same reason:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
IGNORE = [
    ("rtl/.old/**", "superseded versions, kept for reference", "amit",
     "2026-01-20"),
    ("rtl/scratch.v", "an experiment, not in any build", "priya",
     "2026-02-03"),
]

print(f"{'pattern':<18} {'owner':<8} {'since':<12} reason")
for pat, reason, who, when in IGNORE:
    print(f"{pat:<18} {who:<8} {when:<12} {reason}")

print()
print("entries           :", len(IGNORE))
print("with a reason     :", sum(1 for e in IGNORE if e[1]))
print("with an owner     :", sum(1 for e in IGNORE if e[2]))
print("with a date       :", sum(1 for e in IGNORE if e[3]))
print()
print("an ignore entry with no reason is:", "a file nobody will ever ask about")
print("and the date lets you            :", "re-review it when the block changes")
print("which is 35.5 §9's argument      :", "applied to project hygiene")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
pattern            owner    since        reason
rtl/.old/**        amit     2026-01-20   superseded versions, kept for reference
rtl/scratch.v      priya    2026-02-03   an experiment, not in any build

entries           : 2
with a reason     : 2
with an owner     : 2
with a date       : 2

an ignore entry with no reason is: a file nobody will ever ask about
and the date lets you            : re-review it when the block changes
which is 35.5 §9's argument      : applied to project hygiene

9. The Checker, Assembled

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from dataclasses import dataclass
from fnmatch import fnmatch
from pathlib import Path

EXIT_OK, EXIT_PROBLEMS, EXIT_USAGE = 0, 1, 2
SOURCE_SUFFIXES = {".v", ".sv"}


@dataclass(frozen=True)
class Problem:
    kind: str
    entry: str
    detail: str


def read_filelist(path, root, seen=None):
    seen = set() if seen is None else seen
    path = Path(path)
    key = path.resolve()
    if key in seen:
        return [], [Problem("cycle", str(path), "filelist includes itself")]
    seen.add(key)
    entries, problems = [], []
    if not path.is_file():
        return [], [Problem("missing-filelist", str(path), "not a file")]
    for raw in path.read_text(encoding="utf-8").splitlines():
        line = raw.split("//", 1)[0].strip()
        if not line or line.startswith("+"):
            continue
        if line.startswith("-f"):
            nested = line.split(None, 1)[1]
            sub, subprob = read_filelist(Path(root) / nested, root, seen)
            entries.extend(sub)
            problems.extend(subprob)
            continue
        entries.append(line)
    return entries, problems


def check(filelist, root, ignore=(), strict=False):
    root = Path(root)
    entries, problems = read_filelist(filelist, root)

    # 1. existence
    present, groups, order = [], {}, []
    for entry in entries:
        p = root / entry
        if not p.is_file():
            problems.append(Problem("missing", entry, f"no file at {p}"))
            continue
        present.append(entry)
        st = p.stat()
        k = (st.st_dev, st.st_ino)
        if k not in groups:
            groups[k] = []
            order.append(k)
        groups[k].append(entry)

    # 2. duplicates, by file identity
    for k in order:
        if len(groups[k]) > 1:
            problems.append(Problem("duplicate", groups[k][0],
                                    "also listed as "
                                    + ", ".join(groups[k][1:])))

    # 3. orphans, against a declared scope
    listed = {entry for entry in present}
    tree = sorted(p.relative_to(root).as_posix() for p in root.rglob("*")
                  if p.is_file() and p.suffix in SOURCE_SUFFIXES)
    resolved_listed = {(root / e).resolve() for e in present}
    for rel in tree:
        if (root / rel).resolve() in resolved_listed:
            continue
        if any(fnmatch(rel, pat.replace("/**", "/*"))
               or rel.startswith(pat.replace("/**", "/"))
               for pat, *_ in ignore):
            continue
        problems.append(Problem("orphan" if strict else "orphan-warning",
                                rel, "in the tree, not in the filelist"))

    return entries, problems


def report(entries, problems, strict):
    errors = [p for p in problems if p.kind != "orphan-warning"]
    warnings = [p for p in problems if p.kind == "orphan-warning"]
    lines = [f"{len(entries)} entries, {len(errors)} error(s), "
             f"{len(warnings)} warning(s)"]
    for p in sorted(errors + warnings, key=lambda p: (p.kind, p.entry)):
        lines.append(f"  [{p.kind}] {p.entry}: {p.detail}")
    return "\n".join(lines), (EXIT_PROBLEMS if errors else EXIT_OK)


# --- a project with one of everything
for d in ("proj/rtl", "proj/rtl/.old", "proj/tb"):
    Path(d).mkdir(parents=True, exist_ok=True)
for p in ("rtl/a.v", "rtl/b.v", "rtl/c.v", "rtl/.old/a_v1.v", "tb/tb_top.v"):
    (Path("proj") / p).write_text("module m; endmodule\n", encoding="utf-8")
Path("proj/rtl/files.f").write_text(
    "// axi_fifo\n+incdir+rtl/include\n"
    "rtl/a.v\nrtl/A.v\nrtl/b.v\nrtl/gone.v\n-f tb/files.f\n",
    encoding="utf-8")
Path("proj/tb/files.f").write_text("tb/tb_top.v\n", encoding="utf-8")

IGNORE = [("rtl/.old/**", "superseded", "amit", "2026-01-20")]

for strict in (False, True):
    entries, problems = check("proj/rtl/files.f", "proj", IGNORE, strict)
    text, code = report(entries, problems, strict)
    print(f"--- --strict {'on' if strict else 'off'}")
    for line in text.splitlines():
        print("   ", line)
    print("    exit:", code)
    print()

print("the duplicate found is a CASE variant:", True)
print("the missing entry is                 :", "rtl/gone.v")
print("the orphan is                        :", "rtl/c.v")
print("the ignored file never appears       :", True)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
--- --strict off
    5 entries, 2 error(s), 1 warning(s)
      [duplicate] rtl/a.v: also listed as rtl/A.v
      [missing] rtl/gone.v: no file at proj/rtl/gone.v
      [orphan-warning] rtl/c.v: in the tree, not in the filelist
    exit: 1

--- --strict on
    5 entries, 3 error(s), 0 warning(s)
      [duplicate] rtl/a.v: also listed as rtl/A.v
      [missing] rtl/gone.v: no file at proj/rtl/gone.v
      [orphan] rtl/c.v: in the tree, not in the filelist
    exit: 1

the duplicate found is a CASE variant: True
the missing entry is                 : rtl/gone.v
the orphan is                        : rtl/c.v
the ignored file never appears       : True

All three problems found, with --strict moving the orphan from a warning to an error. The duplicate is the case variant from Section 7, which a string-comparison checker would have missed entirely and a resolve()-based one would also have missed.

Two details of report are worth copying. Problems are sorted by (kind, entry) so the output is stable run to run — Chapter 33.14 Section 6 — and the exit code is 1 when there is any error and 0 only when there is none, regardless of warnings, which is Chapter 33.8's contract: a warning must not fail a build or nobody can distinguish it from an error.

10. How It Is Tested

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import io
import shutil
import tempfile
import unittest
from fnmatch import fnmatch
from pathlib import Path

SOURCE_SUFFIXES = {".v", ".sv"}


def read_entries(text):
    out = []
    for raw in text.splitlines():
        line = raw.split("//", 1)[0].strip()
        if line and not line.startswith(("+", "-f")):
            out.append(line)
    return out


def identity_groups(entries, root):
    groups, order = {}, []
    for e in entries:
        p = root / e
        if not p.is_file():
            continue
        st = p.stat()
        k = (st.st_dev, st.st_ino)
        if k not in groups:
            groups[k] = []
            order.append(k)
        groups[k].append(e)
    return [groups[k] for k in order if len(groups[k]) > 1]


def orphans(root, listed, ignore=()):
    resolved = {(root / e).resolve() for e in listed if (root / e).is_file()}
    out = []
    for p in sorted(root.rglob("*")):
        if not p.is_file() or p.suffix not in SOURCE_SUFFIXES:
            continue
        rel = p.relative_to(root).as_posix()
        if p.resolve() in resolved:
            continue
        if any(fnmatch(rel, pat.replace("/**", "/*"))
               or rel.startswith(pat.replace("/**", "/")) for pat in ignore):
            continue
        out.append(rel)
    return out


class Checker(unittest.TestCase):

    def setUp(self):
        self.root = Path(tempfile.mkdtemp())
        self.addCleanup(shutil.rmtree, self.root, True)
        (self.root / "rtl").mkdir()
        for n in ("a.v", "b.v"):
            (self.root / "rtl" / n).write_text("module m; endmodule\n",
                                               encoding="utf-8")

    # --- reading
    def test_comments_are_stripped(self):
        self.assertEqual(read_entries("rtl/a.v // first\n"), ["rtl/a.v"])

    def test_a_full_line_comment_is_dropped(self):
        self.assertEqual(read_entries("// nothing\nrtl/a.v\n"), ["rtl/a.v"])

    def test_blank_lines_are_dropped(self):
        self.assertEqual(read_entries("\n\nrtl/a.v\n\n"), ["rtl/a.v"])

    def test_incdir_is_not_a_source(self):
        self.assertEqual(read_entries("+incdir+rtl/inc\nrtl/a.v\n"),
                         ["rtl/a.v"])

    def test_order_is_preserved(self):
        self.assertEqual(read_entries("rtl/b.v\nrtl/a.v\n"),
                         ["rtl/b.v", "rtl/a.v"])

    # --- resolution
    def test_a_relative_entry_needs_the_root(self):
        self.assertFalse(Path("rtl/a.v").is_absolute())
        self.assertTrue((self.root / "rtl/a.v").is_file())

    # --- duplicates
    def test_string_duplicates_are_found(self):
        self.assertEqual(identity_groups(["rtl/a.v", "rtl/a.v"], self.root),
                         [["rtl/a.v", "rtl/a.v"]])

    def test_dot_and_dotdot_spellings_are_one_file(self):
        got = identity_groups(["rtl/a.v", "./rtl/a.v", "rtl/../rtl/a.v"],
                              self.root)
        self.assertEqual(len(got), 1)
        self.assertEqual(len(got[0]), 3)

    def test_distinct_files_are_not_a_duplicate(self):
        self.assertEqual(identity_groups(["rtl/a.v", "rtl/b.v"], self.root),
                         [])

    def test_a_missing_entry_is_skipped_by_the_dedup(self):
        self.assertEqual(identity_groups(["rtl/gone.v"], self.root), [])

    # --- orphans
    def test_an_unlisted_source_is_an_orphan(self):
        self.assertEqual(orphans(self.root, ["rtl/a.v"]), ["rtl/b.v"])

    def test_an_ignored_orphan_is_not_reported(self):
        self.assertEqual(orphans(self.root, ["rtl/a.v"], ("rtl/b.v",)), [])

    def test_a_fully_listed_tree_has_no_orphans(self):
        self.assertEqual(orphans(self.root, ["rtl/a.v", "rtl/b.v"]), [])

    def test_a_non_source_suffix_is_not_an_orphan(self):
        (self.root / "rtl" / "defs.vh").write_text("`define X 1\n",
                                                   encoding="utf-8")
        self.assertEqual(orphans(self.root, ["rtl/a.v", "rtl/b.v"]), [])


def run(case):
    suite = unittest.TestLoader().loadTestsFromTestCase(case)
    res = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
    return {"run": res.testsRun, "failures": len(res.failures),
            "ok": res.wasSuccessful()}


print("checker suite:", run(Checker))
print()
print("reading tests   :", 5)
print("duplicate tests :", 4)
print("orphan tests    :", 4)
print()
print("what is NOT asserted here:", "the case-variant duplicate")
print("why                      :",
      "it would fail on a case-sensitive filesystem")
print("so it belongs in         :", "a platform-conditional test, or neither")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
checker suite: {'run': 14, 'failures': 0, 'ok': True}

reading tests   : 5
duplicate tests : 4
orphan tests    : 4

what is NOT asserted here: the case-variant duplicate
why                      : it would fail on a case-sensitive filesystem
so it belongs in         : a platform-conditional test, or neither

Fourteen behaviours pinned, and one deliberately absent. The case-variant duplicate from Section 7 is the chapter's most interesting finding and it is not in the suite, because a test asserting that rtl/A.v duplicates rtl/a.v passes here and fails on Linux — and a suite that fails on half the team's machines gets disabled.

That is a real decision rather than an omission, and it has a correct form: a test guarded by a runtime probe of the filesystem's behaviour, asserting the checker agrees with the filesystem rather than asserting a fixed answer. The property is platform-independent even though the answer is not, which is the same move Chapter 35.5 made in preferring a property to a value.

test_a_missing_entry_is_skipped_by_the_dedup encodes Section 7's ordering requirement: existence first, because a missing file has no identity to group by and stat() on it raises.

11. Predict, Run, Observe

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from pathlib import Path

Path("proj/rtl").mkdir(parents=True, exist_ok=True)
(Path("proj/rtl") / "a.v").write_text("module a; endmodule\n",
                                      encoding="utf-8")
ROOT = Path("proj")

# A
print("A:", Path("rtl/a.v").exists(), (ROOT / "rtl/a.v").exists())

# B
print("B:", (ROOT / "rtl/../rtl/a.v").resolve()
      == (ROOT / "rtl/a.v").resolve())

# C
upper = ROOT / "rtl/A.v"
print("C:", upper.exists(), upper.resolve() == (ROOT / "rtl/a.v").resolve())

# D
print("D:", upper.stat().st_ino == (ROOT / "rtl/a.v").stat().st_ino)

The verified output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: False True
B: True
C: True False
D: True

A — the same entry, unresolvable from the current directory and resolvable from the root. Section 4 in two booleans, and the reason --root is required.

B — resolve() collapses .., so the three punctuation variants of one path are correctly identified.

C — the case variant exists and resolve() says it is a different path. That pair is the Section 7 finding, and it is why resolve() alone is not a duplicate detector here.

D — and the same two paths share an inode, so file identity gets it right. The two mechanisms disagree, and only one of them agrees with the filesystem.

12. Exercises

Exercise 1 — three questions. Check which of them anything in your flow answers today.

Exercise 2 — the silent one. Describe what happens when a new module is in the tree and not in the filelist.

Exercise 3 — required and optional. Explain why --root is required and --strict is not.

Exercise 4 — run v1 from the wrong place. Confirm it reports every file missing.

Exercise 5 — the worse outcome. Say why a v1 that happened to work would have been adopted.

Exercise 6 — three bases. Measure which of them finds your own filelist's entries.

Exercise 7 — the assumed base. Confirm whether the filelist's own directory works for you, and explain the layout that breaks it.

Exercise 8 — the simulator's base. Find out what your simulator resolves against.

Exercise 9 — both spellings. Explain why the resolver keeps the written form as well as the resolved one.

Exercise 10 — +incdir+. Confirm your reader does not existence-check it as a file.

Exercise 11 — a nested filelist. Add a -f include and confirm it is flattened.

Exercise 12 — a cycle. Make a filelist include itself and check your reader terminates.

Exercise 13 — three counts. Measure distinct-by-string, by resolve() and by identity on your own filelist.

Exercise 14 — what resolve() collapses. List what it does and does not normalise.

Exercise 15 — your filesystem. Determine whether it is case-insensitive.

Exercise 16 — existence first. Explain why the dedup pass must run after the existence check.

Exercise 17 — report the spellings. Say why the resolved path is the wrong thing to print.

Exercise 18 — the portability hazard. Describe a filelist that is clean on a laptop and broken on the build server.

Exercise 19 — walk your tree. Count the source files and compare with your filelist.

Exercise 20 — your real orphan. Find one and decide what it is.

Exercise 21 — the ignore list's four fields. Write a real entry.

Exercise 22 — training people to ignore output. Explain what reporting deliberate orphans every run achieves.

Exercise 23 — a warning must not fail a build. Confirm your exit code ignores warnings.

Exercise 24 — stable output. Sort your problems and say which chapter requires it.

Exercise 25 — the absent test. Decide whether to write the case-variant test and justify either way.

Exercise 26 — a property, not an answer. Write the platform-independent version of that test.

13. Summary

  • Only one of the three questions is answered by anything today, and the unanswered third has the worst symptom: a module in the tree and not in the filelist compiles, elaborates, and simulates the old behaviour.
  • A filelist is not interpretable on its own. Two of three resolution bases found nothing, including the one everybody assumes — entries relative to the project root in a filelist stored inside a subdirectory.
  • --root is part of the input, not a convenience flag, and the simulator has the same problem resolved against the current directory, which is why flows wrap every invocation in a cd.
  • Keep both the written spelling and the resolved path: messages must quote what the user typed, comparisons must use what the tool will open.
  • +incdir+ is not a source and existence-checking it produces a false positive on a good filelist.
  • A filelist can include itself, so the reader needs a seen-set and should report the cycle rather than ignore it.
  • Six entries naming two files: string comparison says 6, resolve() says 3, file identity says 2. Only the last is right.
  • resolve() collapses ., .. and // and does not collapse case, and this filesystem is case-insensitive — so rtl/A.v opens the same file and resolves to a different path.
  • Check existence before deduplicating, because a missing file has no identity to group by.
  • Inode numbers are machine-specific and are never published — a first-seen surrogate id says everything the reader needs.
  • On a case-sensitive filesystem the same filelist behaves differently, which makes this a portability hazard rather than a curiosity.
  • An orphan needs a declared scope. Three orphans, two explained by a reviewed ignore list carrying a pattern, reason, owner and date, and one real.
  • --strict is a flag because an orphan is sometimes deliberate, and reporting deliberate ones every run trains people to ignore the output.
  • Sort the problems and let only errors set the exit code — a warning that fails a build is indistinguishable from an error.
  • One finding is deliberately not in the test suite. A case-variant assertion passes here and fails on Linux, so the correct form probes the filesystem and asserts the checker agrees with it — a property rather than an answer.

The next chapter checks the environment rather than the project: the script an engineer runs before filing a tool bug.

Where this fits

Part of the Python curriculum.