From b59c04d8196752173b81cb19125158ee2616cbf7 Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:00:44 +0200 Subject: [PATCH] feat(kir): measure canonicalizer tranche coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- .../spec.md | 494 +++++++++++++++++ docs/kern-5-release-train.md | 133 +++++ package.json | 3 +- scripts/check-kern-canonicalizer-coverage.mjs | 19 + scripts/kern-5-fitness-policy.json | 2 +- .../kern-canonicalizer/coverage-catalog.mjs | 152 ++++++ .../coverage-dependencies.mjs | 95 ++++ scripts/kern-canonicalizer/coverage-facts.mjs | 120 +++++ .../kern-canonicalizer/coverage-families.mjs | 194 +++++++ .../coverage-family-registry.json | 16 + .../coverage-implementation.mjs | 497 ++++++++++++++++++ .../coverage-integrity.test.mjs | 347 ++++++++++++ .../kern-canonicalizer/coverage-policy.json | 70 +++ .../kern-canonicalizer/coverage-profile.mjs | 369 +++++++++++++ .../coverage-summary-writer.mjs | 44 ++ .../kern-canonicalizer/coverage-summary.json | 159 ++++++ scripts/kern-canonicalizer/coverage.mjs | 19 + scripts/kern-canonicalizer/coverage.test.mjs | 336 ++++++++++++ scripts/kern-canonicalizer/policy.mjs | 8 +- 19 files changed, 3074 insertions(+), 3 deletions(-) create mode 100644 .Codex/specs/kern-5-r2-m4-2-next-toolchain-slice/spec.md create mode 100644 scripts/check-kern-canonicalizer-coverage.mjs create mode 100644 scripts/kern-canonicalizer/coverage-catalog.mjs create mode 100644 scripts/kern-canonicalizer/coverage-dependencies.mjs create mode 100644 scripts/kern-canonicalizer/coverage-facts.mjs create mode 100644 scripts/kern-canonicalizer/coverage-families.mjs create mode 100644 scripts/kern-canonicalizer/coverage-family-registry.json create mode 100644 scripts/kern-canonicalizer/coverage-implementation.mjs create mode 100644 scripts/kern-canonicalizer/coverage-integrity.test.mjs create mode 100644 scripts/kern-canonicalizer/coverage-policy.json create mode 100644 scripts/kern-canonicalizer/coverage-profile.mjs create mode 100644 scripts/kern-canonicalizer/coverage-summary-writer.mjs create mode 100644 scripts/kern-canonicalizer/coverage-summary.json create mode 100644 scripts/kern-canonicalizer/coverage.mjs create mode 100644 scripts/kern-canonicalizer/coverage.test.mjs diff --git a/.Codex/specs/kern-5-r2-m4-2-next-toolchain-slice/spec.md b/.Codex/specs/kern-5-r2-m4-2-next-toolchain-slice/spec.md new file mode 100644 index 00000000..65ccc252 --- /dev/null +++ b/.Codex/specs/kern-5-r2-m4-2-next-toolchain-slice/spec.md @@ -0,0 +1,494 @@ +# KERN 5 R2 M4.2 — Measured Canonicalizer Tranche + +**Status:** M4.2 SEALED +**Date:** 2026-07-18 +**Confidence:** 0.99 after 6/6 brainstorm, two-round tribunal, and deterministic measurement +**Parent objective:** expand the bounded KERN-authored structural KIR +canonicalizer by exactly one catalog-backed syntactic family selected from the +real handwritten self-host corpus + +## Decision + +[VERIFIED] Full-roster brainstorm +`brainstorm-1784325826674-4rcppx-kern-5-r2-m4-2-next-slice-brains` +split across canonicalizer expansion, bounded frontend, formatter preview, and +WASM-facing formatter. The plurality preferred canonicalizer expansion, but its +winning answer invented an effect/helper semantic-equivalence profile that the +current structural KIR and M4.1 contract do not prove. + +[VERIFIED] Two-round synthesis tribunal +`tribunal-1784325961783-mwaqf2-kern-5-r2-m4-2-next-slice-tribun` +rejected every preselected node family and converged at confidence 0.92 on one +mergeable slice: freeze a deterministic coverage/selection gate over the +handwritten self-host corpus, then implement exactly one winning catalog-backed +structural tranche in the same slice. + +[PROPOSED] M4.2 therefore contains: + +1. one hash-bound handwritten self-host corpus manifest; +2. one deterministic KIR coverage and tranche-selection receipt; +3. RED mutation oracles that prevent corpus, catalog, ranking, or ownership + drift; +4. exactly one measured syntactic family added to `canonicalizer.kern`; +5. valid, boundary, hostile, exact-KIR, and idempotence proof for that family. + +[REJECTED] M4.2 does not build a source frontend, tagged row transport, public +formatter command, semantic-equivalence normalizer, compiler scaffold, WASM +formatter, fixed point, interpreter, or public KIR contract. + +## Verified Baseline + +- [VERIFIED] M4.1 is a KIR-to-source structural canonicalizer over `fn`, direct + structured `param`, one `handler lang="kern"`, and direct `return` children. +- [VERIFIED] M4.1 passes 11 valid, 3 profile-limit, and 105 hostile fixtures and + preserves exact structural KIR bytes plus second-pass canonical source. +- [VERIFIED] The structural catalog is closed and versioned; a tranche may use + only kinds and properties already represented by current structural KIR. +- [VERIFIED] The broad formatter, frontend, compiler, fixed-point, interpreter, + versioned public KIR, and packed release ownership rows remain open. +- [VERIFIED] Runtime helper/class commits prove execution behavior, not + canonical structural equivalence. They cannot justify a formatter tranche. +- [VERIFIED] Handwritten self-host tool modules exist for the assertion engine, + validator, checker, and canonicalizer. Generated fixture harnesses are + separately identifiable by checked-in generated-file headers. + +## Frozen Corpus Contract + +[PROPOSED] The initial handwritten corpus is exactly: + +- `examples/capstone-assertion-engine/compare.kern` +- `examples/capstone-assertion-engine/diag.kern` +- `examples/capstone-assertion-engine/sort.kern` +- `examples/selfhost-validator/validator.kern` +- `examples/capstone-checker-subset/checker-while.kern` +- `examples/capstone-checker-subset/checker.kern` +- `examples/kern-canonicalizer/canonicalizer.kern` + +[REJECTED] Generated fixture harnesses, generated numeric harnesses, showcase +examples, tests, and the new M4.2 fixtures cannot influence ranking. + +[PROPOSED] A checked-in manifest owns membership, source SHA-256, a stable tool +id, and generated/handwritten classification. Unknown fields, duplicate paths, +unsafe paths, missing files, digest drift, or a generated-file marker in a +handwritten member reject before a receipt is produced. + +## Coverage Measurement + +[PROPOSED] Parse every corpus member with current bootstrap diagnostics, reject +any error, project its current structural KIR, and traverse the decoded KIR in +authored order. For every function root, record: + +- all node kinds and property keys encountered; +- the first unsupported ancestor under the M4.1 admitted profile; +- candidate syntactic families required to close that function; +- whether any required property is currently excluded or host-raw; +- source path and function name as the stable witness identity. + +[PROPOSED] Measurement is static and deterministic. It does not execute tools, +infer semantic equivalence, inspect runtime coverage, count generated harnesses, +or rank by source line count. + +[VERIFIED] Measurement is deliberately function-root scoped. Module-level +`use`/`from` structure is outside this tranche and cannot be registered as a +candidate family. Every unsupported fact observed inside the 98 function roots +must be claimed exactly once; missing facts and wholly unobserved claims reject. + +[PROPOSED] Every observed node kind and property must exist in the current +structural catalog. Missing, excluded, or raw-host payload requirements make a +candidate ineligible rather than silently widening KIR. + +## Syntactic Family Rule + +[PROPOSED] A family is a checked-in, closed set of catalog node kinds and the +properties those kinds require. Families are selected from actual observed +unsupported ancestors. A family may include a structurally inseparable pair +such as `if` plus its authored `else` sibling, but may not combine unrelated +control-flow, binding, import, class, or effect kinds merely to improve its +score. + +[VERIFIED] `coverage-family-registry.json` freezes exact family membership. +Policy families must member-for-member match a registry entry, registry facts are +globally disjoint, and the summary binds the registry SHA-256. A policy edit +cannot merge `if` with `while`, omit an observed fact, or retain an unobserved +family without failing the gate. + +[PROPOSED] Candidate eligibility requires: + +- every kind/property is present and included in current structural KIR; +- M4.1 can emit the family without changing the KIR codec or public ABI; +- required nested value tags already have lossless generic transport; +- the family is closed over the selected witnessed functions; +- unsupported descendants outside the family remain fail-closed. + +## Frozen Selection Rule + +[PROPOSED] Freeze the rule before accepting the measurement receipt: + +1. reject ineligible or cross-family candidates; +2. rank by number of newly complete handwritten functions; +3. then number of newly complete handwritten tools; +4. then total observed occurrences in the corpus; +5. then stable family id in code-point order. + +[PROPOSED] "Newly complete" means the entire function becomes canonicalizable +under M4.1 plus the candidate family. A candidate receives no credit for a +function that still contains any unsupported kind, property, value, or raw-host +payload after the candidate is applied. + +[REJECTED] Partial descendant counts, generated fixtures, semantic execution +coverage, hand-selected favorites, or post-result weight changes cannot affect +the winner. + +## Evidence-only Fallback + +[PROPOSED] Stop implementation and retain only the measurement micro-slice for +fresh design review if: + +- no eligible family completes at least one handwritten function; +- corpus membership or handwritten classification is disputed; +- ranking differs across fresh processes, locale, timezone, or input order; +- the winner requires a structural catalog or codec change; +- the winner spans unrelated syntactic families; +- the winner depends on semantic-equivalence reasoning rather than exact KIR; +- support would move semantic parsing or formatting into the host adapter. + +## Measured Result + +[VERIFIED] The frozen receipt measured 98 functions across seven handwritten +modules and four tools. Exactly one function is already complete under M4.1. + +[VERIFIED] Ninety-seven functions are blocked by `fn.params`, the legacy +string-encoded parameter property that R1.5e.1 deliberately classifies as +`excluded-host-type`. One of those functions also contains a `throw.value` +expression outside the current structural expression catalog. + +[VERIFIED] Canonical-codec per-function M4.1 profiling additionally exposes 47 +functions above the value-row ceiling, 12 above the node-row ceiling, and seven +above the property-row ceiling. Thirteen functions exceed the codec depth +limit and one exceeds its node limit before row scoring. One function contains +six distinct text-character facts rejected by KERN's `quotesource` ceiling, +and sanitized whole-node projection independently confirms the unknown +`throw.value` expression blocker. These facts were hidden by the former early +stop at `fn.params`; they do not change the null winner. + +[VERIFIED] Every candidate family receives zero newly complete functions and +zero newly complete tools. The selection winner is therefore `null`; occurrence +counts cannot override the required complete-function score. + +[VERIFIED] The closed candidate universe now includes all observed unsupported +facts, including 491 `index` expression occurrences. The former zero-occurrence +`each`, import, and print candidates were removed; exception-flow contains only +the observed `throw` fact. + +[VERIFIED] Terminal review +`review-1784329031564-2d5ds5-kern-5-r2-m4-2-coverage-terminal` +found two blockers: catalog-projectable properties/children were mistaken for +the exact M4.1 profile, and the candidate universe omitted `index` while +retaining unobserved facts. The implementation now applies an exact M4.1 +property/value/child profile before completion credit, freezes family +membership, proves coverage closure, rethrows unexpected projector failures, +pins the null winner and ranking in a fresh locale/timezone process, and names +the checked-in artifact `coverage-summary.json` according to its format. + +[VERIFIED] Second terminal review +`review-1784331392841-5cylsf-kern-5-r2-m4-2-coverage-terminal` +found two further blockers: family closure ignored observed property facts, and +a candidate expression could suppress validation of a nested M4.1 expression. +The implementation now freezes exact property membership per family, proves +node/expression/property coverage closure, and recursively validates every +base-kind expression even beneath candidate expressions. It also treats an +empty legacy `fn.params` payload as the structural omission it represents, +rethrows unexpected projector failures, assigns ordinal-stable witness ids, +rechecks exact source bytes immediately before parsing, and binds the complete +per-function facts plus canonicalizer/profile/registry/policy digests into the +summary. RED mutations cover digest drift, missing/generated corpus members, +invented properties, nested-base suppression, and every M4.1 golden function. + +[VERIFIED] Third terminal review +`review-1784357710055-jysis6-kern-5-r2-m4-2-coverage-terminal` +found three blockers: the base text-expression mirror omitted KERN +`quotesource` character exclusions, completion ignored the configured +16/30/72 node/property/value row ceilings, and property closure classified +facts by node-kind prefixes rather than an exact property-key domain. The +implementation now applies the exact C0/DEL/C1/line-separator/BOM ceiling, +flattens every projectable function to measured row counts, and freezes every +base catalog property key separately from admitted M4.1 properties. Candidate +handler widening is explicit and requires a candidate direct child; invalid +base-only sequences remain blocked. Exact policy/corpus/implementation digests, +regular-file realpath checks, future-expression visibility, inherited-property +guards, whole-node projection after excluded-property removal, and property +occurrences in ranking resolve the material accompanying findings. + +[VERIFIED] The tribunal's evidence-only fallback is active. M4.2 does not alter +`canonicalizer.kern`, add a syntactic family, or promote an ownership row. + +[VERIFIED] Fourth terminal review +`review-1784360706127-7sgggx-kern-5-r2-m4-2-coverage-terminal` +found one shared blocker through two independent engines: candidate ownership +validated individual handler child kinds but not their combined sequence, so a +candidate sibling could mask duplicate returns or an orphan/reversed `else`. +Completion now validates the combined base-plus-candidate handler grammar, +requires all completion facts, and fails closed on incomplete row-limit +configuration. RED oracles cover duplicate and non-terminal returns, orphan +`else`, absent facts, and absent limits. Corpus rows now bind their explicit +`handwritten` classification, and the receipt has a deterministic writer. + +[VERIFIED] Fifth terminal review +`review-1784363236516-soakf0-kern-5-r2-m4-2-coverage-terminal` +found four blockers: nested candidate containers could still mask invalid +statement sequences, row measurement bypassed the bounded structural codec, +synthetic selection facts were not exact-schema data, and expression-kind +authority was not digest-bound. Completion now validates every candidate +statement container, computes rows only after canonical encode/decode under the +M4.1 limits, validates all 13 per-function fact fields and exact nested rows, +and binds the structural-expression source digest. The writer builds core +before measurement and cannot write before asserting the null fallback. RED +oracles cover nested orphan/duplicate-return sequences, incomplete facts, and +the real codec limit path; an empty registered family also rejects. + +[VERIFIED] Sixth terminal review +`review-1784365878717-6kfy1g-kern-5-r2-m4-2-coverage-terminal` +completed through four of six engines before Codex and Kimi reached the +600-second wall. It reported no verified blocker and two needs-checks. The live +catalog concern is disproved because the exact base property list is frozen in +`coverage-policy.json`, validation compares it to the catalog, and the receipt +separately binds both policy and constitution digests. The node-row concern was +real future-drift exposure: codec-derived nodes were calculated and then +overwritten with the source traversal count. A RED oracle now proves the node +ceiling uses the supplied codec count; codec-rejected functions carry explicit +`null` rows, while malformed or missing row records reject. + +[VERIFIED] Exact-tree Codex/Kimi retry +`review-1784368509065-1oh63s-kern-5-r2-m4-2-coverage-terminal` +completed both engines and found four distinct needs-checks. The receipt-backed +node-row count was stale in prose and is corrected from 24 to 12. Corpus +admission now rejects both repository generated-header conventions with BOM or +leading whitespace. Function facts prove each node, expression, and property +kind set is exactly the unique set derived from its occurrence evidence, so +synthetic counts cannot alter ranking. Profile-only rejection also becomes an +explicit `firstUnsupported` fact. Three RED integrity tests cover these paths; +the focused gates and complete Node 22 `pnpm fitness:kern-5` wall pass after the +fixes. + +[VERIFIED] Eighth terminal full-roster review +`review-1784370930544-az31je-kern-5-r2-m4-2-coverage-terminal` +completed five of six engines before Codex timed out. The reported digest +duplication is intentional: the canonicalizer is one frozen corpus member, so +its semantic digest must equal that member's byte digest and policy validation +makes them drift together. Malformed parser shapes intentionally reject before +a receipt, `fn.async` is catalog-frozen but outside exact M4.1 admission, and +production always supplies the validated profile-limit record. The remaining +compiled-input concern was valid defense in depth: measurement executed core +`dist` modules while binding only source/constitution evidence. The receipt now +path/length-frames and hashes every emitted core JavaScript module, rejects +symlinked compiled entries, and binds the dependency-hasher source into its +implementation digest. A RED oracle proves the compiled-core binding. + +[VERIFIED] Ninth exact-tree Codex review +`review-1784373341918-svmlgh-kern-5-r2-m4-2-coverage-terminal` +found two dependency-authentication blockers and one authored-order integrity +gap. The coverage entry now authenticates the complete emitted core JavaScript +tree before dynamically loading its implementation, verifies the same tree +again after load, and binds every executed local source with path/length +framing. Profile-only `firstUnsupported` evidence now preserves authored +traversal order instead of selecting from the sorted blocker summary. Three RED +oracles cover the corrections; the focused gates pass. + +[VERIFIED] Tenth terminal full-roster review +`review-1784378428611-liei4p-kern-5-r2-m4-2-coverage-terminal` +completed all six engines. Its reported dead constitution loader was disproved +at the receipt-construction call site, and the candidate-property concern was +already killed by both per-occurrence and canonical-codec RED oracles. Two +needs-checks were real hardening gaps. Runtime catalog validation now requires +exact constitution, count, node, property, and non-catalog row schemas; rejects +duplicate node/property identities and orphan property rows; and proves the +validated bytes match the executed catalog. Implementation authentication now +discovers, path/length-frames, and hashes every local `.mjs` instead of relying +on a mirrored allowlist. The focused canonicalizer gate and the complete Node +22 `pnpm fitness:kern-5` wall pass after both corrections; the exact retry +remains pending. + +[VERIFIED] Eleventh terminal full-roster review +`review-1784380865734-63qlrs-kern-5-r2-m4-2-coverage-terminal` +completed five engines before Codex reached the 600-second transport timeout. +The reported missing modules are present in the staged diff. The emitted +expression projector is authenticated by the complete compiled-core digest; +the source digest separately binds its authority source. JavaScript code-unit +comparison is locale-independent, broad local-module binding is an intentional +fail-safe, and production always supplies policy-validated profile limits. A +clean core rebuild reproduced compiled-core digest +`7b00119bb78af4ed955f7f0f3d636393b9ab6f0685bf11df661d5ab9da132725`, +disproving stale emitted output in this slice. One non-blocking hardening gap +was real: function facts now reject malformed `node.property` identities, with +a RED oracle. The focused canonicalizer gate and the complete post-fix Node 22 +`pnpm fitness:kern-5` wall pass; the exact full-roster retry remains pending. + +[VERIFIED] Twelfth terminal review combined the four completed engines from +`review-1784383392836-5e943l-kern-5-r2-m4-2-coverage-terminal` with the exact +Codex/Kimi transport retry +`review-1784384016998-14qhb3-kern-5-r2-m4-2-coverage-terminal`. All six usable +engines therefore reviewed the same post-wall diff. The first run reported zero +verified findings. The retry found three real fail-closed gaps: duplicate +function witness ids could inflate completion counts, selection trusted a +synthetic empty blocker list instead of independently checking profile rows, +and `--write` followed an existing summary symlink. All three now have RED +oracles and reject. Measured duplicate occurrence entries remain intentional +frequency evidence, and a clean rebuild already disproved stale compiled output +in this tree. The focused canonicalizer suite passes 47/47 and the complete +post-fix Node 22 `pnpm fitness:kern-5` wall passes after the corrections; the +final terminal review remains pending. + +[VERIFIED] Thirteenth terminal full-roster review +`review-1784387000656-w3teuz-kern-5-r2-m4-2-coverage-terminal` completed all six +usable engines. Two Codex findings identified one deeper trust-boundary defect: +the exported re-selection helper accepted mutable or cloned function facts, so +a caller could clear measured blockers or forge occurrence/tool scoring. The +measurement now deeply freezes every fact graph and registers its exact array +in a module-private weak map bound to the digest of the validated policy that +produced it. Re-selection rejects clones, mutations, and policy/tool-manifest +drift before ranking. Synthetic caller-supplied selection tests were removed +because that path is deliberately closed; real-corpus measurement remains the +selection proof. The authenticity RED oracle and focused canonicalizer suite +pass 41/41. The post-fix complete Node 22 `pnpm fitness:kern-5` wall passes; +the final terminal review remains pending. + +[VERIFIED] Fourteenth terminal full-roster review +`review-1784389940398-i5qcyc-kern-5-r2-m4-2-coverage-terminal` completed five +structured engine verdicts plus the full unstructured ZAI review. It found two +release-blocking evidence defects and two reachable hardening gaps. Structural +and exact-profile blockers now share function traversal positions, so +`firstUnsupported` selects the actual earliest authored node. Check mode now +requires canonical summary bytes in a regular non-symlink file. Deep freezing +descends through already shallow-frozen containers, and catalog-excluded +candidate properties remain completion blockers without becoming impossible +family claims. All four paths have RED oracles. The structural-only profile +helper intentionally permits omitted row limits, while production measurement +always supplies validated limits; broad local `.mjs` binding intentionally +authenticates both implementation and its executable oracle surface. The +focused canonicalizer suite passes 45/45, and the post-fix complete Node 22 +`pnpm fitness:kern-5` wall passes. The final terminal review remains pending. + +[VERIFIED] Fifteenth terminal full-roster review +`review-1784392563479-1a9dd8-kern-5-r2-m4-2-coverage-terminal` completed five +engines before Kimi returned a parse failure. It reported zero verified +findings. One needs-check was a real prose precision error: the six distinct +text-character blockers belong to one `quotesource` function, not six +functions. That count is corrected here and in the release train. The +concurrent swap/load/restore claim assumes a malicious local filesystem actor +outside this deterministic CI evidence boundary; ordinary pre-load, load-time, +and post-measurement drift remains hash-checked. Compiled-tree and broad local +`.mjs` binding are intentional fail-safe inputs. Canonicalizer policy bytes are +captured at module load, and `coverage.mjs` authenticates dependencies before +dynamic implementation import. An exact Kimi retry and final terminal review +were therefore required. + +[VERIFIED] Final exact-tree terminal full-roster review +`review-1784393191142-xojaa8-kern-5-r2-m4-2-coverage-terminal` completed all six +usable engines with zero verified findings. The remaining needs-checks are +disproved by the current contracts: exact base-property profile blockers run +before completion credit; sparse arrays fail the own-key density check; the +expression universe is deliberately frozen in the digest-bound family +registry; and coverage/canonicalizer policy sources are captured at module +load rather than reread during selection. M4.2 is sealed with an evidence-only +null winner and no production canonicalizer or ownership promotion. + +[PROPOSED] The next prerequisite is a separately designed structured-parameter +ownership slice. It must choose between migrating handwritten tool signatures +to direct `param` children and a new non-host-semantic normalization seam. The +existing R1.5e.1 exclusion cannot be silently relaxed inside this evidence +slice. + +## Required RED Oracles + +- [VERIFIED] Base receipt proves at least one corpus function remains outside + M4.1 and identifies its first unsupported ancestor. +- [VERIFIED] Anti-invention mutation: adding an unobserved or uncataloged kind + to a family rejects. +- [VERIFIED] Corpus mutation: add/remove/reorder a member or alter a digest and + the bound receipt rejects. +- [VERIFIED] Generated contamination: a generated-file member cannot receive + handwritten function/tool credit. +- [VERIFIED] Hostile-ancestor credit: a family receives zero completion credit + when any unsupported descendant remains. +- [VERIFIED] Tie stability: source order, filesystem order, locale, timezone, + and fresh process do not alter the winner or receipt bytes. +- [VERIFIED] Catalog drift: changed kind/property disposition invalidates the + receipt before canonicalization. +- [VERIFIED] Family-boundary mutation: merging unrelated families rejects. +- [VERIFIED] Text-profile mutation: C0 except tab/newline/carriage return, + DEL/C1, line separators, and BOM cannot receive completion credit. +- [VERIFIED] Row-profile mutation: every configured node/property/value row + ceiling participates in completion credit. +- [VERIFIED] Corpus filesystem mutation: symlinks and non-regular members + reject even when their target remains inside the repository. +- [VERIFIED] M4.1 regression: all existing valid goldens, hostile categories, + profile ceilings, exact KIR bytes, and idempotence remain byte-identical. +- [REJECTED] Winning-family RED: at least one measured newly complete function + fails before implementation for the intended unsupported kind. +- [PROPOSED] Unsupported sibling/descendant fixtures still fail before any + source result, stdout, event, or partial KIR escapes. + +## Implementation Contract After Measurement + +[PROPOSED] Update this spec with the exact winning family, witnesses, score, +properties, value shapes, exclusions, and confidence before production code is +written. + +[PROPOSED] Add only the winning family to the KERN canonicalizer. Semantic +spelling, property order, indentation, quoting, child order, and complete-result +construction remain KERN-owned. The generic host adapter remains unaware of +node kinds and formatting rules. + +[PROPOSED] Every new threshold is config-owned. Every hand-written source file +stays below 500 lines; `canonicalizer.kern` must split helpers before expansion +because M4.1 already occupies 438 lines. + +## Gate and Ownership Result + +[PROPOSED] Extend `pnpm test:kern-canonicalizer` with the frozen coverage +receipt, selection mutations, and the winning tranche corpus. Keep one current +`kern-kir-canonicalizer` gate and one +`kern-kir-canonicalizer-profile: internal-oracle` ownership row. + +[REJECTED] Do not add or promote broad formatter/frontend/compiler/fixed-point/ +interpreter rows. Documentation must say "structural KIR emission over measured +profile tranches; unsupported catalog kinds fail closed." + +## Following Order + +[PROPOSED] After M4.2: + +1. M4.3+ remeasure and add the next winning tranche under the frozen rule; +2. prove canonicalizer self-application once the measured profile covers its + own handwritten module graph; +3. expose an explicitly narrow formatter preview only after its complete + admitted corpus is measured; +4. design the bounded KERN frontend transport against the then-current codec; +5. investigate compiler bootstrap, then fixed point and interpreter shadow; +6. keep WASM as an independent product lane. + +## Implementation Hold + +[VERIFIED] Production canonicalizer expansion remains on hold because the +deterministic receipt selected no tranche. The evidence gate is implemented and +release-blocking; the next prerequisite requires its own spec and tribunal. + +## Acceptance Criteria + +- [x] Corpus membership, source digests, tool ids, and handwritten status are + checked in and fail closed on drift. +- [x] Every family kind is catalog-backed, sorted, unique, and disjoint from the + base and other families. +- [x] The family registry is digest-bound and exactly coverage-closed over all + observed non-base node, expression, and property facts. +- [x] Completion credit enforces the exact M4.1 property, value, and child + profile rather than the broader structural catalog. +- [x] Measurement is deterministic and binds the structural constitution. +- [x] Hostile unsupported facts prevent completion credit. +- [x] The checked-in receipt records 1/98 base-complete functions, 97 legacy + parameter blockers, all row/text/structural blockers, and a null winner. +- [x] `pnpm test:kern-canonicalizer` includes the measurement tests and exact + checked-in summary check. +- [x] Local focused gates pass after the thirteenth review correction. +- [x] The full KERN 5 wall passes after the fourteenth review correction. +- [x] Terminal full-roster Agon review has no unresolved material finding. diff --git a/docs/kern-5-release-train.md b/docs/kern-5-release-train.md index aef681cf..ba62c86a 100644 --- a/docs/kern-5-release-train.md +++ b/docs/kern-5-release-train.md @@ -848,6 +848,139 @@ trusted-publishing/provenance configuration is inspected. (`review-1784323647882-9ylf9g-kern-5-r2-m4-1-terminal-sealed-v`, `review-1784324869674-axw52z-kern-5-r2-m4-1-terminal-sealed-v`). M4.1 is closed; the broader M4 toolchain exit remains open. + - [x] M4.2 measured canonicalizer tranche selection: a hash-bound corpus of + seven handwritten modules across the assertion engine, validator, checker, + and canonicalizer drives a deterministic structural-KIR coverage summary. + The summary measures 98 functions; one is already M4.1-complete, 97 are + blocked by the deliberately excluded legacy `fn.params` payload, and one + of those also contains an expression outside the structural catalog. Every + catalog-backed candidate family completes zero additional functions. The + exact family registry is coverage-closed over every observed unsupported + function-root node, expression, and property fact, including 491 index + expressions, and exact M4.1 property/value/child admission is required + before completion credit, so the tribunal-mandated evidence-only fallback + records a null winner and makes no canonicalizer or ownership promotion. + The first three terminal reviews found and fixed profile-admission, + candidate-closure, property-closure, nested-base-expression, + text-character, and row-ceiling blockers. The exact receipt also exposes + 47 value-row, 12 node-row, and seven property-row blockers plus six rejected + text-character facts in one function; the bounded codec also rejects 13 + functions for depth and one for node count. They were previously hidden + behind the legacy parameter + exclusion and do not change the null winner. Exact-byte/regular-file corpus + revalidation, complete per-function fact binding, + policy/canonicalizer/profile/coverage digests, ordinal witness ids, + explicit candidate handler widening, and corpus/property/expression + mutation oracles harden the receipt. The fourth terminal review independently + found the same false-credit path through two engines: a candidate handler + child could mask duplicate returns or an orphan/reversed `else` + (`review-1784360706127-7sgggx-kern-5-r2-m4-2-coverage-terminal`). Combined + handler-sequence validation, fail-closed fact/limit checks, explicit + handwritten corpus classification, and a deterministic receipt writer are + now regression-covered. The fifth terminal review found nested candidate + sequence, codec-bypass, exact-fact-schema, and expression-authority binding + blockers + (`review-1784363236516-soakf0-kern-5-r2-m4-2-coverage-terminal`). Every + candidate statement container now validates its sequence, rows pass through + bounded canonical encode/decode, all 13 function-fact fields are exact, and + the expression source is digest-bound. The deterministic writer builds core + and asserts the null fallback before writing; nested-sequence, codec-limit, + incomplete-fact, and empty-family mutations are regression-covered. + Post-fix focused gates and the repeated complete Node 22 + `pnpm fitness:kern-5` wall passed on 2026-07-18. The sixth review completed + 4/6 before Codex and Kimi timed out, with no verified blockers and two + needs-checks + (`review-1784365878717-6kfy1g-kern-5-r2-m4-2-coverage-terminal`). The + catalog-freeze concern is disproved by exact policy-to-catalog validation + plus separate policy/constitution digests. The real node-row concern is + fixed: codec rows are no longer overwritten by source traversal counts, + codec-rejected functions carry explicit null rows, and malformed rows + reject. The post-fix focused gates and complete Node 22 + `pnpm fitness:kern-5` wall passed on 2026-07-18. The exact Codex/Kimi retry + completed and found a stale prose count plus three integrity gaps + (`review-1784368509065-1oh63s-kern-5-r2-m4-2-coverage-terminal`). The prose + now records 12 node-row blockers; generated-header variants reject; + function kind sets are derived exactly from occurrence evidence; and + profile-only rejection populates `firstUnsupported`. Three RED integrity + tests, focused gates, and the complete Node 22 wall pass after these fixes. + The terminal full-roster review completed 5/6 before Codex timed out + (`review-1784370930544-az31je-kern-5-r2-m4-2-coverage-terminal`). Its + canonicalizer/corpus digest duplication claim is intentional and + drift-safe; malformed parser shapes reject before receipts; `fn.async` + remains outside exact M4.1 admission; and production profile limits are + policy-validated. One defense-in-depth concern was valid: executed compiled + core modules were not receipt-bound. The receipt now hashes the complete + emitted core JavaScript tree with path/length framing, rejects compiled + symlinks, and has a RED binding oracle. Post-fix focused gates and the + complete Node 22 `pnpm fitness:kern-5` wall passed on 2026-07-18. The exact + Codex retry then found that local implementation dependencies were not all + path/length-framed, compiled bytes were hashed after static loading, and a + profile-only first blocker came from sorted summary order + (`review-1784373341918-svmlgh-kern-5-r2-m4-2-coverage-terminal`). The entry + now authenticates compiled and local dependency bytes before dynamic + implementation loading, verifies them after load, and preserves authored + blocker traversal. Three RED oracles, the focused gates, and the post-fix + complete Node 22 `pnpm fitness:kern-5` wall pass. The next full-roster + exact review completed all six engines + (`review-1784378428611-liei4p-kern-5-r2-m4-2-coverage-terminal`). Its dead + constitution-loader claim was disproved at the receipt call site, while + two needs-checks were real: constitution projection accepted ignored or + duplicate rows, and the local dependency digest relied on a mirrored + allowlist. Constitution validation now enforces exact schemas, counts, + identities, and property-to-node membership against the executed catalog; + implementation authentication discovers and path/length-frames every local + `.mjs`. Focused gates and the post-fix complete Node 22 + `pnpm fitness:kern-5` wall pass after the corrections. The exact retry + then completed five engines before Codex hit its 600-second transport + timeout + (`review-1784380865734-63qlrs-kern-5-r2-m4-2-coverage-terminal`). Its + missing-file findings were disproved by the staged tree; the expression + projector is already authenticated by the compiled-core digest; and + code-unit ordering is locale-independent. A clean core rebuild reproduced + compiled digest + `7b00119bb78af4ed955f7f0f3d636393b9ab6f0685bf11df661d5ab9da132725`. + One non-blocking hardening gap was real: function-fact property identities + now enforce exact `node.property` grammar with a RED oracle. Focused gates + and the post-fix complete Node 22 `pnpm fitness:kern-5` wall pass after that + correction; the exact full-roster retry then completed four engines with + zero verified findings before Codex and Kimi timed out + (`review-1784383392836-5e943l-kern-5-r2-m4-2-coverage-terminal`). An exact + longer-budget retry completed both missing engines + (`review-1784384016998-14qhb3-kern-5-r2-m4-2-coverage-terminal`). Three + fail-closed hardening gaps were real and are RED-covered: duplicate function + witness ids reject, selection independently enforces policy row ceilings, + and atomic summary writes reject symlink/non-file destinations. Measured + duplicate occurrences remain the intentional ranking frequency signal. + The focused suite passes 47/47 and the post-fix complete Node 22 + `pnpm fitness:kern-5` wall passes after the corrections; the final terminal + review then completed all six usable engines + (`review-1784387000656-w3teuz-kern-5-r2-m4-2-coverage-terminal`) and exposed + one deeper trust-boundary defect through two Codex findings: exported + re-selection accepted mutable or cloned fact arrays, permitting blocker, + occurrence, or tool-score forgery. Measurement now deeply freezes its fact + graph and registers the exact array in a module-private weak map bound to + the producing policy digest. Clones, mutations, and policy/tool-manifest + drift reject before ranking. The authenticity RED oracle and focused suite + pass 41/41, and the post-fix complete Node 22 `pnpm fitness:kern-5` wall + passes. The next terminal full-roster review completed five structured + verdicts plus the full unstructured ZAI review + (`review-1784389940398-i5qcyc-kern-5-r2-m4-2-coverage-terminal`). It found + two evidence blockers and two reachable hardening gaps. Mixed profile and + structural blockers now share authored traversal positions; check mode + requires canonical summary bytes in a regular non-symlink file; deep + freezing traverses shallow-frozen containers; and catalog-excluded + candidate properties remain ineligible blockers without becoming family + claims. Four RED oracles cover those corrections, the focused suite passes + 45/45, and the post-fix complete Node 22 `pnpm fitness:kern-5` wall passes. + The next terminal review completed five engines before Kimi returned a + parse failure + (`review-1784392563479-1a9dd8-kern-5-r2-m4-2-coverage-terminal`) and reported + zero verified findings. Its one real prose correction records that six + distinct rejected text-character facts belong to one `quotesource` + function. The final exact-tree terminal review completed all six usable + engines with zero verified findings + (`review-1784393191142-xojaa8-kern-5-r2-m4-2-coverage-terminal`). M4.2 is + sealed with a null winner and no canonicalizer or ownership promotion. 1. Correct the support matrix and make `fitness:kern-5` the planned aggregate, without pretending missing commands already exist. diff --git a/package.json b/package.json index b9c26d09..8bcdef64 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,8 @@ "test:core-runtime-internalization": "pnpm --filter @kernlang/core --filter kern-lang build && node scripts/check-core-runtime-internalization.mjs && node --test scripts/core-runtime-internalization.test.mjs", "check:source-runner-convergence": "node ./scripts/check-source-runner-convergence.mjs", "test:source-runner-convergence": "pnpm --filter @kernlang/core test --testPathPatterns='runner-capability-(class-frame|plan)|source-runner-engine|runtime-envelope-effect-machine-(do|each|expression-v1|helper|lambda|non-root|module-ownership|class-)' && node --test ./scripts/source-runner*-convergence.test.mjs && node ./scripts/check-source-runner-convergence.mjs", - "test:kern-canonicalizer": "pnpm --filter @kernlang/core build && pnpm --filter @kernlang/cli build && node packages/cli/dist/cli.js check examples/kern-canonicalizer/canonicalizer.kern --with-semantics --quiet && node --test ./scripts/kern-canonicalizer/flatten.test.mjs ./scripts/kern-canonicalizer/canonicalizer.test.mjs && node ./scripts/check-kern-canonicalizer.mjs", + "test:kern-canonicalizer": "pnpm --filter @kernlang/core build && pnpm --filter @kernlang/cli build && node packages/cli/dist/cli.js check examples/kern-canonicalizer/canonicalizer.kern --with-semantics --quiet && node --test ./scripts/kern-canonicalizer/flatten.test.mjs ./scripts/kern-canonicalizer/canonicalizer.test.mjs ./scripts/kern-canonicalizer/coverage.test.mjs ./scripts/kern-canonicalizer/coverage-integrity.test.mjs && node ./scripts/check-kern-canonicalizer.mjs && node ./scripts/check-kern-canonicalizer-coverage.mjs", + "write:kern-canonicalizer-coverage": "pnpm --filter @kernlang/core build && node ./scripts/check-kern-canonicalizer-coverage.mjs --write", "build:kern-alpha-receipt": "node ./scripts/kir-v1/alpha-receipt.mjs", "test:infra": "pnpm test:prepush && pnpm test:release-policy && pnpm test:kern-5-fitness && pnpm test:kern-semantic-ownership && pnpm test:kern-ir-eligibility && pnpm test:kern-canonical-value && pnpm test:kern-kir-structural-constitution && pnpm test:kern-kir-structural-codec && pnpm test:kern-kir-module-graph && pnpm test:kern-kir-coverage-closure && pnpm test:kern-kir-evidence && pnpm test:kern-alpha-receipt && pnpm test:kern-runtime-envelope && pnpm test:runtime-abi && pnpm test:core-runtime-internalization && pnpm test:source-runner-convergence && pnpm test:kern-canonicalizer", "fitness": "pnpm fitness:kern-5", diff --git a/scripts/check-kern-canonicalizer-coverage.mjs b/scripts/check-kern-canonicalizer-coverage.mjs new file mode 100644 index 00000000..60bc9c40 --- /dev/null +++ b/scripts/check-kern-canonicalizer-coverage.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; + +import { summarizeCanonicalizerCoverage } from './kern-canonicalizer/coverage.mjs'; +import { assertCoverageSummary, writeCoverageSummary } from './kern-canonicalizer/coverage-summary-writer.mjs'; + +const summaryUrl = new URL('./kern-canonicalizer/coverage-summary.json', import.meta.url); +const actual = summarizeCanonicalizerCoverage(); +assert.equal(actual.selection.winner, null, 'evidence-only fallback must remain explicit until a tranche qualifies'); +if (process.argv.includes('--write')) { + writeCoverageSummary(summaryUrl, actual); +} else { + assertCoverageSummary(summaryUrl, actual); +} +const leadingBlocker = actual.blockers[0]; +process.stdout.write( + `KERN canonicalizer coverage: ${actual.baseCompleteFunctions}/${actual.functionCount} base-complete; ` + + `${leadingBlocker ? `${leadingBlocker.count} blocked by ${leadingBlocker.id}` : 'no profile blockers'}; ` + + 'no tranche selected.\n', +); diff --git a/scripts/kern-5-fitness-policy.json b/scripts/kern-5-fitness-policy.json index 00c8ce77..5cbba936 100644 --- a/scripts/kern-5-fitness-policy.json +++ b/scripts/kern-5-fitness-policy.json @@ -22,7 +22,7 @@ "test:runtime-abi": "pnpm --filter @kernlang/core test --testPathPatterns='runtime-handler-(public|helper-link)' && node --test ./scripts/runtime-handler-public-import-closure.test.mjs && node ./scripts/check-runtime-envelope.mjs", "check:source-runner-convergence": "node ./scripts/check-source-runner-convergence.mjs", "test:source-runner-convergence": "pnpm --filter @kernlang/core test --testPathPatterns='runner-capability-(class-frame|plan)|source-runner-engine|runtime-envelope-effect-machine-(do|each|expression-v1|helper|lambda|non-root|module-ownership|class-)' && node --test ./scripts/source-runner*-convergence.test.mjs && node ./scripts/check-source-runner-convergence.mjs", - "test:kern-canonicalizer": "pnpm --filter @kernlang/core build && pnpm --filter @kernlang/cli build && node packages/cli/dist/cli.js check examples/kern-canonicalizer/canonicalizer.kern --with-semantics --quiet && node --test ./scripts/kern-canonicalizer/flatten.test.mjs ./scripts/kern-canonicalizer/canonicalizer.test.mjs && node ./scripts/check-kern-canonicalizer.mjs" + "test:kern-canonicalizer": "pnpm --filter @kernlang/core build && pnpm --filter @kernlang/cli build && node packages/cli/dist/cli.js check examples/kern-canonicalizer/canonicalizer.kern --with-semantics --quiet && node --test ./scripts/kern-canonicalizer/flatten.test.mjs ./scripts/kern-canonicalizer/canonicalizer.test.mjs ./scripts/kern-canonicalizer/coverage.test.mjs ./scripts/kern-canonicalizer/coverage-integrity.test.mjs && node ./scripts/check-kern-canonicalizer.mjs && node ./scripts/check-kern-canonicalizer-coverage.mjs" }, "gates": [ { diff --git a/scripts/kern-canonicalizer/coverage-catalog.mjs b/scripts/kern-canonicalizer/coverage-catalog.mjs new file mode 100644 index 00000000..3410cc4e --- /dev/null +++ b/scripts/kern-canonicalizer/coverage-catalog.mjs @@ -0,0 +1,152 @@ +import { readFileSync } from 'node:fs'; + +import { + STRUCTURAL_KIR_CONSTITUTION_FORMAT, + STRUCTURAL_KIR_NODE_CATALOG, + STRUCTURAL_KIR_PROOF_LABEL, +} from '../../packages/core/dist/kir-structural/catalog.generated.js'; + +const CONSTITUTION_SOURCE = readFileSync(new URL('../kir-structural/constitution.json', import.meta.url)); +const CONSTITUTION_KEYS = ['counts', 'format', 'nodes', 'nonCatalogSchemas', 'proofLabel', 'properties', 'schemaVersion']; +const COUNT_KEYS = ['boundNodes', 'missingSchemas', 'nonCatalogSchemas', 'properties', 'sourceNodes']; +const NODE_KEYS = ['allowedChildren', 'disposition', 'id', 'reasonId', 'schemaStatus']; +const PROPERTY_KEYS = ['disposition', 'nodeKind', 'propertyName', 'reasonId', 'required', 'schemaKind', 'values']; +const NON_CATALOG_KEYS = ['allowedChildren', 'id', 'properties', 'reasonId']; +const NON_CATALOG_PROPERTY_KEYS = ['propertyName', 'required', 'schemaKind', 'values']; + +function fail() { + throw new TypeError( + 'coverage catalog rejection: runtime catalog does not match the structural constitution', + ); +} + +function exactRecord(value, keys) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) fail(); + const actual = Reflect.ownKeys(value); + const descriptors = Object.getOwnPropertyDescriptors(value); + if ( + actual.some((key) => typeof key !== 'string') || + actual.some((key) => descriptors[key].get || descriptors[key].set || !descriptors[key].enumerable) || + actual.length !== keys.length || + actual.toSorted().some((key, index) => key !== keys[index]) + ) fail(); + return value; +} + +function text(value) { + if (typeof value !== 'string' || value.length === 0) fail(); + return value; +} + +function optionalTextArray(value) { + if (value === null) return; + if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) fail(); +} + +function validatePropertyRow(property, keys = PROPERTY_KEYS) { + exactRecord(property, keys); + text(property.propertyName); + text(property.schemaKind); + if (Object.hasOwn(property, 'disposition')) text(property.disposition); + if (Object.hasOwn(property, 'reasonId')) text(property.reasonId); + if (typeof property.required !== 'boolean') fail(); + optionalTextArray(property.values); +} + +function validateConstitutionShape(constitution) { + exactRecord(constitution, CONSTITUTION_KEYS); + exactRecord(constitution.counts, COUNT_KEYS); + if (constitution.schemaVersion !== 1) fail(); + for (const key of COUNT_KEYS) { + if (!Number.isSafeInteger(constitution.counts[key]) || constitution.counts[key] < 0) fail(); + } + if ( + !Array.isArray(constitution.nodes) || + !Array.isArray(constitution.properties) || + !Array.isArray(constitution.nonCatalogSchemas) + ) fail(); + const nodeIds = new Set(); + for (const node of constitution.nodes) { + exactRecord(node, NODE_KEYS); + text(node.id); + text(node.schemaStatus); + text(node.disposition); + text(node.reasonId); + optionalTextArray(node.allowedChildren); + if (nodeIds.has(node.id)) fail(); + nodeIds.add(node.id); + } + const propertyIds = new Set(); + for (const property of constitution.properties) { + validatePropertyRow(property); + text(property.nodeKind); + const id = `${property.nodeKind}\u0000${property.propertyName}`; + if (!nodeIds.has(property.nodeKind) || propertyIds.has(id)) fail(); + propertyIds.add(id); + } + const nonCatalogIds = new Set(); + for (const schema of constitution.nonCatalogSchemas) { + exactRecord(schema, NON_CATALOG_KEYS); + text(schema.id); + text(schema.reasonId); + optionalTextArray(schema.allowedChildren); + if (!Array.isArray(schema.properties) || nodeIds.has(schema.id) || nonCatalogIds.has(schema.id)) fail(); + nonCatalogIds.add(schema.id); + const names = new Set(); + for (const property of schema.properties) { + validatePropertyRow(property, NON_CATALOG_PROPERTY_KEYS); + if (names.has(property.propertyName)) fail(); + names.add(property.propertyName); + } + } + if ( + constitution.counts.sourceNodes !== constitution.nodes.length || + constitution.counts.boundNodes !== constitution.nodes.filter(({ schemaStatus }) => schemaStatus === 'bound').length || + constitution.counts.missingSchemas !== constitution.nodes.filter(({ schemaStatus }) => schemaStatus === 'missing').length || + constitution.counts.properties !== constitution.properties.length || + constitution.counts.nonCatalogSchemas !== constitution.nonCatalogSchemas.length + ) fail(); + return constitution; +} + +function expectedCatalog(constitution) { + validateConstitutionShape(constitution); + const propertiesByNode = new Map(); + for (const property of constitution.properties) { + const properties = propertiesByNode.get(property.nodeKind) ?? {}; + properties[property.propertyName] = { + schemaKind: property.schemaKind, + required: property.required, + values: property.values, + disposition: property.disposition, + reasonId: property.reasonId, + }; + propertiesByNode.set(property.nodeKind, properties); + } + return constitution.nodes.map((node) => [node.id, { + schemaStatus: node.schemaStatus, + allowedChildren: node.allowedChildren, + disposition: node.disposition, + reasonId: node.reasonId, + properties: propertiesByNode.get(node.id) ?? {}, + }]); +} + +export function validateRuntimeCatalogConstitution( + constitution, + runtimeCatalog = STRUCTURAL_KIR_NODE_CATALOG, +) { + validateConstitutionShape(constitution); + if ( + constitution.format !== STRUCTURAL_KIR_CONSTITUTION_FORMAT || + constitution.proofLabel !== STRUCTURAL_KIR_PROOF_LABEL || + !(runtimeCatalog instanceof Map) || + JSON.stringify([...runtimeCatalog.entries()]) !== JSON.stringify(expectedCatalog(constitution)) + ) fail(); + return constitution; +} + +export function loadValidatedRuntimeConstitutionSource() { + validateRuntimeCatalogConstitution(JSON.parse(CONSTITUTION_SOURCE.toString('utf8'))); + return Buffer.from(CONSTITUTION_SOURCE); +} diff --git a/scripts/kern-canonicalizer/coverage-dependencies.mjs b/scripts/kern-canonicalizer/coverage-dependencies.mjs new file mode 100644 index 00000000..51556e4b --- /dev/null +++ b/scripts/kern-canonicalizer/coverage-dependencies.mjs @@ -0,0 +1,95 @@ +import { createHash } from 'node:crypto'; +import { lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(fileURLToPath(new URL('../../', import.meta.url))); +const COMPILED_CORE_ROOT = resolve(ROOT, 'packages/core/dist'); +const IMPLEMENTATION_ROOT = resolve(ROOT, 'scripts/kern-canonicalizer'); + +let authenticatedDependencies; + +function fail(message) { + throw new TypeError(`coverage dependency rejection: ${message}`); +} + +function hashFramedFiles(root, paths) { + const canonicalRoot = realpathSync(root); + const hash = createHash('sha256'); + for (const name of [...paths].sort()) { + const path = resolve(canonicalRoot, name); + const stat = lstatSync(path); + const real = realpathSync(path); + const relativePath = relative(canonicalRoot, real); + const escaped = relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath); + if (!stat.isFile() || real !== path || escaped) fail(`${name} must be a contained regular file`); + const bytes = readFileSync(path); + hash.update(`${name.length}:${name}:${bytes.length}:`); + hash.update(bytes); + } + return hash.digest('hex'); +} + +function compiledJavaScriptFiles(directory, output = []) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isSymbolicLink()) fail(`compiled core entry ${path} must not be a symlink`); + if (entry.isDirectory()) compiledJavaScriptFiles(path, output); + else if (entry.isFile() && entry.name.endsWith('.js')) output.push(path); + } + return output; +} + +function localImplementationModules(directory = IMPLEMENTATION_ROOT, output = []) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isSymbolicLink()) fail(`local implementation entry ${path} must not be a symlink`); + if (entry.isDirectory()) localImplementationModules(path, output); + else if (entry.isFile() && entry.name.endsWith('.mjs')) { + output.push(relative(ROOT, path).split(sep).join('/')); + } + } + return output; +} + +export function digestCompiledCoreJavaScript() { + const stat = lstatSync(COMPILED_CORE_ROOT); + if (!stat.isDirectory()) fail('compiled core root must be a regular directory'); + const canonicalRoot = realpathSync(COMPILED_CORE_ROOT); + const paths = compiledJavaScriptFiles(canonicalRoot) + .map((path) => relative(canonicalRoot, path).split(sep).join('/')); + if (paths.length === 0) fail('compiled core JavaScript must not be empty'); + return hashFramedFiles(canonicalRoot, paths); +} + +export function digestCoverageImplementationSources() { + return hashFramedFiles(ROOT, localImplementationModules()); +} + +function currentDependencyReceipt() { + return Object.freeze({ + compiledCoreDigest: digestCompiledCoreJavaScript(), + coverageImplementationDigest: digestCoverageImplementationSources(), + }); +} + +export function authenticateCoverageDependencies() { + if (authenticatedDependencies !== undefined) return authenticatedDependencies; + authenticatedDependencies = currentDependencyReceipt(); + return authenticatedDependencies; +} + +export function requireAuthenticatedCoverageDependencies() { + if (authenticatedDependencies === undefined) fail('coverage entry must authenticate dependencies first'); + return authenticatedDependencies; +} + +export function verifyAuthenticatedCoverageDependencies(expected) { + const current = currentDependencyReceipt(); + if ( + current.compiledCoreDigest !== expected.compiledCoreDigest || + current.coverageImplementationDigest !== expected.coverageImplementationDigest + ) { + fail('dependencies changed while loading the coverage implementation'); + } +} diff --git a/scripts/kern-canonicalizer/coverage-facts.mjs b/scripts/kern-canonicalizer/coverage-facts.mjs new file mode 100644 index 00000000..665e1bbb --- /dev/null +++ b/scripts/kern-canonicalizer/coverage-facts.mjs @@ -0,0 +1,120 @@ +const FACT_KEYS = [ + 'excludedProperties', 'expressionKinds', 'expressionOccurrences', 'firstUnsupported', + 'handlerChildProfiles', 'id', 'nodeKinds', 'nodeOccurrences', 'profileBlockers', + 'profileRows', 'propertyKeys', 'propertyOccurrences', 'tool', +]; +const ROW_KEYS = ['nodes', 'properties', 'values']; +const UNSUPPORTED_KEYS = ['category', 'path', 'value']; +const PROPERTY_IDENTITY = /^[A-Za-z_$][A-Za-z0-9_$-]*\.[A-Za-z_$][A-Za-z0-9_$-]*$/u; + +function fail(message) { + throw new TypeError(`coverage facts rejection: ${message}`); +} + +function record(value, keys, label) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) fail(`${label} must be a record`); + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) fail(`${label} must be a plain record`); + const actual = Reflect.ownKeys(value); + const descriptors = Object.getOwnPropertyDescriptors(value); + if ( + actual.some((key) => typeof key !== 'string') || + actual.some((key) => descriptors[key].get || descriptors[key].set || !descriptors[key].enumerable) || + actual.length !== keys.length || + actual.toSorted().some((key, index) => key !== keys[index]) + ) { + fail(`${label} must contain exactly ${keys.join(',')}`); + } + return value; +} + +function denseArray(value, label) { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) fail(`${label} must be an array`); + const keys = Reflect.ownKeys(value); + if (keys.length !== value.length + 1 || keys.some((key) => typeof key === 'symbol')) { + fail(`${label} must be a dense array`); + } + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || descriptor.get || descriptor.set || !descriptor.enumerable) fail(`${label} must be a dense array`); + } + return value; +} + +function text(value, label) { + if (typeof value !== 'string' || value.length === 0) fail(`${label} must be non-empty text`); + return value; +} + +function textArray(value, label) { + return denseArray(value, label).map((entry, index) => text(entry, `${label}[${index}]`)); +} + +function exactOccurrenceSet(kinds, occurrences, label) { + const expected = [...new Set(occurrences)].toSorted(); + if (kinds.length !== expected.length || kinds.some((kind, index) => kind !== expected[index])) { + fail(`${label} occurrences must exactly derive kinds`); + } +} + +function profileRows(value, label) { + if (value === null) return null; + const rows = record(value, ROW_KEYS, label); + for (const key of ROW_KEYS) { + if (!Number.isSafeInteger(rows[key]) || rows[key] < 0) fail(`${label}.${key} must be a non-negative safe integer`); + } + return rows; +} + +function firstUnsupported(value, label) { + if (value === null) return null; + const unsupported = record(value, UNSUPPORTED_KEYS, label); + for (const key of UNSUPPORTED_KEYS) text(unsupported[key], `${label}.${key}`); + return unsupported; +} + +function freezeData(value) { + if (value !== null && typeof value === 'object') { + for (const key of Reflect.ownKeys(value)) freezeData(value[key]); + if (!Object.isFrozen(value)) Object.freeze(value); + } + return value; +} + +export function validateFunctionFacts(functions) { + denseArray(functions, 'functions'); + const ids = new Set(); + functions.forEach((input, index) => { + const label = `functions[${index}]`; + const fact = record(input, FACT_KEYS, label); + for (const key of ['excludedProperties', 'profileBlockers']) { + textArray(fact[key], `${label}.${key}`); + } + for (const [kindsKey, occurrencesKey] of [ + ['expressionKinds', 'expressionOccurrences'], + ['nodeKinds', 'nodeOccurrences'], + ['propertyKeys', 'propertyOccurrences'], + ]) { + const kinds = textArray(fact[kindsKey], `${label}.${kindsKey}`); + const occurrences = textArray(fact[occurrencesKey], `${label}.${occurrencesKey}`); + if (kindsKey === 'propertyKeys' && kinds.some((identity) => !PROPERTY_IDENTITY.test(identity))) { + fail(`${label}.propertyKeys must contain node.property identity text`); + } + exactOccurrenceSet(kinds, occurrences, `${label}.${occurrencesKey}`); + } + denseArray(fact.handlerChildProfiles, `${label}.handlerChildProfiles`).forEach((profile, profileIndex) => + textArray(profile, `${label}.handlerChildProfiles[${profileIndex}]`)); + firstUnsupported(fact.firstUnsupported, `${label}.firstUnsupported`); + profileRows(fact.profileRows, `${label}.profileRows`); + const id = text(fact.id, `${label}.id`); + if (ids.has(id)) fail(`duplicate function id ${id}`); + ids.add(id); + text(fact.tool, `${label}.tool`); + }); + return functions; +} + +export function freezeFunctionFacts(functions) { + validateFunctionFacts(functions); + return freezeData(functions); +} diff --git a/scripts/kern-canonicalizer/coverage-families.mjs b/scripts/kern-canonicalizer/coverage-families.mjs new file mode 100644 index 00000000..c5e73ad8 --- /dev/null +++ b/scripts/kern-canonicalizer/coverage-families.mjs @@ -0,0 +1,194 @@ +import { readFileSync } from 'node:fs'; + +import { STRUCTURAL_KIR_NODE_CATALOG } from '../../packages/core/dist/kir-structural/catalog.generated.js'; + +const FAMILY_REGISTRY_FORMAT = 'kern.kir-canonicalizer.coverage-family-registry.1'; +const FAMILY_REGISTRY_SOURCE = readFileSync(new URL('./coverage-family-registry.json', import.meta.url)); +export const STRUCTURAL_EXPRESSION_KINDS = new Set([ + 'binary', 'boolean', 'call', 'conditional', 'decimal', 'identifier', 'index', + 'integer', 'lambda', 'list', 'member', 'null', 'record', 'text', 'unary', +]); + +function fail(message) { + throw new TypeError(`coverage policy rejection: ${message}`); +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function record(value, keys, label) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) fail(`${label} must be a record`); + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) fail(`${label} must be a plain record`); + const actual = Reflect.ownKeys(value); + const descriptors = Object.getOwnPropertyDescriptors(value); + if ( + actual.some((key) => typeof key !== 'string') || + actual.some((key) => descriptors[key].get || descriptors[key].set || !descriptors[key].enumerable) + ) { + fail(`${label} must be inspectable plain data`); + } + const sorted = [...actual].sort(); + if (sorted.length !== keys.length || sorted.some((key, index) => key !== keys[index])) { + fail(`${label} must contain exactly ${keys.join(',')}`); + } + return value; +} + +function sortedUniqueText(value, label) { + if (!Array.isArray(value)) fail(`${label} must be an array`); + const result = value.map((entry, index) => { + if (typeof entry !== 'string' || entry.length === 0) fail(`${label}[${index}] must be non-empty text`); + return entry; + }); + const sorted = [...new Set(result)].sort(compareText); + if (sorted.length !== result.length || sorted.some((entry, index) => entry !== result[index])) { + fail(`${label} must be sorted and unique`); + } + return result; +} + +function sameText(left, right) { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function isCatalogExcludedProperty(identity) { + const separator = identity.indexOf('.'); + const contract = STRUCTURAL_KIR_NODE_CATALOG.get(identity.slice(0, separator)); + const name = identity.slice(separator + 1); + return separator > 0 && contract?.properties[name]?.disposition.startsWith('excluded-') === true; +} + +function loadFamilyRegistry() { + const input = JSON.parse(FAMILY_REGISTRY_SOURCE.toString('utf8')); + const registry = record(input, ['families', 'format'], 'family registry'); + if (registry.format !== FAMILY_REGISTRY_FORMAT) fail(`family registry format must be ${FAMILY_REGISTRY_FORMAT}`); + if (!Array.isArray(registry.families) || registry.families.length === 0) fail('family registry cannot be empty'); + const result = new Map(); + const claimedNodes = new Set(); + const claimedExpressions = new Set(); + const claimedProperties = new Set(); + for (const [index, entry] of registry.families.entries()) { + const family = record( + entry, + ['expressionKinds', 'id', 'nodeKinds', 'propertyKeys'], + `family registry[${index}]`, + ); + const id = family.id; + if (typeof id !== 'string' || !/^[a-z][a-z0-9-]*$/u.test(id) || result.has(id)) { + fail(`invalid duplicate family registry id ${String(id)}`); + } + const nodeKinds = sortedUniqueText(family.nodeKinds, `family registry[${index}].nodeKinds`); + const expressionKinds = sortedUniqueText(family.expressionKinds, `family registry[${index}].expressionKinds`); + const propertyKeys = sortedUniqueText(family.propertyKeys, `family registry[${index}].propertyKeys`); + if (nodeKinds.length + expressionKinds.length + propertyKeys.length === 0) { + fail(`family registry ${id} cannot be empty`); + } + for (const kind of nodeKinds) { + if (STRUCTURAL_KIR_NODE_CATALOG.get(kind)?.disposition !== 'structural-candidate') { + fail(`family registry ${id} invents node kind ${kind}`); + } + if (claimedNodes.has(kind)) fail(`family registry duplicates node kind ${kind}`); + claimedNodes.add(kind); + } + for (const kind of expressionKinds) { + if (!STRUCTURAL_EXPRESSION_KINDS.has(kind)) fail(`family registry ${id} invents expression kind ${kind}`); + if (claimedExpressions.has(kind)) fail(`family registry duplicates expression kind ${kind}`); + claimedExpressions.add(kind); + } + for (const propertyKey of propertyKeys) { + const separator = propertyKey.indexOf('.'); + const nodeKind = propertyKey.slice(0, separator); + const properties = STRUCTURAL_KIR_NODE_CATALOG.get(nodeKind)?.properties ?? {}; + const propertyName = propertyKey.slice(separator + 1); + const property = Object.hasOwn(properties, propertyName) ? properties[propertyName] : undefined; + if ( + separator <= 0 || + !nodeKinds.includes(nodeKind) || + property === undefined || + property.disposition.startsWith('excluded-') + ) { + fail(`family registry ${id} invents property key ${propertyKey}`); + } + if (claimedProperties.has(propertyKey)) fail(`family registry duplicates property key ${propertyKey}`); + claimedProperties.add(propertyKey); + } + result.set(id, { expressionKinds, id, nodeKinds, propertyKeys }); + } + const ids = [...result.keys()]; + if (!sameText(ids, [...ids].sort(compareText))) fail('family registry ids must be sorted'); + return result; +} + +export function coverageFamilyRegistrySource() { + return Buffer.from(FAMILY_REGISTRY_SOURCE); +} + +export function validateCoverageFamilies(families, base) { + if (!Array.isArray(families) || families.length === 0) fail('families must be a non-empty array'); + const registry = loadFamilyRegistry(); + const claimedNodes = new Set(base.nodeKinds); + const claimedExpressions = new Set(base.expressionKinds); + const claimedProperties = new Set(base.propertyKeys); + const ids = []; + const result = families.map((entry, index) => { + const family = record(entry, ['expressionKinds', 'id', 'nodeKinds', 'propertyKeys'], `families[${index}]`); + const id = family.id; + if (typeof id !== 'string' || !/^[a-z][a-z0-9-]*$/u.test(id)) fail(`families[${index}].id must be stable`); + const nodeKinds = sortedUniqueText(family.nodeKinds, `families[${index}].nodeKinds`); + const expressionKinds = sortedUniqueText(family.expressionKinds, `families[${index}].expressionKinds`); + const propertyKeys = sortedUniqueText(family.propertyKeys, `families[${index}].propertyKeys`); + const registered = registry.get(id); + if ( + registered === undefined || + !sameText(nodeKinds, registered.nodeKinds) || + !sameText(expressionKinds, registered.expressionKinds) || + !sameText(propertyKeys, registered.propertyKeys) + ) { + fail(`family ${id} must exactly match the frozen family registry`); + } + for (const kind of nodeKinds) { + if (claimedNodes.has(kind)) fail(`node kind ${kind} is claimed by multiple profiles`); + claimedNodes.add(kind); + } + for (const kind of expressionKinds) { + if (claimedExpressions.has(kind)) fail(`expression kind ${kind} is claimed by multiple profiles`); + claimedExpressions.add(kind); + } + for (const propertyKey of propertyKeys) { + if (claimedProperties.has(propertyKey)) fail(`property key ${propertyKey} is claimed by multiple profiles`); + claimedProperties.add(propertyKey); + } + ids.push(id); + return { expressionKinds, id, nodeKinds, propertyKeys }; + }); + if (!sameText(ids, [...new Set(ids)].sort(compareText))) fail('family ids must be sorted and unique'); + return result; +} + +export function assertFamiliesCoverageClosed(policy, functions) { + const claims = { + expressions: new Set(policy.families.flatMap(({ expressionKinds }) => expressionKinds)), + nodes: new Set(policy.families.flatMap(({ nodeKinds }) => nodeKinds)), + properties: new Set(policy.families.flatMap(({ propertyKeys }) => propertyKeys)), + }; + const observed = { + expressions: new Set(functions.flatMap(({ expressionKinds }) => expressionKinds) + .filter((kind) => !policy.base.expressionKinds.includes(kind))), + nodes: new Set(functions.flatMap(({ nodeKinds }) => nodeKinds) + .filter((kind) => !policy.base.nodeKinds.includes(kind))), + properties: new Set(functions.flatMap(({ propertyKeys = [] }) => propertyKeys) + .filter((key) => !policy.base.propertyKeys.includes(key) && !isCatalogExcludedProperty(key))), + }; + for (const category of ['nodes', 'expressions', 'properties']) { + const singular = category === 'nodes' ? 'node kind' : category === 'expressions' ? 'expression kind' : 'property'; + for (const fact of [...observed[category]].sort(compareText)) { + if (!claims[category].has(fact)) fail(`unclaimed ${singular} ${fact}`); + } + for (const fact of [...claims[category]].sort(compareText)) { + if (!observed[category].has(fact)) fail(`unobserved ${singular} ${fact}`); + } + } + return policy; +} diff --git a/scripts/kern-canonicalizer/coverage-family-registry.json b/scripts/kern-canonicalizer/coverage-family-registry.json new file mode 100644 index 00000000..07002c80 --- /dev/null +++ b/scripts/kern-canonicalizer/coverage-family-registry.json @@ -0,0 +1,16 @@ +{ + "format": "kern.kir-canonicalizer.coverage-family-registry.1", + "families": [ + { "expressionKinds": ["binary"], "id": "binary-expression", "nodeKinds": [], "propertyKeys": [] }, + { "expressionKinds": [], "id": "binding", "nodeKinds": ["assign", "let"], "propertyKeys": ["assign.target", "assign.value", "let.name", "let.value"] }, + { "expressionKinds": ["call"], "id": "call-expression", "nodeKinds": [], "propertyKeys": [] }, + { "expressionKinds": [], "id": "conditional", "nodeKinds": ["else", "if"], "propertyKeys": ["if.cond"] }, + { "expressionKinds": [], "id": "counted-iteration", "nodeKinds": ["for"], "propertyKeys": ["for.from", "for.name", "for.to"] }, + { "expressionKinds": [], "id": "do-statement", "nodeKinds": ["do"], "propertyKeys": ["do.value"] }, + { "expressionKinds": [], "id": "exception-flow", "nodeKinds": ["throw"], "propertyKeys": ["throw.value"] }, + { "expressionKinds": ["index"], "id": "index-expression", "nodeKinds": [], "propertyKeys": [] }, + { "expressionKinds": ["member"], "id": "member-expression", "nodeKinds": [], "propertyKeys": [] }, + { "expressionKinds": ["unary"], "id": "unary-expression", "nodeKinds": [], "propertyKeys": [] }, + { "expressionKinds": [], "id": "while-iteration", "nodeKinds": ["while"], "propertyKeys": ["while.cond"] } + ] +} diff --git a/scripts/kern-canonicalizer/coverage-implementation.mjs b/scripts/kern-canonicalizer/coverage-implementation.mjs new file mode 100644 index 00000000..91b0f002 --- /dev/null +++ b/scripts/kern-canonicalizer/coverage-implementation.mjs @@ -0,0 +1,497 @@ +import { createHash } from 'node:crypto'; +import { lstatSync, readFileSync, realpathSync } from 'node:fs'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { CanonicalValueDecodeError } from '../../packages/core/dist/canonical-value/types.js'; +import { STRUCTURAL_KIR_NODE_CATALOG } from '../../packages/core/dist/kir-structural/catalog.generated.js'; +import { projectExpressionText } from '../../packages/core/dist/kir-structural/expression.js'; +import { StructuralKirError } from '../../packages/core/dist/kir-structural/types.js'; +import { parseDocumentWithDiagnostics } from '../../packages/core/dist/parser.js'; +import { loadValidatedRuntimeConstitutionSource } from './coverage-catalog.mjs'; +import { freezeFunctionFacts, validateFunctionFacts } from './coverage-facts.mjs'; +import { + requireAuthenticatedCoverageDependencies, + verifyAuthenticatedCoverageDependencies, +} from './coverage-dependencies.mjs'; +import { + assertFamiliesCoverageClosed, + coverageFamilyRegistrySource, + STRUCTURAL_EXPRESSION_KINDS, + validateCoverageFamilies, +} from './coverage-families.mjs'; +import { + analyzeProfileBlockersForFunction, + canonicalProfileRowsForFunction, + firstUnsupportedByAuthoredOrder, + handlerChildProfilesComplete, + handlerChildProfilesForFunction, + validateCoverageBase, +} from './coverage-profile.mjs'; +import { canonicalizerPolicySource, loadCanonicalizerPolicy } from './policy.mjs'; +const ROOT = resolve(fileURLToPath(new URL('../../', import.meta.url))); +const POLICY_FORMAT = 'kern.kir-canonicalizer.coverage-policy.1'; +const RECEIPT_FORMAT = 'kern.kir-canonicalizer.coverage-receipt.1'; +const EXPRESSION_KINDS = STRUCTURAL_EXPRESSION_KINDS; +const AUTHENTICATED_DEPENDENCIES = requireAuthenticatedCoverageDependencies(); +const COVERAGE_POLICY_SOURCE = readFileSync(new URL('./coverage-policy.json', import.meta.url)); +const PROFILE_SOURCE = readFileSync(new URL('./coverage-profile.mjs', import.meta.url)); +const EXPRESSION_CATALOG_SOURCE = readFileSync(resolve(ROOT, 'packages/core/src/kir-structural/expression.ts')); +const CANONICALIZER_SOURCE = readFileSync(resolve(ROOT, 'examples/kern-canonicalizer/canonicalizer.kern')); +const AUTHENTICATED_FUNCTION_FACTS = new WeakMap(); +function fail(message) { + throw new TypeError(`coverage policy rejection: ${message}`); +} +function digest(value) { + return createHash('sha256').update(value).digest('hex'); +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function record(value, keys, label) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) fail(`${label} must be a record`); + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) fail(`${label} must be a plain record`); + const actual = Reflect.ownKeys(value); + if (actual.some((key) => typeof key !== 'string')) fail(`${label} contains symbol fields`); + const descriptors = Object.getOwnPropertyDescriptors(value); + if (actual.some((key) => descriptors[key].get || descriptors[key].set || !descriptors[key].enumerable)) { + fail(`${label} must be inspectable plain data`); + } + const sorted = [...actual].sort(); + if (sorted.length !== keys.length || sorted.some((key, index) => key !== keys[index])) { + fail(`${label} must contain exactly ${keys.join(',')}`); + } + return value; +} + +function text(value, label) { + if (typeof value !== 'string' || value.length === 0) fail(`${label} must be non-empty text`); + return value; +} + +function sortedUniqueText(value, label) { + if (!Array.isArray(value)) fail(`${label} must be an array`); + const result = value.map((entry, index) => text(entry, `${label}[${index}]`)); + const sorted = [...new Set(result)].sort(compareText); + if (sorted.length !== result.length || sorted.some((entry, index) => entry !== result[index])) { + fail(`${label} must be sorted and unique`); + } + return result; +} + +function safePath(value, label) { + const path = text(value, label); + if ( + isAbsolute(path) || + /^[A-Za-z]:/u.test(path) || + path.includes('\\') || + path.split('/').some((segment) => segment === '' || segment === '.' || segment === '..') || + !path.endsWith('.kern') + ) { + fail(`${label} must be a normalized relative .kern path`); + } + return path; +} + +export function readCorpusMemberBytes(path, root = ROOT) { + let canonicalRoot; + try { + canonicalRoot = realpathSync(root); + } catch { + fail('corpus root is missing'); + } + const resolved = resolve(canonicalRoot, path); + let stat; + let real; + try { + stat = lstatSync(resolved); + real = realpathSync(resolved); + } catch { + fail(`corpus member ${path} is missing`); + } + const relativePath = relative(canonicalRoot, real); + const escaped = relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath); + if (!stat.isFile() || real !== resolved || escaped) fail(`corpus member ${path} must be a contained regular file`); + return readFileSync(resolved); +} + +function validateCorpus(corpus, allowMissingCorpus) { + if (!Array.isArray(corpus) || corpus.length === 0) fail('corpus must be a non-empty array'); + const paths = []; + const result = corpus.map((entry, index) => { + const item = record(entry, ['digest', 'path', 'sourceKind', 'tool'], `corpus[${index}]`); + const path = safePath(item.path, `corpus[${index}].path`); + const expectedDigest = text(item.digest, `corpus[${index}].digest`); + if (!/^[0-9a-f]{64}$/u.test(expectedDigest)) fail(`corpus[${index}].digest must be lowercase SHA-256`); + const tool = text(item.tool, `corpus[${index}].tool`); + if (!/^[a-z][a-z0-9-]*$/u.test(tool)) fail(`corpus[${index}].tool must be a stable id`); + if (item.sourceKind !== 'handwritten') fail(`corpus[${index}].sourceKind must be handwritten`); + paths.push(path); + if (!allowMissingCorpus) { + const sourceBytes = readCorpusMemberBytes(path); + const source = sourceBytes.toString('utf8'); + if (/^\ufeff?[ \t]*#\s*(?:GENERATED FILE\b|@generated by kern\b)/imu.test(source)) { + fail(`corpus member ${path} is generated`); + } + if (digest(sourceBytes) !== expectedDigest) fail(`corpus member ${path} digest drift`); + } + return { digest: expectedDigest, path, sourceKind: 'handwritten', tool }; + }); + const sorted = [...paths].sort(compareText); + if (new Set(paths).size !== paths.length || sorted.some((path, index) => path !== paths[index])) { + fail('corpus paths must be sorted and unique'); + } + return result; +} + +export function validateCoveragePolicy(input, options = {}) { + const policy = record(input, ['base', 'corpus', 'families', 'format'], 'policy'); + if (policy.format !== POLICY_FORMAT) fail(`format must be ${POLICY_FORMAT}`); + const baseInput = record(policy.base, ['expressionKinds', 'nodeKinds', 'propertyKeys'], 'base'); + const base = { + expressionKinds: sortedUniqueText(baseInput.expressionKinds, 'base.expressionKinds'), + nodeKinds: sortedUniqueText(baseInput.nodeKinds, 'base.nodeKinds'), + propertyKeys: sortedUniqueText(baseInput.propertyKeys, 'base.propertyKeys'), + }; + for (const kind of base.nodeKinds) { + if (STRUCTURAL_KIR_NODE_CATALOG.get(kind)?.disposition !== 'structural-candidate') { + fail(`base invents node kind ${kind}`); + } + } + for (const kind of base.expressionKinds) { + if (!EXPRESSION_KINDS.has(kind)) fail(`base invents expression kind ${kind}`); + } + validateCoverageBase(base); + const corpus = validateCorpus(policy.corpus, options.allowMissingCorpus === true); + const families = validateCoverageFamilies(policy.families, base); + return { base, corpus, families, format: POLICY_FORMAT }; +} + +export function loadCoveragePolicy() { + return validateCoveragePolicy( + JSON.parse(COVERAGE_POLICY_SOURCE.toString('utf8')), + ); +} + +export function assertCoverageClosed(policyInput, functions) { + const policy = validateCoveragePolicy(policyInput, { allowMissingCorpus: true }); + return assertFamiliesCoverageClosed(policy, functions); +} + +function canonicalRecord(value) { + if (value?.tag !== 'record') return null; + return new Map(value.value.map((entry) => [entry.key, entry.value])); +} + +function collectExpressionKinds(value, output) { + const root = canonicalRecord(value); + const kind = root?.get('kind'); + const fields = root?.get('fields'); + if (root?.size === 2 && kind?.tag === 'text' && fields?.tag === 'record') { + output.push(kind.value); + collectNestedExpressions(fields, output); + return; + } + collectNestedExpressions(value, output); +} + +export function collectCanonicalExpressionKinds(value) { + const output = []; + collectExpressionKinds(value, output); + return output; +} + +function collectNestedExpressions(value, output) { + if (value.tag === 'list') { + for (const item of value.value) collectExpressionKinds(item, output); + } else if (value.tag === 'record') { + for (const entry of value.value) collectExpressionKinds(entry.value, output); + } else if (value.tag === 'map') { + for (const entry of value.value) { + collectExpressionKinds(entry.key, output); + collectExpressionKinds(entry.value, output); + } + } +} + +function expressionSource(value, path) { + if (typeof value === 'string') return value; + if ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + value.__expr === true && + typeof value.code === 'string' && + Reflect.ownKeys(value).length === 2 + ) { + return value.code; + } + fail(`observed noncanonical expression payload at ${path}`); +} + +function projectionCode(error) { + if (error instanceof StructuralKirError || error instanceof CanonicalValueDecodeError) { + return typeof error.code === 'string' ? error.code : 'projection-error'; + } + throw error; +} + +function withoutExcludedProperties(node) { + const contract = STRUCTURAL_KIR_NODE_CATALOG.get(node.type); + return { + ...node, + children: (node.children ?? []).map(withoutExcludedProperties), + props: Object.fromEntries( + Object.entries(node.props ?? {}).filter( + ([key]) => !contract?.properties[key]?.disposition?.startsWith('excluded-'), + ), + ), + }; +} + +function inspectFunction(root, path, tool, ordinal, base, canonicalizerPolicy) { + const nodes = []; + const expressions = []; + const properties = []; + const excludedProperties = []; + const unsupported = []; + let nodePosition = 0; + function visit(node, nodePath) { + const position = nodePosition++; + nodes.push(node.type); + const contract = STRUCTURAL_KIR_NODE_CATALOG.get(node.type); + if (contract === undefined) fail(`observed unknown catalog node ${node.type}`); + if (!base.nodeKinds.includes(node.type)) unsupported.push({ category: 'node', path: nodePath, position, value: node.type }); + for (const [key, value] of Object.entries(node.props ?? {})) { + properties.push(`${node.type}.${key}`); + const propertyContract = Object.hasOwn(contract.properties, key) ? contract.properties[key] : undefined; + if (propertyContract === undefined) fail(`observed unknown property ${node.type}.${key}`); + const omittedEmptyParameters = node.type === 'fn' && key === 'params' && + typeof value === 'string' && value.trim() === ''; + if (propertyContract.disposition.startsWith('excluded-') && !omittedEmptyParameters) { + excludedProperties.push(`${node.type}.${key}`); + unsupported.push({ category: 'excluded-property', path: `${nodePath}.${key}`, position, value: `${node.type}.${key}` }); + } + if (propertyContract.disposition === 'lowered-expression') { + let projected; + try { + projected = projectExpressionText(expressionSource(value, `${nodePath}.${key}`), `${nodePath}.${key}`); + } catch (error) { + const code = projectionCode(error); + const blocker = `${node.type}.${key}:${code}`; + excludedProperties.push(blocker); + unsupported.push({ category: 'projection', path: `${nodePath}.${key}`, position, value: blocker }); + } + const found = []; + if (projected !== undefined) collectExpressionKinds(projected, found); + for (const kind of found) { + expressions.push(kind); + if (!base.expressionKinds.includes(kind)) { + unsupported.push({ category: 'expression', path: `${nodePath}.${key}`, position, value: kind }); + } + } + } + } + (node.children ?? []).forEach((child, index) => visit(child, `${nodePath}.children[${index}]`)); + } + visit(root, '$'); + let profileRows; + try { + const projectionInput = excludedProperties.length === 0 ? root : withoutExcludedProperties(root); + profileRows = canonicalProfileRowsForFunction(projectionInput, canonicalizerPolicy.kirLimits); + } catch (error) { + const code = projectionCode(error); + excludedProperties.push(`projection.${code}`); + unsupported.push({ category: 'projection', path: '$', position: nodePosition, value: code }); + } + const profileAnalysis = analyzeProfileBlockersForFunction( + root, + base, + canonicalizerPolicy.profileLimits, + profileRows ?? null, + ); + const profileBlockers = profileAnalysis.blockers; + const displayName = typeof root.props?.name === 'string' ? root.props.name : `fn-${ordinal}`; + return { + excludedProperties: [...new Set(excludedProperties)].sort(compareText), + expressionKinds: [...new Set(expressions)].sort(compareText), + expressionOccurrences: expressions.toSorted(compareText), + firstUnsupported: firstUnsupportedByAuthoredOrder(unsupported, profileAnalysis), + handlerChildProfiles: handlerChildProfilesForFunction(root), + id: `${path}#${ordinal}:${displayName}`, + nodeKinds: [...new Set(nodes)].sort(compareText), + nodeOccurrences: nodes.toSorted(compareText), + profileBlockers, + profileRows: profileRows ?? null, + propertyKeys: [...new Set(properties)].sort(compareText), + propertyOccurrences: properties.toSorted(compareText), + tool, + }; +} +function completes(profile, fn, profileLimits) { + const requiredFacts = [fn.excludedProperties, fn.profileBlockers, fn.handlerChildProfiles, fn.nodeKinds, + fn.expressionKinds, fn.propertyKeys]; + return requiredFacts.every(Array.isArray) && + fn.profileRows !== null && + fn.profileRows.nodes <= profileLimits.maxNodeRows && + fn.profileRows.properties <= profileLimits.maxPropertyRows && fn.profileRows.values <= profileLimits.maxValueRows && + fn.excludedProperties.length === 0 && fn.profileBlockers.length === 0 && + candidateCatalogRequirementsComplete(profile, fn) && + handlerChildProfilesComplete(profile, fn.handlerChildProfiles) && + fn.nodeKinds.every((kind) => profile.nodeKinds.has(kind)) && + fn.expressionKinds.every((kind) => profile.expressionKinds.has(kind)) && + fn.propertyKeys.every((propertyKey) => { + const nodeKind = propertyKey.slice(0, propertyKey.indexOf('.')); + return profile.baseNodeKinds.has(nodeKind) || profile.propertyKeys.has(propertyKey); + }); +} + +function candidateCatalogRequirementsComplete(profile, fn) { + return [...profile.candidateNodeKinds].every((kind) => { + const nodeCount = fn.nodeOccurrences.filter((observed) => observed === kind).length; + if (nodeCount === 0) return true; + const contract = STRUCTURAL_KIR_NODE_CATALOG.get(kind); + return contract !== undefined && Object.entries(contract.properties).every(([name, property]) => + !property.required || + (!property.disposition.startsWith('excluded-') && + fn.propertyOccurrences.filter((observed) => observed === `${kind}.${name}`).length === nodeCount), + ); + }); +} +export function selectCanonicalizerTranche(policyInput, functions) { + const authenticatedPolicyDigest = AUTHENTICATED_FUNCTION_FACTS.get(functions); + if (authenticatedPolicyDigest === undefined) fail('function facts require authenticated measurement'); + validateFunctionFacts(functions); + const profileLimits = loadCanonicalizerPolicy().profileLimits; + const policy = assertCoverageClosed(policyInput, functions); + if (authenticatedPolicyDigest !== digest(JSON.stringify(policy))) fail('function facts require authenticated policy'); + const baseNodes = new Set(policy.base.nodeKinds); + const baseExpressions = new Set(policy.base.expressionKinds); + const ranking = policy.families.map((family) => { + const profile = { + baseNodeKinds: baseNodes, + candidateNodeKinds: new Set(family.nodeKinds), + expressionKinds: new Set([...baseExpressions, ...family.expressionKinds]), + nodeKinds: new Set([...baseNodes, ...family.nodeKinds]), + propertyKeys: new Set(family.propertyKeys), + }; + const baseProfile = { + baseNodeKinds: baseNodes, + candidateNodeKinds: new Set(), + expressionKinds: baseExpressions, + nodeKinds: baseNodes, + propertyKeys: new Set(), + }; + const newlyComplete = functions.filter((fn) => !completes(baseProfile, fn, profileLimits) && + completes(profile, fn, profileLimits)); + const tools = new Set(newlyComplete.map(({ tool }) => tool)); + const occurrences = functions.reduce( + (total, fn) => total + fn.nodeOccurrences.filter((kind) => family.nodeKinds.includes(kind)).length + + fn.expressionOccurrences.filter((kind) => family.expressionKinds.includes(kind)).length + + fn.propertyOccurrences.filter((key) => family.propertyKeys.includes(key)).length, + 0, + ); + return { + completeFunctions: newlyComplete.length, + completeTools: tools.size, + id: family.id, + occurrences, + witnesses: newlyComplete.map(({ id }) => id).sort(compareText), + }; + }).sort((left, right) => + right.completeFunctions - left.completeFunctions || + right.completeTools - left.completeTools || + right.occurrences - left.occurrences || + compareText(left.id, right.id), + ); + const winner = ranking[0]?.completeFunctions > 0 ? ranking[0] : null; + return { ranking, winner }; +} +export function measureCanonicalizerCoverage(policyInput) { + verifyAuthenticatedCoverageDependencies(AUTHENTICATED_DEPENDENCIES); + const policy = policyInput === undefined ? loadCoveragePolicy() : validateCoveragePolicy(policyInput); + const canonicalizerPolicy = loadCanonicalizerPolicy(); + const functions = []; + for (const member of policy.corpus) { + const sourceBytes = readCorpusMemberBytes(member.path); + if (digest(sourceBytes) !== member.digest) fail(`corpus member ${member.path} changed during measurement`); + const source = sourceBytes.toString('utf8'); + const parsed = parseDocumentWithDiagnostics(source); + const errors = parsed.diagnostics.filter(({ severity }) => severity === 'error'); + if (errors.length > 0) fail(`corpus member ${member.path} has parse errors`); + (parsed.root.children ?? []).forEach((root, ordinal) => { + if (root.type === 'fn') { + functions.push(inspectFunction(root, member.path, member.tool, ordinal, policy.base, canonicalizerPolicy)); + } + }); + } + functions.sort((left, right) => compareText(left.id, right.id)); + freezeFunctionFacts(functions); + AUTHENTICATED_FUNCTION_FACTS.set(functions, digest(JSON.stringify(policy))); + const selection = selectCanonicalizerTranche(policy, functions); + const constitution = loadValidatedRuntimeConstitutionSource(); + const familyRegistry = coverageFamilyRegistrySource(); + const boundCanonicalizerPolicySource = canonicalizerPolicySource(); + const baseProfile = { + baseNodeKinds: new Set(policy.base.nodeKinds), + candidateNodeKinds: new Set(), + expressionKinds: new Set(policy.base.expressionKinds), + nodeKinds: new Set(policy.base.nodeKinds), + propertyKeys: new Set(), + }; + const receipt = { + baseCompleteFunctions: functions.filter((fn) => completes(baseProfile, fn, canonicalizerPolicy.profileLimits)).length, + catalogDigest: digest(constitution), + canonicalizerDigest: digest(CANONICALIZER_SOURCE), + canonicalizerPolicyDigest: digest(boundCanonicalizerPolicySource), + compiledCoreDigest: AUTHENTICATED_DEPENDENCIES.compiledCoreDigest, + corpus: policy.corpus, + corpusDigest: digest(JSON.stringify(policy.corpus)), + coverageImplementationDigest: AUTHENTICATED_DEPENDENCIES.coverageImplementationDigest, + coveragePolicyDigest: digest(COVERAGE_POLICY_SOURCE), + familyRegistryDigest: digest(familyRegistry), + expressionCatalogDigest: digest(EXPRESSION_CATALOG_SOURCE), + format: RECEIPT_FORMAT, + functionFactsDigest: digest(JSON.stringify(functions)), + functions, + policyDigest: digest(JSON.stringify(policy)), + profileDigest: digest(PROFILE_SOURCE), + selection, + }; + verifyAuthenticatedCoverageDependencies(AUTHENTICATED_DEPENDENCIES); + return receipt; +} + +export function summarizeCanonicalizerCoverage(receipt = measureCanonicalizerCoverage()) { + const blockerCounts = new Map(); + for (const fn of receipt.functions) { + for (const blocker of [...fn.excludedProperties, ...(fn.profileBlockers ?? [])]) { + blockerCounts.set(blocker, (blockerCounts.get(blocker) ?? 0) + 1); + } + } + return { + baseCompleteFunctions: receipt.baseCompleteFunctions, + blockers: [...blockerCounts] + .map(([id, count]) => ({ count, id })) + .sort((left, right) => right.count - left.count || compareText(left.id, right.id)), + catalogDigest: receipt.catalogDigest, + canonicalizerDigest: receipt.canonicalizerDigest, + canonicalizerPolicyDigest: receipt.canonicalizerPolicyDigest, + compiledCoreDigest: receipt.compiledCoreDigest, + corpusMembers: receipt.corpus.length, + corpusDigest: receipt.corpusDigest, + coverageImplementationDigest: receipt.coverageImplementationDigest, + coveragePolicyDigest: receipt.coveragePolicyDigest, + familyRegistryDigest: receipt.familyRegistryDigest, + expressionCatalogDigest: receipt.expressionCatalogDigest, + format: 'kern.kir-canonicalizer.coverage-summary.1', + functionFactsDigest: receipt.functionFactsDigest, + functionCount: receipt.functions.length, + policyDigest: receipt.policyDigest, + profileDigest: receipt.profileDigest, + selection: receipt.selection, + toolCount: new Set(receipt.corpus.map(({ tool }) => tool)).size, + }; +} diff --git a/scripts/kern-canonicalizer/coverage-integrity.test.mjs b/scripts/kern-canonicalizer/coverage-integrity.test.mjs new file mode 100644 index 00000000..f2b8120b --- /dev/null +++ b/scripts/kern-canonicalizer/coverage-integrity.test.mjs @@ -0,0 +1,347 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative, resolve, sep } from 'node:path'; +import test from 'node:test'; + +import { STRUCTURAL_KIR_NODE_CATALOG } from '../../packages/core/dist/kir-structural/catalog.generated.js'; +import { parseDocumentWithDiagnostics } from '../../packages/core/dist/parser.js'; +import { validateRuntimeCatalogConstitution } from './coverage-catalog.mjs'; +import { freezeFunctionFacts, validateFunctionFacts } from './coverage-facts.mjs'; +import { + loadCoveragePolicy, + measureCanonicalizerCoverage, + selectCanonicalizerTranche, + validateCoveragePolicy, +} from './coverage.mjs'; +import { canonicalProfileRowsForFunction } from './coverage-profile.mjs'; +import { writeCoverageSummary } from './coverage-summary-writer.mjs'; +import { loadCanonicalizerPolicy } from './policy.mjs'; + +function digest(value) { + return createHash('sha256').update(value).digest('hex'); +} + +function corpusPath(path) { + return relative(process.cwd(), path).split(sep).join('/'); +} + +function compiledCoreDigest() { + const root = resolve(process.cwd(), 'packages/core/dist'); + const files = []; + function visit(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isSymbolicLink()) throw new TypeError(`compiled core entry ${path} must not be a symlink`); + if (entry.isDirectory()) visit(path); + else if (entry.isFile() && entry.name.endsWith('.js')) files.push(path); + } + } + visit(root); + const hash = createHash('sha256'); + for (const path of files.sort()) { + const name = relative(root, path).split(sep).join('/'); + const bytes = readFileSync(path); + hash.update(`${name.length}:${name}:${bytes.length}:`); + hash.update(bytes); + } + return hash.digest('hex'); +} + +function framedDigest(paths) { + const hash = createHash('sha256'); + for (const path of [...paths].sort()) { + const bytes = readFileSync(resolve(process.cwd(), path)); + hash.update(`${path.length}:${path}:${bytes.length}:`); + hash.update(bytes); + } + return hash.digest('hex'); +} + +function coverageFact(overrides = {}) { + return { + excludedProperties: [], + expressionKinds: [], + expressionOccurrences: [], + firstUnsupported: null, + handlerChildProfiles: [['return']], + id: 'examples/tool.kern#0:fixture', + nodeKinds: [], + nodeOccurrences: [], + profileBlockers: [], + profileRows: { nodes: 3, properties: 4, values: 5 }, + propertyKeys: [], + propertyOccurrences: [], + tool: 'tool', + ...overrides, + }; +} + +test('handwritten corpus admission rejects every repository generated header variant', () => { + const directory = mkdtempSync(join(process.cwd(), 'scripts/kern-canonicalizer/.generated-header-')); + try { + for (const [index, source] of [ + '\ufeff \t# GENERATED FILE — do not hand-edit.\n', + '\ufeff \t# @generated by kern v5.0.0 — DO NOT EDIT.\n', + ].entries()) { + const path = join(directory, `${index}.kern`); + writeFileSync(path, source); + const policy = loadCoveragePolicy(); + policy.corpus = [{ + digest: digest(source), + path: corpusPath(path), + sourceKind: 'handwritten', + tool: 'generated-probe', + }]; + assert.throws(() => validateCoveragePolicy(policy), /corpus member .* is generated/u); + } + } finally { + rmSync(directory, { force: true, recursive: true }); + } +}); + +test('function facts bind unique sets exactly to occurrence evidence', () => { + for (const fact of [ + coverageFact({ nodeKinds: ['fn'], nodeOccurrences: [] }), + coverageFact({ expressionKinds: ['binary'], expressionOccurrences: [] }), + coverageFact({ propertyKeys: ['fn.name'], propertyOccurrences: [] }), + ]) { + assert.throws(() => validateFunctionFacts([fact]), /occurrences must exactly derive kinds/u); + } +}); + +test('function facts reject malformed property identities before selection', () => { + for (const property of ['fn', '.name', 'fn.', 'fn.name.extra']) { + const fact = coverageFact({ + propertyKeys: [property], + propertyOccurrences: [property], + }); + assert.throws(() => validateFunctionFacts([fact]), /property identity/u); + } +}); + +test('function facts reject duplicate witness ids before ranking', () => { + const fact = coverageFact(); + assert.throws( + () => validateFunctionFacts([fact, structuredClone(fact)]), + /duplicate function id/u, + ); +}); + +test('selection accepts only immutable facts created by the current measurement', () => { + const policy = loadCoveragePolicy(); + const measured = measureCanonicalizerCoverage(policy); + const clone = structuredClone(measured.functions); + const driftedPolicy = structuredClone(policy); + driftedPolicy.corpus[0].tool = 'invented-tool'; + assert.throws(() => measured.functions[0].excludedProperties.pop(), /read only|not extensible|Cannot delete/u); + assert.throws(() => selectCanonicalizerTranche(policy, clone), /authenticated measurement/u); + assert.throws( + () => selectCanonicalizerTranche(driftedPolicy, measured.functions), + /authenticated policy/u, + ); + assert.deepEqual(selectCanonicalizerTranche(policy, measured.functions), measured.selection); +}); + +test('deep freezing descends through an already shallow-frozen fact container', () => { + const functions = [coverageFact({ handlerChildProfiles: Object.freeze([['return']]) })]; + freezeFunctionFacts(functions); + assert.throws(() => functions[0].handlerChildProfiles[0].push('throw'), /read only|not extensible/u); +}); + +test('coverage summary writes reject symlink destinations without touching targets', () => { + const directory = mkdtempSync(join(tmpdir(), 'kern-coverage-summary-')); + try { + const target = join(directory, 'outside.json'); + const output = join(directory, 'coverage-summary.json'); + writeFileSync(target, 'outside\n'); + symlinkSync(target, output); + assert.throws(() => writeCoverageSummary(output, { changed: true }), /regular file/u); + assert.equal(readFileSync(target, 'utf8'), 'outside\n'); + } finally { + rmSync(directory, { force: true, recursive: true }); + } +}); + +test('coverage summary checks require canonical bytes in a regular file', async () => { + const { assertCoverageSummary } = await import('./coverage-summary-writer.mjs'); + assert.equal(typeof assertCoverageSummary, 'function'); + const directory = mkdtempSync(join(tmpdir(), 'kern-coverage-summary-check-')); + try { + const target = join(directory, 'target.json'); + const output = join(directory, 'coverage-summary.json'); + const summary = { format: 'fixture', value: 1 }; + writeCoverageSummary(output, summary); + assert.doesNotThrow(() => assertCoverageSummary(output, summary)); + writeFileSync(output, `${JSON.stringify(summary)}\n`); + assert.throws(() => assertCoverageSummary(output, summary), /canonical bytes/u); + writeFileSync(target, `${JSON.stringify(summary, null, 2)}\n`); + rmSync(output); + symlinkSync(target, output); + assert.throws(() => assertCoverageSummary(output, summary), /regular file/u); + } finally { + rmSync(directory, { force: true, recursive: true }); + } +}); + +test('profile-only rejection becomes the first unsupported fact', () => { + const directory = mkdtempSync(join(process.cwd(), 'scripts/kern-canonicalizer/.profile-fact-')); + try { + const source = [ + 'fn name=profileGap returns=string async=true', + ' handler lang=kern', + ' return value="\\"ok\\""', + '', + ].join('\n'); + const path = join(directory, 'profile-gap.kern'); + writeFileSync(path, source); + const policy = loadCoveragePolicy(); + policy.corpus.push({ + digest: digest(source), + path: corpusPath(path), + sourceKind: 'handwritten', + tool: 'profile-probe', + }); + policy.corpus.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0); + const measured = measureCanonicalizerCoverage(policy); + const fact = measured.functions.find(({ id }) => id.endsWith(':profileGap')); + assert.deepEqual(fact?.firstUnsupported, { + category: 'profile', + path: '$', + value: 'fn.properties.async', + }); + } finally { + rmSync(directory, { force: true, recursive: true }); + } +}); + +test('profile-only firstUnsupported follows authored traversal order', () => { + const directory = mkdtempSync(join(process.cwd(), 'scripts/kern-canonicalizer/.profile-order-')); + try { + const source = [ + 'fn name=profileOrder returns=string stream=true async=true', + ' handler lang=kern', + ' return value="\\"ok\\""', + '', + ].join('\n'); + const path = join(directory, 'profile-order.kern'); + writeFileSync(path, source); + const policy = loadCoveragePolicy(); + policy.corpus.push({ + digest: digest(source), + path: corpusPath(path), + sourceKind: 'handwritten', + tool: 'profile-order-probe', + }); + policy.corpus.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0); + const measured = measureCanonicalizerCoverage(policy); + const fact = measured.functions.find(({ id }) => id.endsWith(':profileOrder')); + assert.deepEqual(fact?.firstUnsupported, { + category: 'profile', + path: '$', + value: 'fn.properties.stream', + }); + } finally { + rmSync(directory, { force: true, recursive: true }); + } +}); + +test('firstUnsupported orders root profile blockers before descendant structural blockers', () => { + const directory = mkdtempSync(join(process.cwd(), 'scripts/kern-canonicalizer/.combined-order-')); + try { + const source = [ + 'fn name=combinedOrder returns=void async=true', + ' handler lang=kern', + ' if cond=true', + ' return', + '', + ].join('\n'); + const path = join(directory, 'combined-order.kern'); + writeFileSync(path, source); + const policy = loadCoveragePolicy(); + policy.corpus.push({ + digest: digest(source), + path: corpusPath(path), + sourceKind: 'handwritten', + tool: 'combined-order-probe', + }); + policy.corpus.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0); + const measured = measureCanonicalizerCoverage(policy); + const fact = measured.functions.find(({ id }) => id.endsWith(':combinedOrder')); + assert.deepEqual(fact?.firstUnsupported, { + category: 'profile', + path: '$', + value: 'fn.properties.async', + }); + } finally { + rmSync(directory, { force: true, recursive: true }); + } +}); + +test('the receipt binds the compiled core JavaScript used during measurement', () => { + assert.equal(measureCanonicalizerCoverage().compiledCoreDigest, compiledCoreDigest()); +}); + +test('the implementation digest path-frames every executed local dependency', () => { + const directory = resolve(process.cwd(), 'scripts/kern-canonicalizer'); + const paths = readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.mjs')) + .map((entry) => `scripts/kern-canonicalizer/${entry.name}`); + assert.equal(measureCanonicalizerCoverage().coverageImplementationDigest, framedDigest(paths)); +}); + +test('the coverage entry authenticates dependencies before loading implementation modules', () => { + const source = readFileSync(resolve(process.cwd(), 'scripts/kern-canonicalizer/coverage.mjs'), 'utf8'); + const authentication = source.indexOf('authenticateCoverageDependencies()'); + const implementationLoad = source.indexOf("await import('./coverage-implementation.mjs')"); + const verification = source.indexOf('verifyAuthenticatedCoverageDependencies(authenticated)'); + assert.ok(authentication >= 0 && implementationLoad > authentication && verification > implementationLoad); +}); + +test('catalog-required candidate properties and value grammar fail before completion rows exist', () => { + const parsed = parseDocumentWithDiagnostics([ + 'fn name=candidateContract returns=void', + ' handler lang=kern', + ' if cond=true', + ' return', + ].join('\n')); + const missing = structuredClone(parsed.root.children[0]); + delete missing.children[0].children[0].props.cond; + assert.throws( + () => canonicalProfileRowsForFunction(missing, loadCanonicalizerPolicy().kirLimits), + (error) => error?.code === 'missing-property', + ); + const malformed = structuredClone(parsed.root.children[0]); + malformed.children[0].children[0].props.cond = { future: true }; + assert.throws( + () => canonicalProfileRowsForFunction(malformed, loadCanonicalizerPolicy().kirLimits), + /expected expression text or exact expression object/u, + ); +}); + +test('the executed runtime catalog must exactly match the hashed constitution', () => { + const constitution = JSON.parse(readFileSync( + new URL('../kir-structural/constitution.json', import.meta.url), + 'utf8', + )); + assert.doesNotThrow(() => validateRuntimeCatalogConstitution(constitution, STRUCTURAL_KIR_NODE_CATALOG)); + const mutated = structuredClone(constitution); + mutated.nodes[0].reasonId = `${mutated.nodes[0].reasonId}-drift`; + assert.throws( + () => validateRuntimeCatalogConstitution(mutated, STRUCTURAL_KIR_NODE_CATALOG), + /runtime catalog does not match the structural constitution/u, + ); + for (const mutate of [ + (copy) => { copy.future = true; }, + (copy) => { copy.properties.push(structuredClone(copy.properties[0])); }, + (copy) => { copy.properties[0].nodeKind = 'not-a-constitution-node'; }, + ]) { + const malformed = structuredClone(constitution); + mutate(malformed); + assert.throws( + () => validateRuntimeCatalogConstitution(malformed, STRUCTURAL_KIR_NODE_CATALOG), + /runtime catalog does not match the structural constitution/u, + ); + } +}); diff --git a/scripts/kern-canonicalizer/coverage-policy.json b/scripts/kern-canonicalizer/coverage-policy.json new file mode 100644 index 00000000..5681bfc4 --- /dev/null +++ b/scripts/kern-canonicalizer/coverage-policy.json @@ -0,0 +1,70 @@ +{ + "format": "kern.kir-canonicalizer.coverage-policy.1", + "base": { + "expressionKinds": ["boolean", "identifier", "integer", "list", "null", "text"], + "nodeKinds": ["fn", "handler", "param", "return"], + "propertyKeys": [ + "fn.async", "fn.effects", "fn.export", "fn.expr", "fn.generics", "fn.name", "fn.params", "fn.returns", "fn.stream", + "handler.code", "handler.lang", "handler.reason", "handler.review", + "param.default", "param.description", "param.max", "param.min", "param.name", "param.optional", "param.required", "param.type", "param.value", "param.variadic", + "return.trailingComment", "return.value" + ] + }, + "corpus": [ + { + "digest": "ff5fdbc8c9f63f592c930c04a14f77379b13279bedb71dcb20a7907d40a3db2a", + "path": "examples/capstone-assertion-engine/compare.kern", + "sourceKind": "handwritten", + "tool": "assertion-engine" + }, + { + "digest": "18c3d9def86d464d008311d4fd35a3e6d23e4d9499243c90d1fcdb473a51af61", + "path": "examples/capstone-assertion-engine/diag.kern", + "sourceKind": "handwritten", + "tool": "assertion-engine" + }, + { + "digest": "33b1d41d1f94e01fec3cdae46cbdf9aa0288dce1cb43c828c2a5b45433ceb8a7", + "path": "examples/capstone-assertion-engine/sort.kern", + "sourceKind": "handwritten", + "tool": "assertion-engine" + }, + { + "digest": "cfe9837888d038d9de7d1f3685e3f28871c0bfe7e6b67e2234b4c1c4e89f5818", + "path": "examples/capstone-checker-subset/checker-while.kern", + "sourceKind": "handwritten", + "tool": "checker" + }, + { + "digest": "cdb09bd110cf0b8ec419d3f8ab7f80e59f924c6a32bb4c54d790e0e8d9f9962c", + "path": "examples/capstone-checker-subset/checker.kern", + "sourceKind": "handwritten", + "tool": "checker" + }, + { + "digest": "2f0fec12660ba194c92371ce10121461e917258555a792b0b22ead8bc5c4f081", + "path": "examples/kern-canonicalizer/canonicalizer.kern", + "sourceKind": "handwritten", + "tool": "canonicalizer" + }, + { + "digest": "a54013ee9523d290a525d839ee6c0fae979c9fcb71ef2e46418b37c7d13ab464", + "path": "examples/selfhost-validator/validator.kern", + "sourceKind": "handwritten", + "tool": "validator" + } + ], + "families": [ + { "expressionKinds": ["binary"], "id": "binary-expression", "nodeKinds": [], "propertyKeys": [] }, + { "expressionKinds": [], "id": "binding", "nodeKinds": ["assign", "let"], "propertyKeys": ["assign.target", "assign.value", "let.name", "let.value"] }, + { "expressionKinds": ["call"], "id": "call-expression", "nodeKinds": [], "propertyKeys": [] }, + { "expressionKinds": [], "id": "conditional", "nodeKinds": ["else", "if"], "propertyKeys": ["if.cond"] }, + { "expressionKinds": [], "id": "counted-iteration", "nodeKinds": ["for"], "propertyKeys": ["for.from", "for.name", "for.to"] }, + { "expressionKinds": [], "id": "do-statement", "nodeKinds": ["do"], "propertyKeys": ["do.value"] }, + { "expressionKinds": [], "id": "exception-flow", "nodeKinds": ["throw"], "propertyKeys": ["throw.value"] }, + { "expressionKinds": ["index"], "id": "index-expression", "nodeKinds": [], "propertyKeys": [] }, + { "expressionKinds": ["member"], "id": "member-expression", "nodeKinds": [], "propertyKeys": [] }, + { "expressionKinds": ["unary"], "id": "unary-expression", "nodeKinds": [], "propertyKeys": [] }, + { "expressionKinds": [], "id": "while-iteration", "nodeKinds": ["while"], "propertyKeys": ["while.cond"] } + ] +} diff --git a/scripts/kern-canonicalizer/coverage-profile.mjs b/scripts/kern-canonicalizer/coverage-profile.mjs new file mode 100644 index 00000000..bd8ef8e5 --- /dev/null +++ b/scripts/kern-canonicalizer/coverage-profile.mjs @@ -0,0 +1,369 @@ +import { CanonicalValueDecodeError } from '../../packages/core/dist/canonical-value/types.js'; +import { + decodeStructuralKir, + encodeStructuralKir, +} from '../../packages/core/dist/kir-structural/canonical.js'; +import { STRUCTURAL_KIR_NODE_CATALOG } from '../../packages/core/dist/kir-structural/catalog.generated.js'; +import { projectExpressionText } from '../../packages/core/dist/kir-structural/expression.js'; +import { StructuralKirError } from '../../packages/core/dist/kir-structural/types.js'; + +import { flattenKirRoots } from './flatten.mjs'; + +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/u; +const INTEGER = /^(?:0|[1-9][0-9]*)$/u; +const RESERVED_EXPRESSION_IDENTIFIERS = new Set([ + 'await', 'false', 'new', 'none', 'null', 'true', 'typeof', 'undefined', +]); +const PARAMETER_TYPES = new Set([ + 'boolean', 'boolean[]', 'number', 'number[]', 'string', 'string[]', +]); +const RETURN_TYPES = new Set([...PARAMETER_TYPES, 'void']); +const BASE_EXPRESSION_KINDS = [ + 'boolean', 'identifier', 'integer', 'list', 'null', 'text', +]; +const BASE_NODE_KINDS = ['fn', 'handler', 'param', 'return']; +const BASE_PROPERTIES = Object.freeze({ + fn: { optional: ['export'], required: ['name', 'returns'] }, + handler: { optional: [], required: ['lang'] }, + param: { optional: [], required: ['name', 'type'] }, + return: { optional: ['value'], required: [] }, +}); +const BASE_PROPERTY_KEYS = BASE_NODE_KINDS.flatMap((kind) => + Object.keys(STRUCTURAL_KIR_NODE_CATALOG.get(kind)?.properties ?? {}).map((key) => `${kind}.${key}`) +).sort(); +const PROFILE_LIMIT_KEYS = ['maxNodeRows', 'maxPropertyRows', 'maxValueRows']; +const PROFILE_ROW_KEYS = ['nodes', 'properties', 'values']; +const STATEMENT_CONTAINERS = new Set(['else', 'for', 'handler', 'if', 'while']); +const STATEMENT_KINDS = new Set(['assign', 'do', 'else', 'for', 'if', 'let', 'return', 'throw', 'while']); + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function sameText(left, right) { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +export function validateCoverageBase(base) { + if ( + !sameText(base.nodeKinds, BASE_NODE_KINDS) || + !sameText(base.expressionKinds, BASE_EXPRESSION_KINDS) || + !sameText(base.propertyKeys, BASE_PROPERTY_KEYS) + ) { + throw new TypeError('coverage policy rejection: base must exactly match the frozen M4.1 profile'); + } + return base; +} + +function expressionSource(value, path) { + if (typeof value === 'string') return value; + if ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + value.__expr === true && + typeof value.code === 'string' && + Reflect.ownKeys(value).length === 2 + ) { + return value.code; + } + throw new TypeError(`coverage profile rejection: malformed expression payload at ${path}`); +} + +function canonicalRecord(value) { + if (value?.tag !== 'record') return null; + return new Map(value.value.map((entry) => [entry.key, entry.value])); +} + +function localBaseExpressionBlocker(value) { + const root = canonicalRecord(value); + const kind = root?.get('kind'); + const fieldsValue = root?.get('fields'); + const fields = canonicalRecord(fieldsValue); + if (root?.size !== 2 || kind?.tag !== 'text' || fields === null) return 'expression.shape'; + if (kind.value === 'null') return fields.size === 0 ? null : 'expression.null.shape'; + if (kind.value === 'identifier') { + const name = fields.get('name'); + if (fields.size !== 1 || name?.tag !== 'text' || !IDENTIFIER.test(name.value)) { + return 'expression.identifier.shape'; + } + return RESERVED_EXPRESSION_IDENTIFIERS.has(name.value) ? `expression.identifier.${name.value}` : null; + } + if (kind.value === 'boolean') { + return fields.size === 1 && fields.get('value')?.tag === 'bool' ? null : 'expression.boolean.shape'; + } + if (kind.value === 'integer') { + const integer = fields.get('value'); + return fields.size === 1 && integer?.tag === 'int' && INTEGER.test(integer.value) + ? null + : `expression.integer.${integer?.tag === 'int' ? integer.value : 'shape'}`; + } + if (kind.value === 'text') { + const text = fields.get('value'); + if (fields.size !== 1 || text?.tag !== 'text') return 'expression.text.shape'; + for (let index = 0; index < text.value.length; index += 1) { + const code = text.value.charCodeAt(index); + if ( + (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) || + (code >= 0x7f && code <= 0x9f) || + code === 0x2028 || + code === 0x2029 || + code === 0xfeff + ) { + return `expression.text.character-u${code.toString(16).padStart(4, '0')}`; + } + } + return null; + } + if (kind.value !== 'list') return null; + const items = fields.get('items'); + return fields.size === 1 && items?.tag === 'list' ? null : 'expression.list.shape'; +} + +export function baseExpressionProfileBlockers(value, base) { + const blockers = []; + function visit(current) { + const root = canonicalRecord(current); + const kind = root?.get('kind'); + const fields = root?.get('fields'); + if (root?.size === 2 && kind?.tag === 'text' && fields?.tag === 'record') { + if (base.expressionKinds.includes(kind.value)) { + const blocker = localBaseExpressionBlocker(current); + if (blocker !== null) blockers.push(blocker); + } + visit(fields); + return; + } + if (current?.tag === 'list') { + for (const item of current.value) visit(item); + } else if (current?.tag === 'record') { + for (const entry of current.value) visit(entry.value); + } else if (current?.tag === 'map') { + for (const entry of current.value) { + visit(entry.key); + visit(entry.value); + } + } + } + visit(value); + return [...new Set(blockers)].sort(compareText); +} + +function includedUnexpectedProperties(node, profile) { + const allowed = new Set([...profile.required, ...profile.optional]); + const contract = STRUCTURAL_KIR_NODE_CATALOG.get(node.type); + return Object.keys(node.props ?? {}).filter((key) => + !allowed.has(key) && + (!Object.hasOwn(contract?.properties ?? {}, key) || !contract.properties[key].disposition.startsWith('excluded-')), + ); +} + +function validBoolean(value) { + return value === true || value === false || value === 'true' || value === 'false'; +} + +function statementChildrenInvalid(node) { + const children = node.children ?? []; + if (!STATEMENT_CONTAINERS.has(node.type)) { + return STATEMENT_KINDS.has(node.type) && children.length !== 0; + } + const kinds = children.map(({ type }) => type); + if (kinds.some((kind) => !STATEMENT_KINDS.has(kind))) return true; + const returns = kinds.flatMap((kind, index) => kind === 'return' ? [index] : []); + if (returns.length > 1 || (returns.length === 1 && returns[0] !== kinds.length - 1)) return true; + return kinds.some((kind, index) => kind === 'else' && kinds[index - 1] !== 'if'); +} + +export function canonicalProfileRowsForFunction(root, limits) { + const artifact = decodeStructuralKir(encodeStructuralKir(root, limits), limits); + const tables = flattenKirRoots([artifact.root]); + return { + nodes: tables.nodeKind.length, + properties: tables.propNode.length, + values: tables.valueTag.length, + }; +} + +function collectProfileBlockersForFunction(root, base, profileLimits, profileRows) { + validateCoverageBase(base); + if (root?.type !== 'fn') return { authored: ['root.kind'], firstPath: '$', firstPosition: 0 }; + const events = []; + const nodes = []; + const locations = new WeakMap(); + let eventOrder = 0; + let nodePosition = 0; + const add = (blocker, node = null) => { + const location = node === null ? { path: '$', position: nodePosition } : locations.get(node); + events.push({ blocker, order: eventOrder++, path: location.path, position: location.position }); + }; + + function visit(node, path) { + nodes.push(node); + locations.set(node, { path, position: nodePosition++ }); + if (statementChildrenInvalid(node)) add(`${node.type}.children`, node); + const profile = Object.hasOwn(BASE_PROPERTIES, node.type) ? BASE_PROPERTIES[node.type] : undefined; + if (profile) { + for (const key of profile.required) { + if (!Object.hasOwn(node.props ?? {}, key)) add(`${node.type}.properties.${key}`, node); + } + for (const key of includedUnexpectedProperties(node, profile)) add(`${node.type}.properties.${key}`, node); + } + if ((node.type === 'param' || node.type === 'return') && (node.children?.length ?? 0) !== 0) { + add(`${node.type}.children`, node); + } + const contract = STRUCTURAL_KIR_NODE_CATALOG.get(node.type); + for (const [key, value] of Object.entries(node.props ?? {})) { + if (contract?.properties[key]?.disposition !== 'lowered-expression') continue; + let projected; + try { + projected = projectExpressionText(expressionSource(value, `${node.type}.${key}`), `${node.type}.${key}`); + } catch (error) { + if (error instanceof StructuralKirError || error instanceof CanonicalValueDecodeError) continue; + throw error; + } + for (const blocker of baseExpressionProfileBlockers(projected, base)) { + add(`${node.type}.properties.${key}.${blocker}`, node); + } + } + (node.children ?? []).forEach((child, index) => visit(child, `${path}.children[${index}]`)); + } + visit(root, '$'); + + const rootProperties = root.props ?? {}; + if (typeof rootProperties.name !== 'string' || !IDENTIFIER.test(rootProperties.name)) { + add('fn.properties.name.value', root); + } + if (typeof rootProperties.returns !== 'string' || !RETURN_TYPES.has(rootProperties.returns)) { + add('fn.properties.returns.value', root); + } + if (Object.hasOwn(rootProperties, 'export') && !validBoolean(rootProperties.export)) { + add('fn.properties.export.value', root); + } + + const directChildren = root.children ?? []; + if ( + directChildren.length === 0 || + directChildren.at(-1)?.type !== 'handler' || + directChildren.slice(0, -1).some(({ type }) => type !== 'param') + ) { + add('fn.children', root); + } + const parameterNames = new Set(); + for (const param of directChildren.filter(({ type }) => type === 'param')) { + if (typeof param.props?.name !== 'string' || !IDENTIFIER.test(param.props.name) || parameterNames.has(param.props.name)) { + add('param.properties.name.value', param); + } + parameterNames.add(param.props?.name); + if (typeof param.props?.type !== 'string' || !PARAMETER_TYPES.has(param.props.type)) { + add('param.properties.type.value', param); + } + } + + const allKindsAreBase = nodes.every(({ type }) => base.nodeKinds.includes(type)); + for (const handler of nodes.filter(({ type }) => type === 'handler')) { + if (handler.props?.lang !== 'kern') add('handler.properties.lang.value', handler); + if (allKindsAreBase && ((handler.children?.length ?? 0) !== 1 || handler.children[0]?.type !== 'return')) { + add('handler.children', handler); + } + } + for (const returned of nodes.filter(({ type }) => type === 'return')) { + const hasValue = Object.hasOwn(returned.props ?? {}, 'value'); + if ((rootProperties.returns === 'void' && hasValue) || (rootProperties.returns !== 'void' && !hasValue)) { + add('return.properties.value', returned); + } + } + if (profileLimits !== undefined) { + const limitKeys = Reflect.ownKeys(profileLimits).toSorted(); + if ( + limitKeys.length !== PROFILE_LIMIT_KEYS.length || + limitKeys.some((key, index) => key !== PROFILE_LIMIT_KEYS[index]) || + PROFILE_LIMIT_KEYS.some((key) => !Number.isSafeInteger(profileLimits[key]) || profileLimits[key] < 1) + ) { + throw new TypeError('coverage profile rejection: invalid profile limits'); + } + if (profileRows !== null) { + const rowKeys = typeof profileRows !== 'object' || Array.isArray(profileRows) + ? [] + : Reflect.ownKeys(profileRows); + if ( + rowKeys.some((key) => typeof key !== 'string') || + rowKeys.length !== PROFILE_ROW_KEYS.length || + rowKeys.toSorted().some((key, index) => key !== PROFILE_ROW_KEYS[index]) || + PROFILE_ROW_KEYS.some((key) => !Number.isSafeInteger(profileRows[key]) || profileRows[key] < 0) + ) { + throw new TypeError('coverage profile rejection: invalid profile rows'); + } + for (const [kind, limitName] of [ + ['nodes', 'maxNodeRows'], + ['properties', 'maxPropertyRows'], + ['values', 'maxValueRows'], + ]) { + if (profileRows[kind] > profileLimits[limitName]) add(`profile.rows.${kind}`); + } + } + } + const seen = new Set(); + const authoredEvents = events.toSorted((left, right) => left.position - right.position || left.order - right.order) + .filter(({ blocker }) => !seen.has(blocker) && seen.add(blocker)); + return { + authored: authoredEvents.map(({ blocker }) => blocker), + firstPath: authoredEvents[0]?.path ?? null, + firstPosition: authoredEvents[0]?.position ?? Number.POSITIVE_INFINITY, + }; +} + +export function analyzeProfileBlockersForFunction(root, base, profileLimits, profileRows) { + const { authored, firstPath, firstPosition } = collectProfileBlockersForFunction( + root, base, profileLimits, profileRows, + ); + return { blockers: [...authored].sort(compareText), first: authored[0] ?? null, firstPath, firstPosition }; +} + +export function firstUnsupportedByAuthoredOrder(structural, profile) { + const firstStructural = structural[0] ?? null; + if (profile.first !== null && profile.firstPosition < (firstStructural?.position ?? Number.POSITIVE_INFINITY)) { + return { category: 'profile', path: profile.firstPath, value: profile.first }; + } + return firstStructural === null ? null : { + category: firstStructural.category, + path: firstStructural.path, + value: firstStructural.value, + }; +} + +export function firstProfileBlockerForFunction(root, base, profileLimits, profileRows) { + return analyzeProfileBlockersForFunction(root, base, profileLimits, profileRows).first; +} + +export function profileBlockersForFunction(root, base, profileLimits, profileRows) { + return analyzeProfileBlockersForFunction(root, base, profileLimits, profileRows).blockers; +} + +export function handlerChildProfilesForFunction(root) { + const profiles = []; + function visit(node) { + if (node.type === 'handler') profiles.push((node.children ?? []).map(({ type }) => type)); + for (const child of node.children ?? []) visit(child); + } + visit(root); + return profiles; +} + +export function handlerChildProfilesComplete(profile, profiles) { + if (!Array.isArray(profiles)) return false; + return profiles.every((childKinds) => { + if (!Array.isArray(childKinds)) return false; + if (childKinds.length === 1 && childKinds[0] === 'return') return true; + if ( + profile.candidateNodeKinds.size === 0 || + childKinds.length === 0 || + !childKinds.some((kind) => profile.candidateNodeKinds.has(kind)) || + !childKinds.every((kind) => kind === 'return' || profile.candidateNodeKinds.has(kind)) + ) return false; + const returnIndexes = childKinds.flatMap((kind, index) => kind === 'return' ? [index] : []); + if (returnIndexes.length > 1 || (returnIndexes.length === 1 && returnIndexes[0] !== childKinds.length - 1)) { + return false; + } + return childKinds.every((kind, index) => kind !== 'else' || childKinds[index - 1] === 'if'); + }); +} diff --git a/scripts/kern-canonicalizer/coverage-summary-writer.mjs b/scripts/kern-canonicalizer/coverage-summary-writer.mjs new file mode 100644 index 00000000..e8534045 --- /dev/null +++ b/scripts/kern-canonicalizer/coverage-summary-writer.mjs @@ -0,0 +1,44 @@ +import { randomUUID } from 'node:crypto'; +import { closeSync, lstatSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +function outputPath(summaryUrl) { + return summaryUrl instanceof URL ? fileURLToPath(summaryUrl) : summaryUrl; +} + +function summaryBytes(summary) { + return Buffer.from(`${JSON.stringify(summary, null, 2)}\n`); +} + +export function assertCoverageSummary(summaryUrl, summary) { + const path = outputPath(summaryUrl); + const source = lstatSync(path, { throwIfNoEntry: false }); + if (source === undefined || !source.isFile()) { + throw new TypeError('coverage summary source must be a regular file'); + } + if (!readFileSync(path).equals(summaryBytes(summary))) { + throw new TypeError('coverage summary source must contain canonical bytes'); + } +} + +export function writeCoverageSummary(summaryUrl, summary) { + const path = outputPath(summaryUrl); + const destination = lstatSync(path, { throwIfNoEntry: false }); + if (destination !== undefined && !destination.isFile()) { + throw new TypeError('coverage summary destination must be a regular file'); + } + const temporaryPath = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`); + try { + const descriptor = openSync(temporaryPath, 'wx', 0o644); + try { + writeFileSync(descriptor, summaryBytes(summary)); + } finally { + closeSync(descriptor); + } + renameSync(temporaryPath, path); + } catch (error) { + rmSync(temporaryPath, { force: true }); + throw error; + } +} diff --git a/scripts/kern-canonicalizer/coverage-summary.json b/scripts/kern-canonicalizer/coverage-summary.json new file mode 100644 index 00000000..d95c6034 --- /dev/null +++ b/scripts/kern-canonicalizer/coverage-summary.json @@ -0,0 +1,159 @@ +{ + "baseCompleteFunctions": 1, + "blockers": [ + { + "count": 97, + "id": "fn.params" + }, + { + "count": 47, + "id": "profile.rows.values" + }, + { + "count": 13, + "id": "projection.limit-depth" + }, + { + "count": 12, + "id": "profile.rows.nodes" + }, + { + "count": 7, + "id": "profile.rows.properties" + }, + { + "count": 1, + "id": "if.properties.cond.expression.text.character-u007f" + }, + { + "count": 1, + "id": "if.properties.cond.expression.text.character-u0080" + }, + { + "count": 1, + "id": "if.properties.cond.expression.text.character-u009f" + }, + { + "count": 1, + "id": "if.properties.cond.expression.text.character-u2028" + }, + { + "count": 1, + "id": "if.properties.cond.expression.text.character-u2029" + }, + { + "count": 1, + "id": "if.properties.cond.expression.text.character-ufeff" + }, + { + "count": 1, + "id": "projection.limit-nodes" + }, + { + "count": 1, + "id": "projection.unknown-expression-kind" + }, + { + "count": 1, + "id": "throw.value:unknown-expression-kind" + } + ], + "catalogDigest": "fa3a0cddc280ff2d8dd9f09cf575953b5adbaaf6f8c716c05e06faf2d43cd6ea", + "canonicalizerDigest": "2f0fec12660ba194c92371ce10121461e917258555a792b0b22ead8bc5c4f081", + "canonicalizerPolicyDigest": "87463f6a56c75aeffc853c52923312a99b6ff864e9e37afe8d984c5704f917c2", + "compiledCoreDigest": "7b00119bb78af4ed955f7f0f3d636393b9ab6f0685bf11df661d5ab9da132725", + "corpusMembers": 7, + "corpusDigest": "414746c7d4d1fd5e1027b02dff2b8f20402b36348a445b77a5a8f32fd2d7c089", + "coverageImplementationDigest": "fd6f0a53ec23088f82d8a6c06ad40c5ff31f4b1db00f6a6f6251efd77b361550", + "coveragePolicyDigest": "7788328e7862f5a697f5d750633cc1b005069f09b53ad38a090b30d0c9a537fa", + "familyRegistryDigest": "a7ea4bdc1af766f893b7491a59c727b0459ecb637a71f9f54d6087ee5baeeb87", + "expressionCatalogDigest": "d6d2a37f3b2ecc9989e3ff52c2d13357a85bd181e6b066e6ee523a127f05340d", + "format": "kern.kir-canonicalizer.coverage-summary.1", + "functionFactsDigest": "088ff03fb9157e42f8c28e40f7df7ed4720a30e5008ce135816ae0e8e09244dd", + "functionCount": 98, + "policyDigest": "56e9d72beac6349eba3d161bd5b6d05cec3ce60f9b606f3dc58932981832d858", + "profileDigest": "89b8d503b64d83a6475283ba3b58763b675a23a0f053b7875964c2867b2d3f85", + "selection": { + "ranking": [ + { + "completeFunctions": 0, + "completeTools": 0, + "id": "conditional", + "occurrences": 1097, + "witnesses": [] + }, + { + "completeFunctions": 0, + "completeTools": 0, + "id": "binary-expression", + "occurrences": 941, + "witnesses": [] + }, + { + "completeFunctions": 0, + "completeTools": 0, + "id": "binding", + "occurrences": 633, + "witnesses": [] + }, + { + "completeFunctions": 0, + "completeTools": 0, + "id": "index-expression", + "occurrences": 491, + "witnesses": [] + }, + { + "completeFunctions": 0, + "completeTools": 0, + "id": "call-expression", + "occurrences": 445, + "witnesses": [] + }, + { + "completeFunctions": 0, + "completeTools": 0, + "id": "counted-iteration", + "occurrences": 440, + "witnesses": [] + }, + { + "completeFunctions": 0, + "completeTools": 0, + "id": "member-expression", + "occurrences": 251, + "witnesses": [] + }, + { + "completeFunctions": 0, + "completeTools": 0, + "id": "do-statement", + "occurrences": 160, + "witnesses": [] + }, + { + "completeFunctions": 0, + "completeTools": 0, + "id": "unary-expression", + "occurrences": 43, + "witnesses": [] + }, + { + "completeFunctions": 0, + "completeTools": 0, + "id": "exception-flow", + "occurrences": 36, + "witnesses": [] + }, + { + "completeFunctions": 0, + "completeTools": 0, + "id": "while-iteration", + "occurrences": 2, + "witnesses": [] + } + ], + "winner": null + }, + "toolCount": 4 +} diff --git a/scripts/kern-canonicalizer/coverage.mjs b/scripts/kern-canonicalizer/coverage.mjs new file mode 100644 index 00000000..ed65e7c3 --- /dev/null +++ b/scripts/kern-canonicalizer/coverage.mjs @@ -0,0 +1,19 @@ +import { + authenticateCoverageDependencies, + verifyAuthenticatedCoverageDependencies, +} from './coverage-dependencies.mjs'; + +const authenticated = authenticateCoverageDependencies(); +const implementation = await import('./coverage-implementation.mjs'); +verifyAuthenticatedCoverageDependencies(authenticated); + +export const { + assertCoverageClosed, + collectCanonicalExpressionKinds, + loadCoveragePolicy, + measureCanonicalizerCoverage, + readCorpusMemberBytes, + selectCanonicalizerTranche, + summarizeCanonicalizerCoverage, + validateCoveragePolicy, +} = implementation; diff --git a/scripts/kern-canonicalizer/coverage.test.mjs b/scripts/kern-canonicalizer/coverage.test.mjs new file mode 100644 index 00000000..bbf2fe4b --- /dev/null +++ b/scripts/kern-canonicalizer/coverage.test.mjs @@ -0,0 +1,336 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { parseDocumentWithDiagnostics } from '../../packages/core/dist/parser.js'; +import { loadCanonicalizerPolicy } from './policy.mjs'; +import { + assertCoverageClosed, + collectCanonicalExpressionKinds, + loadCoveragePolicy, + measureCanonicalizerCoverage, + readCorpusMemberBytes, + summarizeCanonicalizerCoverage, + validateCoveragePolicy, +} from './coverage.mjs'; +import { + baseExpressionProfileBlockers, + canonicalProfileRowsForFunction, + profileBlockersForFunction, +} from './coverage-profile.mjs'; +import { VALID_FIXTURES } from './fixtures.mjs'; + +function canonicalExpression(kind, fields) { + return { + tag: 'record', + value: [ + { + key: 'fields', + value: { + tag: 'record', + value: Object.entries(fields).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) + .map(([key, value]) => ({ key, value })), + }, + }, + { key: 'kind', value: { tag: 'text', value: kind } }, + ], + }; +} + +test('the handwritten corpus produces one deterministic catalog-bound selection receipt', () => { + const policy = loadCoveragePolicy(); + const first = measureCanonicalizerCoverage(policy); + const second = measureCanonicalizerCoverage(policy); + assert.deepEqual(second, first); + assert.equal(first.format, 'kern.kir-canonicalizer.coverage-receipt.1'); + assert.equal( + first.canonicalizerPolicyDigest, + createHash('sha256').update(readFileSync(new URL('./policy.json', import.meta.url))).digest('hex'), + ); + assert.match(first.coveragePolicyDigest, /^[0-9a-f]{64}$/u); + assert.match(first.corpusDigest, /^[0-9a-f]{64}$/u); + assert.equal(first.corpus.length, policy.corpus.length); + assert.equal(new Set(first.corpus.map(({ tool }) => tool)).size, 4); + assert.deepEqual(new Set(first.corpus.map(({ sourceKind }) => sourceKind)), new Set(['handwritten'])); + assert.ok(first.functions.length > 0); + assert.ok(first.functions.some(({ firstUnsupported }) => firstUnsupported !== null)); + assert.equal(first.selection.winner, null); + assert.equal(first.selection.ranking.every(({ completeFunctions }) => completeFunctions === 0), true); + assert.deepEqual(first.selection.ranking.map(({ id }) => id), [ + 'conditional', + 'binary-expression', + 'binding', + 'index-expression', + 'call-expression', + 'counted-iteration', + 'member-expression', + 'do-statement', + 'unary-expression', + 'exception-flow', + 'while-iteration', + ]); + const checkedIn = JSON.parse(readFileSync(new URL('./coverage-summary.json', import.meta.url), 'utf8')); + assert.deepEqual(summarizeCanonicalizerCoverage(first), checkedIn); + const fresh = spawnSync(process.execPath, [ + '--input-type=module', + '-e', + "import {summarizeCanonicalizerCoverage} from './scripts/kern-canonicalizer/coverage.mjs'; process.stdout.write(JSON.stringify(summarizeCanonicalizerCoverage()))", + ], { + cwd: new URL('../../', import.meta.url), + encoding: 'utf8', + env: { ...process.env, LANG: 'C', LC_ALL: 'C', TZ: 'UTC' }, + }); + assert.equal(fresh.status, 0, fresh.stderr); + assert.deepEqual(JSON.parse(fresh.stdout), checkedIn); +}); + +test('policy validation rejects corpus, family, ordering, and catalog invention', () => { + const policy = loadCoveragePolicy(); + for (const mutate of [ + (copy) => { copy.future = true; }, + (copy) => { copy.corpus[0].future = true; }, + (copy) => { copy.corpus[0].sourceKind = 'generated'; }, + (copy) => { copy.corpus[1].path = copy.corpus[0].path; }, + (copy) => { copy.corpus[0].digest = '0'.repeat(64); }, + (copy) => { + copy.corpus.push({ + digest: '0'.repeat(64), + path: 'examples/000-missing.kern', sourceKind: 'handwritten', + tool: 'missing', + }); + copy.corpus.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0); + }, + (copy) => { + const path = 'examples/capstone-assertion-engine/main.kern'; + copy.corpus.push({ + digest: createHash('sha256').update(readFileSync(new URL(`../../${path}`, import.meta.url))).digest('hex'), + path, sourceKind: 'handwritten', + tool: 'assertion-engine', + }); + copy.corpus.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0); + }, + (copy) => { copy.corpus.reverse(); }, + (copy) => { copy.families[0].future = true; }, + (copy) => { copy.families[1].id = copy.families[0].id; }, + (copy) => { copy.families[0].nodeKinds = ['not-a-real-kir-kind']; }, + (copy) => { copy.families[0].expressionKinds = ['not-a-real-expression-kind']; }, + (copy) => { copy.families[1].nodeKinds.push('while'); copy.families[1].nodeKinds.sort(); }, + (copy) => { copy.families.reverse(); }, + (copy) => { copy.base.expressionKinds.push('decimal'); copy.base.expressionKinds.sort(); }, + (copy) => { copy.base.propertyKeys.pop(); }, + ]) { + const copy = structuredClone(policy); + mutate(copy); + assert.throws(() => validateCoveragePolicy(copy), /coverage policy rejection/u); + } +}); + +test('coverage closure rejects missing observed facts and unobserved claims', () => { + const policy = loadCoveragePolicy(); + const measured = measureCanonicalizerCoverage(policy); + const missing = structuredClone(policy); + missing.families = missing.families.filter(({ id }) => id !== 'index-expression'); + assert.throws(() => assertCoverageClosed(missing, measured.functions), /unclaimed expression kind index/u); + assert.throws( + () => assertCoverageClosed(policy, measured.functions.filter(({ nodeKinds }) => !nodeKinds.includes('while'))), + /unobserved node kind while/u, + ); + const inventedProperty = structuredClone(measured.functions); + inventedProperty[0].propertyKeys.push('if.future'); + assert.throws(() => assertCoverageClosed(policy, inventedProperty), /unclaimed property if.future/u); + const inventedBaseProperty = structuredClone(measured.functions); + inventedBaseProperty[0].propertyKeys.push('fn.future'); + assert.throws(() => assertCoverageClosed(policy, inventedBaseProperty), /unclaimed property fn.future/u); +}); + +test('coverage closure records catalog-excluded candidate properties as blockers rather than family claims', () => { + const policy = loadCoveragePolicy(); + const functions = structuredClone(measureCanonicalizerCoverage(policy).functions); + const target = functions.find(({ nodeKinds }) => nodeKinds.includes('let')); + assert.ok(target); + target.propertyKeys.push('let.type'); + target.propertyKeys.sort(); + target.propertyOccurrences.push('let.type'); + target.propertyOccurrences.sort(); + assert.doesNotThrow(() => assertCoverageClosed(policy, functions)); +}); + +test('the exact M4.1 property and child profile blocks catalog-wide false completion', () => { + const parsed = parseDocumentWithDiagnostics([ + 'fn name=profileGap returns=string async=true', + ' handler lang=kern', + ' return value="\\"one\\""', + ' return value="\\"two\\""', + ].join('\n')); + const root = parsed.root.children[0]; + assert.deepEqual(profileBlockersForFunction(root, loadCoveragePolicy().base), [ + 'fn.properties.async', + 'handler.children', + ]); +}); + +test('nested base expressions remain validated under a candidate expression', () => { + const candidate = canonicalExpression('binary', { + left: canonicalExpression('identifier', { name: { tag: 'text', value: 'x' } }), + op: { tag: 'text', value: '+' }, + right: canonicalExpression('identifier', { name: { tag: 'text', value: 'await' } }), + }); + assert.deepEqual(baseExpressionProfileBlockers(candidate, loadCoveragePolicy().base), [ + 'expression.identifier.await', + ]); +}); + +test('every expression-shaped record remains visible to catalog closure', () => { + assert.deepEqual(collectCanonicalExpressionKinds(canonicalExpression('future-expression', {})), [ + 'future-expression', + ]); +}); + +test('the base text-expression profile enforces the KERN quotesource character ceiling', () => { + const base = loadCoveragePolicy().base; + for (const value of ['\0', '\u007f', '\u0080', '\u009f', '\u2028', '\u2029', '\ufeff']) { + const blockers = baseExpressionProfileBlockers( + canonicalExpression('text', { value: { tag: 'text', value } }), + base, + ); + assert.equal(blockers.length, 1, `must reject U+${value.charCodeAt(0).toString(16).padStart(4, '0')}`); + assert.match(blockers[0], /^expression\.text\.character-/u); + } + for (const value of ['\t', '\n', '\r', 'plain']) { + assert.deepEqual( + baseExpressionProfileBlockers(canonicalExpression('text', { value: { tag: 'text', value } }), base), + [], + ); + } +}); + +test('the exact M4.1 profile rejects a function above its node-row ceiling', () => { + const parsed = parseDocumentWithDiagnostics([ + 'fn name=tooManyRows returns=void', + ...Array.from({ length: 14 }, (_, index) => ` param name=p${index} type=number`), + ' handler lang=kern', + ' return', + ].join('\n')); + const blockers = profileBlockersForFunction( + parsed.root.children[0], + loadCoveragePolicy().base, + loadCanonicalizerPolicy().profileLimits, + { nodes: 17, properties: 31, values: 73 }, + ); + assert.ok(blockers.includes('profile.rows.nodes')); + assert.ok(blockers.includes('profile.rows.properties')); + assert.ok(blockers.includes('profile.rows.values')); +}); + +test('profile node ceilings use the codec-measured row count', () => { + const parsed = parseDocumentWithDiagnostics([ + 'fn name=codecRowsOnly returns=void', + ' handler lang=kern', + ' return', + ].join('\n')); + const blockers = profileBlockersForFunction( + parsed.root.children[0], + loadCoveragePolicy().base, + loadCanonicalizerPolicy().profileLimits, + { nodes: 17, properties: 3, values: 4 }, + ); + assert.ok(blockers.includes('profile.rows.nodes')); +}); + +test('unknown base-node properties fail closed without inherited-property crashes', () => { + const parsed = parseDocumentWithDiagnostics([ + 'fn name=unknownProperty returns=void', + ' handler lang=kern', + ' return', + ].join('\n')); + const root = structuredClone(parsed.root.children[0]); + root.props.constructor = true; + root.props.future = true; + assert.deepEqual(profileBlockersForFunction(root, loadCoveragePolicy().base), [ + 'fn.properties.constructor', + 'fn.properties.future', + ]); +}); + +test('corpus reads reject symlinks even when their target remains inside the root', () => { + const root = mkdtempSync(join(tmpdir(), 'kern-coverage-corpus-')); + try { + writeFileSync(join(root, 'target.kern'), 'fn name=target returns=void\n handler lang=kern\n return\n'); + symlinkSync('target.kern', join(root, 'alias.kern')); + assert.match(readCorpusMemberBytes('target.kern', root).toString('utf8'), /name=target/u); + assert.throws( + () => readCorpusMemberBytes('alias.kern', root), + /corpus member alias\.kern must be a contained regular file/u, + ); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); + +test('the base profile admits every M4.1 golden function', () => { + const base = loadCoveragePolicy().base; + for (const fixture of VALID_FIXTURES) { + const parsed = parseDocumentWithDiagnostics(fixture.source); + for (const root of parsed.root.children ?? []) { + assert.deepEqual(profileBlockersForFunction(root, base), [], `${fixture.id}:${root.props?.name}`); + } + } +}); + +test('coverage rows pass through the canonical KIR codec and enforce its limits', () => { + const parsed = parseDocumentWithDiagnostics([ + 'fn name=canonicalRows returns=void', + ' handler lang=kern', + ' return', + ].join('\n')); + const root = parsed.root.children[0]; + const limits = loadCanonicalizerPolicy().kirLimits; + assert.deepEqual(canonicalProfileRowsForFunction(root, limits), { + nodes: 3, + properties: 3, + values: 4, + }); + assert.throws( + () => canonicalProfileRowsForFunction(root, { ...limits, maxStringBytes: 1 }), + (error) => error?.code === 'limit-string', + ); +}); + +test('nested candidate containers reject orphan else and duplicate returns', () => { + for (const body of [ + [' if cond=true', ' else'], + [' if cond=true', ' return', ' return'], + ]) { + const parsed = parseDocumentWithDiagnostics([ + 'fn name=nestedSequence returns=void', + ' handler lang=kern', + ...body, + ].join('\n')); + assert.deepEqual(parsed.diagnostics.filter(({ severity }) => severity === 'error'), []); + assert.ok( + profileBlockersForFunction(parsed.root.children[0], loadCoveragePolicy().base).includes('if.children'), + body.join('\n'), + ); + } +}); + +test('profile row ceilings fail closed when a configured limit is missing', () => { + const parsed = parseDocumentWithDiagnostics([ + 'fn name=missingLimit returns=void', + ' handler lang=kern', + ' return', + ].join('\n')); + assert.throws( + () => profileBlockersForFunction( + parsed.root.children[0], + loadCoveragePolicy().base, + { maxNodeRows: 16, maxPropertyRows: 30 }, + { nodes: 3, properties: 4, values: 5 }, + ), + /coverage profile rejection: invalid profile limits/u, + ); +}); diff --git a/scripts/kern-canonicalizer/policy.mjs b/scripts/kern-canonicalizer/policy.mjs index 061ae9d9..327222d5 100644 --- a/scripts/kern-canonicalizer/policy.mjs +++ b/scripts/kern-canonicalizer/policy.mjs @@ -1,5 +1,7 @@ import { readFileSync } from 'node:fs'; +const CANONICALIZER_POLICY_SOURCE = readFileSync(new URL('./policy.json', import.meta.url)); + const POLICY_KEYS = { expansionLimits: ['kirToSourceMaxFactor', 'runtimeEnvelopeMaxFactor'], kirLimits: [ @@ -50,6 +52,10 @@ export function validateCanonicalizerPolicy(policy) { export function loadCanonicalizerPolicy() { return validateCanonicalizerPolicy( - JSON.parse(readFileSync(new URL('./policy.json', import.meta.url), 'utf8')), + JSON.parse(CANONICALIZER_POLICY_SOURCE.toString('utf8')), ); } + +export function canonicalizerPolicySource() { + return Buffer.from(CANONICALIZER_POLICY_SOURCE); +}