diff --git a/.coherence/journal/a8c20833df2abb361.a46f7968.jsonl b/.coherence/journal/a8c20833df2abb361.a46f7968.jsonl new file mode 100644 index 00000000..5f720c6e --- /dev/null +++ b/.coherence/journal/a8c20833df2abb361.a46f7968.jsonl @@ -0,0 +1,2 @@ +{"id":"en-82833b4c","kind":"enactment","at":"2026-10-25T02:12:45.982Z","session":"a8c20833df2abb361","agent":"general-purpose","commit":"b91e4ad","dirty":true,"practice":"src/enforcement/witness a refutation","version":"5559d799","trigger":"command refute","steps":[{"text":"run the totality oracles being refuted in one batched run confirmed with run --each (run --invariant ... --each), and see each green batched and alone","leaves":"run record for the bullets, pass; per-test run record, pass"},{"text":"stage the smallest break that changes the behavior the bullet claims, not the strings the detector reads; read the diff to confirm the edit touched only the lines you meant"},{"text":"refute / --broke \"\"; the detector fails on its own assertion, not by a hang, a kill, a timeout, or what an earlier test left behind","leaves":"run record for the bullet, refutation red"},{"text":"restore the source byte for byte: from HEAD when the code under test is committed, and from a copy taken before the break when it is not","leaves":"the file matches its state before the break (git diff against HEAD, or cmp against the copy)"},{"text":"run them again in one batched run confirmed with run --each, and see each green batched and alone","leaves":"run record for the bullets, pass; per-test run record, pass"},{"text":"when the red did not come from the break, record a decision that this refutation does not count and refute again; the run store is append-only, so the correction is a later record"}],"pitfalls":["a sed matched two lines, so the test hung and went red only when it was killed (d-828ddc83)","a substring detector passed an unconditional early return that kept every expected string (df-c40bb904, x-429ed176)","the red came from the selected test depending on what earlier tests appended, not from the break (d-c4be0b1a, df-9e673484)","a self-asserted refuted: line read as witnessed, and an automatic refutation proved the instrument reports a site, not that the classifier calls it a bypass (df-b9b2711b, df-87881a8d)","a break that turns a test red when the via does not test the claim refutes nothing; record unable instead (u-2e6ee400)"],"results":{"1":{"result":"done","evidence":"run --invariant for both bullets with --each, ef76c476 runs shard: batched 2 pass, alone 2 pass (after two earlier attempts there recorded not run and fail alone because Node 25 prints the spec reporter and the config's testMatch reads TAP; rerun through a node shim that adds --test-reporter=tap when none is named)"},"2":{"result":"done","evidence":"one-line breaks, each an exact single replacement asserted present: the wasm impl's exports set false in foreignExports (plugins/rust/rust-entrances.ts), and the lists slot dropped from readLiteral (src/lifecycle/mirrors.ts); git diff --no-index against the copy showed only that line"},"3":{"result":"done","evidence":"two refute records in the ef76c476 runs shard, each red on its own test"},"4":{"result":"done","evidence":"each file restored from a copy taken before its break, cmp clean"},"5":{"result":"done","evidence":"run --invariant for both bullets with --each after restoring: batched 2 pass, alone 2 pass, refutation witnessed"},"6":{"result":"skipped","because":"every red came from its break"}},"binding":"none: this session owns no active work order"} +{"id":"d-bd0638ec","kind":"decision","at":"2026-10-25T02:12:46.213Z","session":"a8c20833df2abb361","agent":"general-purpose","commit":"b91e4ad","dirty":true,"chose":"an exported impl's methods, #[wasm_bindgen(start)] and UniFFI exports are read under the Rust plugin's existing foreign export rule, named Type::method, and an example's main under a new example rule","over":["a wasm export, a uniffi export and a C-ABI export rule of their own (the shape Holofield's ejected copy used) | an example read as a binary"],"because":"upstream's foreign export rule already says what each of these is, a function code outside Rust calls by its name, and splitting it would rename the rule every adopter's coverage already counts; an example is run by cargo run --example, not installed as a binary, so naming it a binary would propose binary names for files Cargo never builds as one. Checked on Holofield: the branch detects the same 153 (file, symbol) pairs as its ejected plugin, against 34 on main","binding":"none: this session owns no active work order"} diff --git a/.coherence/runs/a8c20833df2abb361.ef76c476.jsonl b/.coherence/runs/a8c20833df2abb361.ef76c476.jsonl new file mode 100644 index 00000000..6df561bf --- /dev/null +++ b/.coherence/runs/a8c20833df2abb361.ef76c476.jsonl @@ -0,0 +1,14 @@ +{"at":"2026-10-25T02:05:59.477Z","session":"a8c20833df2abb361","agent":"general-purpose","binding":"none: this session owns no active work order","commit":"b91e4ad","dirty":true,"instrument":{"language":"typescript","server":"none"},"latency":25673,"load":{"average":7.08,"cores":16},"batch":{"ms":25387},"invariants":[{"component":"src/adapters","name":"entrance detection follows listed rules","form":"totality oracle","verdict":"pass","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":25387,"reason":"7 tests under \"entrance detection\" passed in one invocation of node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-reporter=./src/enforcement/node-test-reporter.ts --test-reporter-destination= --test-name-pattern=<2 names> src/**/*.test.ts src/*.e2e.ts","testMs":75,"mode":"batched","state":"invariant","enforces":["entrance detection"]},{"component":"src/lifecycle","name":"a mirror's list is read in its own language","form":"totality oracle","verdict":"pass","refutation":"missing","bypasses":[],"testReferences":0,"files":[],"latency":25387,"reason":"1 test under \"a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone\" passed in one invocation of node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-reporter=./src/enforcement/node-test-reporter.ts --test-reporter-destination= --test-name-pattern=<2 names> src/**/*.test.ts src/*.e2e.ts","testMs":120,"mode":"batched","state":"requirement","enforces":["a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone"]}]} +{"at":"2026-10-25T02:06:50.132Z","session":"a8c20833df2abb361","agent":"general-purpose","binding":"none: this session owns no active work order","commit":"b91e4ad","dirty":true,"instrument":{"language":"typescript","server":"none"},"latency":50423,"load":{"average":7.51,"cores":16},"invariants":[{"component":"src/adapters","name":"entrance detection follows listed rules","form":"totality oracle","verdict":"fail","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":25155,"reason":"passed in the batched invocation, failed in its own: node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-name-pattern='entrance detection' 'src/**/*.test.ts' 'src/*.e2e.ts' exited 0 but its output did not match /# pass [1-9][0-9]*/; no test ran under that name","mode":"one-at-a-time","state":"structural defect","enforces":["entrance detection"]},{"component":"src/lifecycle","name":"a mirror's list is read in its own language","form":"totality oracle","verdict":"fail","refutation":"missing","bypasses":[],"testReferences":0,"files":[],"latency":25242,"reason":"passed in the batched invocation, failed in its own: node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-name-pattern='a list in a Rust file is read by the Rust plugin'\\''s lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone' 'src/**/*.test.ts' 'src/*.e2e.ts' exited 0 but its output did not match /# pass [1-9][0-9]*/; no test ran under that name","mode":"one-at-a-time","state":"requirement","enforces":["a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone"]}]} +{"at":"2026-10-25T02:07:19.440Z","session":"a8c20833df2abb361","agent":"general-purpose","binding":"none: this session owns no active work order","commit":"b91e4ad","dirty":true,"instrument":{"language":"typescript","server":"none"},"latency":311,"load":{"average":10.51,"cores":16},"batch":{"ms":26},"invariants":[{"component":"src/adapters","name":"entrance detection follows listed rules","form":"totality oracle","verdict":"not run","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":26,"reason":"node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-reporter=./src/enforcement/node-test-reporter.ts --test-reporter-destination= --test-name-pattern=<2 names> src/**/*.test.ts src/*.e2e.ts exited 1 and wrote no report at {out}","mode":"batched","state":"invariant","enforces":["entrance detection"]},{"component":"src/lifecycle","name":"a mirror's list is read in its own language","form":"totality oracle","verdict":"not run","refutation":"missing","bypasses":[],"testReferences":0,"files":[],"latency":26,"reason":"node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-reporter=./src/enforcement/node-test-reporter.ts --test-reporter-destination= --test-name-pattern=<2 names> src/**/*.test.ts src/*.e2e.ts exited 1 and wrote no report at {out}","mode":"batched","state":"requirement","enforces":["a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone"]}]} +{"at":"2026-10-25T02:08:10.099Z","session":"a8c20833df2abb361","agent":"general-purpose","binding":"none: this session owns no active work order","commit":"b91e4ad","dirty":true,"instrument":{"language":"typescript","server":"none"},"latency":50423,"load":{"average":7.43,"cores":16},"invariants":[{"component":"src/adapters","name":"entrance detection follows listed rules","form":"totality oracle","verdict":"pass","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":25223,"reason":"node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-name-pattern='entrance detection' 'src/**/*.test.ts' 'src/*.e2e.ts' exited 0 and its output matched /# pass [1-9][0-9]*/","mode":"one-at-a-time","state":"invariant","enforces":["entrance detection"]},{"component":"src/lifecycle","name":"a mirror's list is read in its own language","form":"totality oracle","verdict":"pass","refutation":"missing","bypasses":[],"testReferences":0,"files":[],"latency":25175,"reason":"node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-name-pattern='a list in a Rust file is read by the Rust plugin'\\''s lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone' 'src/**/*.test.ts' 'src/*.e2e.ts' exited 0 and its output matched /# pass [1-9][0-9]*/","mode":"one-at-a-time","state":"requirement","enforces":["a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone"]}]} +{"at":"2026-10-25T02:08:52.456Z","session":"a8c20833df2abb361","agent":"general-purpose","binding":"none: this session owns no active work order","commit":"b91e4ad","dirty":true,"instrument":{"language":"typescript","server":"none"},"latency":25735,"load":{"average":6.6,"cores":16},"batch":{"ms":25439},"invariants":[{"component":"src/adapters","name":"entrance detection follows listed rules","form":"totality oracle","verdict":"pass","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":25439,"reason":"7 tests under \"entrance detection\" passed in one invocation of node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-reporter=./src/enforcement/node-test-reporter.ts --test-reporter-destination= --test-name-pattern=<2 names> src/**/*.test.ts src/*.e2e.ts","testMs":78,"mode":"batched","state":"invariant","enforces":["entrance detection"]},{"component":"src/lifecycle","name":"a mirror's list is read in its own language","form":"totality oracle","verdict":"pass","refutation":"missing","bypasses":[],"testReferences":0,"files":[],"latency":25439,"reason":"1 test under \"a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone\" passed in one invocation of node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-reporter=./src/enforcement/node-test-reporter.ts --test-reporter-destination= --test-name-pattern=<2 names> src/**/*.test.ts src/*.e2e.ts","testMs":119,"mode":"batched","state":"requirement","enforces":["a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone"]}]} +{"at":"2026-10-25T02:09:43.454Z","session":"a8c20833df2abb361","agent":"general-purpose","binding":"none: this session owns no active work order","commit":"b91e4ad","dirty":true,"instrument":{"language":"typescript","server":"none"},"latency":50767,"load":{"average":6.38,"cores":16},"invariants":[{"component":"src/adapters","name":"entrance detection follows listed rules","form":"totality oracle","verdict":"pass","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":25303,"reason":"node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-name-pattern='entrance detection' 'src/**/*.test.ts' 'src/*.e2e.ts' exited 0 and its output matched /# pass [1-9][0-9]*/","mode":"one-at-a-time","state":"invariant","enforces":["entrance detection"]},{"component":"src/lifecycle","name":"a mirror's list is read in its own language","form":"totality oracle","verdict":"pass","refutation":"missing","bypasses":[],"testReferences":0,"files":[],"latency":25437,"reason":"node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-name-pattern='a list in a Rust file is read by the Rust plugin'\\''s lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone' 'src/**/*.test.ts' 'src/*.e2e.ts' exited 0 and its output matched /# pass [1-9][0-9]*/","mode":"one-at-a-time","state":"requirement","enforces":["a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone"]}]} +{"at":"2026-10-25T02:10:21.869Z","session":"a8c20833df2abb361","agent":"general-purpose","binding":"none: this session owns no active work order","commit":"b91e4ad","dirty":true,"instrument":{"language":"typescript","server":"none"},"latency":25924,"load":{"average":6.56,"cores":16},"batch":{"ms":25627},"invariants":[{"component":"src/adapters","name":"entrance detection follows listed rules","form":"totality oracle","verdict":"fail","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":25627,"reason":"2 of 7 tests under \"entrance detection\" failed: entrance detection the Rust rules detect what wasm-bindgen and UniFFI export (an exported impl's pub methods and constructor as Type::method, a start fn) and an example's main at Cargo's default paths; never an extern block's imports, a private or plain impl's method, a trait impl wasm-bindgen cannot export, a test module or a tests.rs; entrance detection","testMs":75,"mode":"batched","state":"structural defect","enforces":["entrance detection"]}]} +{"kind":"refutation","at":"2026-10-25T02:10:22.114Z","session":"a8c20833df2abb361","agent":"general-purpose","component":"src/adapters","name":"entrance detection follows listed rules","form":"totality oracle","broke":"read a #[wasm_bindgen] impl as exporting none of its methods, in foreignExports (plugins/rust/rust-entrances.ts)","verdict":"fail","reason":"2 of 7 tests under \"entrance detection\" failed: entrance detection the Rust rules detect what wasm-bindgen and UniFFI export (an exported impl's pub methods and constructor as Type::method, a start fn) and an example's main at Cargo's default paths; never an extern block's imports, a private or plain impl's method, a trait impl wasm-bindgen cannot export, a test module or a tests.rs; entrance detection","commit":"b91e4ad","dirty":true} +{"at":"2026-10-25T02:10:56.453Z","session":"a8c20833df2abb361","agent":"general-purpose","binding":"none: this session owns no active work order","commit":"b91e4ad","dirty":true,"instrument":{"language":"typescript","server":"none"},"latency":25584,"load":{"average":8.17,"cores":16},"batch":{"ms":25317},"invariants":[{"component":"src/lifecycle","name":"a mirror's list is read in its own language","form":"totality oracle","verdict":"fail","refutation":"missing","bypasses":[],"testReferences":0,"files":[],"latency":25317,"reason":"1 of 1 tests under \"a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone\" failed: a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone","testMs":86,"mode":"batched","state":"requirement","enforces":["a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone"]}]} +{"kind":"refutation","at":"2026-10-25T02:10:56.693Z","session":"a8c20833df2abb361","agent":"general-purpose","component":"src/lifecycle","name":"a mirror's list is read in its own language","form":"totality oracle","broke":"read every mirror's list with the TypeScript reader, never the list's language's lists slot, in readLiteral (src/lifecycle/mirrors.ts)","verdict":"fail","reason":"1 of 1 tests under \"a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone\" failed: a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone","commit":"b91e4ad","dirty":true} +{"at":"2026-10-25T02:11:39.703Z","session":"a8c20833df2abb361","agent":"general-purpose","binding":"none: this session owns no active work order","commit":"b91e4ad","dirty":true,"instrument":{"language":"typescript","server":"none"},"latency":25699,"load":{"average":8.22,"cores":16},"batch":{"ms":25408},"invariants":[{"component":"src/adapters","name":"entrance detection follows listed rules","form":"totality oracle","verdict":"pass","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":25408,"reason":"7 tests under \"entrance detection\" passed in one invocation of node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-reporter=./src/enforcement/node-test-reporter.ts --test-reporter-destination= --test-name-pattern=<2 names> src/**/*.test.ts src/*.e2e.ts","testMs":74,"mode":"batched","state":"invariant","enforces":["entrance detection"]},{"component":"src/lifecycle","name":"a mirror's list is read in its own language","form":"totality oracle","verdict":"pass","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":25408,"reason":"1 test under \"a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone\" passed in one invocation of node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-reporter=./src/enforcement/node-test-reporter.ts --test-reporter-destination= --test-name-pattern=<2 names> src/**/*.test.ts src/*.e2e.ts","testMs":120,"mode":"batched","state":"invariant","enforces":["a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone"]}]} +{"at":"2026-10-25T02:12:30.378Z","session":"a8c20833df2abb361","agent":"general-purpose","binding":"none: this session owns no active work order","commit":"b91e4ad","dirty":true,"instrument":{"language":"typescript","server":"none"},"latency":50441,"load":{"average":10.73,"cores":16},"invariants":[{"component":"src/adapters","name":"entrance detection follows listed rules","form":"totality oracle","verdict":"pass","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":25151,"reason":"node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-name-pattern='entrance detection' 'src/**/*.test.ts' 'src/*.e2e.ts' exited 0 and its output matched /# pass [1-9][0-9]*/","mode":"one-at-a-time","state":"invariant","enforces":["entrance detection"]},{"component":"src/lifecycle","name":"a mirror's list is read in its own language","form":"totality oracle","verdict":"pass","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":25260,"reason":"node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-name-pattern='a list in a Rust file is read by the Rust plugin'\\''s lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone' 'src/**/*.test.ts' 'src/*.e2e.ts' exited 0 and its output matched /# pass [1-9][0-9]*/","mode":"one-at-a-time","state":"invariant","enforces":["a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone"]}]} +{"at":"2026-10-25T02:16:21.633Z","session":"a8c20833df2abb361","agent":"general-purpose","binding":"none: this session owns no active work order","commit":"b91e4ad","dirty":true,"instrument":{"language":"typescript","server":"none"},"latency":25797,"load":{"average":21.1,"cores":16},"batch":{"ms":25496},"invariants":[{"component":"src/adapters","name":"entrance detection follows listed rules","form":"totality oracle","verdict":"pass","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":25496,"reason":"7 tests under \"entrance detection\" passed in one invocation of node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-reporter=./src/enforcement/node-test-reporter.ts --test-reporter-destination= --test-name-pattern=<2 names> src/**/*.test.ts src/*.e2e.ts","testMs":72,"mode":"batched","state":"invariant","enforces":["entrance detection"]},{"component":"src/lifecycle","name":"a mirror's list is read in its own language","form":"totality oracle","verdict":"pass","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":25496,"reason":"1 test under \"a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone\" passed in one invocation of node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-reporter=./src/enforcement/node-test-reporter.ts --test-reporter-destination= --test-name-pattern=<2 names> src/**/*.test.ts src/*.e2e.ts","testMs":120,"mode":"batched","state":"invariant","enforces":["a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone"]}]} +{"at":"2026-10-25T02:17:12.469Z","session":"a8c20833df2abb361","agent":"general-purpose","binding":"none: this session owns no active work order","commit":"b91e4ad","dirty":true,"instrument":{"language":"typescript","server":"none"},"latency":50605,"load":{"average":12.21,"cores":16},"invariants":[{"component":"src/adapters","name":"entrance detection follows listed rules","form":"totality oracle","verdict":"pass","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":25293,"reason":"node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-name-pattern='entrance detection' 'src/**/*.test.ts' 'src/*.e2e.ts' exited 0 and its output matched /# pass [1-9][0-9]*/","mode":"one-at-a-time","state":"invariant","enforces":["entrance detection"]},{"component":"src/lifecycle","name":"a mirror's list is read in its own language","form":"totality oracle","verdict":"pass","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":25284,"reason":"node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-name-pattern='a list in a Rust file is read by the Rust plugin'\\''s lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone' 'src/**/*.test.ts' 'src/*.e2e.ts' exited 0 and its output matched /# pass [1-9][0-9]*/","mode":"one-at-a-time","state":"invariant","enforces":["a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone"]}]} diff --git a/README.md b/README.md index e81c4948..bf7c7a2d 100644 --- a/README.md +++ b/README.md @@ -411,7 +411,9 @@ test setup per runner, as [docs/config.md](docs/config.md#more-than-one-language Rust chokepoints are graded through rust-analyzer, found on the machine rather than installed by npm ([docs/enforcement.md](docs/enforcement.md#rust)); the economy and Scope readings read Rust's text by its crates' folder layout, and detect its -binaries, build scripts, routes and foreign exports as entrances. +binaries, examples, build scripts, routes and foreign exports (C, wasm-bindgen and +UniFFI, an exported impl's methods among them) as entrances; a declared mirror +reads a Rust list from the array a const or static is set to. Go is an opt-in bundled plugin, `"plugins": { "go": "bundled" }`, graded through gopls ([docs/plugins.md](docs/plugins.md#go)); a language Coherence lacks is a plugin you can write, held by `coherence plugin check` to what every diff --git a/docs/plugins.md b/docs/plugins.md index e77e4405..3efa74e0 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -232,7 +232,7 @@ reading first asks for that slot: | `source` | the economy and mass | `SourceSyntax` | | `declares` | the observation's interface map | `(line) => "type" \| "value" \| "body" \| "unknown"` | | `notice` | the session start | `(root) => string` | -| `lists` | declared mirrors | `(text, name) => Literal \| undefined` | +| `lists` | declared mirrors, for a list in the language's files; a file of a language whose plugin fills none is read as TypeScript | `(text, name) => Literal \| undefined` | | `topLevel` | the spec model's check that a handler, guard or mirror is declared | `(text, name) => boolean` | | `boundaries` | `scaffold import` and adoption | `BoundarySource`: a tool's declaration file read into modules, as tach's | diff --git a/plugins/rust/plugin.json b/plugins/rust/plugin.json index f4be9eff..a7b80f1b 100644 --- a/plugins/rust/plugin.json +++ b/plugins/rust/plugin.json @@ -11,6 +11,9 @@ "serverExtensions": [ ".rs" ], + "testFiles": [ + "^tests\\.rs$" + ], "basisFiles": [ "^Cargo\\.toml$", "^Cargo\\.lock$", @@ -51,16 +54,26 @@ { "rule": "foreign export", "plural": "foreign exports", - "detects": "a function carrying #[no_mangle], #[unsafe(no_mangle)], #[export_name = …] or #[wasm_bindgen], which code outside Rust calls by name" + "detects": "a function code outside Rust calls by name: a fn carrying #[no_mangle], #[unsafe(no_mangle)], #[export_name = …], #[wasm_bindgen] (#[wasm_bindgen(start)] included) or #[uniffi::export]; each pub method or constructor of a #[wasm_bindgen] impl, named Type::method; and each pub method of a #[uniffi::export] impl (every method of a trait impl), named Type::method; never a fn of an extern block, which the host provides" + }, + { + "rule": "example", + "plural": "examples", + "detects": "the main of an example at Cargo's default paths, examples/x.rs or examples/x/main.rs, which cargo run --example x runs" } ], - "notDetected": "a router built in a helper and merged in, a route macro of another framework (warp filters, poem), a trait a framework calls (a tonic gRPC service, a tower Service), a message consumer and a proc-macro are not detected" + "notDetected": "a router built in a helper and merged in, a route macro of another framework (warp filters, poem), a trait a framework calls (a tonic gRPC service, a tower Service), a message consumer, a proc-macro, an export a macro generates, a function a UniFFI .udl file declares, the getters and setters wasm-bindgen generates for an exported struct's pub fields, and an example Cargo.toml places away from Cargo's default paths are not detected" }, "markedFolders": [ { "name": "target", "beside": "Cargo.toml", "reason": "Cargo build folder" + }, + { + "name": "pkg", + "beside": "Cargo.toml", + "reason": "wasm-pack output" } ], "ladder": { @@ -85,6 +98,7 @@ "interfaces": "rust-scope.ts#RUST_INTERFACES", "source": "rust-source.ts#RUST_SOURCE", "declares": "rust-interfaces.ts#rustDeclares", - "topLevel": "rust-top-level.ts#rustDeclaresAtTop" + "topLevel": "rust-top-level.ts#rustDeclaresAtTop", + "lists": "rust-lists.ts#rustListOf" } } diff --git a/plugins/rust/rust-entrances.ts b/plugins/rust/rust-entrances.ts index 0d3a07d4..ae31933c 100644 --- a/plugins/rust/rust-entrances.ts +++ b/plugins/rust/rust-entrances.ts @@ -1,17 +1,25 @@ /** - * Rust's entrance rules and the syntax they read by: a binary's main, a build - * script, the route attributes actix-web and Rocket fix, a route registered - * on an axum or actix-web router, a function exported across a foreign - * boundary (C or wasm), and the binaries Cargo.toml names at a path of their - * own (entrance-candidates.ts reads them through ENTRANCE_SYNTAX). + * Rust's entrance rules and the syntax they read by: a binary's main, an + * example's main, a build script, the route attributes actix-web and Rocket + * fix, a route registered on an axum or actix-web router, a function exported + * across a foreign boundary (C, wasm-bindgen or UniFFI, a free fn or an + * exported impl's method), and the binaries Cargo.toml names at a path of + * their own (entrance-candidates.ts reads them through ENTRANCE_SYNTAX). */ import { posix } from "node:path"; import { handlerName, languageOfFile, spanOf, throughOf, type EntranceCandidate, type EntranceSyntax } from "../../src/plugins/api.ts"; -import { binaryOf, declaredModules, itemOf, manifestOf, testRanges, uncommented, useLeaves } from "./rust-source.ts"; +import { binaryOf, codeOnly, declaredModules, exampleOf, itemOf, manifestOf, testRanges, uncommented, useLeaves } from "./rust-source.ts"; const ROUTE_ATTRIBUTE = /^\s*#\[\s*(?:[\w:]*::)?(get|post|put|patch|delete|head|options|route)\s*\(\s*"([^"]*)"/; -const FOREIGN = /^\s*#\[\s*(no_mangle|unsafe\s*\(\s*no_mangle\s*\)|export_name\s*=|(?:[\w:]*::)?wasm_bindgen\b)/; +// The attributes that export a fn by its name: C's unmangled symbol, wasm-bindgen's binding (plain, by path or under cfg_attr, its arguments captured) and UniFFI's. +const UNMANGLED = /#\[\s*(no_mangle|unsafe\s*\(\s*no_mangle\s*\)|export_name\s*=)/; +const WASM_BINDGEN = /#\[\s*(?:cfg_attr\s*\([^\]]*?,\s*)?(?:[\w:]*::)?wasm_bindgen\b\s*(?:\(([^\]]*)\))?/; +const UNIFFI_EXPORT = /#\[\s*(?:cfg_attr\s*\([^\]]*?,\s*)?(?:::)?uniffi::export\b/; +/** An impl header, its generics passed: what follows is the type, or the trait for the type. */ +const IMPL = /^\s*(?:unsafe\s+)?impl\b(?:\s*<[^{]*?>)?\s+([^{]*)/; +/** An extern block, whose fns the host provides: imports, never exports. */ +const EXTERN_BLOCK = /^\s*(?:pub(?:\s*\([^)]*\))?\s+)?(?:unsafe\s+)?extern(?:\s+"[^"]*")?\s*\{/; /** A handler argument: a path, or a call wrapping one, one level deep. */ const ARGUMENT = String.raw`([\w:]+(?:\s*\([^()]*\))?)`; const METHOD = new RegExp(String.raw`(?:\b(?:get|post|put|patch|delete|head|options|trace|any)|\.to)\s*\(\s*${ARGUMENT}\s*\)`, "g"); @@ -28,6 +36,140 @@ function isMain(line: string): boolean { return item?.top === true && item.keyword === "fn" && item.name === "main"; } +/** The type an impl header is for: impl Viewer and impl Shape for Ring are Viewer and Ring. */ +function implType(header: string): string | undefined { + const target = /\bfor\s+(.+)$/.exec(header)?.[1] ?? header; + return /^&?\s*(?:'\w+\s+)?(?:mut\s+)?(?:(?:r#)?[A-Za-z_]\w*::)*(?:r#)?([A-Za-z_]\w*)/.exec(target.trim())?.[1]; +} + +/** Whether a wasm_bindgen attribute's arguments name the flag (constructor, getter, skip), as one of its comma-separated arguments and never a value. */ +function flag(args: string, name: string): boolean { + return new RegExp(`(?:^|,)\\s*${name}\\s*(?=[,=]|$)`).test(args.trim()); +} + +/** A fn code outside Rust calls by name: where its attributes start, its symbol, and why. */ +interface ForeignExport { + start: number; + symbol: string; + why: string; +} + +/** A block the scan is inside: an impl (its type, and what exports it), an extern block, or any other. */ +interface Block { + kind: "impl" | "extern" | "other"; + type?: string; + exports?: "wasm" | "uniffi"; + trait?: boolean; +} + +/** + * The fns a file exports across a foreign boundary, read line by line with + * its blocks tracked by their braces (over the code alone, so a brace in a + * string is none): a free fn under an exporting attribute; a pub method of a + * #[wasm_bindgen] impl, a trait's impl excepted (wasm-bindgen exports + * inherent impls alone) and a #[wasm_bindgen(skip)] one too; a method of a + * #[uniffi::export] impl, pub, or any of a trait impl's; never a fn of an + * extern block, which the host provides. + */ +function foreignExports(lines: readonly string[], code: readonly string[]): ForeignExport[] { + const out: ForeignExport[] = []; + const stack: Block[] = []; + let attributes = ""; + let attributesAt = -1; + let open = 0; + /** The block the next brace opens, when a header above it says what it is. */ + let next: Block | undefined; + for (let i = 0; i < code.length; i++) { + const line = code[i]!; + // The attributes at the line's start, each whole across lines (#[wasm_bindgen(\n js_name = x\n)]), read from the text as written. + let at = 0; + for (;;) { + if (open === 0) { + const lead = /^\s*/.exec(line.slice(at))![0].length; + if (!line.startsWith("#[", at + lead)) break; + if (attributesAt < 0) attributesAt = i; + at += lead; + } + let j = at; + for (; j < line.length; j++) { + if (line[j] === "[") open += 1; + else if (line[j] === "]" && --open === 0) { + j += 1; + break; + } + } + attributes += ` ${(lines[i] ?? "").slice(at, j).trim()}`; + at = j; + if (open > 0) break; + } + const rest = line.slice(at); + if (open > 0 || rest.trim() === "") continue; + const inner = stack[stack.length - 1]; + const fn = itemOf(rest); + const impl = IMPL.exec(rest); + if (EXTERN_BLOCK.test(rest)) next = { kind: "extern" }; + else if (impl !== null) { + const header = impl[1]!; + const type = implType(header); + const trait = /\bfor\s/.test(header); + // An impl whose type cannot be read names no method, so it exports none the scan can name. + const exports = type === undefined ? undefined : WASM_BINDGEN.test(attributes) && !trait ? "wasm" : UNIFFI_EXPORT.test(attributes) ? "uniffi" : undefined; + next = { kind: "impl", ...(type === undefined ? {} : { type }), ...(exports === undefined ? {} : { exports }), trait }; + } else if (fn?.keyword === "fn") { + next = { kind: "other" }; + const start = attributesAt < 0 ? i : attributesAt; + // pub alone: pub(crate) and pub(super) are not seen outside the crate. + const pub = /^\s*pub\s/.test(rest); + if (stack.some((b) => b.kind === "extern")) { + // An import, which the host provides. + } else if (inner?.kind === "impl") { + const symbol = `${inner.type}::${fn.name}`; + const wasm = WASM_BINDGEN.exec(attributes); + const args = wasm?.[1] ?? ""; + if (inner.exports === "wasm" && pub && !flag(args, "skip")) { + const why = flag(args, "constructor") + ? `${symbol} is the #[wasm_bindgen(constructor)] of a #[wasm_bindgen] impl: JavaScript's new ${inner.type}(…) calls it` + : flag(args, "getter") || flag(args, "setter") + ? `${symbol} is a #[wasm_bindgen(${flag(args, "getter") ? "getter" : "setter"})] of a #[wasm_bindgen] impl: JavaScript reaches it as a ${inner.type}'s property` + : `${symbol} is a pub method of a #[wasm_bindgen] impl: JavaScript calls it on a ${inner.type}`; + out.push({ start, symbol, why }); + } + // A trait impl's methods carry no visibility of their own: UniFFI exports every one. + if (inner.exports === "uniffi" && (pub || inner.trait === true)) { + const why = /#\[\s*(?:::)?uniffi::constructor\b/.test(attributes) + ? `${symbol} is a #[uniffi::constructor] of a #[uniffi::export] impl: Swift, Kotlin or Python constructs a ${inner.type} through it` + : `${symbol} is a method of a #[uniffi::export] impl: Swift, Kotlin or Python calls it on a ${inner.type}`; + out.push({ start, symbol, why }); + } + } else { + const wasm = WASM_BINDGEN.exec(attributes); + const unmangled = UNMANGLED.exec(attributes); + if (wasm !== null && !flag(wasm[1] ?? "", "skip")) { + const why = flag(wasm[1] ?? "", "start") + ? `${fn.name} carries #[wasm_bindgen(start)]: it runs when JavaScript instantiates the module` + : `${fn.name} carries #[wasm_bindgen]: code outside Rust calls it by that name`; + out.push({ start, symbol: fn.name, why }); + } else if (UNIFFI_EXPORT.test(attributes)) { + out.push({ start, symbol: fn.name, why: `${fn.name} carries #[uniffi::export]: Swift, Kotlin or Python calls it through the bindings UniFFI generates` }); + } else if (unmangled !== null) { + out.push({ start, symbol: fn.name, why: `${fn.name} carries #[${unmangled[1]!.replace(/\s*=$/, "").replace(/\s+/g, "")}]: code outside Rust calls it by that name` }); + } + } + } else if (fn !== undefined) next = undefined; + for (const c of rest) { + if (c === "{") { + stack.push(next ?? { kind: "other" }); + next = undefined; + } else if (c === "}") stack.pop(); + } + // A header that ends in ; opens no block: a trait's or an extern block's fn without a body. + if (/;\s*$/.test(rest)) next = undefined; + attributes = ""; + attributesAt = -1; + } + return out; +} + /** The last segment of a path expression: handlers::create is create. */ function lastSegment(path: string): string { return path.split("::").pop() ?? path; @@ -61,20 +203,22 @@ function rustCandidates(file: string, original: string, wrappers: readonly strin const seen = new Set(); const script = /(^|\/)build\.rs$/.test(file); const binary = binaryOf(file); + const example = exampleOf(file); lines.forEach((line, i) => { const main = isMain(line); if (main && binary !== undefined) add({ file, line: i + 1, symbol: "main", rule: "binary", name: `binary ${binary}`, why: `${file} is a binary's root by Cargo's layout, and main runs when the binary starts` }, spanOf(lines, i, RUST_ENTRANCES)); + if (main && example !== undefined) add({ file, line: i + 1, symbol: "main", rule: "example", name: `example ${example}`, why: `${file} is an example's root by Cargo's layout, and main runs when cargo run --example ${example} starts it` }, spanOf(lines, i, RUST_ENTRANCES)); if (main && script) add({ file, line: i + 1, symbol: "main", rule: "build script", name: `build script ${posix.basename(posix.dirname(file)).replace(/^\.$/, "crate")}`, why: "Cargo runs build.rs's main before it compiles the crate" }, spanOf(lines, i, RUST_ENTRANCES)); const route = ROUTE_ATTRIBUTE.exec(line); - const foreign = FOREIGN.exec(line); - if (route === null && foreign === null) return; + if (route === null) return; const target = attributed(i); if (target === undefined || seen.has(target.start)) return; seen.add(target.start); - const span = spanOf(lines, target.start, RUST_ENTRANCES); - if (route !== null) add({ file, line: target.start + 1, symbol: target.name, rule: "route attribute", why: `${target.name} carries #[${route[1]}("${route[2]}")]: a web route` }, span); - else add({ file, line: target.start + 1, symbol: target.name, rule: "foreign export", why: `${target.name} carries #[${foreign![1]!.replace(/\s*=$/, "").replace(/\s+/g, "")}]: code outside Rust calls it by that name` }, span); + add({ file, line: target.start + 1, symbol: target.name, rule: "route attribute", why: `${target.name} carries #[${route[1]}("${route[2]}")]: a web route` }, spanOf(lines, target.start, RUST_ENTRANCES)); }); + for (const { start, symbol, why } of foreignExports(lines, codeOnly(original).split("\n"))) { + add({ file, line: start + 1, symbol, rule: "foreign export", why }, spanOf(lines, start, RUST_ENTRANCES)); + } // A router's route is read over the whole text: a chain breaks across lines, its path and its handlers on the lines below. const lineAt = (index: number): number => text.slice(0, index).split("\n").length; for (const call of text.matchAll(/\.route\(\s*"([^"]*)"\s*,/g)) { diff --git a/plugins/rust/rust-lists.ts b/plugins/rust/rust-lists.ts new file mode 100644 index 00000000..42a5e8dd --- /dev/null +++ b/plugins/rust/rust-lists.ts @@ -0,0 +1,107 @@ +/** + * Rust's mirror reader (the lists slot): the literal a top-level const or + * static is set to, read as the declared mirrors read a TypeScript list + * (mirrors.ts reads it through SyntaxTables.lists). A list a Rust file keeps + * by hand beside one elsewhere (a shader's bindings, a client's names, a + * schema's kinds) is one of these: + * + * pub const KINDS: &[&str] = &["box", "sphere", "plane"]; + * static TABLE: [(&str, u32); 2] = [("a", 1), ("b", 2)]; + * + * An array (behind any &, or in vec![…]) counts its string literals, a tuple + * by its first element, as a TypeScript table's rows count by their first + * column; a path (Kind::Box) counts by its last segment, the name it spells. + * What it cannot read as a member (a number, a call, a nested array) is kept + * as written, so a mirror over it compares nothing rather than something + * wrong. Comments are skipped; a raw string reads as its contents. + */ + +import type { Literal } from "../../src/plugins/api.ts"; +import { codeOnly, itemOf, uncommented } from "./rust-source.ts"; + +/** + * The top-level segments between the bracket opening at `open` and its + * match, cut from `text` where `code` (the same text with its literals and + * comments blanked, every offset kept) shows the brackets and commas; + * undefined when it never closes. + */ +function segmentsOf(code: string, text: string, open: number): string[] | undefined { + const segments: string[] = []; + let depth = 0; + let from = open + 1; + for (let i = open; i < code.length; i++) { + const c = code[i]; + if (c === "[" || c === "{" || c === "(") depth += 1; + else if (c === "]" || c === "}" || c === ")") { + depth -= 1; + if (depth === 0) { + const last = text.slice(from, i).trim(); + if (last !== "") segments.push(last); + return segments; + } + } else if (c === "," && depth === 1) { + segments.push(text.slice(from, i).trim()); + from = i + 1; + } + } + return undefined; +} + +/** A segment that is one string literal, plain, byte or raw: its value. */ +function stringValue(segment: string): string | undefined { + const raw = /^b?r(#*)"([\s\S]*)"\1$/.exec(segment); + if (raw !== null) return raw[2]; + const plain = /^b?"((?:\\[\s\S]|[^"\\])*)"$/.exec(segment); + return plain === null ? undefined : plain[1]!.replace(/\\(.)/g, "$1"); +} + +function member(segment: string): string | undefined { + const value = stringValue(segment); + if (value !== undefined) return value; + // A tuple counts by its first element: a table's rows by their first column. + if (segment.startsWith("(")) { + const inner = segmentsOf(codeOnly(segment), segment, 0); + return inner === undefined || inner.length === 0 ? undefined : member(inner[0]!); + } + // A path spells its last segment: Kind::Box is Box. + return /^(?:(?:r#)?[A-Za-z_]\w*::)+(?:r#)?([A-Za-z_]\w*)$/.exec(segment.replace(/\s+/g, ""))?.[1]; +} + +/** The literal a top-level const or static named `name` is set to, or undefined when none is set to an array. */ +export function rustListOf(original: string, name: string): Literal | undefined { + const text = uncommented(original); + const code = codeOnly(original); + const lines = code.split("\n"); + const at = lines.findIndex((line) => { + const item = itemOf(line); + return item?.top === true && (item.keyword === "const" || item.keyword === "static") && item.name === name; + }); + if (at < 0) return undefined; + const start = lines.slice(0, at).reduce((n, line) => n + line.length + 1, 0); + // The = that sets it: past the type annotation, whose brackets ([&str; 3]) may hold a ; but no =. + let i = start + lines[at]!.indexOf(name) + name.length; + let depth = 0; + for (; i < code.length; i++) { + const c = code[i]; + if (c === "[" || c === "(" || c === "<") depth += 1; + else if (c === "]" || c === ")" || (c === ">" && code[i - 1] !== "-")) depth -= 1; + else if (c === ";" && depth === 0) return undefined; + else if (c === "=" && depth === 0 && code[i + 1] !== "=" && code[i + 1] !== ">") break; + } + if (i >= code.length) return undefined; + // Past any & and a vec! that hold the array. + const rest = /^\s*(?:&\s*(?:'static\s+)?(?:mut\s+)?)*(?:vec!\s*)?/.exec(code.slice(i + 1))![0]; + const open = i + 1 + rest.length; + if (code[open] !== "[") return undefined; + const segments = segmentsOf(code, text, open); + if (segments === undefined) return undefined; + const members: string[] = []; + const unread: string[] = []; + for (const segment of segments) { + if (segment === "") continue; + const value = member(segment); + if (value === undefined) unread.push(segment.length > 40 ? `${segment.slice(0, 37)}...` : segment); + else members.push(value); + } + return { line: at + 1, kind: "array", members, unread }; +} diff --git a/plugins/rust/rust-source.ts b/plugins/rust/rust-source.ts index ed5b6c64..30136958 100644 --- a/plugins/rust/rust-source.ts +++ b/plugins/rust/rust-source.ts @@ -176,6 +176,18 @@ export function binaryOf(file: string): string | undefined { return parts[src - 1] ?? "crate"; } +/** + * The example a file is the root of by Cargo's default paths, by the name + * cargo run --example takes: examples/demo.rs and examples/demo/main.rs are + * demo. Undefined for any other file, and for an examples folder inside a + * src/ folder, which is a module's and no package's. + */ +export function exampleOf(file: string): string | undefined { + const m = /(?:^|\/)examples\/([^/]+?)(?:\.rs|\/main\.rs)$/.exec(file); + if (m === null) return undefined; + return file.slice(0, m.index).split("/").includes("src") ? undefined : m[1]; +} + /** The module path a file defines within its crate: [] for the crate root, [a, b] for a/b.rs or a/b/mod.rs. */ export function modulePathOf(file: string, crateRoot: string): string[] { if (file === crateRoot) return []; diff --git a/src/adapters/Adapters.spec.md b/src/adapters/Adapters.spec.md index 41204701..7aa458e0 100644 --- a/src/adapters/Adapters.spec.md +++ b/src/adapters/Adapters.spec.md @@ -177,12 +177,12 @@ The language adapter seam: how to ask a language's server for definitions, refer crossing: project-source -> reading refuted: dropped the exact-path match from underIgnored in project-files.ts, so an ignore entry naming a file by its path left it in the bounded listing, the vocabulary corpus and an edit's reading -> "the ignore list names a file by its path: every walk, the vocabulary corpus, an edit's reading and coverage leave it out, and a file elsewhere by the same name is read" went red in src/adapters/ignore-files.test.ts on its own assertion; restored byte for byte, green batched and alone (2026-10-08) kinds: none -- entrance detection follows listed rules: The entrances the reading detects come only from the rules its language lists (CANDIDATE_RULES: for TypeScript, server functions, wrapped exports, server routes, route handlers, request proxies, page routes, server actions, route tables, route methods, package bins, package scripts and top-level scripts; for Python, URL patterns, viewsets, route decorators, management commands, tasks, console scripts and scripts; for Rust, binaries, build scripts, route attributes, route registrations and foreign exports), each carrying its rule and why it matched, never from a test file, a path inside a string, an include(), a TanStack Start file route without server handlers or a script another file imports; a detected entrance records the declared handlers and guards its own statement calls, bound in its file, one level into the file's own helpers. - over: every rule the three languages list over a TypeScript, a Python and a Rust project holding each shape and each near miss (a doc example's route, a route in a #[cfg(test)] module), and a wrapper a file neither imports nor declares +- entrance detection follows listed rules: The entrances the reading detects come only from the rules its language lists (CANDIDATE_RULES: for TypeScript, server functions, wrapped exports, server routes, route handlers, request proxies, page routes, server actions, route tables, route methods, package bins, package scripts and top-level scripts; for Python, URL patterns, viewsets, route decorators, management commands, tasks, console scripts and scripts; for Rust, binaries, examples, build scripts, route attributes, route registrations and foreign exports, a wasm-bindgen or UniFFI impl's exported methods named Type::method among them), each carrying its rule and why it matched, never from a test file, a path inside a string, an include(), a TanStack Start file route without server handlers or a script another file imports; a detected entrance records the declared handlers and guards its own statement calls, bound in its file, one level into the file's own helpers. + over: every rule the three languages list over a TypeScript, a Python and a Rust project holding each shape and each near miss (a doc example's route, a route in a #[cfg(test)] module, an extern block's import, a private, pub(crate), skipped or plain impl's method, a trait impl wasm-bindgen cannot export, an example's helper file and a tests.rs), and a wrapper a file neither imports nor declares via: entrance detection because: c-3760638e: the coverage of the declared entrances is only as honest as what they are measured against, so detection is a short list of shapes a framework or runner fixes, each explained where it is shown, and precision wins over recall; what the rules cannot see is said beside the counts. A wrapper's namesake counted as a registration would group entrances that are not registered through it. A URL pattern or a registration is read across lines: a per-line scan missed every path( call whose route sat on the next line, and three of billing's routes were declared by hand (df-8f004647) crossing: project-source -> reading - refuted: counted a wrapper call as a registration without checking the file imports or declares it, in throughOf -> the totality oracle went red; restored, green (2026-09-28); matched a URL pattern only when its route sat on the line of path(, as the per-line scan did -> the Python rules test went red; restored, green (2026-10-06); detected an entrance inside a #[cfg(test)] module, dropping the test-range filter from rust-entrances.ts -> the Rust rules test went red on its own assertion; restored from a copy, byte for byte, green (2026-10-23) + refuted: counted a wrapper call as a registration without checking the file imports or declares it, in throughOf -> the totality oracle went red; restored, green (2026-09-28); matched a URL pattern only when its route sat on the line of path(, as the per-line scan did -> the Python rules test went red; restored, green (2026-10-06); detected an entrance inside a #[cfg(test)] module, dropping the test-range filter from rust-entrances.ts -> the Rust rules test went red on its own assertion; restored from a copy, byte for byte, green (2026-10-23; read a #[wasm_bindgen] impl as exporting none of its methods, in foreignExports (rust-entrances.ts) -> the Rust rules test for wasm-bindgen, UniFFI and examples went red on its own assertion; restored from a copy, byte for byte, green batched and alone (2026-10-24) kinds: none - the request proxy is an entrance: The proxy a Next.js proxy.ts exports, or the middleware a middleware.ts exports, at the project root or under src/, is detected as a request proxy, whether declared, named in an export list or re-export, or the file's default export; a helper beside it, a namesake in another folder and a test file are not. over: a declared proxy with a helper and a config beside it, a middleware re-exported under an alias, a default-exported middleware, a proxy under lib/ and one in a test file diff --git a/src/adapters/entrance-candidates.test.ts b/src/adapters/entrance-candidates.test.ts index 48e64fba..056fe639 100644 --- a/src/adapters/entrance-candidates.test.ts +++ b/src/adapters/entrance-candidates.test.ts @@ -236,6 +236,114 @@ describe("entrance detection", () => { } }); + test("the Rust rules detect what wasm-bindgen and UniFFI export (an exported impl's pub methods and constructor as Type::method, a start fn) and an example's main at Cargo's default paths; never an extern block's imports, a private or plain impl's method, a trait impl wasm-bindgen cannot export, a test module or a tests.rs", () => { + const { root, files, remove } = project({ + "Cargo.toml": '[package]\nname = "viewer"\n', + "src/lib.rs": [ + "use wasm_bindgen::prelude::*;", + "", + "#[wasm_bindgen]", + 'extern "C" {', + " #[wasm_bindgen(js_namespace = console)]", + " fn log(s: &str);", + "}", + "", + "#[wasm_bindgen(start)]", + "fn boot() {}", + "", + "/// The viewer: { a brace in a doc comment opens nothing.", + "#[wasm_bindgen]", + "pub struct Viewer {", + " frames: u32,", + "}", + "", + "#[wasm_bindgen]", + "impl Viewer {", + " #[wasm_bindgen(constructor)]", + " pub fn new() -> Viewer {", + ' let close = "}";', + " Viewer { frames: 0 }", + " }", + "", + " /// Draws a frame.", + " pub fn draw(&mut self) {", + " fn nested() {}", + " if self.frames > 0 {", + " nested();", + " }", + " }", + "", + " #[wasm_bindgen(getter)]", + " pub fn frames(&self) -> u32 {", + " self.frames", + " }", + "", + " #[wasm_bindgen(skip)]", + " pub fn skipped(&self) {}", + " fn private(&self) {}", + " pub(crate) fn crate_only(&self) {}", + "}", + "", + "impl Viewer {", + " pub fn not_exported(&self) {}", + "}", + "", + "#[wasm_bindgen]", + "impl Default for Viewer {", + " fn default() -> Self { Viewer::new() }", + "}", + "", + "#[uniffi::export]", + "pub fn open_store(path: String) -> u32 { 0 }", + "", + "#[uniffi::export]", + "impl Store {", + " #[uniffi::constructor]", + " pub fn new() -> Self { Store }", + " pub fn rows(&self) -> u32 { 0 }", + " fn private(&self) {}", + "}", + "", + "#[uniffi::export]", + "impl Shape for Ring {", + " fn area(&self) -> f32 { 0.0 }", + "}", + "", + "#[cfg(test)]", + "mod tests {", + " #[wasm_bindgen]", + " impl Fake {", + " pub fn fake(&self) {}", + " }", + "}", + ].join("\n"), + "src/scene/tests.rs": '#[no_mangle]\npub extern "C" fn only_for_tests() {}\n', + "examples/demo.rs": "fn main() {}\n", + "examples/tour/main.rs": "fn main() {}\n", + "examples/tour/helper.rs": "fn main() {}\n", + "src/examples/inner.rs": "fn main() {}\n", + }); + try { + const found = detectEntranceCandidates(root, { language: "rust", files, wrappers: [], testFolders: [] }); + const rules = new Set(CANDIDATE_RULES["rust"]!.map((r) => r.rule)); + for (const c of found) assert.ok(rules.has(c.rule) && c.why.length > 0, `${c.rule} is a listed rule and says why`); + const names = (rule: string): string[] => found.filter((c) => c.rule === rule).map((c) => `${c.file}:${c.line} ${c.symbol}`).sort(); + assert.deepEqual( + names("foreign export"), + ["src/lib.rs:20 Viewer::new", "src/lib.rs:27 Viewer::draw", "src/lib.rs:34 Viewer::frames", "src/lib.rs:54 open_store", "src/lib.rs:59 Store::new", "src/lib.rs:61 Store::rows", "src/lib.rs:67 Ring::area", "src/lib.rs:9 boot"], + "an exported impl's methods by Type::method at their attributes; a skipped, private, pub(crate), plain impl's or wasm trait impl's method, a nested fn, an extern block's import and a test module's are none; a tests.rs is a test file", + ); + assert.match(found.find((c) => c.symbol === "boot")!.why, /wasm_bindgen\(start\)/); + assert.match(found.find((c) => c.symbol === "Viewer::new")!.why, /constructor/); + assert.match(found.find((c) => c.symbol === "Ring::area")!.why, /uniffi::export/); + assert.deepEqual(names("example"), ["examples/demo.rs:1 main", "examples/tour/main.rs:1 main"], "an example's main at Cargo's default paths alone: a helper beside it and a module's examples folder under src/ are none"); + assert.deepEqual(found.filter((c) => c.rule === "example").map((c) => entranceName(c)).sort(), ["example demo", "example tour"]); + assert.deepEqual(names("binary"), [], "an example is no binary"); + } finally { + remove(); + } + }); + test("the Go rules detect a command's main, a handler registered on a ServeMux, a route on a router the file imports, and a cgo export; a doc comment, a string, an inline handler, a test file and a router method in a file that imports no router detect nothing", () => { const { root, files, remove } = project({ "coherence.config.json": JSON.stringify({ language: "go", plugins: { go: "bundled" } }), diff --git a/src/adapters/rust-source.test.ts b/src/adapters/rust-source.test.ts index b3ee6ef8..426c6bf2 100644 --- a/src/adapters/rust-source.test.ts +++ b/src/adapters/rust-source.test.ts @@ -1,8 +1,9 @@ /** * The Rust plugin's text scans (plugins/rust/rust-source.ts, rust-scope.ts, - * rust-interfaces.ts): what a line declares, what a use tree brings in, where - * a module path resolves by Cargo's layout and the workspace's manifests, - * and that mass reads Rust as it reads the other languages. + * rust-interfaces.ts, rust-lists.ts): what a line declares, what a use tree + * brings in, where a module path resolves by Cargo's layout and the + * workspace's manifests, what a hand-kept list holds, and that mass reads + * Rust as it reads the other languages. */ import assert from "node:assert/strict"; @@ -15,6 +16,7 @@ import { declarationsOf, importsOf } from "../economy/source.ts"; import { declares } from "../observation/interfaces.ts"; import { isTypeDeclaration } from "../readings/scope/component-interfaces.ts"; import { RUST_INTERFACES } from "../../plugins/rust/rust-scope.ts"; +import { rustListOf } from "../../plugins/rust/rust-lists.ts"; import { uncommented, useLeaves } from "../../plugins/rust/rust-source.ts"; const madeFolders: string[] = []; @@ -79,6 +81,29 @@ test("a use tree is read leaf by leaf through nested braces, renames, self and g assert.deepEqual(RUST_INTERFACES.importLists("use a::{b as c};"), ["a::{b as c}"], "the renames are read from the tree's text"); }); +test("a Rust list is the array a top-level const or static is set to: its string literals, a tuple by its first, a path by its last segment, through & and vec!; what it cannot read is kept as written, and a comment or a string's brackets are no member", () => { + const text = [ + 'pub const KINDS: &[&str] = &["box", /* ground */ "plane", r#"say "hi""#];', + "static TABLE: [(&str, u32); 2] = [", + ' ("a", 1), // the first', + ' ("b, ]", 2),', + "];", + "pub(crate) static SHAPES: &'static [Kind] = &[Kind::Box, shape::Kind::Sphere, 3, make(\"x\")];", + "const NAMES: Vec<&str> = vec![\"x\"];", + "const COUNT: usize = 3;", + "fn body() {", + ' const INNER: &[&str] = &["not top"];', + "}", + ].join("\n"); + assert.deepEqual(rustListOf(text, "KINDS"), { line: 1, kind: "array", members: ["box", "plane", 'say "hi"'], unread: [] }); + assert.deepEqual(rustListOf(text, "TABLE"), { line: 2, kind: "array", members: ["a", "b, ]"], unread: [] }, "past the type's [..; 2], a row by its first column, a comma and a bracket in a string kept in it"); + assert.deepEqual(rustListOf(text, "SHAPES"), { line: 6, kind: "array", members: ["Box", "Sphere"], unread: ["3", 'make("x")'] }); + assert.deepEqual(rustListOf(text, "NAMES")?.members, ["x"]); + assert.equal(rustListOf(text, "COUNT"), undefined, "a const set to no array is no list"); + assert.equal(rustListOf(text, "INNER"), undefined, "a const inside a fn is not at the top"); + assert.equal(rustListOf(text, "MISSING"), undefined); +}); + test("a Rust path resolves by its crate's folder layout: crate, self, super and a module of the file's own, a.rs or a/mod.rs, a crate of the workspace by its package name, a [[bin]] root's modules beside it, and only under the file's exact spelling", () => { const root = project({ "Cargo.toml": '[workspace]\nmembers = ["crates/*", "tool"]\n', diff --git a/src/adapters/walks.test.ts b/src/adapters/walks.test.ts index b247bab2..d492f839 100644 --- a/src/adapters/walks.test.ts +++ b/src/adapters/walks.test.ts @@ -4,8 +4,9 @@ * folders a name list once skipped (api/public, dist, build, public) beside * the folders a rule does leave out: the config's ignore list, a hidden * folder, a host's folder, a dependency install, an interpreter's cache and - * a virtual environment, and Cargo's build folder (a target beside a - * Cargo.toml; a folder named target elsewhere is code). Every walker must read exactly what the one rule + * a virtual environment, Cargo's build folder (a target beside a + * Cargo.toml; a folder named target elsewhere is code) and wasm-pack's + * output (a pkg beside a Cargo.toml; a pkg elsewhere is code). Every walker must read exactly what the one rule * leaves in, narrowed only by kind, and the rule must account for every * project file it leaves out. */ @@ -29,7 +30,7 @@ import { detectedEntrances } from "../readings/scope/component-interfaces.ts"; const IGNORE = ["vendor"]; /** Code a folder-name list once skipped: every walk must read it. */ -const NAMED_FOLDER_CODE = ["api/public/urls.py", "api/public/views.py", "dist/tool.py", "build/steps.py", "public/page.ts", "target/plan.py"]; +const NAMED_FOLDER_CODE = ["api/public/urls.py", "api/public/views.py", "dist/tool.py", "build/steps.py", "public/page.ts", "target/plan.py", "pkg/plan.py"]; /** Files a rule leaves out, with the rule that must name it. */ const LEFT_OUT: Record = { @@ -40,6 +41,7 @@ const LEFT_OUT: Record = { "pkg/__pycache__/cached.py": "environment folder: pkg/__pycache__", "env/lib/site.py": "virtual environment: env", "crate/target/debug/generated.py": "Cargo build folder: crate/target", + "crate/pkg/crate.ts": "wasm-pack output: crate/pkg", }; let root: string; @@ -60,6 +62,7 @@ before(() => { write("build/steps.py", "def steps():\n return None\n"); write("public/page.ts", "export const page = 1;\n"); write("target/plan.py", "def plan():\n return None\n"); + write("pkg/plan.py", "def plan():\n return None\n"); write("crate/Cargo.toml", '[package]\nname = "crate"\nversion = "0.1.0"\n'); write("env/pyvenv.cfg", "home = /usr/bin\n"); for (const file of Object.keys(LEFT_OUT)) write(file, file.endsWith(".ts") ? "export const x = 1;\n" : "def x():\n return None\n"); diff --git a/src/lifecycle/Lifecycle.spec.md b/src/lifecycle/Lifecycle.spec.md index 7a4bb878..dc957f1a 100644 --- a/src/lifecycle/Lifecycle.spec.md +++ b/src/lifecycle/Lifecycle.spec.md @@ -1308,6 +1308,13 @@ The gyroscope delivered through harness events: orient at start, regulate at sto crossing: project-source -> reading refuted: said every disagreement at every watched edit, ignoring what the session was told -> "an edit that makes a list and its partner literal disagree names what each side holds alone, once per session" went red in mirrors.test.ts on its own assertion; restored byte for byte, green (2026-10-12) kinds: none +- a mirror's list is read in its own language: A declared mirror's list or partner literal in a file of a language whose plugin fills the lists slot is read by that plugin's reader (Rust's: the array a top-level const or static is set to), and one in any other file by the TypeScript reader in mirrors.ts, so a Rust list and a TypeScript list kept equal by hand are compared at the edit that makes them disagree. + over: a Rust const list mirrored by a TypeScript literal, agreeing and then broken by an edit to the Rust file + via: a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone + because: the lists slot was declared in the plugin interface and read by nothing, so a Rust list named under mirrors was said to be gone at every edit (no longer set to an array) and a project kept its Rust lists out of the alarm; the reader for a language is its plugin's, so the core reads the slot and names no language + crossing: project-source -> reading + refuted: read every mirror's list with the TypeScript reader, never the list's language's lists slot, in readLiteral -> "a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone" went red in mirrors.test.ts on its own assertion (the Rust list read as gone); restored from a copy, byte for byte, green batched and alone (2026-10-24) + kinds: none - a gone mirror is said at the edit: A write to a file a declared mirror spans, after which its list or partner literal is no longer declared as a literal there, adds a line to the edit's context saying the mirror cannot be checked and why, and the spec model names the same mirror as a problem; a mirror is never skipped in silence. over: a mirror whose list is removed from its file by an edit via: a mirror whose list is gone is said at the edit and is a spec problem, never skipped diff --git a/src/lifecycle/mirrors.test.ts b/src/lifecycle/mirrors.test.ts index 5f3131a4..676afe8d 100644 --- a/src/lifecycle/mirrors.test.ts +++ b/src/lifecycle/mirrors.test.ts @@ -138,6 +138,28 @@ test("an edit no mirror watches costs the alarm one lookup and reads no source", assert.ok(unwatched < 25 && watched < 250, `unwatched ${unwatched.toFixed(2)} ms, watched ${watched.toFixed(2)} ms`); }); +test("a list in a Rust file is read by the Rust plugin's lists slot: an edit that makes it and its TypeScript partner disagree names what each holds alone", async () => { + const root = mkdtempSync(join(tmpdir(), "coherence-mirrors-rust-")); + made.push(root); + const files: Record = { + "coherence.config.json": JSON.stringify({ name: "mirrors", language: ["typescript", "rust"] }), + "Cargo.toml": '[package]\nname = "kinds"\n', + "src/Kinds.spec.md": ["# Kinds", "", "The shape kinds.", "", "## mirrors", "- KINDS in kinds.rs: the shape kinds the kernel reads", " mirrors: SHAPE_KINDS in client.ts", "", "## invariants", ""].join("\n"), + "src/kinds.rs": '/// The kinds, kept by hand beside the client\'s.\npub const KINDS: &[&str] = &["box", /* the ground */ "plane"];\n', + "src/client.ts": 'export const SHAPE_KINDS = ["box", "plane"] as const;\n', + }; + for (const [file, text] of Object.entries(files)) { + mkdirSync(join(root, file, ".."), { recursive: true }); + writeFileSync(join(root, file), text); + } + execFileSync("git", ["init", "-q"], { cwd: root }); + keepMirrorIndex(root); + const agree = await edit(root, "src/client.ts", 'export const SHAPE_KINDS = ["box", "plane"] as const;\n// touched\n'); + assert.equal(agree.context, "", "the Rust list and its partner agree"); + const broke = await edit(root, "src/kinds.rs", 'pub const KINDS: &[&str] = &["box", "plane", "sphere"];\n'); + assert.match(broke.context, /KINDS \(src\/kinds\.rs:1\) and SHAPE_KINDS \(src\/client\.ts:1\) no longer agree: "sphere" only in KINDS/); +}); + test("a literal is read without the compiler: an array's strings, a tuple by its first, an object's keys, through a wrapping call", () => { const text = [ "export const A: readonly string[] = [\"a\", 'b', `c`, /* not */ \"d\" as const];", diff --git a/src/lifecycle/mirrors.ts b/src/lifecycle/mirrors.ts index 3bc501ce..804430b4 100644 --- a/src/lifecycle/mirrors.ts +++ b/src/lifecycle/mirrors.ts @@ -16,9 +16,10 @@ * At a watched edit, each of its mirrors is read again from the files on * disk, without the TypeScript compiler: a regular expression finds the * list's declaration, a scan that skips strings and comments finds its - * literal's top-level members, and another reads value imports, kept per - * file by size and modification time so only the files that changed are - * read. A disagreement is said as advice in the hook's context, never a + * literal's top-level members (in a language whose plugin fills the lists + * slot, that plugin's reader does both), and another reads value imports, + * kept per file by size and modification time so only the files that + * changed are read. A disagreement is said as advice in the hook's context, never a * block, and once per session until it changes; a list or a partner that is * gone is said too, never skipped. */ @@ -26,6 +27,8 @@ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; import { dirname, join, relative, resolve, sep } from "node:path"; import { readProjectText } from "./work-meter.ts"; +import { languageOfFile } from "../adapters/language-files.ts"; +import { syntaxOf } from "../plugins/languages.ts"; import { escapeRegExp } from "../enforcement/totality.ts"; import { mirrorRecipeText, type MirrorRecipe } from "../spec/grammar.ts"; import { declaredMirrors, SPEC_SUFFIX, type ModelMirror } from "../spec/model.ts"; @@ -411,7 +414,11 @@ function declaredAt(mirror: IndexedMirror): string { function readLiteral(root: string, file: string, name: string): { literal: Literal } | { gone: string } { const absolute = resolve(root, file); if (!existsSync(absolute)) return { gone: `${file} is gone` }; - const literal = literalOf(readProjectText(absolute, "source"), name); + // A list in a language whose plugin fills the lists slot is read by that plugin's reader; any other file, by the TypeScript reader here. + const language = languageOfFile(file); + const provided = language === undefined ? undefined : syntaxOf(language, "lists"); + const reader = typeof provided === "function" ? provided : literalOf; + const literal = reader(readProjectText(absolute, "source"), name); if (literal === undefined) return { gone: `${name} is no longer set to an array or object literal at the top of ${file}` }; return { literal }; } diff --git a/src/plugins/api.ts b/src/plugins/api.ts index fdf9d7a2..52d126c8 100644 --- a/src/plugins/api.ts +++ b/src/plugins/api.ts @@ -48,6 +48,7 @@ export { lineOf as tomlLineOf, parseToml, TomlError, type TomlTable, type TomlVa export { isSourceFile, toRelative, type SourceSyntax } from "../economy/source.ts"; export { configRecord } from "../adapters/project-config.ts"; export { killTree } from "../enforcement/totality.ts"; +export type { Literal } from "../lifecycle/mirrors.ts"; export { spawn, spawnSync } from "../lifecycle/work-meter.ts"; export { typescriptCompiler } from "./specifiers.ts"; export type { InterfaceSymbol } from "../observation/interfaces.ts";