nx_closurehash.nx source
↩ module page · 241 lines · 12675 B
1// nx_closurehash.nx -- PROVENANCE AT PRODUCTION, GENERALISED FROM ONE FILE TO THE WHOLE IMPORT CLOSURE.
2//
3// THE DEFECT THIS RETIRES (measured all day 2026-08-07): the build lane decides "has this tree forked?"
4// by comparing the LIVE NAS tree against `treecanon_laptop_hash.mf` -- a manifest generated from the
5// LAPTOP mirror and pushed across. But edits are made with nx_fs_write, which writes DIRECTLY to the NAS,
6// and builds compile FROM the NAS. So the newest truth is on the NAS and the judge is the stale mirror:
7// *THE CANON GUARD RUNS BACKWARDS RELATIVE TO HOW EDITS ACTUALLY FLOW, AND CALLS THE TRUTH A FORK.*
8// Consequences seen today: builds blocked by my own newer edit · a manifest push losing a file-lock race ·
9// four different hashes for one header inside ten minutes · ~3,800 NAS-ahead files nobody can reconcile.
10// Every guard involved was working correctly on a bad premise.
11//
12// nx_mgmt_api already states the right principle, for ONE file:
13// "PROVENANCE, CAPTURED AT THE MOMENT OF PRODUCTION ... A CLOCK CANNOT ESTABLISH PROVENANCE.
14// RECORD IT WHERE IT IS CHEAP: HERE. LATER IT IS UNRECOVERABLE."
15// It emits src_sha256/src_stable for the target's own source. That is the whole idea, one file wide.
16// This widens it to every source the compiler will actually read.
17//
18// *A MANIFEST IS A CLAIM ABOUT A TREE AT A PAST INSTANT; A CLOSURE HASH IS A FACT ABOUT THE BINARY IN
19// YOUR HAND.* With the closure recorded at build time you can answer "was this artifact built from these
20// exact bytes?" with no manifest, no push, no watermark, and nothing to go stale -- and the question a
21// census can never answer, "which of the two trees was I compiled from", becomes trivial.
22//
23// SOURCE RESOLUTION ORDER IS LOAD-BEARING and copied from nx_stalesweep's header rather than guessed:
24// _hdl_build/<t>.nx FIRST, then runtime/<t>.nx -- because buildrun compiles the _hdl_build twin when BOTH
25// exist, so hashing the runtime twin would describe a file the compiler never opened.
26//
27// UNRESOLVED IMPORTS ARE THEIR OWN BUCKET AND ARE COUNTED. An import that resolves nowhere is exactly the
28// kind of thing this must not silently omit -- omitting it would make a BROKEN closure hash the same
29// shape as a healthy one. *A PROVENANCE RECORD THAT DROPS WHAT IT COULD NOT FIND IS A FORGERY.*
30//
31// nx_closurehash <target> [root] root default "." (run from the buildroot)
32// -> one line per source, then: closure_sha=<64hex> files=N unresolved=M bytes=B
33// exit 0 complete | 1 closure has unresolved imports | 2 target source not found | 3 usage
34// license_tier: ORIGINAL No hw writes (Rule 26). expect_exit: 0
35import "nx_syscalls.nx"
36import "nx_sha256.nx"
37// ONE RESOLVER, ONE OWNER (2026-08-25, rung LN10). chresolve's body moved VERBATIM into
38// nx_incclosure_lib.icl_resolve and this organ now DELEGATES to it, because nx_inc_compile -- the
39// per-module incremental build cache -- must resolve module names to exactly the files THIS organ read,
40// and a second resolver that disagrees by one path hashes the wrong file, reports a cache hit and serves
41// a stale artifact. Disagreement is now impossible by construction rather than by discipline. Neutrality
42// was proven, not asserted: nx_behaveprobe live-vs-staged over real closures.
43import "nx_incclosure_lib.nx"
44const CH_MAGIC_262144: i64 = 262144
45const CH_MAGIC_1024: i64 = 1024
46
47const CH_MAXF: i64 = 1024 // closure entries; refuses past this rather than silently truncating
48const CH_NAMEW: i64 = 192 // bytes per stored name
49const CH_FCAP: i64 = 4194304 // 4MB per source file
50const CH_ACC: i64 = 262144 // accumulator for the combined digest input
51
52func chp(s: *u8) -> i64 { var n: i64=0; while s[n]!=(0 as u8){n=n+1} sys_write(1,s,n); return 0 }
53func che(s: *u8) -> i64 { var n: i64=0; while s[n]!=(0 as u8){n=n+1} sys_write(2,s,n); return 0 }
54func chn(v: i64) -> i64 {
55 var m: i64 = v
56 if m < 0 { chp("-" as *u8); m = 0 - m }
57 let t: *u8 = sys_mmap(32)
58 var k: i64 = 0
59 if m == 0 { t[0] = 48 as u8; k = 1 }
60 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 }
61 let b: *u8 = sys_mmap(32)
62 var i: i64 = 0
63 while i < k { b[i] = t[k-1-i]; i = i + 1 }
64 sys_write(1, b, k)
65 return 0
66}
67func chcat(d: *u8, o: i64, s: *u8) -> i64 { var x: i64=o; var i: i64=0; while s[i]!=(0 as u8){d[x]=s[i];x=x+1;i=i+1} return x }
68func chlen(s: *u8) -> i64 { var n: i64=0; while s[n]!=(0 as u8){n=n+1} return n }
69func chslot(b: *u8, i: i64) -> *u8 { return ((b as i64) + i*CH_NAMEW) as *u8 }
70func chseq(a: *u8, b: *u8) -> i64 {
71 var i: i64 = 0
72 var go: i64 = 1
73 while go == 1 {
74 let x: i64 = a[i] as i64
75 let y: i64 = b[i] as i64
76 if x != y { return 0 }
77 if x == 0 { go = 0 }
78 i = i + 1
79 }
80 return 1
81}
82func chhex(dig: *u8, out: *u8) -> i64 {
83 let h: *u8 = "0123456789abcdef" as *u8
84 var i: i64 = 0
85 while i < 32 {
86 let v: i64 = dig[i] as i64
87 out[i*2] = h[(v>>4)&15]
88 out[i*2+1] = h[v&15]
89 i = i + 1
90 }
91 out[64] = 0 as u8
92 return 64
93}
94func chexists(p: *u8) -> i64 { let fd: i64 = sys_openat_rd(p); if fd < 0 { return 0 } sys_close(fd); return 1 }
95func chread(path: *u8, buf: *u8, cap: i64) -> i64 {
96 let fd: i64 = sys_openat_rd(path)
97 if fd < 0 { return 0 - 1 }
98 var n: i64 = 0
99 var go: i64 = 1
100 while go == 1 {
101 let r: i64 = sys_read(fd, ((buf as i64) + n) as *u8, cap - n)
102 if r <= 0 { go = 0 } else { n = n + r }
103 if n >= cap { go = 0 }
104 }
105 sys_close(fd)
106 return n
107}
108
109// Resolve a bare source name (e.g. "nx_syscalls.nx") to a path under root. _hdl_build FIRST -- see header.
110// Returns 1 and fills `out`, or 0 when it resolves nowhere.
111// MEASURED 2026-08-07: a two-path resolver (_hdl_build, runtime) reported unresolved=2 on nx_mgmt_api --
112// nx_h2c_p256.nx and nx_voprf.nx -- while that exact target COMPILES CLEAN. They live in `runtime/hub/`,
113// a THIRD search path I did not know about. *AN INCOMPLETE RESOLVER REPORTS A HEALTHY TREE AS BROKEN*,
114// and a provenance tool that does that is worse than none: it manufactures forks.
115// Hardcoding the directory list would drift from nx_cc the first time a lane adds a folder, so after the
116// two ORDERED paths this ENUMERATES the subdirectories of runtime/ and tries each. The order still
117// matters and is preserved: _hdl_build first (buildrun compiles that twin when both exist), then runtime,
118// then any subdir -- so a name present in two places still resolves to the one the compiler would read.
119func chresolve(root: *u8, name: *u8, out: *u8) -> i64 { return icl_resolve(root, name, out) }
120
121func main(argc: i64, argv: *i64) -> i64 {
122 if argc < 2 { che("usage: nx_closurehash <target> [root]\n" as *u8); sys_exit(3); return 3 }
123 let tgt: *u8 = argv[1] as *u8
124 var root: *u8 = "." as *u8
125 if argc >= 3 { let r: *u8 = argv[2] as *u8; if r[0] != (0 as u8) { root = r } }
126
127 // worklist of bare source names; `seen` doubles as the visited set and the emit order
128 let seen: *u8 = sys_mmap(CH_MAXF * CH_NAMEW)
129 var nseen: i64 = 0
130 var head: i64 = 0
131 var unresolved: i64 = 0
132 var total_bytes: i64 = 0
133
134 // seed with "<target>.nx"
135 let s0: *u8 = chslot(seen, 0)
136 var so: i64 = chcat(s0, 0, tgt)
137 so = chcat(s0, so, ".nx" as *u8)
138 s0[so] = 0 as u8
139 nseen = 1
140
141 let path: *u8 = sys_mmap(CH_MAGIC_1024)
142 let fbuf: *u8 = sys_mmap(CH_FCAP)
143 let acc: *u8 = sys_mmap(CH_ACC)
144 var acco: i64 = 0
145 let dig: *u8 = sys_mmap(64)
146 let hex: *u8 = sys_mmap(80)
147 let imp: *u8 = sys_mmap(CH_NAMEW)
148
149 if chresolve(root, s0, path) == 0 { che("nx_closurehash: target source not found for " as *u8); che(tgt); che("\n" as *u8); sys_exit(2); return 2 }
150
151 chp("# sha256 bytes source\n" as *u8)
152 while head < nseen {
153 let nm: *u8 = chslot(seen, head)
154 if chresolve(root, nm, path) == 0 {
155 unresolved = unresolved + 1
156 chp("UNRESOLVED - " as *u8)
157 chp(nm)
158 chp("\n" as *u8)
159 } else {
160 let fn: i64 = chread(path, fbuf, CH_FCAP)
161 if fn < 0 {
162 unresolved = unresolved + 1
163 chp("UNREADABLE - " as *u8)
164 chp(nm); chp("\n" as *u8)
165 } else {
166 total_bytes = total_bytes + fn
167 sha256_digest(fbuf, fn, dig)
168 chhex(dig, hex)
169 chp(hex); chp(" " as *u8); chn(fn); chp(" " as *u8); chp(nm); chp("\n" as *u8)
170 // the combined digest is taken over "<name> <sha>\n" rows in DISCOVERY order. Order is
171 // deterministic because the walk is: seed, then imports in file order, breadth-first.
172 acco = chcat(acc, acco, nm)
173 acc[acco] = 32 as u8; acco = acco + 1
174 acco = chcat(acc, acco, hex)
175 acc[acco] = 10 as u8; acco = acco + 1
176
177 // scan for `import "` and enqueue each unseen name
178 // LINE-ANCHORED. MEASURED on the FIRST run of this organ against its own source: an
179 // unanchored scan matched the marker inside this very string constant and enqueued a
180 // fragment of source code as an import, which then reported UNRESOLVED.
181 // *A SCANNER THAT MATCHES ITS OWN SOURCE TEXT WILL FIND ITSELF* -- and it was caught only
182 // because unresolved entries are their own bucket with a non-zero exit; had they been
183 // silently dropped, this would have published a confident closure hash for a set it had
184 // mis-parsed. In NishiLang an import is line-initial, so require offset 0 or a preceding
185 // newline: the language's own grammar is the discriminator, not a heuristic.
186 var i: i64 = 0
187 while i + 8 < fn {
188 var anchored: i64 = 0
189 if i == 0 { anchored = 1 }
190 if i > 0 { if (fbuf[i-1] as i64) == 10 { anchored = 1 } }
191 var hit: i64 = anchored
192 let k: *u8 = "import \"" as *u8
193 var m: i64 = 0
194 while m < 8 { if fbuf[i+m] != k[m] { hit = 0; m = 8 } else { m = m + 1 } }
195 if hit == 1 {
196 var s: i64 = i + 8
197 var o: i64 = 0
198 while s < fn {
199 let c: i64 = fbuf[s] as i64
200 if c == 34 { s = fn } else {
201 if o < CH_NAMEW - 2 { imp[o] = fbuf[s]; o = o + 1 }
202 s = s + 1
203 }
204 }
205 imp[o] = 0 as u8
206 if o > 0 {
207 var dup: i64 = 0
208 var q: i64 = 0
209 while q < nseen { if chseq(chslot(seen, q), imp) == 1 { dup = 1; q = nseen } else { q = q + 1 } }
210 if dup == 0 {
211 // REFUSE rather than truncate: a closure silently cut at the cap would
212 // publish a confident hash for a PARTIAL set, which is the same defect
213 // class as a directory read that stops at one getdents batch.
214 if nseen >= CH_MAXF { che("nx_closurehash: REFUSED -- closure exceeds CH_MAXF; raise the cap deliberately rather than publish a partial hash\n" as *u8); sys_exit(2); return 2 }
215 let dstn: *u8 = chslot(seen, nseen)
216 var z: i64 = 0
217 while imp[z] != (0 as u8) { dstn[z] = imp[z]; z = z + 1 }
218 dstn[z] = 0 as u8
219 nseen = nseen + 1
220 }
221 }
222 }
223 i = i + 1
224 }
225 }
226 }
227 head = head + 1
228 }
229
230 sha256_digest(acc, acco, dig)
231 chhex(dig, hex)
232 chp("closure_sha=" as *u8); chp(hex)
233 chp(" files=" as *u8); chn(nseen)
234 chp(" unresolved=" as *u8); chn(unresolved)
235 chp(" bytes=" as *u8); chn(total_bytes)
236 chp("\n" as *u8)
237 // An incomplete closure must NOT exit 0: a caller gating a promote on this needs the failure in the
238 // exit code, not buried in a field it may not read.
239 if unresolved > 0 { sys_exit(1); return 1 }
240 return 0
241}