AtlatestRepositoryfolio
1
#!/usr/bin/env python32
"""F2a witness channel 4 -- the TRANSITIVE import closure of the passive core.4
WHY TRANSITIVE, AND WHY THIS EXISTS AT ALL.6
F1a's gate arm 6 and SD-P4's gate arm 3 both check DIRECT imports: they read one7
file's `(import ...)` form and refuse a forbidden library there. That is a real8
check and it stays. It is also blind to the case that matters most at a trust9
boundary -- a module that reaches the author runtime through an innocent-looking10
intermediary. F1a's blocking review closed the umbrella-import hole for11
`(sigil doc)` specifically and left the general transitive question open as12
informational "for F2/P0". F2a is that phase, so this walks the whole graph.14
WHAT IT REFUSES TO DO: guess. A library that resolves to no file and is not in15
`NATIVE_LEAVES` below stops the walk with a failure. A closure that silently16
skips what it cannot find reports "clean" for the wrong reason, and "clean"17
because we could not look is indistinguishable from "clean" -- which is the18
single most repeated instrument failure in this estate.20
WHAT IT DOES NOT CLAIM, stated plainly because a broad claim that is almost true21
invites nobody to look for the gap:23
* It is a claim about MODULES, not about primitives. Nothing here prevents a24
primitive from existing, and reading a manuscript does use a reader. The25
RUNTIME witness is what establishes that reading is not evaluating; this arm26
cannot.27
* It does not follow `include`, `cond-expand`, or re-exports. A library that28
reaches a forbidden module only through one of those is invisible to this29
walk. Stated because an unstated gap in a passivity claim is worse than a30
narrower claim honestly drawn.31
* `NATIVE_LEAVES` is a hole BY DESIGN: anything reachable only through a32
declared leaf is unwalked. The result line reports how many leaves were33
actually HIT, so a transcript shows whether the hole was entered at all34
rather than how many entries the dict happens to contain.35
* It is a claim about REACHABILITY OF MODULES in the source graph, not about36
what the VM can do. Nothing here prevents a primitive from existing.38
NO I/O MODULE IS IN THE CLOSURE, AND THAT IS AN OUTCOME OF THIS TOOL RATHER THAN39
AN ASSUMPTION BEHIND IT. The first version of the passive core imported40
`(sigil language)` (for the `#lang` header scan) and `(sigil error)` (for41
`exception-message`). Both look inert in an import list. This walk reported 1742
modules including `(sigil fs)`, `(sigil io)`, `(sigil socket)` and the async43
scheduler. The header offset became a caller-supplied parameter and44
`exception-message` came from `(sigil core)` instead, and the closure is now the45
list this tool prints -- which the gate reads, so no number is written down here46
to go stale.48
So the forbidden set below is the EVALUATION SIDE specifically: the author49
runtime, the semantic models, the verifier and its subprocess reach, and the50
renderers that pull the verifier in.51
"""53
import os54
import re55
import sys57
# Library name -> reason it legitimately has no source file in these roots.58
# Every entry is a decision someone made; an unlisted unresolvable library is an59
# error, not a leaf.60
NATIVE_LEAVES = {61
"(sigil compiler)": "VM-provided; the reader and the language-header scanner live here",62
}64
# The I/O modules are HERE, in the walker, rather than only in a `grep` over its65
# output. They were previously refused by a single grep in gate-f2a.sh keyed on66
# the walker printing exactly two leading spaces -- an absence assertion with no67
# positive control, that a change to this file's output format would have68
# silenced permanently and silently. In the dict they get the walker's own69
# reached-via trail, and the gate's grep becomes a second opinion rather than the70
# only one.71
FORBIDDEN = {72
"(sigil fs)": "filesystem access; the caller reads the file, not this module",73
"(sigil io)": "port I/O; reaches the socket and scheduler layers",74
"(sigil socket)": "network access",75
"(sigil process)": "subprocess execution",76
"(sigil shell)": "shell execution",77
"(sigil doc author)": "the author-facing command vocabulary",78
"(sigil doc private author-runtime)": "the author runtime itself",79
"(sigil doc model)": "constructing semantic records is the lowering side",80
"(sigil doc verify)": "runs example code in subprocesses",81
"(sigil doc presentation)": "reaches (sigil doc verify)",82
"(sigil doc render html)": "reaches (sigil doc presentation)",83
"(sigil doc render typst)": "reaches (sigil doc presentation)",84
"(sigil doc print)": "reaches the print toolchain",85
"(folio doc model)": "the frozen semantic model; would drag (sigil doc model) in",86
"(folio doc publication)": "lowers to semantic records",87
}89
LIB_RE = re.compile(r"\(([a-z][a-z0-9-]*(?:\s+[a-z0-9-]+)+)\)")92
def strip_comments(text):93
"""Drop whole-line comments only.95
NOT everything after the first `;`: a semicolon inside a string literal96
would truncate real code, and a truncated import form is an import this walk97
never sees. Same reasoning as gate-f1a.sh arm 7, which learned it the hard98
way by matching its own explanatory prose.99
"""100
return "\n".join(l for l in text.split("\n") if not l.lstrip().startswith(";"))103
def imports_of(path):104
text = strip_comments(open(path, "r", encoding="utf-8").read())105
out = []106
i = 0107
while True:108
i = text.find("(import", i)109
if i < 0:110
break111
depth = 0112
j = i113
while j < len(text):114
if text[j] == "(":115
depth += 1116
elif text[j] == ")":117
depth -= 1118
if depth == 0:119
break120
j += 1121
form = text[i:j + 1]122
for m in LIB_RE.finditer(form):123
name = "(" + " ".join(m.group(1).split()) + ")"124
if name != "(import)":125
out.append(name)126
i = j + 1127
return out130
def resolve(name, roots):131
parts = name[1:-1].split()132
rel = os.path.join(*parts) + ".sgl"133
for root in roots:134
candidate = os.path.join(root, rel)135
if os.path.isfile(candidate):136
return candidate137
return None140
def main():141
if len(sys.argv) < 3:142
print("usage: f2a-import-closure.py <root-library> <source-root>...", file=sys.stderr)143
sys.exit(1)144
root_lib = sys.argv[1]145
roots = sys.argv[2:]146
for r in roots:147
if not os.path.isdir(r):148
print("F2A_IMPORT_CLOSURE RED: source root %r does not exist; the walk "149
"would report every library in it as unresolvable" % r, file=sys.stderr)150
sys.exit(1)152
start = resolve(root_lib, roots)153
if start is None:154
print("F2A_IMPORT_CLOSURE RED: cannot resolve the ROOT library %s in %r"155
% (root_lib, roots), file=sys.stderr)156
sys.exit(1)158
seen = {}159
unresolved = []160
leaves_hit = set()161
order = []162
pending = [(root_lib, start, ["<root>"])]163
while pending:164
name, path, trail = pending.pop()165
if name in seen:166
continue167
seen[name] = trail168
order.append(name)169
if path is None:170
continue171
for dep in imports_of(path):172
if dep in seen:173
continue174
dpath = resolve(dep, roots)175
if dpath is None:176
if dep not in NATIVE_LEAVES:177
unresolved.append((dep, name))178
continue179
leaves_hit.add(dep)180
pending.append((dep, dpath, trail + [name]))182
# BOTH FAILURE CLASSES ARE REPORTED, THEN THE WALK FAILS ONCE.183
#184
# An earlier version returned on `unresolved` before ever looking for185
# forbidden modules, which made the gate's own positive control unfalsifiable186
# in the direction that mattered: planting `(sigil process)` one level down187
# produced an UNRESOLVED failure (the plant drags `(sigil io)` in, which188
# reaches `(sigil socket)`) and never printed FORBIDDEN at all. The walk189
# failed for a true reason and reported the wrong one -- which is the same190
# defect as a check that passes for the wrong reason, wearing a red coat.191
violations = [n for n in order if n in FORBIDDEN]192
for v in violations:193
print(" FORBIDDEN: %s (%s)\n reached via: %s"194
% (v, FORBIDDEN[v], " -> ".join(seen[v] + [v])), file=sys.stderr)195
for dep, via in unresolved:196
print(" UNRESOLVED: %s (imported by %s)" % (dep, via), file=sys.stderr)197
if violations or unresolved:198
print("F2A_IMPORT_CLOSURE RED: %d forbidden module(s) reachable, %d "199
"librar(ies) unresolvable. A forbidden module is a passivity "200
"breach; an unresolvable one means the walk could not look, and "201
"\"clean because I could not look\" is indistinguishable from "202
"clean." % (len(violations), len(unresolved)), file=sys.stderr)203
sys.exit(1)205
# NON-VACUITY. A walk that visited one module would report a clean closure206
# for free. The floor is not a magic number: it is "more than the root plus207
# its direct imports", i.e. the walk demonstrably recursed.208
direct = len(set(imports_of(start)))209
if len(order) <= direct + 1:210
print("F2A_IMPORT_CLOSURE RED: closure is %d modules and the root has %d "211
"direct imports -- the walk did not recurse, so a violation one "212
"level down would be invisible" % (len(order), direct), file=sys.stderr)213
sys.exit(1)215
# `leaves` is the count actually HIT during this walk, not the size of the216
# dict. It printed `len(NATIVE_LEAVES)` -- a constant 1 -- so the gate217
# transcript read "leaves=1" while the walk had entered no leaf at all, and a218
# reader would reasonably conclude one library was legitimately unresolvable.219
print("F2A_IMPORT_CLOSURE modules=%d direct=%d forbidden=0 leaves=%d"220
% (len(order), direct, len(leaves_hit)))221
for n in sorted(order):222
print(" " + n)225
if __name__ == "__main__":226
main()