diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..607a0bd --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu +cd "$(git rev-parse --show-toplevel)" +command -v node >/dev/null 2>&1 || { echo "pre-push: Node.js is required for the comment gate" >&2; exit 1; } +exec node tools/comment-gate.cjs diff --git a/.github/workflows/comments.yml b/.github/workflows/comments.yml new file mode 100644 index 0000000..7b052e6 --- /dev/null +++ b/.github/workflows/comments.yml @@ -0,0 +1,13 @@ +name: Source comment policy +on: [push, pull_request] +permissions: + contents: read +jobs: + comments: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24' + - run: make comments comments-test diff --git a/AGENTS.md b/AGENTS.md index 33d2d24..bbcb0d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -340,3 +340,11 @@ Cross-repo sequencing, and what this repo owes station and when, is in [station-and-plugin-plan.md](https://github.com/voxgig/station/blob/main/docs/design/station-and-plugin-plan.md). The agreed position between the two designs is [station-and-plugin.md](https://github.com/voxgig/station/blob/main/docs/design/station-and-plugin.md). + +## Source code comments + +Follow [COMMENT-POLICY.md](COMMENT-POLICY.md): comments are sparse and terse, +only for intricate or surprising code. Names carry intent; documents carry +requirements. Run `make comments comments-test` after editing source. + +Durable implementation rationale is in [COMMENT-NOTES.md](COMMENT-NOTES.md). diff --git a/COMMENT-NOTES.md b/COMMENT-NOTES.md new file mode 100644 index 0000000..fc19f8f --- /dev/null +++ b/COMMENT-NOTES.md @@ -0,0 +1,11 @@ +# Implementation rationale + +Host transitions are sequential and reconciliation reaches a fixed point eagerly. Reentrant lifecycle transitions are rejected. These properties allow the same lifecycle semantics in runtimes with different concurrency mechanisms. + +Capability providers are ranked by descending version, ascending priority, then declaration position. Point-binding order bands do not rank capabilities: a provider may have several bindings or no bindings. + +Rust drops RefCell borrows before invoking callbacks. Holding a borrow over plugin code can turn a lifecycle operation into a panic. + +Sources: [host](typescript/src/Host.ts), [capability resolution](go/plugin/capability.go), [Rust guide](rust/AGENTS.md). + +Corpus shape validation is separate from JSON generation so optional empty containers retain their assertion meaning. The runner checks cross-field relationships and nonempty test groups. Artifact validation checks that the corpus version marker exists; unification alone can fill an absent marker. diff --git a/COMMENT-POLICY.md b/COMMENT-POLICY.md new file mode 100644 index 0000000..c9d0c88 --- /dev/null +++ b/COMMENT-POLICY.md @@ -0,0 +1,51 @@ +# Source code comment policy + +Adapted from [Aontu ADR-032](https://github.com/aontu-lang/aontu/blob/main/ADR.md#adr-032--code-comments-are-sparse-and-terse-intent-lives-in-names-requirements-live-in-documents). + +Comments are sparse and terse. Use them only for intricate or surprising code: +why an unobvious form is necessary, or what a maintainer could otherwise break. +Prefer a precise identifier, named constant, or extracted function to an +explanation of what the code does. Business rules and requirements belong in +documents. History belongs in commit messages and issues. + +A line or two is the norm. Delete redundant or stale comments; move durable +explanations into a design document. A coverage exclusion still needs its short +justification. Paths, backticked symbols, and ADR numbers must resolve. Avoid +issue numbers, dates, versions, unverifiable counts, TODO narratives, and +commented-out code. These rules apply to authored code in every language. + +## Automated enforcement + +`make comments` runs `tools/comment-gate.cjs`. `make comments-test` runs its +positive and negative tests, including the repository gate. CI runs both on +pushes and pull requests. `make hooks` installs the tracked pre-push hook; +Node.js and Git are required, and a missing Node.js fails the hook. + +The automated scope covers `.ts`, `.go`, `.rs`, `.aon`, and `.aontu`, including +authored models, test corpora and generator templates. Other languages follow +the written policy through review. JavaScript build/release tooling is outside +the automated scope, as in Aontu. Tracked and unignored new files are checked; +symlinks, dependency directories, compiled output and Rust build artifacts are +not. `tools/comment-scope.json` lists any additional exclusions with reasons. +License notices and compiler, lint, formatting and coverage directives remain. +Generator slot and insertion markers (such as `<[SLOT]>`, `#SecretsImport`, and `EJECT-START`/`EJECT-END`) +are executable tooling directives and are exempt too. +Aontu models use `#` line comments; quoted strings and multiline backtick values +are preserved. Authored `model/target/` files are included. Generated model +goldens are excluded explicitly through `excludeModels` in the scope config; +this does not exclude implementation files in the same directories. + +| Rule | Rejected content | +| --- | --- | +| `long-block` | More than 5 consecutive comment lines | +| `dense-file` | More than 12% comment/code lines, when comment lines exceed 8 | +| `narrative` | First person, change history, reviews, issues, commits, TODOs | +| `requirements` | Requirement and business-rule vocabulary | +| `commented-code` | Disabled source code | +| `count-claim`, `dated-claim`, `issue-ref`, `version-claim` | Unverifiable claims | +| `stale-path`, `stale-symbol`, `stale-adr` | Unresolved references | + +The checker uses lexical heuristics; passing it does not establish the truth +of every sentence. Review still checks meaning and whether a comment is needed. +`node tools/comment-gate.cjs --json` emits findings with a failing exit status; +`--measure` prints statistics. There is no baseline that accepts existing debt. diff --git a/Makefile b/Makefile index 912cef7..d707266 100644 --- a/Makefile +++ b/Makefile @@ -146,3 +146,15 @@ scan-prose: @python3 tools/check_prose.py check: spec-check parity probes versions scan-prose test + +.PHONY: comments comments-test hooks +comments: + node tools/comment-gate.cjs + +comments-test: + node --test tools/comment-gate.test.cjs + +hooks: + git config core.hooksPath .githooks + +test: comments diff --git a/go/plugin/capability.go b/go/plugin/capability.go index 79095ea..c0af993 100644 --- a/go/plugin/capability.go +++ b/go/plugin/capability.go @@ -1,14 +1,3 @@ -/* Capabilities (§11.1). - * - * A DEPENDENCY IS ON A CAPABILITY, NOT ON A REF — because it is a - * dependency on something that can do the job, and which instance is - * doing it is exactly the configuration detail a plugin must not care - * about. - * - * But A BINDING IS TO AN INSTANCE, not to a capability, which is what - * decides behaviour when the bound provider leaves while another match - * remains. */ - package plugin import ( @@ -29,12 +18,7 @@ type Required struct { Range string `json:"range,omitempty"` Match map[string]any `json:"match,omitempty"` Optional bool `json:"optional,omitempty"` - // Policy is §11.3: `static` restarts the consumer when its SELECTED - // provider leaves, even though another still matches; `dynamic` says - // in writing that it can survive the swap. Static is the default - // because most plugins cannot, and the cost of wrongly assuming they - // can is a live instance holding a dead reference. - Policy string `json:"policy,omitempty"` + Policy string `json:"policy,omitempty"` } type Candidate struct { @@ -43,19 +27,6 @@ type Candidate struct { Provides Provided `json:"provides"` } -// ResolveCapability ranks the matching live providers and returns them -// best-first: highest `version`, then LOWEST `priority` (default 0), -// then declaration position `pos` ascending. -// -// `priority` is a field on the capability rather than §7's `order` band, -// because bands live on POINT BINDINGS: a provider may have several -// bindings with different bands, or none at all, so a rank reaching for -// one would be undefined in the common case. -// -// Without a total rank, "any provider satisfies" is true of the GRAPH -// and useless to the PLUGIN — two ports could bind different `store` -// instances, both resolve green, and behave differently, which is -// precisely the divergence a shared corpus exists to catch. func ResolveCapability(req Required, candidates []Candidate) []Candidate { hits := []Candidate{} for _, c := range candidates { @@ -115,10 +86,6 @@ func Matches(req Required, prov Provided) bool { } } - // `match` is checked against the provider's `attrs`, key by key. A - // key the provider does not carry is a miss, not a pass: a - // requirement asking for `transactional: true` must not be satisfied - // by a provider that never said. if nil != req.Match { attrs := prov.Attrs if nil == attrs { @@ -161,23 +128,6 @@ func parts(v string) []int { return out } -/* PARTIAL MATCH, RECURSING INTO MAPS (§11.1). - * - * §11.1 says `match` is "a partial match against `attrs`, with exactly - * the semantics voxgig/struct and the omni corpus already define for - * `match` — every leaf in the requirement must be present and equal in - * the capability, keys not mentioned are not checked." - * - * THE CANONICAL DID NOT IMPLEMENT THAT. It compared `attrs[k] !== - * req.match[k]`, which for any compound value is JavaScript reference - * identity: a requirement and a capability are declared in different - * places and are never the same object, so `match: {limits: {max: 5}}` - * could not be satisfied by ANY provider, including one declaring - * exactly that. The flat reading is invisible while every corpus entry - * is scalar, which is why P4 found it and P2 did not. - * - * Fixed in the canonical (AGENTS.md: change canonical first), pinned by - * `capability/nested`, and ported here. */ func matchvalue(want any, got any) bool { switch w := want.(type) { @@ -207,13 +157,6 @@ func matchvalue(want any, got any) bool { return true default: - // GO HAS MANY NUMBER TYPES AND THE MODEL HAS ONE. Corpus JSON - // decodes every number as float64, but a Go host declaring - // `Attrs: map[string]any{"max": 5}` writes an int, and `any(5) - // == any(5.0)` is false — so an attribute that matched in every - // other port silently missed here. Canonical is type-strict - // between KINDS (`true` never matches `1`), not between Go's - // spellings of one kind. if wn, wok := numval(want); wok { if gn, gok := numval(got); gok { return wn == gn @@ -224,9 +167,6 @@ func matchvalue(want any, got any) bool { } } -// numval reports a value's numeric value, and whether it had one. `bool` -// is deliberately absent: Go does not make it numeric and neither does -// the model. func numval(v any) (float64, bool) { switch n := v.(type) { case int: diff --git a/go/plugin/catalog.go b/go/plugin/catalog.go index 29f7149..2010345 100644 --- a/go/plugin/catalog.go +++ b/go/plugin/catalog.go @@ -1,18 +1,5 @@ -/* The definition catalog (§10.1). - * - * A definition is registered once and may back many instances. Option - * shapes are validated AT REGISTRATION, not when a document happens to - * exercise a key — so a malformed shape fails once, and in the same - * place everywhere (§9.4). */ - package plugin -// Definition is what a plugin author writes. -// -// GO RETURNS ERRORS FROM LIFECYCLE CALLBACKS (§18, P4). The canonical -// throws; a Go callback returns `error`, and the host treats a non-nil -// return exactly as the canonical treats a throw — including -// `plugin_activate_failed`'s unwind and the `failed` status. type Definition struct { Name string Shape any @@ -27,12 +14,6 @@ type Catalog struct { defs map[string]Definition } -// MakeCatalog builds a catalog, adding each definition in turn. -// -// GO IS TIER S (§10.3): "package `init()` registration ... or an -// explicit list handed to makeHost", and both spellings land here. A Go -// developer's experience is "add the import, add one line to the config" -// rather than "write a factory". func MakeCatalog(defs ...Definition) (*Catalog, error) { c := &Catalog{defs: map[string]Definition{}} for _, d := range defs { diff --git a/go/plugin/config.go b/go/plugin/config.go index 8837856..3cd2661 100644 --- a/go/plugin/config.go +++ b/go/plugin/config.go @@ -1,18 +1,3 @@ -/* The declarative document (§9): normalization, and the ten-level - * precedence ladder. - * - * TWO FUNCTIONS, AND THE SPLIT BETWEEN THEM IS FORCED. - * - * NormalizeConfig normalizes STRUCTURE and ENTRY KEYS. It does not - * merge options, and cannot: §9.4 makes merge behaviour a property of - * the definition's option SHAPE, which normalization has never seen. A - * normalizer that flattened the option layers would make - * `$MERGE: append` unimplementable at load time, because the layers it - * must concatenate would already be collapsed. - * - * ResolveOptions applies the ladder, and it is the only place that - * knows the shape. */ - package plugin import "sort" @@ -27,12 +12,9 @@ type Keys struct { } type NormalizeInput struct { - Doc any `json:"doc"` - Profile string `json:"profile,omitempty"` - // Keys is §9.1: a host may rename `instance` and `default` into its - // own vocabulary. - Keys Keys `json:"keys,omitempty"` - // Reserved is §9.1: refs the host declares itself and always wins on. + Doc any `json:"doc"` + Profile string `json:"profile,omitempty"` + Keys Keys `json:"keys,omitempty"` Reserved []string `json:"reserved,omitempty"` } @@ -42,12 +24,6 @@ func NormalizeConfig(input NormalizeInput) (Normalized, error) { dkey := or(input.Keys.Default, "default") reserved := input.Reserved - // The rename is applied at TWO PLACES AND NO OTHERS: the document - // root, and every profile. overlay root (§9.1). A rename - // applied only at the root would leave `profile.prod.sdk` - // untranslated and silently drop every environment override the host - // depends on. Recursing further would be worse: option data is the - // definition's. baseinst := doc[ikey] basedef := asmap(doc[dkey]) @@ -106,17 +82,10 @@ func NormalizeConfig(input NormalizeInput) (Normalized, error) { b := base.emap[ref] o := over.emap[ref] - // MERGE THE ENTRIES AS AUTHORED, THEN APPLY DEFAULTS TO THE - // RESULT (§9.3). A safety rule, not a tidiness one: if the - // overlay had its defaults filled in before merging it would - // carry a synthesized active:true and overwrite a base's false — - // silently re-enabling a deliberately disabled integration in - // production. active := asbool(pick(o, "active", pick(b, "active", true)), true) start := asstring(pick(o, "start", pick(b, "start", "eager")), "eager") ord := pick(o, "order", pick(b, "order", nil)) - // Option layers, levels 3-6, IN LADDER ORDER. Never merged here. layers := []any{} nm := refname(ref) if v, ok := asmap(basedef[nm])["options"]; ok { @@ -215,22 +184,16 @@ func checkreservedref(ref string, reserved []string) error { return nil } -// --------------------------------------------------------------------- -// ResolveOptions — §9.3's ten levels, and §9.4's merge directives -// --------------------------------------------------------------------- - type ResolveInput struct { - Ref string `json:"ref"` - // Shape is level 1 — the definition's option shape. Also carries the - // $MERGE directives, which is why merging cannot happen without it. + Ref string `json:"ref"` Shape any `json:"shape,omitempty"` - HostDefaults any `json:"hostdefaults,omitempty"` // 2 - Doc any `json:"doc,omitempty"` // 3-6 + HostDefaults any `json:"hostdefaults,omitempty"` + Doc any `json:"doc,omitempty"` Profile string `json:"profile,omitempty"` - Env any `json:"env,omitempty"` // 7 - HostOptions any `json:"hostoptions,omitempty"` // 8 - LoadOptions any `json:"loadoptions,omitempty"` // 9 - Patch any `json:"patch,omitempty"` // 10 + Env any `json:"env,omitempty"` + HostOptions any `json:"hostoptions,omitempty"` + LoadOptions any `json:"loadoptions,omitempty"` + Patch any `json:"patch,omitempty"` } func ResolveOptions(input ResolveInput) (map[string]any, error) { @@ -251,11 +214,6 @@ func ResolveOptions(input ResolveInput) (map[string]any, error) { overlay = asmap(asmap(doc["profile"])[input.Profile]) } - // ONE ordered merge, lowest to highest. Levels 3-6 are not two - // namespaces collapsed separately and composed afterwards: that - // inverts the rule that PROFILE SPECIFICITY OUTRANKS DEFINITION - // SPECIFICITY, so a prod per-definition default would lose to a base - // instance value. lo3, err := optsof(doc["default"], name) if nil != err { return nil, err @@ -274,13 +232,13 @@ func ResolveOptions(input ResolveInput) (map[string]any, error) { } layers := []any{ - defaultsof(shape), // 1 - input.HostDefaults, // 2 - lo3, lo4, lo5, lo6, // 3-6 - input.Env, // 7 - input.HostOptions, // 8 - input.LoadOptions, // 9 - input.Patch, // 10 + defaultsof(shape), + input.HostDefaults, + lo3, lo4, lo5, lo6, + input.Env, + input.HostOptions, + input.LoadOptions, + input.Patch, } var out any = map[string]any{} @@ -293,7 +251,6 @@ func ResolveOptions(input ResolveInput) (map[string]any, error) { return asmap(out), nil } -// defaultsof: the shape's non-directive values are the level-1 defaults. func defaultsof(shape map[string]any) map[string]any { out := map[string]any{} for _, k := range sortedkeys(shape) { @@ -312,7 +269,6 @@ func optsof(src any, key string) (any, error) { if nil == src { return nil, nil } - // The array form is equivalent to the map form (§9.1). if list, ok := aslist(src); ok { for _, item := range list { raw, _ := asmap(item)["ref"] @@ -348,10 +304,6 @@ func optsof(src any, key string) (any, error) { return nil, nil } -// mergeone merges ONE layer onto the accumulator, honouring the shape's -// directives. The directive holds at EVERY precedence level, not only -// between document levels — §9.4 makes it a property of the shape, which -// does not know which layer a value arrived from. func mergeone(base any, over any, shape map[string]any) any { if nil == over { return base @@ -408,7 +360,6 @@ func mergeone(base any, over any, shape map[string]any) any { return out } -// deepto merges N levels below this key, replacing below that. func deepto(base any, over any, n int) any { if 0 >= n { return clonevalue(over) @@ -427,14 +378,6 @@ func deepto(base any, over any, n int) any { return out } -// §9.4: N is an integer of at least 1, and everything else is an error. -// -// `{"deep": 0}` is rejected DESPITE having an obvious reading, because -// "replace at this key" already has a spelling and two spellings for one -// behaviour is the defect class this repo exists to avoid. Without the -// stated domain each port picks its own reading — reject, replace, -// unlimited merge, or clamp to 1 — and the same document resolves -// differently per language. var mergeWords = []string{"replace", "append"} func CheckShape(shape any) error { @@ -567,25 +510,6 @@ func isposint(v any) bool { return f == float64(int(f)) && 1 <= f } -// asorderref reads ONE spelling or a LIST of them into an OrderRef, and -// KEEPS THE AUTHORED VALUE so normalization can hand it back untouched. -// -// It used to be a bare asstring(), so a list decoded to "" and the -// constraint was SILENTLY DROPPED - the sort ran as if nothing had been -// declared. -// -// `stated` is separate from the value because an ABSENT key and a key -// authored as `null` are different documents, and a map index alone -// cannot tell them apart. -// -// The other four ports have no type like this at all: they carry the -// authored block straight through (`ent.order = ord`), so every spelling -// survives normalization by construction. Go is the only port that -// decodes and rebuilds, and a rebuild loses whatever it does not model - -// first the list form, then scalar-vs-one-element-list, then an authored -// empty list, then an authored null. One cause, four parity breaks, none -// of which the corpus could see until `config/normorder` was written. -// Keeping the authored value ends the class rather than the instance. func asorderref(v any, stated bool) OrderRef { if !stated { return OrderRef{} diff --git a/go/plugin/depend.go b/go/plugin/depend.go index 73c372b..45b20a9 100644 --- a/go/plugin/depend.go +++ b/go/plugin/depend.go @@ -1,28 +1,3 @@ -/* Dependency cardinality, policy, and the restart graph (§11.3). - * - * TWO AXES, BOTH DECLARED BY THE DEFINITION THAT HAS THE REQUIREMENT, - * because only it knows what it can cope with: - * - * | static (default) | dynamic - * -------------|---------------------------|-------------------------- - * mandatory | unmet -> pending; | unmet -> pending; - * (default) | lost -> pending, | lost -> STAYS LIVE, - * | recursively | notified - * -------------|---------------------------|-------------------------- - * optional:true| never gates activation; | never gates activation; - * | a change deactivates and | a change is a - * | reactivates | notification, nothing else - * - * `dynamic` means the plugin has said, IN WRITING, that it can survive - * its provider being swapped underneath it. It is not the default - * because most plugins cannot, and the cost of wrongly assuming they can - * is a live instance holding a dead reference. - * - * The rebinding-preference axis is deliberately omitted. OSGi has - * reluctant vs greedy and it is a knob every author must understand to - * read anyone else's component; we take always-reluctant. Three axes - * were more than the model can carry across twenty ports. */ - package plugin import "sort" @@ -45,21 +20,6 @@ func NormRequire(r any) Required { return out } -/* Requirements are the requirements a definition declared, normalized. - * - * BOTH AXES ARE READ AT TWO LEVELS, AND THE PER-REQUIREMENT ONE WINS. - * - * The instance-level `policy` and `optional` list are how a DOCUMENT - * states the axis without editing the definition, and they apply to - * every requirement. The per-requirement form is the one §11.1's object - * syntax exists for, and it is strictly more expressive: an instance - * that is `static` on its store and `dynamic` on its metrics cannot be - * written at all at the instance level, and that is the ordinary case - * rather than an exotic one. - * - * `optional` unions rather than overriding — both spellings are - * statements that this requirement need not gate activation, and there - * is no reading under which one of them means "actually, mandatory". */ func Requirements(options map[string]any) []Required { raw, _ := aslist(options["requires"]) markedraw, _ := aslist(options["optional"]) @@ -83,11 +43,6 @@ func Requirements(options map[string]any) []Required { return out } -// RestartsOnLoss: does losing this requirement's SELECTED provider -// restart the consumer? The mandatory ones under `static`, and the -// `static` optional ones — both make a capability change deactivate and -// reactivate. `dynamic` never restarts: mandatory-dynamic stays live and -// is notified, optional-dynamic is a notification and nothing else. func RestartsOnLoss(r Required) bool { policy := r.Policy if "" == policy { @@ -96,64 +51,21 @@ func RestartsOnLoss(r Required) bool { return "dynamic" != policy } -// GatesActivation: does an unmet requirement keep the consumer out of -// `live`? -// -// Cardinality alone decides this, NOT policy. `dynamic` is a statement -// about surviving a SWAP, not about starting without the thing at all — -// a mandatory-dynamic consumer still waits in `pending` for its first -// provider. Conflating the two would let a plugin that declared it can -// cope with replacement activate with nothing to call. func GatesActivation(r Required) bool { return !r.Optional } -/* RestartCausing marks the edges that can cause a restart, which is - * exactly the set a cycle must be detected over (§11.3). - * - * Those are the mandatory requirements AND THE `static` OPTIONAL ONES, - * because both make a capability change deactivate and reactivate the - * consumer — and a cycle of restarts does not settle: A comes up, B - * restarts, which changes B's capability, which restarts A, - * indefinitely. - * - * ONLY `dynamic` OPTIONAL EDGES ARE EXCLUDED, and they are the ones the - * exclusion was for: two plugins that optionally and dynamically consume - * each other's capabilities both activate happily, neither gates on the - * other, and each is merely notified when the other appears. Nothing - * restarts, so nothing oscillates. - * - * An earlier draft of §11.3 excluded EVERY optional edge and thereby - * admitted the non-terminating case it was trying to permit. */ func RestartCausing(r Required) bool { return GatesActivation(r) || RestartsOnLoss(r) } -// DependNode is the requirement graph as plain data, for the pure -// detector. type DependNode struct { Ref string Provides []string Requires []Required } -/* DependencyCycle: a cycle through restart-causing requirements is - * `plugin_dependency_cycle`, detected AT LOAD — before anything runs, - * because the failure it describes is a non-terminating reconcile and - * the only safe time to report that is before it starts. - * - * The graph is over capabilities, not refs: an edge runs from a consumer - * to EVERY node that provides what it needs, because any of them could - * be the one selected and a cycle through any is a cycle. A node also - * satisfies its own name as a ref (§11.1), which is why the ref is a - * provider of itself here. */ func DependencyCycle(nodes []DependNode) []string { - // TWO INDEXES, NOT ONE MERGED MAP. Capability names and refs are - // matched differently — a capability by its exact name, a ref through - // the canonical spelling (§4 rule 5) — and one map keyed by both can - // only do one of them. Keyed by both and looked up raw, as this was, - // a cycle spelled `a$`/`b$` found no providers and EVADED the - // load-time check that exists to catch a non-terminating reconcile. bycap := map[string][]string{} isref := map[string]bool{} for _, n := range nodes { @@ -171,12 +83,6 @@ func DependencyCycle(nodes []DependNode) []string { continue } from := append([]string{}, bycap[r.Name]...) - // A node satisfies its own name AS A REF (§11.1), - // canonically — exactly what `providersof` does at runtime, - // so the load-time graph and the running one agree about - // what an edge is. `canon` hands back a name no ref could - // have unchanged, and no instance ref can equal one, so it - // is the tolerant test this needs. if asref := canon(r.Name); isref[asref] && !hasstring(from, asref) { from = append(from, asref) } @@ -190,8 +96,6 @@ func DependencyCycle(nodes []DependNode) []string { edges[n.Ref] = out } - // Iterative DFS with an explicit stack: twenty ports, and several of - // them have no recursion budget worth relying on. const white, grey, black = 0, 1, 2 colour := map[string]int{} for _, n := range nodes { diff --git a/go/plugin/env.go b/go/plugin/env.go index 670b8e2..a1b7e7a 100644 --- a/go/plugin/env.go +++ b/go/plugin/env.go @@ -1,26 +1,3 @@ -/* Environment overrides (§9.5) — level 7 of the ladder. - * - * One prefix, so nothing drifts: `VOXGIG_PLUGIN_*`. - * - * VOXGIG_PLUGIN_PROFILE the profile name - * VOXGIG_PLUGIN__ one option - * VOXGIG_PLUGIN_ACTIVE/INACTIVE comma-separated refs, INACTIVE wins - * - * THE ENCODING IS LOSSY, AND THIS SAYS SO RATHER THAN PRETENDING - * OTHERWISE. Ref and path are upper-snake with `$` -> `__` and `.` -> - * `_`. But `_` is legal in a name and in a tag, and the mapping folds - * case, so `retry$fast` and `retry__fast` both encode to `RETRY__FAST`, - * as do `Retry$fast` and `retry$Fast`. - * - * Rather than restrict a grammar the rest of the stack already uses, the - * host DETECTS THE COLLISION: it encodes every ref it holds, and a key - * two refs claim is `plugin_env_ambiguous`, naming both. The affected - * pair stays configurable by document and by API, just not by - * environment — which is the honest trade. - * - * Pure: a function over a string map and a ref set. The corpus tests it - * without touching a real environment. */ - package plugin import ( @@ -39,15 +16,11 @@ type EnvResult struct { } type EnvInput struct { - Env map[string]string `json:"env"` - // Refs is every ref the host holds. Needed because the encoding is - // lossy: without the set there is no way to know where the ref ends - // and the path begins in `RETRY__FAST_MIN_DELAY`. - Refs []string `json:"refs,omitempty"` - Reserved []string `json:"reserved,omitempty"` + Env map[string]string `json:"env"` + Refs []string `json:"refs,omitempty"` + Reserved []string `json:"reserved,omitempty"` } -// EncodeRef: `retry$fast` -> `RETRY__FAST`. func EncodeRef(ref string) string { s := strings.ReplaceAll(ref, "$", "__") s = strings.ReplaceAll(s, ".", "_") @@ -86,11 +59,6 @@ func ApplyEnv(input EnvInput) (EnvResult, error) { } } - // Longest encoded ref first, so `retry$fast` wins over `retry` on - // `RETRY__FAST_MIN`. Shortest-first would read the tag as a path. - // Equal lengths break bytewise: the canonical leans on JavaScript's - // stable sort over insertion order, which a Go map cannot reproduce - // and which no entry can distinguish anyway. encoded := sortedkeys(byencoded) sort.SliceStable(encoded, func(i, j int) bool { return len(encoded[i]) > len(encoded[j]) @@ -113,11 +81,6 @@ func ApplyEnv(input EnvInput) (EnvResult, error) { if nil != err { return out, err } - // The reservation covers EVERY input layer (§9.1). - // VOXGIG_PLUGIN_INACTIVE=station is easier to set than - // editing a config file, and INACTIVE has the final - // word — so guarding documents alone would leave the one - // lever this mechanism exists to deny wide open. if err := checkreservedref(c, input.Reserved); nil != err { return out, err } @@ -179,9 +142,6 @@ func splitlist(v string) []string { return out } -// parsevalue: values parse as JSON, FALLING BACK TO STRING — so `8080` -// is a number, `true` is a boolean, `{"a":1}` is a map, and `hello` is -// the string it looks like rather than a parse error. func parsevalue(v string) any { var out any if err := json.Unmarshal([]byte(v), &out); nil != err { diff --git a/go/plugin/export.go b/go/plugin/export.go index a3a7993..2f756a4 100644 --- a/go/plugin/export.go +++ b/go/plugin/export.go @@ -1,15 +1,3 @@ -/* Exports (§11). - * - * An instance publishes values for other plugins and for the - * application. Read with `host.Exports("retry$fast/client")`. - * - * THE UNQUALIFIED ALIAS IS THE INTERESTING PART. `retry/client` resolves - * to the UNTAGGED instance if one exists; if not, and exactly one tagged - * instance exports that key, it resolves to that one; if two do, it is - * `plugin_export_ambiguous` — deliberately diverging from seneca's - * silent last-wins, because with multi-instance as a headline feature an - * ambiguous alias is a defect waiting for production. */ - package plugin import ( diff --git a/go/plugin/graph.go b/go/plugin/graph.go index e3677f7..7add055 100644 --- a/go/plugin/graph.go +++ b/go/plugin/graph.go @@ -1,22 +1,3 @@ -/* Whole-graph resolution (§11.4) — a phase, not a discovery. - * - * "Activate, and wait in `pending` if you must" is correct and, on its - * own, produces a terrible experience: apply twenty instances against a - * registry missing one thing and you get NINETEEN pending rows and no - * statement of what is actually wrong. - * - * ResolveGraph is a PURE FUNCTION of the registry and the intended - * activation set. No callbacks run, no state changes, nothing is - * touched. It answers for the whole graph at once which instances can be - * live, and for each blocked one THE SPECIFIC REQUIREMENT that is - * unmet, and why. - * - * The failure mode being designed against is a famous one: OSGi's - * resolver is correct and its diagnostics are legendarily unusable. A - * resolver that says "blocked" without saying WHY has moved the problem - * rather than solved it, so `why` is part of the contract and the - * corpus pins its shape. */ - package plugin import "sort" @@ -35,9 +16,6 @@ type Blocked struct { Why Why `json:"why"` } -// Why is the tagged union §11.4 pins. Go has no sum type, so the fields -// carry `omitempty` and the corpus pins which are present per `kind` — -// the same discrimination the canonical gets from its union. type Why struct { Kind string `json:"kind"` // kind: version @@ -54,12 +32,6 @@ type Why struct { Chain []string `json:"chain,omitempty"` } -// MarshalJSON exists for one reason: `why.found` is a LIST OF VERSIONS -// under `kind: version` and a SINGLE ATTRIBUTE VALUE under `kind: -// match`. The canonical's union gives each variant its own `found`; -// Go's struct cannot, so the two are separate fields that render to one -// name. A `found` of `false` or `null` under `kind: match` must still -// appear, which is why it is not merely another omitempty field. func (w Why) MarshalJSON() ([]byte, error) { out := map[string]any{"kind": w.Kind} switch w.Kind { @@ -90,10 +62,6 @@ func ResolveGraph(nodes []Node) Resolution { resolved := map[string]bool{} blocked := map[string]Blocked{} - // Fixed point: a node resolves when every mandatory requirement is - // met by an ALREADY-RESOLVED provider. Iterating to a fixed point is - // what makes a provider that is itself blocked propagate, rather - // than each node being judged against the raw registry. for moved := true; moved; { moved = false for _, n := range nodes { @@ -127,13 +95,6 @@ func ResolveGraph(nodes []Node) Resolution { return res } -// firstunmet returns the FIRST unmet requirement, with the most specific -// explanation available. Order matters: "no provider at all" and "a -// provider at the wrong version" are different problems and a reader -// must not have to guess which they have. -// -// The second return is the canonical's `null` — Go has no nullable -// struct without a pointer, and a bool says what it means. func firstunmet(n Node, byref map[string]Node, resolved map[string]bool) (Blocked, bool) { for _, req := range n.Requires { if req.Optional { diff --git a/go/plugin/host.go b/go/plugin/host.go index 24e2421..383c3dc 100644 --- a/go/plugin/host.go +++ b/go/plugin/host.go @@ -1,19 +1,3 @@ -/* The host: the lifecycle state machine (§5), extension points (§6), and - * resource capture (§8). - * - * TWO RULES SHAPE EVERY METHOD BELOW. - * - * Transitions are SEQUENTIAL (§5.2). One at a time, in call order, never - * interleaved; a transition triggered from inside a lifecycle callback - * is `plugin_reentrant`. A hard rule, because it is the only way the - * semantics can be identical in Go, in Ruby and in single-threaded - * JavaScript. - * - * Reconciliation is EAGER (§18's portability budget). A transition - * settles by running the state machine to a fixed point, not by - * suspending on a promise. Every port must be able to do the same, and - * fourteen of them will not have JavaScript's event loop. */ - package plugin import ( @@ -25,21 +9,12 @@ import ( type PointSpec = Spec type HostOptions struct { - Catalog *Catalog - Reserved []string - Keys Keys - Defaults map[string]any - Profile string - Points map[string]Spec - // Dependency is §11.3. `restart` (the default) treats provider - // replacement as an ordinary runtime operation: deactivate the old - // store, activate the new one, and everything that depended on it - // rides through, having released the old one's resources in between. - // - // `hold` is the strict reading — deactivating a required instance is - // `plugin_dependency_held`, naming the holders. NOT the default, - // because a station that cannot swap a provider without a restart - // has lost the argument for having a plugin system. + Catalog *Catalog + Reserved []string + Keys Keys + Defaults map[string]any + Profile string + Points map[string]Spec Dependency string } @@ -55,15 +30,8 @@ type Live struct { // Inner is set when this instance is itself a host (§6.5). Inner *Host - def Definition - order *OrderBlock - // selected is §11.4's ALWAYS-RELUCTANT rebinding made concrete: the - // provider ref this instance's activation actually chose, per - // requirement name. "A satisfied requirement is not re-bound while - // it stays satisfied" is a statement about a REMEMBERED choice — - // re-ranking on every question silently re-points a live consumer at - // any better newcomer, and then losing the provider it was really - // using does not restart it. Captured at activate, cleared on exit. + def Definition + order *OrderBlock selected map[string]string // barred is §9.6's `active: false` — "declares it and bars it: it // appears in `host.list()`, and `activate` and `ready` on it fail @@ -71,9 +39,7 @@ type Live struct { // THAT SET IT: a flag consulted only while `apply` ran let a later // direct `Ready` bring the instance live. barred bool - // unmet holds requirements this instance declared but has not been - // given. - unmet []string + unmet []string // scope holds the resources the instance scope holds, newest last — // unwound in REVERSE, because that is the only order in which // teardown mirrors setup (§8.3). @@ -115,19 +81,6 @@ type Host struct { // deactivation. coordinated bool - // §18: "a port uses its idiom (a mutex in Go/Rust/Java, the GIL - // where that is enough)". §5.2 makes transitions SEQUENTIAL — one at - // a time, in call order, never interleaved — and `intransition` - // cannot deliver that: it is set inside `run`, so two goroutines - // both pass `guard` and both run a callback. This holds for the - // WHOLE public transition, reconciliation included, which is the - // unit §5.2 names. - // - // It is NOT reentrant, and must not be: a transition attempted from - // inside a lifecycle callback is `plugin_reentrant`, and `enter` - // answers that without ever blocking. Below the door the unlocked - // bodies (`declare`, `load`, `activate`, …) call each other, so the - // lock is taken exactly once per public call. mu sync.Mutex inst map[string]*Live @@ -139,12 +92,7 @@ type Host struct { seqn int open int intransition bool - // phase is WHICH callback is running, not merely that one is. §8.1 - // puts resource capture in `activate` and §8.3 says `Release` - // outside `activate` is `plugin_release_scope` — and `intransition` - // alone cannot tell `activate` from `define`, so it admitted an - // Acquire in `define` whose scope `Unload` would never unwind. - phase string + phase string } func MakeHost(options HostOptions) *Host { @@ -171,9 +119,6 @@ func (h *Host) Catalog() *Catalog { return h.catalog } // --- observation ----------------------------------------------------- -// List is introspection, and introspection NEVER advances the state -// (§5.2). A status page must not be a way to accidentally import twenty -// packages. func (h *Host) List() map[string]Status { out := map[string]Status{} for _, r := range sortedkeys(h.inst) { @@ -216,29 +161,6 @@ func (h *Host) guard() error { return nil } -// enter is the door every PUBLIC transition goes through, and the only -// place `h.mu` is taken. It returns the matching release. -// -// The two rules it has to serve pull in opposite directions. §5.2 wants -// transitions SEQUENTIAL, which is a lock; and §5.2 also wants a -// transition attempted from INSIDE a lifecycle callback to answer -// `plugin_reentrant`, which is the one caller that must not block — -// it is the goroutine already holding the lock, so blocking is a -// deadlock. -// -// TryLock separates them as far as Go allows. Taking the lock proves -// nothing else is in flight, so the call cannot be reentrant. Failing to -// take it means SOMEONE holds it, and `intransition` says whether that -// someone is running a callback — the only state from which a reentrant -// call can arise. -// -// THE RESIDUAL WINDOW IS REAL AND IS NOT PAPERED OVER: a genuinely -// concurrent caller that arrives while a callback is running gets -// `plugin_reentrant` instead of waiting, because Go exposes no goroutine -// identity to tell the two apart. It is a refusal with a code, not a -// corruption, and it is strictly better than the interleaving an -// unlocked host allowed; a host that needs the other answer should -// serialise its own calls. See AGENTS.md. func (h *Host) enter() (func(), error) { if h.mu.TryLock() { return h.mu.Unlock, nil @@ -280,12 +202,6 @@ func (h *Host) run(e *Live, fn func(*Inst) error, at string) error { if nil == err { return nil } - // §12: `plugin_define_failed` and its three siblings are "a callback - // raised; wraps the cause". AN ERROR THAT ALREADY CARRIES A CODE - // KEEPS IT — the code is the error's identity, and a plugin - // returning `store_unreachable` must not have it rewritten. Only a - // code-less error is wrapped, which is the ordinary case for a - // callback that let a library error through. if "" != CodeOf(err) { return err } @@ -294,9 +210,6 @@ func (h *Host) run(e *Live, fn func(*Inst) error, at string) error { map[string]any{"ref": e.Ref, "cause": err.Error()}) } -// Inst is what a definition's callbacks see. Deliberately not the -// internal record: a plugin that could reach `Status` could also write -// it. type Inst struct { h *Host e *Live @@ -336,12 +249,6 @@ func (i *Inst) Release(fn func()) error { return nil } -// Acquire is the synthetic counter the driver owns, so "what is open" is -// data rather than an assertion each port words differently. -// -// Returns its own release, so a plugin can hand one back early. The -// scope still holds the entry and unwinding it twice is a no-op — -// releasing early must not make teardown wrong. func (i *Inst) Acquire() (func(), error) { // §8.1: resources are "acquired during `activate` — the scope's // actual job". Same reason as `Release` above. @@ -361,16 +268,7 @@ func (i *Inst) Acquire() (func(), error) { return rel, nil } -// Bind attaches a function to a host point. Declared in `define`; the -// host inserts it only after `activate` returns successfully (§8.1), -// which is why a failing activate leaves no live binding behind. func (i *Inst) Bind(point string, fn BindFn, band int) error { - // §12's `plugin_bind_scope`: "binding declared outside `define`". - // §8.1 puts binding DECLARATION in `define` and INSERTION at a - // successful activate, and the guard was the half nobody wrote — so - // a binding added from `activate` went live without being part of - // the loaded definition, and a deactivate/activate cycle appended - // it again. The code was in the table before anything raised it. if "define" != i.h.phase { return Fail("plugin_bind_scope", "bind called outside define: "+point, map[string]any{"ref": i.e.Ref, "point": point}) @@ -390,21 +288,6 @@ func (i *Inst) Export(key string, value any) { i.e.exports[key] = value } // Provides declares what this instance can do for others (§11.1). func (i *Inst) Provides(p Provided) { i.e.provides = append(i.e.provides, p) } -// Capability is WHICH provider this instance is bound to for name -// (§11.1), as a ref, or "" when nothing provides it. -// -// §11.1 says "the first is bound, and inst.capability(name) returns it", -// and §11.3 gives the case that needs it: a plugin that works without -// metrics and uses metrics when it is there has to be able to ask. -// Host.Capability answers with the live providers RANKED -- not with the -// one THIS instance took, and §11.4's reluctant rebinding makes those -// differ: a better-ranked newcomer tops the ranking while the consumer -// keeps what it had. -// -// A REF, not the instance: every port can return a string and a corpus -// entry can assert on one. The selection is REMEMBERED, because this is -// the instance asking; `remember` is false only for questions asked -// ABOUT an instance, where answering must not create a binding. func (i *Inst) Capability(name string) string { for _, req := range Requirements(i.e.Options) { if req.Name == name { @@ -421,17 +304,6 @@ type Position struct { Innermost bool `json:"innermost"` } -/* Position reports where this binding landed (§6.6) — the plugin-side - * counterpart to a host pin. Station found that a plugin can need to - * KNOW it is in the right place: its middleware must sit immediately - * outside the base transport or its "wire truth" events are fiction. - * - * THE HOST DOES NOT POLICE THIS; it just makes the fact available. A - * plugin that requires a position it did not get fails loudly rather - * than reporting nonsense — and that is the plugin's call, because only - * it knows what its position means. Verification tells a plugin it was - * misplaced; a pin (§7) stops the misplacement from being expressible at - * all. The two are not substitutes. */ func (i *Inst) Position(point string) (Position, error) { return i.h.PositionOf(i.e.Ref, point) } @@ -451,13 +323,6 @@ func (i *Inst) Nest(nestopts HostOptions) (*Host, error) { return inner, nil } -// AutoTag is EXPLICIT (§4 rule 3). `Declare("stripe", {Tag: "?"})` -// assigns the LOWEST UNUSED POSITIVE INTEGER tag and returns the -// assigned pair. Without `"?"`, a collision is an error. -// -// It needs a host because it must know what is already declared, which -// is why it cannot live in the pure `ref` section — the correction P1.7 -// made to §15.3. func (h *Host) AutoTag(name string) (string, error) { for n := 1; ; n++ { cand, err := FormatRef(name, itoa(n)) @@ -555,14 +420,6 @@ func (h *Host) declare(ref string, spec DeclareSpec) (*Live, error) { return e, nil } -// HostDeclare is §9.1's host-owned path: a host that reserves a name -// MUST still be able to declare the instance it reserved. -// -// THE BOUNDARY IS BY METHOD, NOT BY CALLER, and that is a real limit: no -// language here can tell the embedding host from a plugin holding the -// same host object. What reservation protects is CONFIGURATION — -// documents, overlays, `VOXGIG_PLUGIN_*`, construction options and -// ordinary Declare/Load/Options — and all of that still checks. func (h *Host) HostDeclare(ref string, spec DeclareSpec) (*Live, error) { leave, err := h.enter() if nil != err { @@ -599,11 +456,6 @@ func (h *Host) load(ref string, spec DeclareSpec) (*Live, error) { } e.Status = StatusLoaded - // AT LOAD, and before anything runs: a cycle through - // restart-causing requirements does not settle, and the only safe - // time to report a non-terminating reconcile is before it starts - // (§11.3). `provides` is populated by `define`, which has just run, - // so this is the first moment the graph is complete. if err := CheckCycle(h.graphnodes()); nil != err { e.Status = StatusFailed return nil, err @@ -611,8 +463,6 @@ func (h *Host) load(ref string, spec DeclareSpec) (*Live, error) { return e, nil } -// graphnodes is the requirement graph as plain data, for the pure -// detector. func (h *Host) graphnodes() []DependNode { out := []DependNode{} for _, r := range sortedkeys(h.inst) { @@ -662,8 +512,6 @@ func (h *Host) activate(ref string) (*Live, error) { } } - // A declared requirement that is not live means `pending`: - // activation is a STANDING REQUEST, not a one-shot event. if unmet := h.unmetof(e); 0 < len(unmet) { e.unmet = unmet e.Status = StatusPending @@ -712,12 +560,6 @@ func (h *Host) deactivate(ref string) (*Live, error) { } if StatusPending == e.Status { - // DEACTIVATING A PENDING INSTANCE RUNS NO CALLBACK (§5.2). It - // never reached activate, so it holds no scope and no live - // bindings; running the definition's deactivate there would be - // teardown without matching setup, which plugins are not written - // to survive and which could fail an instance that had done - // nothing wrong. It cannot fail. e.Status = StatusLoaded e.unmet = []string{} return e, nil @@ -785,10 +627,6 @@ func (h *Host) unload(ref string) error { return nil } -// Ready runs the whole forward path in one call (§5.1). §15.2's verb -// list omits this; §5.1 defines it and §15.3's `declare` row requires -// the corpus to pin it, so the list was incomplete rather than excluding -// it (DOCS.md §4.2). func (h *Host) Ready(ref string) (*Live, error) { leave, err := h.enter() if nil != err { @@ -816,20 +654,6 @@ func (h *Host) ready(ref string) (*Live, error) { return h.activate(r) } -// unwind: bindings go live only when activation succeeds (§8.1), so the -// teardown is the exact inverse: reverse order, always. -// unwind runs the scope in reverse and returns the errors it raised. -// §8.3: "A failing release does not stop the rest. Every entry runs, in -// reverse order, whatever any of them does; the errors are collected and -// raised as one `plugin_release_failed`." -// -// A Go release is `func()` and cannot return an error, so it signals -// failure by PANICKING — which is what a Go author's `defer f.Close()` -// wrapper does when it has nowhere to put the error, and which is -// recovered here rather than taking the host down. -// A selection belongs to ONE activation (§11.4). Leaving `live` by any -// door drops it, so the next activation ranks afresh — keeping it would -// make a consumer prefer a provider it never actually ran against. func (h *Host) unwind(e *Live) []string { e.selected = map[string]string{} errors := []string{} @@ -864,13 +688,6 @@ func (h *Host) releasecheck(e *Live, errors []string) error { map[string]any{"ref": e.Ref, "cause": errors}) } -/* unmetof: A REQUIREMENT IS ON A CAPABILITY, not on a ref (§11.1) — it - * is a dependency on something that can do the job, and which instance - * is doing it is exactly the configuration detail a plugin must not care - * about. A bare string is shorthand for `{name}`. - * - * A ref satisfies too, because a host that genuinely needs a specific - * instance should not have to invent a capability for it. */ func (h *Host) unmetof(e *Live) []string { out := []string{} for _, r := range Requirements(e.Options) { @@ -884,20 +701,6 @@ func (h *Host) unmetof(e *Live) []string { return out } -/* boundproviders: the instance currently SELECTED for each of this one's - * restart-causing requirements. A BINDING IS TO AN INSTANCE, not to a - * capability (§11.1), and that is what decides behaviour when the bound - * provider leaves while another match remains: the selected one going - * away restarts a `static` consumer even though a survivor is available. - * It is not silently re-pointed — `static` is the plugin saying in - * writing that it cannot survive a provider swap, and a survivor being - * available does not make the swap survivable. */ -// chosen is §11.4's always-reluctant selection, and the ONE place a -// provider is picked for a live instance. If this instance already -// selected a provider for `req` and that provider is STILL a candidate, -// it keeps it — a better-ranked newcomer does not take it. `remember` -// is false for the questions asked ABOUT an instance rather than BY it: -// introspection must not create a binding. func (h *Host) chosen(e *Live, req Required, remember bool) string { cands := h.providersof(req) if 0 == len(cands) { @@ -942,24 +745,6 @@ func (h *Host) consumersof(ref string) []string { return out } -// holdersof answers §11.3's `hold` question, which is a DIFFERENT -// question from the cascade's — and reading it off `consumersof` -// answered the cascade's. -// -// The cascade wants the edges that RESTART (mandatory-static and -// optional-static), because a restart is what it performs. `hold` says -// "deactivating a REQUIRED instance is `plugin_dependency_held`", and -// required is cardinality: GatesActivation, not RestartsOnLoss. The two -// sets differ in both directions and each difference was a real bug. -// -// A MANDATORY-DYNAMIC consumer was excluded, so the strictest policy -// let a provider go that a live consumer could not do without — -// `dynamic` promises survival of a SWAP, and under `hold` there is no -// swap, so the consumer falls back to `pending`. -// -// An OPTIONAL-STATIC consumer was included, so `hold` refused a -// deactivation on behalf of an instance that had said in writing it -// does not need the thing. func (h *Host) holdersof(ref string) []string { out := []string{} for _, r := range sortedkeys(h.inst) { @@ -1002,19 +787,6 @@ func (h *Host) providersof(req Required) []Candidate { return ResolveCapability(req, cands) } -/* CONSUMERS GO DOWN FIRST, NOT AFTERWARDS (§11.3). - * - * The cascade is part of the provider's own deactivation and runs BEFORE - * the provider's `deactivate` callback and scope unwind, so a consumer's - * teardown can still call the thing it depends on — flushing a buffer to - * the store it is about to lose is exactly what a `deactivate` callback - * is for, and a cascade that fired after the provider was already gone - * would make that impossible. - * - * Order: consumers deepest-first, then the provider. `Unload` and - * `Close` inherit it, UNDER EITHER DEPENDENCY POLICY, which is what - * makes apply's reverse-load-order teardown safe even when a document - * happens to list a consumer before its provider. */ func (h *Host) cascade(provider *Live, seen map[string]bool) { if seen[provider.Ref] { return @@ -1042,16 +814,6 @@ func (h *Host) cascade(provider *Live, seen map[string]bool) { } } -/* held is A GUARD ON AD-HOC DEACTIVATION, NOT ON COORDINATED TEARDOWN. - * In a bulk operation that is removing the holders too — `Close()`, or - * an `Apply` plan whose own steps deactivate them — it is suspended for - * exactly those holders, and the teardown still runs consumers before - * providers. - * - * Otherwise `Close()` under `hold` would raise on the first provider it - * reached whenever a document happened to list a consumer after it, - * which is the policy refusing to allow the one teardown it has no - * reason to object to. */ func (h *Host) held(e *Live) error { if "hold" != h.dependency { return nil @@ -1068,13 +830,6 @@ func (h *Host) held(e *Live) error { map[string]any{"ref": e.Ref, "holders": holders}) } -/* reconcile is EAGER: run to a fixed point rather than scheduling. - * - * Two directions, and both are the reason `pending` exists. Activation - * is a STANDING REQUEST, not a one-shot event: a pending instance whose - * requirement arrives activates without being asked again, and a LIVE - * instance whose requirement is lost goes back to pending — - * recursively, through its own consumers. */ func (h *Host) reconcile() { rounds := 0 for moved := true; moved; { @@ -1100,13 +855,6 @@ func (h *Host) reconcile() { if 0 == len(lost) { continue } - // POLICY IS PER REQUIREMENT, not per instance (§11.3): only - // the definition that has the requirement knows what it can - // cope with, and one instance may hold both a `static` and a - // `dynamic` one. A `dynamic` requirement whose provider is - // gone leaves the consumer LIVE and notified; it is a - // statement about surviving a swap, so it does not restart - // here. restarts := false for _, q := range lost { if RestartsOnLoss(q) { @@ -1152,13 +900,6 @@ func (h *Host) reconcile() { // --- ordering -------------------------------------------------------- func (h *Host) Order(point string) ([]string, error) { - // Sorted by declaration SEQUENCE, which is what makes §7's sort - // deterministic here. §7 breaks ties by `pos`, and two instances CAN - // share one — `Declare` defaults `Pos` to the registry size, so an - // unload followed by a fresh declare reuses a surviving instance's. - // Past that the canonical fell through to its map's insertion order; - // a Go map has none, and sorting by REF gave the opposite answer. - // `Seq` IS that order, made explicit — in the canonical too. refs := sortedkeys(h.inst) sort.SliceStable(refs, func(i, j int) bool { return h.inst[refs[i]].Seq < h.inst[refs[j]].Seq @@ -1345,15 +1086,6 @@ func (h *Host) Capability(name string) []string { // --- documents ------------------------------------------------------- -// Apply is §9.6: "load what is missing, UNLOAD WHAT IS GONE, patch what -// changed, and move activation state to match", with the stated -// ordering — "deactivations and unloads first (reverse load order), then -// loads, then activations in load order". -// -// FOUR PHASES, NOT ONE INTERLEAVED LOOP. An earlier draft walked the -// document once, which never looked at instances the new document had -// DROPPED — so an integration removed from a config reload stayed live -// with its bindings and resources. func (h *Host) Apply(doc any, profile string) error { leave, err := h.enter() if nil != err { @@ -1423,13 +1155,6 @@ func (h *Host) apply(doc any, profile string) error { for _, ref := range want { ent := norm.Instance[ref] pos := ent.Pos - // NO OPTIONS HERE, and the omission is the fix rather than an - // oversight. `declare` ADOPTS the options map it is handed as - // the instance's own, so passing the resolved map made target - // and source THE SAME MAP in the refill below — which cleared - // its own source and left a first-time instance with no options - // at all. `declare` makes its own empty map and the refill fills - // it, so both paths are now one path. if _, err := h.declare(ref, DeclareSpec{ Order: ent.Order, Pos: &pos}); nil != err { return err @@ -1447,9 +1172,6 @@ func (h *Host) apply(doc any, profile string) error { h.inst[ref].Pos = ent.Pos } - // --- phase 3: loads, in load order ------------------------------- - // ONLY THE EAGER, ACTIVE ONES: "a document of twenty lazy instances - // is twenty map entries and no executed code" (§9.6). for _, ref := range want { if wantlive(ref) { if _, err := h.load(ref, DeclareSpec{}); nil != err { @@ -1521,8 +1243,6 @@ func (h *Host) setoptions(ref string, patch map[string]any) error { return nil } -// refill empties the target and refills it, so callers holding the -// reference see the new values. func refill(target map[string]any, source map[string]any) { for k := range target { delete(target, k) @@ -1553,9 +1273,6 @@ func (h *Host) Close() error { } func (h *Host) closeall() error { - // A bulk teardown removing the holders too, so `hold` is suspended - // for exactly those holders (§11.3) - while the consumers-first - // cascade still runs, which is the half that matters. h.coordinated = true defer func() { h.coordinated = false }() refs := sortedkeys(h.inst) @@ -1567,9 +1284,6 @@ func (h *Host) closeall() error { return nil } -// PositionOf is the same record §6.6 gives a plugin about itself, -// reachable from outside for the corpus. A plugin asks via -// `inst.Position(point)`. func (h *Host) PositionOf(ref string, point string) (Position, error) { e := h.inst[canon(ref)] if nil == e { @@ -1583,10 +1297,6 @@ func (h *Host) PositionOf(ref string, point string) (Position, error) { index := indexof(ranked, e.Ref) return Position{ Index: index, Count: len(ranked), - // §6.2 composes b1(b2(b3(base))) with the FIRST binding - // OUTERMOST, so these are not index 0 and index count-1 the - // other way round. Getting this backwards is the exact error the - // positional pin vocabulary exists to prevent. Outermost: 0 == index, Innermost: index == len(ranked)-1, }, nil diff --git a/go/plugin/order.go b/go/plugin/order.go index ceb8438..ee03827 100644 --- a/go/plugin/order.go +++ b/go/plugin/order.go @@ -1,19 +1,3 @@ -/* Ordering (§7) — one rule, one place. - * - * sdkgen grew two special cases in `makeOptions` (`test`, then - * `station`) and the third was not far off. This sort is the whole - * replacement, and the tiers are in this order for a reason: - * - * 1 constraints before/after edges, by ref or by name - * 2 bands integer, lower first, default 0 - * 3 declaration ties break by `pos` - * - * CONSTRAINTS BEAT BANDS precisely so the correct tool wins when both - * are present. A band expresses a genuine cross-cutting layer; a - * constraint expresses a relationship between two specific things; and a - * band chosen by trial and error to fix an ordering bug is a bug wearing - * a number. */ - package plugin import ( @@ -27,10 +11,6 @@ type Binding struct { Order *OrderBlock `json:"order,omitempty"` } -// Pin is where a host has pinned a binding. Positional, not ordinal: -// §6.2 composes b1(b2(b3(base))) with the FIRST binding OUTERMOST, so -// `first` and `innermost` are opposites, and a pin spelled in sort terms -// would be read backwards by exactly the people it protects. type Pin map[string]string func ResolveOrder(bindings []Binding, pin Pin) ([]string, error) { @@ -132,9 +112,6 @@ func band(b Binding) int { return *b.Order.Band } -// targets: matching is by REF, or by NAME across all of that -// definition's instances (§7) — which is the whole reason the two -// spellings exist. func targets(spec OrderRef, nodes []Binding) []string { hit := []string{} // A list fans out to the UNION of what each spelling names, so @@ -176,19 +153,6 @@ func seen(hit []string, ref string) bool { return false } -/* A PIN IS NOT A CONSTRAINT (§7). - * - * Constraints and bands are negotiable by definition — they are what - * plugins and documents say they want, and the sort's job is to satisfy - * them all. A pin is the host stating a structural invariant of its own - * architecture, which is a different kind of claim and must not lose a - * tie to a document. - * - * So a pin PLACES the binding at the named end, and an ordering that - * would move it away is `plugin_order_pinned` — rejected, not honoured - * into a broken wrap. Station's transport adapter must sit immediately - * outside the base transport; an `order` list that moves it has to be an - * error rather than a preference. */ func applypin(order []string, edges map[string][]string, pin Pin) ([]string, error) { if nil == pin { return order, nil @@ -208,10 +172,6 @@ func applypin(order []string, edges map[string][]string, pin Pin) ([]string, err continue } - // `first`/`outermost` is index 0; `last`/`innermost` is the end. - // §6.2 makes the first chain binding outermost, which is why the - // vocabulary is positional and why the two spellings pair this - // way. wantfirst := "first" == want || "outermost" == want ref := out[idx] out = append(out[:idx], out[idx+1:]...) diff --git a/go/plugin/point.go b/go/plugin/point.go index 5e61867..a5d71e5 100644 --- a/go/plugin/point.go +++ b/go/plugin/point.go @@ -1,13 +1,3 @@ -/* Extension points (§6). Three kinds, chosen because they are what the - * two existing systems actually needed, and no more. - * - * A PLUGIN NEVER MUTATES THE HOST. That inversion is what makes - * deactivation possible: sdkgen's `utility.fetcher = wrapped` is not - * undoable, but "this instance holds slot 3 of the request chain" is - * undoable in O(1). OSGi named it the whiteboard pattern in 2004, in a - * paper called *Listeners Considered Harmful*, and for exactly this - * reason. */ - package plugin import "sort" @@ -20,11 +10,6 @@ const ( KindProvider Kind = "provider" ) -// Mode is §6.1: "fan-out" is not one answer but four. In a language with -// asynchrony, "call every binding" hides a decision — start them all and -// wait, await each in turn, or do not wait — and a design that leaves it -// unsaid gets four different answers from four ports, in the concurrency -// behaviour of production code no corpus entry happens to cover. type Mode string const ( @@ -34,22 +19,6 @@ const ( ModeBail Mode = "bail" ) -/* BindFn IS ONE TYPE FOR ALL THREE KINDS, AND THAT IS A PORT DECISION - * WORTH STATING. - * - * The canonical calls `fn(arg)` for a hook, `fn(next, ...args)` for a - * chain and `fn(...args)` for a provider — three arities on one - * untyped slot. Go could give each kind its own func type, but then - * `bind` needs three spellings and a probe that binds the same - * function to a hook point in one corpus entry and a provider point in - * the next (which `point/bail` and `point/provider` do, with the same - * `provider` probe) could not be written at all. - * - * So one variadic signature carries all three, and a chain's `next` is - * simply its first argument — exactly as in the canonical. - * - * A BINDING CANNOT RAISE IN GO. It RETURNS an error value instead, and - * §6.1's collecting modes gather those. */ type BindFn func(args ...any) any type Spec struct { @@ -58,24 +27,17 @@ type Spec struct { // Base is `chain` only: the host owns the base, and a plugin cannot // replace it (§6.2). One that wants to SUBSTITUTE rather than wrap // binds innermost and simply does not call `next`. - Base BindFn `json:"-"` - // Exclusive is `provider` only: a second binding is an error rather - // than a shadow. - Exclusive bool `json:"exclusive,omitempty"` - // Default is `provider` only: the host's fallback. - Default any `json:"default,omitempty"` - Pin Pin `json:"pin,omitempty"` + Base BindFn `json:"-"` + Exclusive bool `json:"exclusive,omitempty"` + Default any `json:"default,omitempty"` + Pin Pin `json:"pin,omitempty"` } type Bound struct { Ref string Point string Fn BindFn - // Band: `provider` ranks by HIGHEST band, unlike hook and chain - // which run lowest first. Kept as declared so the two rules stay - // visibly different rather than one being derived from the other by - // a reader who then gets it backwards. - Band int + Band int } // Emit is fan-out. Return values are ignored except in `bail`. @@ -107,7 +69,6 @@ func Emit(bindings []Bound, mode Mode, arg any) (any, error) { if !bad { continue } - // `emit` raises synchronously; the collecting modes gather. if ModeEmit == mode { return nil, err } @@ -119,13 +80,6 @@ func Emit(bindings []Bound, mode Mode, arg any) (any, error) { return errors, nil } -// Compose is b1(b2(b3(base))), FIRST BINDING OUTERMOST (§6.2). -// -// Recomputed by the host whenever the live set changes, and cached -// between changes. Plugins receive `next` as an argument; they never see -// or store the previous value of anything. A plugin that stashes `next` -// and calls it after deactivation is a bug the host cannot prevent, and -// this says so rather than pretending otherwise. func Compose(bindings []Bound, base BindFn) BindFn { next := base for i := len(bindings) - 1; 0 <= i; i-- { @@ -143,9 +97,6 @@ type Picked struct { Shadowed []string } -// Provider selects at most one live implementation (§6.3). The winner is -// the highest band, ties broken by ref sort, and THE LOSERS ARE VISIBLE -// rather than silently ignored. func Provider(bindings []Bound, spec Spec) (Picked, error) { if 0 == len(bindings) { return Picked{Shadowed: []string{}}, nil @@ -167,7 +118,7 @@ func Provider(bindings []Bound, spec Spec) (Picked, error) { sort.SliceStable(ranked, func(i, j int) bool { a, b := ranked[i], ranked[j] if a.Band != b.Band { - return a.Band > b.Band // HIGHEST band wins + return a.Band > b.Band } return a.Ref < b.Ref }) diff --git a/go/plugin/ref.go b/go/plugin/ref.go index 260f6ed..3a21afa 100644 --- a/go/plugin/ref.go +++ b/go/plugin/ref.go @@ -1,9 +1,3 @@ -/* Identity: name+tag, written `name$tag` (§4). - * - * The four pure functions, and the whole of what `ref` pins. They are - * the first thing a new port implements and the first corpus section it - * passes. */ - package plugin import ( @@ -11,15 +5,8 @@ import ( "strings" ) -// §4: `^[a-zA-Z@][a-zA-Z0-9.~_\-/]*$`, max 1024. var nameRe = regexp.MustCompile(`^[a-zA-Z@][a-zA-Z0-9.~_\-/]*$`) -// §4: `^[a-zA-Z0-9.~_-]+$`, max 1024, or empty. -// -// The asymmetry with a name is deliberate: a tag MAY start with a digit -// because auto-tagging assigns integer tags (`stripe$1`), and a tag -// admits neither `@` nor `/` because a name is a package specifier and a -// tag is not. var tagRe = regexp.MustCompile(`^[a-zA-Z0-9.~_-]+$`) const refMax = 1024 @@ -43,13 +30,7 @@ func CheckTag(tag string) bool { return tagRe.MatchString(tag) } -// ParseRef turns `name$tag` into the pair. Canonicalizing: `stripe$` and -// `stripe` both give tag "". func ParseRef(str string) (Ref, error) { - // Split on the FIRST `$`. Nothing in the grammar decides this — `$` - // is in neither character class — so the corpus is the arbiter (§4 - // rule 5), and it picks the split that blames the part actually at - // fault: `a$b$c` is a good name with a bad tag, not the reverse. name := str tag := "" if cut := strings.Index(str, "$"); -1 != cut { @@ -69,10 +50,6 @@ func ParseRef(str string) (Ref, error) { return Ref{Name: name, Tag: tag}, nil } -// FormatRef turns the pair into `name$tag`. An empty tag NEVER writes -// the separator, which is the half of canonicalization FormatRef owns: -// parse tolerates `stripe$`, format never produces it, so a round trip -// is idempotent. func FormatRef(name string, tag string) (string, error) { if !CheckName(name) { return "", Fail("plugin_bad_name", "invalid plugin name: "+name, diff --git a/go/plugin/resolve.go b/go/plugin/resolve.go index 4f0c3d0..0badc18 100644 --- a/go/plugin/resolve.go +++ b/go/plugin/resolve.go @@ -1,16 +1,3 @@ -/* Dynamic resolution (§10.2) — name to candidate module ids. - * - * PURE. It returns the ids a host WOULD try, in order; it does not load - * anything. That separation is what lets the corpus pin resolution in - * every language including those with no dynamic loading at all, and it - * is why §15.4 puts real module loading in per-port integration tests - * rather than here. - * - * GO IS TIER S (§10.3): static-only, registration through `init()` or - * an explicit list handed to MakeHost. That changes nothing here — - * candidate generation is what the corpus pins, and a port with no - * dynamic loading at all still answers it identically. */ - package plugin import "strings" @@ -61,16 +48,6 @@ var defaultSources = []Source{ {Kind: "module", Prefix: []string{"@voxgig/plugin-", "voxgig-plugin-", "plugin-", ""}}, } -// ResolveFrom: A MODULE PATH IS NOT A NAME (§10.2). The ref grammar -// starts a name with a letter or `@`, so `./local/thing` is not a ref -// and never reaches candidate generation — seneca allows a path where a -// plugin name goes, and this design deliberately does not, because a ref -// is an ADDRESS WITHIN A HOST and a path is a LOCATION ON A DISK. -// -// Loading from an explicit location is a separate field that bypasses -// candidate generation entirely: `from` is passed to the resolver -// verbatim, and a resolver that cannot honour a location raises -// plugin_resolve_failed. func ResolveFrom(from string) []string { return []string{from} } diff --git a/go/plugin/types.go b/go/plugin/types.go index 6fec2d5..f00fcfb 100644 --- a/go/plugin/types.go +++ b/go/plugin/types.go @@ -1,15 +1,3 @@ -/* Shared types. Deliberately small: the design's §19 budget says the - * library owns naming, configuration, lifecycle, ordering, binding and - * teardown, and nothing else. - * - * GO IS THE FIRST PORT AND IT CHANGES ONE THING ON PURPOSE (§18, P4): - * errors are RETURNED, not raised. The canonical raises, and every - * signature that could fail here returns `error` instead. That is the - * point of porting Go first — "static-only + typed extension points + - * explicit errors will find every TypeScript-shaped assumption in the - * model" — and the corpus compares by CODE, which survives the change - * intact. */ - package plugin import ( @@ -18,17 +6,11 @@ import ( "fmt" ) -// Ref is the two halves of an identity (§4). Tag is "" when absent — -// never nil and never missing, because a port returning three shapes -// for two states makes every downstream comparison a special case. type Ref struct { Name string `json:"name"` Tag string `json:"tag"` } -// Status is one of §5.1's seven statuses, and no more. A port that adds -// an eighth is diverging. `loading` and `closing` are observable only -// from inside a callback or from another thread. type Status string const ( @@ -41,20 +23,12 @@ const ( StatusClosing Status = "closing" ) -// OrderBlock is §4.4 of DOCS.md — `band` rather than a nested `order`, -// because `order.order` needs explaining every time it is read. type OrderBlock struct { Before OrderRef `json:"before,omitempty"` After OrderRef `json:"after,omitempty"` Band *int `json:"band,omitempty"` } -// MarshalJSON omits an unstated constraint entirely. -// -// `omitempty` does not apply to a struct, so without this an absent -// `before` serialized as `"before": null` while canonical simply has no -// key - the same language-dependent shape divergence as emitting a list -// for an authored scalar, one level up. func (block OrderBlock) MarshalJSON() ([]byte, error) { out := map[string]any{} if block.Before.Stated() { @@ -67,47 +41,15 @@ func (block OrderBlock) MarshalJSON() ([]byte, error) { out["band"] = *block.Band } - // `marshal` for the same reason as OrderRef.MarshalJSON above. return marshal(out) } -// OrderRef is ONE spelling or a LIST of them. -// -// plugin used to type this as a bare string, so a list matched nothing and -// was SILENTLY DROPPED - the sort came out as if no constraint had been -// declared. Go could not even represent the input. -// -// `raw` is THE AUTHORED VALUE, kept so normalization can hand the block -// back exactly as written; `List` is the parsed form the sort consumes, -// and `set` says whether the key was stated at all. Canonical does not -// need any of this: it never decodes the block, it assigns it (`ent.order -// = ord`), so every spelling survives untouched. Go is the only port that -// decodes and rebuilds, and a rebuild silently loses whatever it does not -// model. That single cause produced four separate parity breaks - the -// list form, scalar-vs-one-element-list, an authored empty list, an -// authored null - and the corpus could see none of them until -// `config/normorder` was written to assert the block's own shape. -// -// `set` is deliberately not `0 < len(list)`: an authored `[]` states a -// constraint that names nothing, which is NOT the same document as an -// absent key. -// -// `raw` is the ONE source of truth and `list` is a derived cache, both -// unexported. An earlier round exported `List`, which made the value -// mutable from outside and gave it two sources that could disagree: a -// caller mutating the slice it passed in changed what marshalled while -// ResolveOrder kept the old constraint, and editing `List` directly did -// the reverse. A persisted-then-reloaded config could then order -// differently from the live one. NewOrderRef is now the only way to build -// one, it copies what it is given, and Refs() hands back a copy. type OrderRef struct { raw any list []string set bool } -// Refs is the parsed spellings this ref names, as a copy. Callers get no -// handle on the ref's own state. func (ref OrderRef) Refs() []string { out := make([]string, len(ref.list)) copy(out, ref.list) @@ -115,15 +57,6 @@ func (ref OrderRef) Refs() []string { return out } -// UnmarshalJSON decodes to `any` first and then goes through the SAME -// decoder the in-memory path uses. -// -// It used to try `string` and then `[]string` directly. That is wrong -// twice over: json.Unmarshal into a string is a documented NO-OP on JSON -// null and returns a nil error, so `{"after":null}` took the string -// branch and came back as `{"after":""}` - an empty-string constraint -// nobody wrote; and having two decoders for one rule let them drift, so -// this path hard-failed on values the in-memory path quietly accepted. func (ref *OrderRef) UnmarshalJSON(data []byte) error { var authored any @@ -136,30 +69,10 @@ func (ref *OrderRef) UnmarshalJSON(data []byte) error { return nil } -// Stated reports whether this ref says anything at all - because a -// document stated it, or because a Go caller built it by hand. -// -// It is NOT `0 < len(List)`: an authored `[]` states a constraint that -// names nothing, and an absent key states nothing, and those are -// different documents. func (ref OrderRef) Stated() bool { return ref.set } -// MarshalJSON replays the AUTHORED value. -// -// A ref that was never stated marshals as `null`, and OrderBlock omits -// the key rather than emitting that. There is no `List` fallback and no -// way to build a ref outside NewOrderRef: an earlier round had both, and -// carrying two ways to construct one value is exactly what let them -// desync. -// -// `marshal`, not `json.Marshal`. The latter escapes `<`, `>` and `&` as -// \u00XX, which canonical's JSON.stringify does not, and an outer encoder -// cannot undo an escape its input already carries - so a spelling holding -// any of those three came back with different bytes from every other -// port. go/AGENTS.md states this rule outright and both marshallers here -// broke it. func (ref OrderRef) MarshalJSON() ([]byte, error) { if ref.set { return marshal(ref.raw) @@ -177,22 +90,12 @@ func NewOrderRef(spelling any) OrderRef { return asorderref(spelling, true) } -// Instance is a normalized instance entry. Option data is NOT merged -// here — see OptionLayers. type Instance struct { - Pos int `json:"pos"` - Active bool `json:"active"` - Start string `json:"start"` - Order *OrderBlock `json:"order,omitempty"` - // OptionLayers holds levels 3-6 that are present, IN LADDER ORDER - // (§9.3). - // - // Normalization does not merge these, and cannot: §9.4 makes merge - // behaviour a property of the definition's option shape, which - // normalization has never seen. Flattening them here would make - // `$MERGE: append` unimplementable at load time, because the layers - // it must concatenate would already be collapsed. - OptionLayers []any `json:"optionlayers"` + Pos int `json:"pos"` + Active bool `json:"active"` + Start string `json:"start"` + Order *OrderBlock `json:"order,omitempty"` + OptionLayers []any `json:"optionlayers"` } type Normalized struct { @@ -201,12 +104,6 @@ type Normalized struct { Default map[string]any `json:"default"` } -// DetailOrder is §12's detail fields, IN THIS FIXED ORDER. -// -// The order is part of the contract, not a formatting preference. An -// earlier draft named six fields while other sections promised -// diagnostics that had nowhere to go, which would have left each port -// inventing its own order and breaking message parity. var DetailOrder = []string{ "host", "ref", "name", "tag", "point", "key", "capability", "range", "version", "match", "candidates", "cycle", "holders", @@ -254,11 +151,6 @@ func FormatError(code string, text string, details map[string]any) string { return "plugin/" + code + ": " + text + tail } -// PluginError carries a §12 code. Ports compare by CODE and never by -// message: wording is a port's own business, and pinning the words would -// make every translation a corpus change. The FORMAT, however, is -// pinned — a parseable message is what makes a log searchable across -// twenty languages. type PluginError struct { Code string Text string @@ -268,8 +160,6 @@ type PluginError struct { func (e *PluginError) Error() string { return e.message } -// Fail builds the error the canonical would have thrown. Go RETURNS it; -// every caller in this port propagates rather than unwinding. func Fail(code string, text string, details map[string]any) *PluginError { if nil == details { details = map[string]any{} diff --git a/go/plugin/util.go b/go/plugin/util.go index 02922a5..4d94d5f 100644 --- a/go/plugin/util.go +++ b/go/plugin/util.go @@ -1,13 +1,3 @@ -/* The handful of helpers the port needs that the canonical gets from - * JavaScript for free: sorted map keys (Object.keys(x).sort()), a - * JSON.stringify-equivalent marshal, and deep clone. - * - * `each(...) in sorted-key order` is a house rule across voxgig - * because it makes output BYTE-STABLE. Go's map iteration is - * deliberately randomized, so every traversal here goes through - * sortedkeys — an unsorted `for k := range m` is a bug that passes its - * own tests most of the time. */ - package plugin import ( diff --git a/go/plugin/version.go b/go/plugin/version.go index 9f1637b..8def3bc 100644 --- a/go/plugin/version.go +++ b/go/plugin/version.go @@ -1,17 +1,3 @@ -/* Versions and ranges (§11.2). - * - * TWO FIELDS AND ONE PREDICATE. A capability declares `version`, a - * concrete version. A requirement declares `range`. A requirement is - * satisfied when the names match, the `match` passes, and: - * - * the provider's `version` falls inside the requirement's `range`. - * - * That is the whole rule. There is no third field and no second - * comparison — an earlier draft added a provider-side `compat` range, - * which left three values and no statement of how they combine, and - * three defensible readings of one declaration is worse than the - * ambiguity it was introduced to fix. */ - package plugin import ( @@ -27,21 +13,8 @@ type Range struct { var versionRe = regexp.MustCompile(`^(\d+)(?:\.(\d+))?(?:\.(\d+))?$`) -// componentMax bounds a version component, like §4's 1024 bounds a ref. -// -// The grammar admits an unbounded digit sequence and every language then -// disagrees past its integer range: JavaScript silently loses precision, -// Go's Atoi errors (and this port was ignoring that, yielding 0), C -// overflows. `Satisfies("0", "9223372036854775808")` was false in the -// canonical and TRUE here, from the same corpus. -// -// 2^31-1, because every port has a signed 32-bit integer. const componentMax = 2147483647 -// ParseRange accepts two forms and no more (§11.2): -// -// '2.1' >= 2.1.0 and < 3.0.0 -// '~2.1' >= 2.1.0 and < 2.2.0 func ParseRange(rng string) (Range, error) { bad := func() (Range, error) { return Range{}, Fail("plugin_bad_range", "invalid range: "+rng, @@ -154,5 +127,3 @@ func at(l []int, i int) int { } return 0 } - - diff --git a/go/test/corpus.go b/go/test/corpus.go index be3df5b..0744d4b 100644 --- a/go/test/corpus.go +++ b/go/test/corpus.go @@ -1,21 +1,3 @@ -/* The corpus runner. - * - * Reads spec/plugin.json — the COMMITTED artifact, not the aontu source - * — exactly as every other port's runner does. No port needs a Node - * toolchain to run its tests, and this one does not get a private door - * into the source either. - * - * A group name selects the subject. That is the whole dispatch, and it - * is deliberately dumb: a runner that inferred the subject from the - * entry's shape would silently run the wrong function when an entry was - * mistyped. - * - * COMPARISON RUNS OVER JSON-NORMALIZED VALUES. A typed port's result is - * a struct; the corpus's expectation is decoded JSON. Marshalling the - * result and decoding it back puts both on the same footing — and it is - * also the honest test, because a field the port forgot to tag is a - * field a caller cannot see either. */ - package plugintest import ( @@ -40,9 +22,7 @@ type Entry struct { Out any Err any Match any - // raw keeps PRESENCE, which the three legal field combinations turn - // on: `out: null` is an assertion and an absent `out` is not. - raw map[string]any + raw map[string]any } func (e Entry) has(key string) bool { _, ok := e.raw[key]; return ok } @@ -186,14 +166,6 @@ func Equal(a any, b any) bool { return a == b } -/* Matches is a partial match: every key the expectation names must - * agree, and keys it does not name are ignored. `__EXISTS__` asserts - * presence without pinning a value; `/re/` matches a string as a regular - * expression. - * - * `present` carries what JavaScript gets from `undefined` for free: a - * key that is absent and a key holding JSON `null` are different, and a - * Go map lookup collapses them. */ func Matches(expect any, actual any, present bool) bool { if s, ok := expect.(string); ok { switch s { @@ -247,12 +219,6 @@ func Matches(expect any, actual any, present bool) bool { return expect == actual } -/* Check runs one entry against a subject and reports the disagreement, - * if any. - * - * The three combinations the spec format allows are enforced here as - * well as at build time, because a runner that quietly accepted `err` - * beside `out` would let a contradictory entry pass. */ func Check(e Entry, subject func(Entry) (any, error)) string { if e.has("err") && e.has("out") { return "entry has both err and out" @@ -265,9 +231,6 @@ func Check(e Entry, subject func(Entry) (any, error)) string { return "expected a raise, got: " + jsonof(value) } if want, ok := e.Err.(string); ok { - // Errors compare by CODE (§12). Message wording is a port's - // own business, and pinning it would make every translation - // a corpus change. if got := plugin.CodeOf(raised); got != want { return "expected code " + want + ", got " + got + " (" + raised.Error() + ")" } diff --git a/go/test/coverage_test.go b/go/test/coverage_test.go index 13c9c00..412fc40 100644 --- a/go/test/coverage_test.go +++ b/go/test/coverage_test.go @@ -1,13 +1,3 @@ -/* EVERY CORPUS SECTION IS RUN. - * - * The per-section tests already fail on a GROUP with no subject. This - * closes the level above: a whole SECTION the runner never mentions is - * a section silently not run, and it would pass a suite that claims - * P4's exit ("both pass every corpus section"). - * - * It also counts the entries, so a section that decodes to an empty set - * cannot masquerade as a passing one. */ - package plugintest import ( diff --git a/go/test/driver.go b/go/test/driver.go index 851fd15..49fcc45 100644 --- a/go/test/driver.go +++ b/go/test/driver.go @@ -1,15 +1,3 @@ -/* The driver (DOCS.md §4). - * - * Every port implements this same small thing and nothing else is - * port-specific: the probe catalog, the command interpreter, and the - * canonical observable. - * - * THE ONE GO-SHAPED DIFFERENCE is that a probe cannot throw. §12's - * errors are RETURNED here, from the callback and from every host verb, - * and the interpreter propagates instead of unwinding. `catch: true` - * still means "record the raise and let the run continue", which is the - * only way to observe a `failed` instance. */ - package plugintest import ( @@ -19,9 +7,6 @@ import ( plugin "github.com/voxgig/plugin/go/plugin" ) -// Probes are §4.3's six probes. Their behaviour is as much the contract -// as the runner is — this is where twenty implementations of `noisy` are -// made to fail at the same callback in the same way. func Probes() []plugin.Definition { record := func(name string) plugin.Definition { return plugin.Definition{ @@ -42,8 +27,6 @@ func Probes() []plugin.Definition { Define: func(i *plugin.Inst) error { i.State()["count"] = countof(i) band := intopt(i, "band") - // One hook binding (`p`) and one chain wrap (`c`) — the - // workhorse shape DOCS.md §4.3 specifies. if err := i.Bind("p", func(args ...any) any { i.State()["count"] = countof(i) + 1 return nil @@ -109,10 +92,6 @@ func Probes() []plugin.Definition { return boom(i, "define") }, Activate: func(i *plugin.Inst) error { - // Acquire BEFORE the raise, so a failing activate has - // something to leak if the scope does not unwind — which is - // the whole point of the entry that asserts open === 0 - // afterwards. if _, err := i.Acquire(); nil != err { return err } @@ -129,9 +108,6 @@ func Probes() []plugin.Definition { Name: "greedy", Define: func(i *plugin.Inst) error { i.State()["count"] = 0 - // §8.1 puts resource capture in `activate`. `early` NAMES the - // call that reaches for it in `define`, because Acquire and - // Release carry the guard separately. if "acquire" == tostr(i.Options()["early"]) { if _, err := i.Acquire(); nil != err { return err @@ -162,19 +138,6 @@ func Probes() []plugin.Definition { handles[k]() } - // `mark` registers N FOREIGN releases — §8.3's `release`, - // the half `acquire` cannot exercise — each recording its - // own index as it runs. - // - // THE RECORDED LIST IS THE ONLY THING THAT DISTINGUISHES A - // REVERSE UNWIND FROM A FORWARD ONE. An acquired handle is - // an idempotent counter decrement, so running the handles in - // either direction leaves the same `open`, and a port - // unwinding forwards passed every other entry in this - // section. - // `bind` is `early`'s counterpart for §8.1's OTHER half. - // Binding declaration belongs in `define`; this names the - // callback that tries it from somewhere else. if "activate" == tostr(i.Options()["bind"]) { if err := i.Bind("p", func(args ...any) any { return nil }, 0); nil != err { return err @@ -345,12 +308,6 @@ func tostr(v any) string { return fmt.Sprint(v) } -/* BASEPOINTS are the points every driver host declares. DOCS.md §4.3 - * defines `probe` as binding one hook point (`p`) and wrapping one chain - * point (`c`), so a host without them cannot load the probe at all — - * they are part of the contract's baseline rather than a fixture - * convenience. `v` is the provider point the `provider` probe defaults - * to. */ func basepoints() map[string]plugin.Spec { return map[string]plugin.Spec{ "p": {Kind: plugin.KindHook}, @@ -451,23 +408,7 @@ func docmd(host *plugin.Host, c map[string]any) (*plugin.Host, *produced, error) }), nil, nil case "define": - // §10.1's static registration: the definition ENTERS THE CATALOG - // here, and registration is where its option shape is validated - // (§9.4) — before any load, so a malformed shape fails at one - // moment in every host rather than whenever a document happens - // to exercise the key. - // - // The catalog is pre-seeded with the probe set, so re-registering - // a probe by name is the identity this command has always been; - // `shape` is what makes it do work. A name the probe set does not - // hold registers a bare definition — enough to reach the catalog, - // and never loaded. { - // §4.2's three keys, all of them live. `probe` names the - // PROBE whose callbacks back the definition and `name` is - // what the definition is called — two keys that ten entries - // passed as equal strings, so a driver ignoring `probe` - // passed them all. name := tostr(c["name"]) source := name if p, ok := c["probe"]; ok { @@ -643,14 +584,9 @@ func docall(host *plugin.Host, c map[string]any, ref string, point string) (*plu } return nil, &produced{[]any{}}, nil case "position": - // Reached through the instance api, which is where §6.6 puts it - // — a plugin asks about itself. p, err := host.PositionOf(ref, point) return nil, &produced{p}, err case "stray": - // A release from OUTSIDE a lifecycle callback. THIS BRANCH USED - // TO DO NOTHING, and its corpus row stayed green whatever - // Release did with its guard. raw, err := host.Exports(ref + "/inst") if nil != err { return nil, nil, err diff --git a/go/test/golocal_test.go b/go/test/golocal_test.go index 5ec3770..8e152db 100644 --- a/go/test/golocal_test.go +++ b/go/test/golocal_test.go @@ -1,21 +1,3 @@ -/* THE TWO THINGS THE CORPUS STRUCTURALLY CANNOT SEE. - * - * Every other test in this directory is corpus-driven, and that is the - * rule: the corpus is the contract, and a port-local test is a way to - * disagree with it quietly. These two are the exception the rule needs, - * because neither claim is EXPRESSIBLE in a language-neutral JSON - * corpus: - * - * - the corpus decodes every number as `float64`, so it cannot ask - * what happens when a Go host writes an `int`; and - * - the corpus is single-threaded, so it cannot ask what two - * goroutines do. - * - * Both were found by review rather than by a failing entry, which is - * exactly why they are written down here. Neither adds a rule; each - * pins a rule the model already states, in the one dimension Go has - * and the corpus does not. */ - package plugintest import ( @@ -25,16 +7,11 @@ import ( plugin "github.com/voxgig/plugin/go/plugin" ) -// A Go host declares attributes in Go, not in JSON — so `5` is an `int` -// while the requirement it must satisfy, read from a document, is a -// `float64`. The model has ONE number type; matching across Go's -// spellings of it is not leniency, it is the model. func TestCapabilityMatchesAcrossNumberTypes(t *testing.T) { cands := []plugin.Candidate{{ Ref: "store$a", Pos: 0, Provides: plugin.Provided{ Name: "store", Version: "1.0.0", - // int, uint and float32 — three spellings of one value. Attrs: map[string]any{"max": 5, "min": uint(1), "rate": float32(2)}, }, }} @@ -70,13 +47,6 @@ func TestCapabilityMatchesAcrossNumberTypes(t *testing.T) { } } -// §5.2 makes transitions SEQUENTIAL. `intransition` alone could not -// deliver that — it is set inside `run`, so two goroutines both passed -// the guard — and the damage was to the registry itself: `Declare` -// reads `len(h.inst)` for `Pos` and `h.seqn` for `Seq`, so an -// interleaved pair produced two instances sharing both. -// -// -race is what makes this test worth having; `make test` runs it. func TestConcurrentDeclaresAreSequential(t *testing.T) { host := plugin.MakeHost(plugin.HostOptions{ Catalog: withprobes(), Points: withpoints(nil)}) diff --git a/go/test/pure_test.go b/go/test/pure_test.go index d3064fe..8f889f9 100644 --- a/go/test/pure_test.go +++ b/go/test/pure_test.go @@ -1,6 +1,3 @@ -/* The P2 pure sections: env, version, capability, graph, resolve — plus - * config, whose two functions the group name selects. */ - package plugintest import ( diff --git a/rust/src/capability.rs b/rust/src/capability.rs index 446ba17..90cd0dd 100644 --- a/rust/src/capability.rs +++ b/rust/src/capability.rs @@ -1,31 +1,8 @@ -//! Capabilities (§11.1). -//! -//! A DEPENDENCY IS ON A CAPABILITY, NOT ON A REF - because it is a -//! dependency on something that can do the job, and which instance is -//! doing it is exactly the configuration detail a plugin must not care -//! about. -//! -//! But A BINDING IS TO AN INSTANCE, not to a capability, which is what -//! decides behaviour when the bound provider leaves while another match -//! remains. use crate::types::stable_sort_by; use crate::value::Value; use crate::version::{satisfiesq, version_parts}; -/// Rank the matching live providers and return them best-first: highest -/// `version`, then LOWEST `priority` (default 0), then declaration -/// position `pos` ascending. -/// -/// `priority` is a field on the capability rather than §7's `order` band, -/// because bands live on POINT BINDINGS: a provider may have several -/// bindings with different bands, or none at all, so a rank reaching for -/// one would be undefined in the common case. -/// -/// Without a total rank, "any provider satisfies" is true of the GRAPH and -/// useless to the PLUGIN - two ports could bind different `store` -/// instances, both resolve green, and behave differently, which is -/// precisely the divergence a shared corpus exists to catch. pub fn resolve_capability(req: &Value, candidates: &[Value]) -> Vec { let mut hits: Vec = candidates .iter() @@ -70,10 +47,6 @@ pub fn matches(req: &Value, prov: &Value) -> bool { } } - // `match` is checked against the provider's `attrs`, key by key. A key - // the provider does not carry is a miss, not a pass: a requirement - // asking for `transactional: true` must not be satisfied by a provider - // that never said. let want = req.get("match"); if !want.is_null() { let attrs = prov.get("attrs"); @@ -90,20 +63,6 @@ pub fn matches(req: &Value, prov: &Value) -> bool { true } -/// PARTIAL MATCH, RECURSING INTO MAPS (§11.1). -/// -/// §11.1 defines `match` as "a partial match against `attrs`, with exactly -/// the semantics voxgig/struct and the omni corpus already define for -/// `match` - every leaf in the requirement must be present and equal in -/// the capability, keys not mentioned are not checked." -/// -/// Equality is by JSON TYPE as well as value: `transactional: 1` does not -/// satisfy `transactional: true`. RUST NEEDS NO GUARD FOR THAT - `Bool` -/// and `Num` are different variants and no coercion exists between them. -/// The dynamic ports each need one, and `capability/match` pins the -/// behaviour for every port rather than trusting a language's `==`. -/// -/// A LIST IS COMPARED LEAF-WISE AT THE SAME LENGTH, not as a subset. pub fn matchvalue(want: &Value, got: &Value) -> bool { if let Value::Map(w) = want { let g = match got.as_map() { diff --git a/rust/src/catalog.rs b/rust/src/catalog.rs index 4ec5acd..6196570 100644 --- a/rust/src/catalog.rs +++ b/rust/src/catalog.rs @@ -1,9 +1,3 @@ -//! The definition catalog (§10.1). -//! -//! A definition is registered once and may back many instances. Option -//! shapes are validated AT REGISTRATION, not when a document happens to -//! exercise a key - so a malformed shape fails once, and in the same place -//! everywhere (§9.4). use std::collections::BTreeMap; use std::rc::Rc; @@ -19,8 +13,6 @@ use crate::value::Value; /// corpus compares by code, which survives the change intact. pub type Callback = Rc Result<(), PluginError>>; -/// §9.4's cheap path: the host hands the new options and the old ones, and -/// the instance stays live. pub type Reconfigure = Rc Result<(), PluginError>>; #[derive(Clone)] diff --git a/rust/src/config.rs b/rust/src/config.rs index daaf4c7..dbe7c97 100644 --- a/rust/src/config.rs +++ b/rust/src/config.rs @@ -1,17 +1,3 @@ -//! The declarative document (§9): normalization, and the ten-level -//! precedence ladder. -//! -//! TWO FUNCTIONS, AND THE SPLIT BETWEEN THEM IS FORCED. -//! -//! `normalize_config` normalizes STRUCTURE and ENTRY KEYS. It does not -//! merge options, and cannot: §9.4 makes merge behaviour a property of the -//! definition's option SHAPE, which normalization has never seen. A -//! normalizer that flattened the option layers would make `$MERGE: append` -//! unimplementable at load time, because the layers it must concatenate -//! would already be collapsed. -//! -//! `resolve_options` applies the ladder, and it is the only place that -//! knows the shape. use std::collections::BTreeMap; @@ -29,11 +15,6 @@ pub fn normalize_config(input: &Value) -> Result { let reserved = input.get("reserved"); let profile = input.get("profile"); - // The rename is applied at TWO PLACES AND NO OTHERS: the document - // root, and every profile. overlay root (§9.1). A rename applied - // only at the root would leave `profile.prod.sdk` untranslated and - // silently drop every environment override the host depends on. - // Recursing further would be worse: option data is the definition's. let baseinst = doc.get(&ikey); let basedef = doc.get(&dkey); @@ -82,16 +63,10 @@ pub fn normalize_config(input: &Value) -> Result { let b = base.0.get(eref); let o = over.0.get(eref); - // MERGE THE ENTRIES AS AUTHORED, THEN APPLY DEFAULTS TO THE RESULT - // (§9.3). A safety rule, not a tidiness one: if the overlay had - // its defaults filled in before merging it would carry a - // synthesized active:true and overwrite a base's false - silently - // re-enabling a deliberately disabled integration in production. let active = config_pick(o, "active", config_pick(b, "active", Value::Bool(true))); let start = config_pick(o, "start", config_pick(b, "start", Value::str("eager"))); let block = config_pick(o, "order", config_pick(b, "order", Value::Null)); - // Option layers, levels 3-6, IN LADDER ORDER. Never merged here. let nm = refname(eref); let mut layers: Vec = Vec::new(); let bd = basedef.get(&nm); @@ -202,9 +177,6 @@ fn config_pick(src: Option<&Value>, key: &str, dflt: Value) -> Value { } } -// --------------------------------------------------------------------- -// resolve_options - §9.3's ten levels, and 9.4's directives -// --------------------------------------------------------------------- pub fn resolve_options(input: &Value) -> Result { let shape = input.get("shape"); @@ -225,22 +197,17 @@ pub fn resolve_options(input: &Value) -> Result { Value::map() }; - // ONE ordered merge, lowest to highest. Levels 3-6 are not two - // namespaces collapsed separately and composed afterwards: that - // inverts the rule that PROFILE SPECIFICITY OUTRANKS DEFINITION - // SPECIFICITY, so a prod per-definition default would lose to a base - // instance value. let layers = [ - config_defaultsof(&shape), // 1 - input.get("hostdefaults"), // 2 - config_optsof(&doc.get("default"), &name)?, // 3 - config_optsof(&doc.get("instance"), &eref)?, // 4 - config_optsof(&overlay.get("default"), &name)?, // 5 - config_optsof(&overlay.get("instance"), &eref)?, // 6 - input.get("env"), // 7 - input.get("hostoptions"), // 8 - input.get("loadoptions"), // 9 - input.get("patch"), // 10 + config_defaultsof(&shape), + input.get("hostdefaults"), + config_optsof(&doc.get("default"), &name)?, + config_optsof(&doc.get("instance"), &eref)?, + config_optsof(&overlay.get("default"), &name)?, + config_optsof(&overlay.get("instance"), &eref)?, + input.get("env"), + input.get("hostoptions"), + input.get("loadoptions"), + input.get("patch"), ]; let mut out = Value::map(); @@ -253,7 +220,6 @@ pub fn resolve_options(input: &Value) -> Result { Ok(out) } -/// The shape's non-directive values are the level-1 defaults. fn config_defaultsof(shape: &Value) -> Value { let mut out = Value::map(); for k in shape.keys() { @@ -271,7 +237,6 @@ fn config_optsof(src: &Value, key: &str) -> Result { return Ok(Value::Null); } - // The array form is equivalent to the map form (§9.1). if let Some(items) = src.as_list() { for item in items.iter() { if canon_ref(&item.get("ref"))? == key { @@ -294,10 +259,6 @@ fn config_optsof(src: &Value, key: &str) -> Result { Ok(Value::Null) } -/// Merge ONE layer onto the accumulator, honouring the shape's directives. -/// The directive holds at EVERY precedence level, not only between -/// document levels - §9.4 makes it a property of the shape, which does not -/// know which layer a value arrived from. fn config_mergeone(base: &Value, over: &Value, shape: &Value) -> Value { if over.is_null() { return base.clone(); @@ -341,7 +302,6 @@ fn config_mergeone(base: &Value, over: &Value, shape: &Value) -> Value { Value::Map(out) } -/// Merge N levels below this key, replace below that. fn config_deepto(base: &Value, over: &Value, n: f64) -> Value { if n <= 0.0 { return over.clone(); @@ -358,11 +318,6 @@ fn config_deepto(base: &Value, over: &Value, n: f64) -> Value { Value::Map(out) } -/// §9.4: N is an integer of at least 1, and everything else is an error. -/// -/// `{"deep": 0}` is rejected DESPITE having an obvious reading, because -/// "replace at this key" already has a spelling and two spellings for one -/// behaviour is the defect class this repo exists to avoid. pub fn check_shape(shape: &Value) -> Result<(), PluginError> { if shape.as_map().is_none() { return Ok(()); diff --git a/rust/src/depend.rs b/rust/src/depend.rs index a12e61e..6e1454e 100644 --- a/rust/src/depend.rs +++ b/rust/src/depend.rs @@ -1,29 +1,3 @@ -//! Dependency cardinality, policy, and the restart graph (§11.3). -//! -//! TWO AXES, BOTH DECLARED BY THE DEFINITION THAT HAS THE REQUIREMENT, -//! because only it knows what it can cope with: -//! -//! ```text -//! | static (default) | dynamic -//! -------------|---------------------------|-------------------------- -//! mandatory | unmet -> pending; | unmet -> pending; -//! (default) | lost -> pending, | lost -> STAYS LIVE, -//! | recursively | notified -//! -------------|---------------------------|-------------------------- -//! optional:true| never gates activation; | never gates activation; -//! | a change deactivates and | a change is a -//! | reactivates | notification, nothing else -//! ``` -//! -//! `dynamic` means the plugin has said, IN WRITING, that it can survive -//! its provider being swapped underneath it. It is not the default because -//! most plugins cannot, and the cost of wrongly assuming they can is a -//! live instance holding a dead reference. -//! -//! The rebinding-preference axis is deliberately omitted. OSGi has -//! reluctant vs greedy and it is a knob every author must understand to -//! read anyone else's component; we take always-reluctant. Three axes were -//! more than the model can carry across twenty ports. use std::collections::{BTreeMap, BTreeSet}; @@ -44,20 +18,6 @@ pub fn normrequire(raw: &Value) -> Value { } } -/// The requirements a definition declared, normalized. -/// -/// BOTH AXES ARE READ AT TWO LEVELS, AND THE PER-REQUIREMENT ONE WINS. -/// -/// The instance-level `policy` and `optional` list are how a DOCUMENT -/// states the axis without editing the definition, and they apply to every -/// requirement. The per-requirement form is the one §11.1's object syntax -/// exists for, and it is strictly more expressive: an instance that is -/// `static` on its store and `dynamic` on its metrics cannot be written at -/// all at the instance level. -/// -/// `optional` unions rather than overriding - both spellings are -/// statements that this requirement need not gate activation, and there is -/// no reading under which one of them means "actually, mandatory". pub fn requirements(options: &Value) -> Vec { let raw = options.get("requires"); let marked = options.get("optional"); @@ -81,65 +41,26 @@ pub fn requirements(options: &Value) -> Vec { out } -/// Does losing this requirement's SELECTED provider restart the consumer? -/// The mandatory ones under `static`, and the `static` optional ones - -/// both make a capability change deactivate and reactivate. `dynamic` -/// never restarts. pub fn restartsonloss(req: &Value) -> bool { let policy = req.get("policy"); policy.as_str().unwrap_or("static") != "dynamic" } -/// Does an unmet requirement keep the consumer out of `live`? -/// -/// Cardinality alone decides this, NOT policy. `dynamic` is a statement -/// about surviving a SWAP, not about starting without the thing at all - a -/// mandatory-dynamic consumer still waits in `pending` for its first -/// provider. pub fn gatesactivation(req: &Value) -> bool { !matches!(req.get("optional"), Value::Bool(true)) } -/// Edges that can cause a restart, which is exactly the set a cycle must -/// be detected over (§11.3). -/// -/// ONLY `dynamic` OPTIONAL EDGES ARE EXCLUDED, and they are the ones the -/// exclusion was for: two plugins that optionally and dynamically consume -/// each other's capabilities both activate happily, neither gates on the -/// other, and each is merely notified when the other appears. Nothing -/// restarts, so nothing oscillates. -/// -/// An earlier draft of §11.3 excluded EVERY optional edge and thereby -/// admitted the non-terminating case it was trying to permit. pub fn restartcausing(req: &Value) -> bool { gatesactivation(req) || restartsonloss(req) } -/// One node of the requirement graph, as plain data for the pure detector. pub struct Node { pub eref: String, pub provides: Vec, pub requires: Vec, } -/// A cycle through restart-causing requirements is -/// `plugin_dependency_cycle`, detected AT LOAD - before anything runs, -/// because the failure it describes is a non-terminating reconcile and the -/// only safe time to report that is before it starts. -/// -/// The graph is over capabilities, not refs: an edge runs from a consumer -/// to EVERY node that provides what it needs, because any of them could be -/// the one selected and a cycle through any is a cycle. A node also -/// satisfies its own name as a ref (§11.1), which is why the ref is a -/// provider of itself here. pub fn dependencycycle(nodes: &[Node]) -> Option> { - // TWO INDEXES, NOT ONE MERGED MAP. Capability names and refs are - // matched differently - a capability by its exact name, a ref through - // the canonical spelling (section 4 rule 5) - and one map keyed by - // both can only do one of them. Keyed by both and looked up raw, as - // this was, a cycle spelled `a$`/`b$` found no providers and EVADED - // the load-time check that exists to catch a non-terminating - // reconcile. let mut bycap: BTreeMap> = BTreeMap::new(); let mut isref: BTreeSet = BTreeSet::new(); for n in nodes.iter() { @@ -177,8 +98,6 @@ pub fn dependencycycle(nodes: &[Node]) -> Option> { edges.insert(n.eref.clone(), out); } - // Iterative DFS with an explicit stack: twenty ports, and several of - // them have no recursion budget worth relying on. const WHITE: u8 = 0; const GREY: u8 = 1; const BLACK: u8 = 2; diff --git a/rust/src/env.rs b/rust/src/env.rs index 5d30137..3e9516e 100644 --- a/rust/src/env.rs +++ b/rust/src/env.rs @@ -1,21 +1,3 @@ -//! Environment overrides (§9.5) - level 7 of the ladder. -//! -//! One prefix, so nothing drifts: `VOXGIG_PLUGIN_*`. -//! -//! ```text -//! VOXGIG_PLUGIN_PROFILE the profile name -//! VOXGIG_PLUGIN__ one option -//! VOXGIG_PLUGIN_ACTIVE/INACTIVE comma-separated refs, INACTIVE wins -//! ``` -//! -//! THE ENCODING IS LOSSY, AND THIS SAYS SO RATHER THAN PRETENDING -//! OTHERWISE. Ref and path are upper-snake with `$` -> `__` and `.` -> -//! `_`. But `_` is legal in a name and in a tag, and the mapping folds -//! case, so `retry$fast` and `retry__fast` both encode to `RETRY__FAST`. -//! -//! Rather than restrict a grammar the rest of the stack already uses, the -//! host DETECTS THE COLLISION: it encodes every ref it holds, and a key -//! two refs claim is `plugin_env_ambiguous`, naming both. use std::collections::BTreeMap; @@ -25,7 +7,6 @@ use crate::value::{self, Value}; pub const ENV_PREFIX: &str = "VOXGIG_PLUGIN_"; -/// `retry$fast` -> `RETRY__FAST`. pub fn encode_ref(eref: &str) -> String { eref.replace('$', "__").replace('.', "_").to_uppercase() } @@ -73,8 +54,6 @@ pub fn apply_env(input: &Value) -> Result { ); } - // Longest encoded ref first, so `retry$fast` wins over `retry` on - // `RETRY__FAST_MIN`. Shortest-first would read the tag as a path. let mut encoded: Vec = byencoded.keys().cloned().collect(); stable_sort_by(&mut encoded, |e| -(e.len() as i64)); @@ -94,11 +73,6 @@ pub fn apply_env(input: &Value) -> Result { let mut list = out.get(field).as_list().cloned().unwrap_or_default(); for raw in env_split(&env.get(&key)) { let eref = canon_ref(&Value::str(&raw))?; - // The reservation covers EVERY input layer (§9.1). - // VOXGIG_PLUGIN_INACTIVE=station is easier to set than - // editing a config file, and INACTIVE has the final word - - // so guarding documents alone would leave the one lever - // this mechanism exists to deny wide open. env_checkreserved(&eref, &reserved)?; list.push(Value::str(&eref)); } @@ -142,10 +116,6 @@ pub fn apply_env(input: &Value) -> Result { Ok(out) } -/// Write a value at a dotted path, creating maps on the way down. A map is -/// REPLACED when the path needs to descend through a scalar - the same -/// rule every other port applies, and the reason it is written out here is -/// that rust cannot express it as a chain of `//=`. fn set_path(node: &mut Value, path: &[String], value: Value) { if 1 == path.len() { node.set(&path[0], value); @@ -186,9 +156,6 @@ fn env_checkreserved(eref: &str, reserved: &Value) -> Result<(), PluginError> { ) } -/// Values parse as JSON, FALLING BACK TO STRING - so `8080` is a number, -/// `true` is a boolean, `{"a":1}` is a map, and `hello` is the string it -/// looks like rather than a parse error. fn env_parsevalue(value: &Value) -> Value { match value.as_str() { Some(text) => value::parse(text).unwrap_or_else(|_| value.clone()), diff --git a/rust/src/export.rs b/rust/src/export.rs index 145ad61..ad2a29d 100644 --- a/rust/src/export.rs +++ b/rust/src/export.rs @@ -1,14 +1,3 @@ -//! Exports (§11). -//! -//! An instance publishes values for other plugins and for the application. -//! Read with `host.exports("retry$fast/client")`. -//! -//! THE UNQUALIFIED ALIAS IS THE INTERESTING PART. `retry/client` resolves -//! to the UNTAGGED instance if one exists; if not, and exactly one tagged -//! instance exports that key, it resolves to that one; if two do, it is -//! `plugin_export_ambiguous` - deliberately diverging from seneca's silent -//! last-wins, because with multi-instance as a headline feature an -//! ambiguous alias is a defect waiting for production. use crate::refs::{canon, parse_ref, refname}; use crate::types::{details, fail, PluginError}; diff --git a/rust/src/graph.rs b/rust/src/graph.rs index 44f02e2..e4b62f8 100644 --- a/rust/src/graph.rs +++ b/rust/src/graph.rs @@ -1,20 +1,3 @@ -//! Whole-graph resolution (§11.4) - a phase, not a discovery. -//! -//! "Activate, and wait in `pending` if you must" is correct and, on its -//! own, produces a terrible experience: apply twenty instances against a -//! registry missing one thing and you get NINETEEN pending rows and no -//! statement of what is actually wrong. -//! -//! `resolve_graph` is a PURE FUNCTION of the registry and the intended -//! activation set. No callbacks run, no state changes, nothing is touched. -//! It answers for the whole graph at once which instances can be live, and -//! for each blocked one THE SPECIFIC REQUIREMENT that is unmet, and why. -//! -//! The failure mode being designed against is a famous one: OSGi's -//! resolver is correct and its diagnostics are legendarily unusable. A -//! resolver that says "blocked" without saying WHY has moved the problem -//! rather than solved it, so `why` is part of the contract and the corpus -//! pins its shape. use std::collections::{BTreeMap, BTreeSet}; @@ -33,10 +16,6 @@ pub fn resolve_graph(nodes: &Value) -> Value { let mut resolved: BTreeSet = BTreeSet::new(); let mut blocked: BTreeMap = BTreeMap::new(); - // Fixed point: a node resolves when every mandatory requirement is met - // by an ALREADY-RESOLVED provider. Iterating to a fixed point is what - // makes a provider that is itself blocked propagate, rather than each - // node being judged against the raw registry. let mut moved = true; while moved { moved = false; @@ -75,10 +54,6 @@ pub fn resolve_graph(nodes: &Value) -> Value { out } -/// The FIRST unmet requirement, with the most specific explanation -/// available. Order matters: "no provider at all" and "a provider at the -/// wrong version" are different problems and a reader must not have to -/// guess which they have. fn firstunmet( node: &Value, byref: &BTreeMap, diff --git a/rust/src/host.rs b/rust/src/host.rs index c42ae80..39ba803 100644 --- a/rust/src/host.rs +++ b/rust/src/host.rs @@ -1,25 +1,3 @@ -//! The host: the lifecycle state machine (§5), extension points (§6), and -//! resource capture (§8). -//! -//! TWO RULES SHAPE EVERY METHOD BELOW. -//! -//! Transitions are SEQUENTIAL (§5.2). One at a time, in call order, never -//! interleaved; a transition triggered from inside a lifecycle callback is -//! `plugin_reentrant`. A hard rule, because it is the only way the -//! semantics can be identical in Go, in Rust and in single-threaded -//! JavaScript. -//! -//! Reconciliation is EAGER (§18's portability budget). A transition -//! settles by running the state machine to a fixed point, not by -//! suspending on a promise. -//! -//! AND ONE RULE IS RUST'S OWN: NEVER HOLD A BORROW ACROSS A CALLBACK. A -//! definition's `define` calls back into the host - `bind`, `export`, -//! `acquire`, even `nest` - so every method here reads what it needs out -//! of a `RefCell`, DROPS the borrow, and only then runs anything a plugin -//! wrote. A held borrow does not produce a wrong answer, it panics, which -//! is the one failure mode a conformance suite cannot report as a -//! divergence. use std::cell::{Cell, RefCell}; use std::collections::{BTreeMap, BTreeSet}; @@ -52,11 +30,6 @@ pub struct Entry { pub order: Value, pub unmet: Vec, pub scope: Vec, - /// §11.4's ALWAYS-RELUCTANT rebinding made concrete: the provider ref - /// this instance's activation actually chose, per requirement name. - /// Re-ranking on every question silently re-points a live consumer at - /// any better newcomer, and then losing the provider it was really - /// using does not restart it. pub selected: BTreeMap, pub bindings: Vec, pub exports: BTreeMap, @@ -80,11 +53,6 @@ pub struct HostInner { seqn: Cell, open: Cell, transition: Cell, - /// WHICH callback is running, not merely that one is. §8.1 puts - /// resource capture in `activate` and 8.3 says `release` outside - /// `activate` is `plugin_release_scope` - and a bare flag cannot tell - /// `activate` from `define`, so it admitted an acquire in `define` - /// whose scope `unload` would never unwind. phase: RefCell, /// Set for the duration of a bulk teardown, so `held` knows this is a /// coordinated operation rather than an ad-hoc deactivation. @@ -155,8 +123,6 @@ impl Host { // --- observation ------------------------------------------------ - /// Introspection NEVER advances the state (§5.2). A status page must - /// not be a way to accidentally import twenty packages. pub fn list(&self) -> Value { let mut out = Value::map(); for (eref, entry) in self.0.inst.borrow().iter() { @@ -266,11 +232,6 @@ impl Host { match outcome { Ok(()) => Ok(()), - // §12: `plugin_define_failed` and its three siblings are "a - // callback raised; wraps the cause". AN ERROR THAT ALREADY - // CARRIES A CODE KEEPS IT - the code is the error's identity, - // and a plugin raising `store_unreachable` must not have it - // rewritten. Only a code-less error is wrapped. Err(e) if !e.code.is_empty() => Err(e), Err(e) => fail( &format!("plugin_{}_failed", at), @@ -370,15 +331,6 @@ impl Host { Ok(entry) } - /// §9.1: a host that reserves a name MUST still be able to declare the - /// instance it reserved - "The host declares those instances itself, - /// after the user merge, and always wins." - /// - /// THE BOUNDARY IS BY METHOD, NOT BY CALLER, and that is a real limit: - /// no language here can tell the embedding host from a plugin holding - /// the same host object. What reservation protects is CONFIGURATION - - /// documents, overlays, `VOXGIG_PLUGIN_*`, construction options and - /// ordinary declare/load/options - and all of that still checks. pub fn hostdeclare( &self, eref: &Value, @@ -411,11 +363,6 @@ impl Host { } entry.borrow_mut().status = "loaded".to_string(); - // AT LOAD, and before anything runs: a cycle through - // restart-causing requirements does not settle, and the only safe - // time to report a non-terminating reconcile is before it starts - // (§11.3). `provides` is populated by `define`, which has just - // run, so this is the first moment the graph is complete. if let Err(e) = checkcycle(&self.graphnodes()) { entry.borrow_mut().status = "failed".to_string(); return Err(e); @@ -423,7 +370,6 @@ impl Host { Ok(entry) } - /// The requirement graph as plain data, for the pure detector. fn graphnodes(&self) -> Vec { let mut out = Vec::new(); for (eref, entry) in self.0.inst.borrow().iter() { @@ -473,8 +419,6 @@ impl Host { self.load(&Value::str(&myref), &Value::map())?; } - // A declared requirement that is not live means `pending`: - // activation is a STANDING REQUEST, not a one-shot event. let unmet = self.unmetof(&entry); if !unmet.is_empty() { let mut e = entry.borrow_mut(); @@ -522,12 +466,6 @@ impl Host { } if "pending" == status { - // DEACTIVATING A PENDING INSTANCE RUNS NO CALLBACK (§5.2). It - // never reached activate, so it holds no scope and no live - // bindings; running the definition's deactivate there would be - // teardown without matching setup, which plugins are not - // written to survive and which could fail an instance that had - // done nothing wrong. It cannot fail. let mut e = entry.borrow_mut(); e.status = "loaded".to_string(); e.unmet = Vec::new(); @@ -602,17 +540,6 @@ impl Host { self.activate(&Value::str(&r)) } - /// Bindings go live only when activation succeeds (§8.1), so the - /// teardown is the exact inverse: reverse order, always. Returns the - /// errors the scope raised. §8.3: "A failing release does not stop the - /// rest. Every entry runs, in reverse order, whatever any of them - /// does; the errors are collected and raised as one - /// `plugin_release_failed`." - /// - /// A selection belongs to ONE activation (§11.4). Leaving `live` by - /// any door drops it, so the next activation ranks afresh - keeping it - /// would make a consumer prefer a provider it never actually ran - /// against. fn unwind(&self, entry: &Rc>) -> Vec { // TAKE the scope out under a short borrow: the closures below // reach back into this same entry (a foreign release records its @@ -659,10 +586,6 @@ impl Host { ) } - /// A REQUIREMENT IS ON A CAPABILITY, not on a ref (§11.1). A bare - /// string is shorthand for `{name}`. A ref satisfies too, because a - /// host that genuinely needs a specific instance should not have to - /// invent a capability for it. fn unmetof(&self, entry: &Rc>) -> Vec { let reqs = requirements(&entry.borrow().options); reqs.iter() @@ -672,13 +595,6 @@ impl Host { .collect() } - /// §11.4's always-reluctant selection, and the ONE place a provider is - /// picked for a live instance. If this instance already selected a - /// provider for `req` and that provider is STILL a candidate, it keeps - /// it - a better-ranked newcomer does not take it. - /// - /// `remember` is false for the questions asked ABOUT an instance - /// rather than BY it: introspection must not create a binding. fn chosen(&self, entry: &Rc>, req: &Value, remember: bool) -> Option { let cands = self.providersof(req); if cands.is_empty() { @@ -733,15 +649,6 @@ impl Host { .collect() } - /// §11.3's `hold` asks a DIFFERENT question from the cascade, and - /// reading it off `consumersof` answered the cascade's. - /// - /// The cascade wants the edges that RESTART - mandatory-static and - /// optional-static - because a restart is what it performs. `hold` - /// says "deactivating a REQUIRED instance is - /// `plugin_dependency_held`", and required is cardinality: - /// `gatesactivation`, not `restartsonloss`. The two sets differ in - /// both directions and each difference was a real bug. fn holdersof(&self, eref: &str) -> Vec { let all: Vec<(String, Rc>)> = self .0 @@ -809,14 +716,6 @@ impl Host { resolve_capability(req, &cands) } - /// CONSUMERS GO DOWN FIRST, NOT AFTERWARDS (§11.3). - /// - /// The cascade is part of the provider's own deactivation and runs - /// BEFORE the provider's `deactivate` callback and scope unwind, so a - /// consumer's teardown can still call the thing it depends on - - /// flushing a buffer to the store it is about to lose is exactly what - /// a `deactivate` callback is for, and a cascade that fired after the - /// provider was already gone would make that impossible. fn cascade(&self, provider: &str, seen: &mut BTreeSet) { if seen.contains(provider) { return; @@ -879,10 +778,6 @@ impl Host { ) } - /// EAGER reconciliation: run to a fixed point rather than scheduling. - /// - /// Two directions, and both are the reason `pending` exists. - /// Activation is a STANDING REQUEST, not a one-shot event. fn reconcile(&self) { let mut moved = true; let mut rounds = 0; @@ -913,9 +808,6 @@ impl Host { if lost.is_empty() { continue; } - // POLICY IS PER REQUIREMENT, not per instance (§11.3). A - // `dynamic` requirement whose provider is gone leaves the - // consumer LIVE and notified. if !lost.iter().any(|q| restartsonloss(q)) { continue; } @@ -966,13 +858,6 @@ impl Host { // --- ordering --------------------------------------------------- pub fn order(&self, point: Option<&str>) -> Result, PluginError> { - // Sorted by declaration SEQUENCE, which is what makes the §7 - // sort's fall-through deterministic in a language whose maps have - // no insertion order. §7 breaks ties by `pos`; two instances CAN - // share one - `declare` defaults `pos` to the registry size, so an - // unload followed by a fresh declare reuses a surviving - // instance's - and past that this was falling through to map - // order. `seq` is that order, made explicit. let mut live: Vec = Vec::new(); for entry in self.0.inst.borrow().values() { let e = entry.borrow(); @@ -1151,15 +1036,6 @@ impl Host { // --- documents -------------------------------------------------- - /// §9.6: "load what is missing, UNLOAD WHAT IS GONE, patch what - /// changed, and move activation state to match", with the stated - /// ordering - "deactivations and unloads first (reverse load order), - /// then loads, then activations in load order". - /// - /// FOUR PHASES, NOT ONE INTERLEAVED LOOP. An earlier draft walked the - /// document once, which never looked at instances the new document had - /// DROPPED - so an integration removed from a config reload stayed live - /// with its bindings and resources. pub fn apply(&self, doc: &Value, profile: &Value) -> Result<(), PluginError> { self.guard()?; let profile = if profile.is_null() { @@ -1248,9 +1124,6 @@ impl Host { e.pos = ent.get("pos").as_num().unwrap_or(0.0); } - // --- phase 3: loads, in load order ----------------------------- - // ONLY THE EAGER, ACTIVE ONES: "a document of twenty lazy - // instances is twenty map entries and no executed code" (§9.6). for eref in want.iter() { if wantlive(eref) { self.load(&Value::str(eref), &Value::map())?; @@ -1318,9 +1191,6 @@ impl Host { } pub fn close(&self) -> Result<(), PluginError> { - // A bulk teardown removing the holders too, so `hold` is suspended - // for exactly those holders (§11.3) - while the consumers-first - // cascade still runs, which is the half that matters. self.0.coordinated.set(true); let refs: Vec = self.0.inst.borrow().keys().rev().cloned().collect(); let mut outcome = Ok(()); @@ -1336,8 +1206,6 @@ impl Host { outcome } - /// The same record §6.6 gives a plugin about itself, reachable from - /// outside for the corpus. pub fn positionof(&self, eref: &str, point: Option<&str>) -> Result { let myref = canon(eref); let entry = self.0.inst.borrow().get(&myref).cloned(); @@ -1354,8 +1222,6 @@ impl Host { let mut out = Value::map(); out.set("index", Value::Num(index as f64)); out.set("count", Value::Num(ranked.len() as f64)); - // §6.2 composes b1(b2(b3(base))) with the FIRST binding OUTERMOST, - // so these are not index 0 and index count-1 the other way round. out.set("outermost", Value::Bool(0 == index)); out.set( "innermost", @@ -1365,12 +1231,6 @@ impl Host { } } -// --------------------------------------------------------------------- -// Inst - what a definition's callbacks see -// --------------------------------------------------------------------- -// -// Deliberately not the internal record: a plugin that could reach `status` -// could also write it. #[derive(Clone)] pub struct Inst { @@ -1413,12 +1273,6 @@ impl Inst { self.entry.borrow_mut().state.set(key, value); } - /// Foreign resources the host did not hand out are registered - /// explicitly (§8.3); host calls are recorded automatically. - /// - /// SYMMETRIC WITH `acquire`, and it has to be: `open` counts the - /// resources CURRENTLY HELD, so an entry that is registered and then - /// unwound must leave the count where it found it. pub fn release( &self, func: Rc Result<(), PluginError>>, @@ -1449,12 +1303,6 @@ impl Inst { Ok(()) } - /// The synthetic counter the driver owns, so "what is open" is data - /// rather than an assertion each port words differently. - /// - /// Returns its own release, so a plugin can hand one back early. The - /// scope still holds the entry and unwinding it twice is a no-op - - /// releasing early must not make teardown wrong. pub fn acquire(&self) -> Result { // §8.1: resources are "acquired during `activate` - the scope's // actual job". Same reason as `release` above. @@ -1480,19 +1328,12 @@ impl Inst { Ok(rel) } - /// Bind into a host point. Declared in `define`; the host inserts it - /// only after `activate` returns successfully (§8.1), which is why a - /// failing activate leaves no live binding behind. pub fn bind( &self, point: &str, func: crate::point::BindFn, band: &Value, ) -> Result<(), PluginError> { - // §12 has carried `plugin_bind_scope` - "binding declared outside - // `define`" - since before anything raised it. §8.1 puts binding - // DECLARATION in `define` and INSERTION at a successful activate, - // and the guard was the half nobody wrote. if "define" != self.host.phase() { return fail( "plugin_bind_scope", @@ -1527,19 +1368,7 @@ impl Inst { .insert(key.to_string(), value); } - /// WHICH provider this instance is bound to for `name` (§11.1), as a - /// ref, or None when nothing provides it. - /// - /// The host's own `capability` answers with the live providers - /// RANKED, not with the one THIS instance took; §11.4's reluctant - /// rebinding makes those differ. A REF, not the instance: every port - /// can return a string and a corpus entry can assert on one. The - /// selection is REMEMBERED, because this is the instance asking. pub fn capability(&self, name: &str) -> Option { - // THE BORROW ENDS BEFORE `chosen` DOES. `chosen` remembers the - // selection, which takes `borrow_mut` on the same entry -- so - // reading the requirements inline held a shared borrow across it - // and panicked at the write. Rust is the only port that says so. let reqs = requirements(&self.entry.borrow().options); for req in reqs { if req.get("name").as_str() == Some(name) { diff --git a/rust/src/order.rs b/rust/src/order.rs index ebb775b..c58ee1f 100644 --- a/rust/src/order.rs +++ b/rust/src/order.rs @@ -1,17 +1,3 @@ -//! Ordering (§7) - one rule, one place. -//! -//! sdkgen grew two special cases in `makeOptions` (`test`, then `station`) -//! and the third was not far off. This sort is the whole replacement, and -//! the tiers are in this order for a reason: -//! -//! 1 constraints before/after edges, by ref or by name -//! 2 bands integer, lower first, default 0 -//! 3 declaration ties break by `pos` -//! -//! CONSTRAINTS BEAT BANDS precisely so the correct tool wins when both are -//! present. A band expresses a genuine cross-cutting layer; a constraint -//! expresses a relationship between two specific things; and a band chosen -//! by trial and error to fix an ordering bug is a bug wearing a number. use std::collections::BTreeMap; @@ -19,8 +5,6 @@ use crate::refs::refname; use crate::types::{details, fail, stable_sort_by, PluginError}; use crate::value::Value; -/// One thing to place: its ref, its declared position, and its ordering -/// block as authored. #[derive(Clone)] pub struct Binding { pub eref: String, @@ -45,7 +29,6 @@ pub fn resolve_order(bindings: &[Binding], pin: &Value) -> Result, P for b in bindings.iter() { let block = &b.order; - // An ABSENT constraint and an EMPTY LIST are both "no constraint". if order_declared(&block.get("after")) { for t in order_targets(&block.get("after"), bindings) { edges.get_mut(&t).unwrap().push(b.eref.clone()); @@ -107,7 +90,6 @@ pub fn resolve_order(bindings: &[Binding], pin: &Value) -> Result, P applypin(out, &edges, pin) } -/// An integer, and only an integer: `true` and `"2"` are not bands. pub fn order_band(block: &Value) -> i64 { block.get("band").as_int().unwrap_or(0) } @@ -150,17 +132,6 @@ pub fn order_targets(spec: &Value, nodes: &[Binding]) -> Vec { hit } -/// A PIN IS NOT A CONSTRAINT (§7). -/// -/// Constraints and bands are negotiable by definition - they are what -/// plugins and documents say they want, and the sort's job is to satisfy -/// them all. A pin is the host stating a structural invariant of its own -/// architecture, which is a different kind of claim and must not lose a -/// tie to a document. -/// -/// So a pin PLACES the binding at the named end, and an ordering that -/// would move it away is `plugin_order_pinned` - rejected, not honoured -/// into a broken wrap. fn applypin( order: Vec, edges: &BTreeMap>, @@ -172,11 +143,6 @@ fn applypin( let mut out = order; - // SORTED, not insertion order. A pin map is data - it can arrive from - // a host's own construction options in any order, and two names pinned - // to the same end are order-sensitive (`{b:'first', a:'first'}` and - // `{a:'first', b:'first'}` give different results). A BTreeMap is - // sorted by construction, which is why `Value::Map` is one. for name in pin.keys() { let want = pin.get(&name); let idx = out.iter().position(|r| refname(r) == name); @@ -185,10 +151,6 @@ fn applypin( None => continue, }; - // `first`/`outermost` is index 0; `last`/`innermost` is the end. - // §6.2 makes the first chain binding outermost, which is why the - // vocabulary is positional and why the two spellings pair this - // way. let wantfirst = matches!(want.as_str(), Some("first") | Some("outermost")); let eref = out.remove(idx); if wantfirst { diff --git a/rust/src/point.rs b/rust/src/point.rs index 9bdce28..fcbc267 100644 --- a/rust/src/point.rs +++ b/rust/src/point.rs @@ -1,12 +1,3 @@ -//! Extension points (§6). Three kinds, chosen because they are what the -//! two existing systems actually needed, and no more. -//! -//! A PLUGIN NEVER MUTATES THE HOST. That inversion is what makes -//! deactivation possible: sdkgen's `utility.fetcher = wrapped` is not -//! undoable, but "this instance holds slot 3 of the request chain" is -//! undoable in O(1). OSGi named it the whiteboard pattern in 2004, in a -//! paper called *Listeners Considered Harmful*, and for exactly this -//! reason. use std::rc::Rc; @@ -16,11 +7,6 @@ use crate::value::Value; /// The next link of a chain (§6.2), or the base at the end of it. pub type NextFn = Rc Result>; -/// EVERY binding has one signature, whatever kind of point it is on: a -/// hook and a provider ignore the `next` they are handed, a chain uses it. -/// One signature is what lets `bound` return a single list the three -/// callers share, rather than three parallel registries that can disagree -/// about which instance holds slot 3. pub type BindFn = Rc, &[Value]) -> Result>; #[derive(Clone)] @@ -31,11 +17,6 @@ pub struct Bound { pub band: i64, } -/// §6.1: "fan-out" is not one answer but four. In a language with -/// asynchrony, "call every binding" hides a decision - start them all and -/// wait, await each in turn, or do not wait - and a design that leaves it -/// unsaid gets four different answers from four ports, in the concurrency -/// behaviour of production code no corpus entry happens to cover. pub const MODES: [&str; 4] = ["emit", "parallel", "serial", "bail"]; /// Fan-out. Return values are ignored except in `bail`. @@ -75,13 +56,6 @@ pub fn point_emit(bindings: &[Bound], mode: &str, arg: &Value) -> Result NextFn { let mut nxt = base; for b in bindings.iter().rev() { @@ -92,9 +66,6 @@ pub fn compose(bindings: &[Bound], base: NextFn) -> NextFn { nxt } -/// At most one live implementation (§6.3). The winner is the highest band, -/// ties broken by ref sort, and THE LOSERS ARE VISIBLE rather than -/// silently ignored. pub struct Picked { pub winner: Option, pub shadowed: Vec, diff --git a/rust/src/refs.rs b/rust/src/refs.rs index 22d02da..4dd89db 100644 --- a/rust/src/refs.rs +++ b/rust/src/refs.rs @@ -1,14 +1,3 @@ -//! Identity: name+tag, written `name$tag` (§4). -//! -//! The four pure functions, and the whole of what `ref` pins. They are the -//! first thing a new port implements and the first corpus section it -//! passes. -//! -//! NO REGEX ENGINE, because the standard library has none and §16 permits -//! no crate to supply one. The grammar is a character-class walk instead - -//! which is also why the `#trailing-newline` entries cannot bite this port -//! the way they bit ruby, python and perl: there is no `$` here to match -//! before a newline. use crate::types::{details, fail, PluginError}; use crate::value::Value; @@ -32,12 +21,6 @@ pub fn check_name(name: &Value) -> bool { chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '~' | '_' | '-' | '/')) } -/// §4: `^[a-zA-Z0-9.~_-]+$`, max 1024, or empty. -/// -/// The asymmetry with a name is deliberate: a tag MAY start with a digit -/// because auto-tagging assigns integer tags (`stripe$1`), and a tag -/// admits neither `@` nor `/` because a name is a package specifier and a -/// tag is not. pub fn check_tag(tag: &Value) -> bool { let text = match tag.as_str() { Some(s) => s, @@ -55,18 +38,12 @@ pub fn check_tag(tag: &Value) -> bool { .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '~' | '_' | '-')) } -/// `name$tag` -> the pair. Canonicalizing: `stripe$` and `stripe` both -/// give tag "". pub fn parse_ref(value: &Value) -> Result { let text = match value.as_str() { Some(s) => s, None => return fail("plugin_bad_name", "ref must be a string", Value::Null), }; - // Split on the FIRST `$`. Nothing in the grammar decides this - `$` is - // in neither character class - so the corpus is the arbiter (§4 rule - // 5), and it picks the split that blames the part actually at fault: - // `a$b$c` is a good name with a bad tag, not the reverse. let (name, tag) = match text.find('$') { Some(cut) => (&text[..cut], &text[cut + 1..]), None => (text, ""), @@ -95,9 +72,6 @@ pub fn parse_ref(value: &Value) -> Result { Ok(out) } -/// The pair -> `name$tag`. An empty tag NEVER writes the separator, which -/// is the half of canonicalization `format_ref` owns: parse tolerates -/// `stripe$`, format never produces it, so a round trip is idempotent. pub fn format_ref(name: &Value, tag: &Value) -> Result { let tag = if tag.is_null() { Value::str("") } else { tag.clone() }; if !check_name(name) { diff --git a/rust/src/resolve.rs b/rust/src/resolve.rs index 49e7331..aa29506 100644 --- a/rust/src/resolve.rs +++ b/rust/src/resolve.rs @@ -1,10 +1,3 @@ -//! Dynamic resolution (§10.2) - name to candidate module ids. -//! -//! PURE. It returns the ids a host WOULD try, in order; it does not load -//! anything. That separation is what lets the corpus pin resolution in -//! every language including those with no dynamic loading at all, and it -//! is why §15.4 puts real module loading in per-port integration tests -//! rather than here. use crate::value::Value; @@ -70,11 +63,6 @@ pub fn resolve_candidates(name: &str, sources: &Value) -> Vec { out } -/// A MODULE PATH IS NOT A NAME (§10.2). The ref grammar starts a name with -/// a letter or `@`, so `./local/thing` is not a ref and never reaches -/// candidate generation - seneca allows a path where a plugin name goes, -/// and this design deliberately does not, because a ref is an ADDRESS -/// WITHIN A HOST and a path is a LOCATION ON A DISK. pub fn resolve_from(from: &Value) -> Vec { vec![from.clone()] } diff --git a/rust/src/types.rs b/rust/src/types.rs index 05573bb..a335d9b 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1,26 +1,10 @@ -//! Shared types. Deliberately small: the design's §19 budget says the -//! library owns naming, configuration, lifecycle, ordering, binding and -//! teardown, and nothing else. -//! -//! RUST FOLLOWS GO'S ONE DELIBERATE CHANGE (§18, P4): errors are RETURNED, -//! not raised. Every fallible signature is `Result<_, PluginError>`, and -//! the corpus compares by CODE, which survives the change intact. use crate::value::Value; -/// §5.1's seven statuses, and no more. A port that adds an eighth is -/// diverging. `loading` and `closing` are observable only from inside a -/// callback or from another thread. pub const STATUSES: [&str; 7] = [ "declared", "loaded", "pending", "live", "failed", "loading", "closing", ]; -/// §12's detail fields, IN THIS FIXED ORDER. -/// -/// The order is part of the contract, not a formatting preference. An -/// earlier draft named six fields while other sections promised -/// diagnostics that had nowhere to go, which would have left each port -/// inventing its own order and breaking message parity. pub const DETAIL_ORDER: [&str; 16] = [ "host", "ref", "name", "tag", "point", "key", "capability", "range", "version", "match", "candidates", "cycle", "holders", "refs", "path", "cause", @@ -47,11 +31,6 @@ pub fn formaterror(code: &str, text: &str, details: &Value) -> String { format!("plugin/{}: {}{}", code, text, tail) } -/// Every error carries a §12 code. Ports compare by CODE and never by -/// message: wording is a port's own business, and pinning the words would -/// make every translation a corpus change. The FORMAT, however, is pinned -/// - a parseable message is what makes a log searchable across twenty -/// languages. #[derive(Clone, Debug)] pub struct PluginError { pub code: String, @@ -99,12 +78,10 @@ pub fn fail(code: &str, text: &str, details: Value) -> Result Err(PluginError::new(code, text, details)) } -/// The §12 code of an error, or "" for one this library did not raise. pub fn codeof(err: &PluginError) -> &str { err.code.as_str() } -/// A detail map, spelled once rather than at forty call sites. pub fn details(pairs: &[(&str, Value)]) -> Value { let mut out = Value::map(); for (k, v) in pairs { @@ -113,9 +90,6 @@ pub fn details(pairs: &[(&str, Value)]) -> Value { out } -/// STABLE sort by a computed key. `sort_by` in the standard library is -/// already stable, which is what the canonical's comparators need where -/// they fall through to a `pos` or ref tie-break. pub fn stable_sort_by(list: &mut [T], keyof: F) where F: Fn(&T) -> K, diff --git a/rust/src/value.rs b/rust/src/value.rs index 6eb975e..603f21c 100644 --- a/rust/src/value.rs +++ b/rust/src/value.rs @@ -1,19 +1,3 @@ -//! The JSON value, and the only parser this port has. -//! -//! NO SERDE, AND NO CRATE GRAPH AT ALL (§16). The library is allowed -//! exactly one runtime dependency, `voxgig/struct`, which has no rust -//! port; everything else is the standard library. Parsing the corpus is a -//! hundred lines, and a hundred lines is cheaper than a supply chain. -//! -//! `Map` is a `BTreeMap`, which is not a convenience: every port has to -//! sort its keys before iterating - the canonical's status maps, export -//! lookups and registry walks all depend on it - and a sorted map makes -//! that the default rather than a discipline to remember. -//! -//! `Num` is `f64` because JSON HAS ONE NUMBER TYPE. The canonical is -//! javascript, `1` and `1.0` are the same value there, and a port that -//! split them would disagree with the corpus on which of two spellings a -//! document used. use std::any::Any; use std::collections::BTreeMap; @@ -87,9 +71,6 @@ impl Value { } } - /// The value at a key, or `Null`. Absence and null read the same here - /// ON PURPOSE at the call sites that want a default; the call sites - /// that must tell them apart use `has` instead, and say so. pub fn get(&self, key: &str) -> Value { match self { Value::Map(m) => m.get(key).cloned().unwrap_or(Value::Null), @@ -151,8 +132,6 @@ impl Value { && a.iter() .all(|(k, v)| b.get(k).map(|o| v.same(o)).unwrap_or(false)) } - // Two opaque handles are the same only if they are the same - // allocation. Nothing in the corpus compares one. (Value::Opaque(a), Value::Opaque(b)) => Rc::ptr_eq(a, b), _ => false, } @@ -172,9 +151,6 @@ impl Value { Value::Null => out.push_str("null"), Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), Value::Num(n) => { - // An integral f64 prints without a fractional part, so a - // `pos` of 3 renders as `3` and not `3.0` - JSON has one - // number type and the corpus writes them as it means them. if n.fract() == 0.0 && n.is_finite() && n.abs() < 1e15 { let _ = write!(out, "{}", *n as i64); } else { @@ -371,9 +347,6 @@ fn parse_string(b: &[char], at: &mut usize) -> Result { code = code * 16 + d; *at += 1; } - // A surrogate PAIR is two escapes; a lone - // surrogate is left as the replacement character - // rather than failing the whole corpus. if (0xD800..0xDC00).contains(&code) && *at + 1 < b.len() && '\\' == b[*at] diff --git a/rust/src/version.rs b/rust/src/version.rs index ed823c0..af28d25 100644 --- a/rust/src/version.rs +++ b/rust/src/version.rs @@ -1,16 +1,3 @@ -//! Versions and ranges (§11.2). -//! -//! TWO FIELDS AND ONE PREDICATE. A capability declares `version`, a -//! concrete version. A requirement declares `range`. A requirement is -//! satisfied when the names match, the `match` passes, and: -//! -//! the provider's `version` falls inside the requirement's `range`. -//! -//! That is the whole rule. There is no third field and no second -//! comparison - an earlier draft added a provider-side `compat` range, -//! which left three values and no statement of how they combine, and three -//! defensible readings of one declaration is worse than the ambiguity it -//! was introduced to fix. use crate::types::{details, fail, PluginError}; use crate::value::Value; @@ -61,10 +48,6 @@ fn parts(text: &str, whole: &str, field: &str) -> Option= 2.1.0 and < 3.0.0 -/// '~2.1' >= 2.1.0 and < 2.2.0 pub fn parse_range(range: &Value) -> Result { let text = match range.as_str() { Some(s) if !s.is_empty() => s, @@ -139,7 +122,6 @@ pub fn parse_version(version: &Value) -> Result<[u64; 3], PluginError> { } } -/// The one satisfaction predicate: lo <= version < hi. pub fn satisfies(version: &Value, range: &Value) -> Result { let v = parse_version(version)?; let r = parse_range(range)?; @@ -163,7 +145,6 @@ fn triple(value: &Value) -> [u64; 3] { ] } -/// The version triple as a comparable key, for the capability rank. pub fn version_parts(text: &str) -> Vec { text.split('.') .map(|p| p.parse::().unwrap_or(0)) diff --git a/rust/tests/corpus.rs b/rust/tests/corpus.rs index 4e011d2..77efc71 100644 --- a/rust/tests/corpus.rs +++ b/rust/tests/corpus.rs @@ -1,11 +1,3 @@ -//! The whole suite: pure sections by direct call, driver sections by -//! command list, and a coverage guard above both. -//! -//! One `#[test]`, not one per section, for the same reason the other ports -//! have a plain runner: a conformance suite whose only job is to run one -//! corpus and report which entries disagree does not need a framework -//! around it, and 539 separate test cases would bury the one line that -//! says which entry is wrong. mod support; @@ -191,8 +183,6 @@ fn corpus_conformance() { &[("candidates", s_candidates), ("from", s_from)], ); - // `config` picks its subject by group PREFIX rather than by name, - // because the two functions split the section cleanly. run.sections += 1; for (group, set) in section(&spec, "config") { let subject: Subject = if group.starts_with("norm") { @@ -236,12 +226,6 @@ fn corpus_conformance() { } } - // -- coverage ------------------------------------------------------ - // - // EVERY CORPUS SECTION IS RUN. The per-section dispatch already fails - // on a GROUP with no subject; this closes the level above, because a - // whole SECTION the runner never mentions is a section silently not - // run. let primary = spec.get("primary"); diff --git a/rust/tests/support/driver.rs b/rust/tests/support/driver.rs index 597f6b4..8168ef9 100644 --- a/rust/tests/support/driver.rs +++ b/rust/tests/support/driver.rs @@ -1,15 +1,3 @@ -//! The driver (DOCS.md §4). -//! -//! Every port implements this same small thing and nothing else is -//! port-specific: the probe catalog, the command interpreter, and the -//! canonical observable. -//! -//! ONE RUST-ONLY NOTE. Every probe closure captures the `Inst` it was -//! handed, and the `Inst` holds the entry the closure is stored in: an -//! `Rc` cycle that is never collected. It is deliberate and it is bounded -//! - a driver process runs 539 entries and exits - and the alternative -//! (`Weak` everywhere) would put lifetime plumbing into the one file whose -//! job is to read like the other ports. use std::rc::Rc; @@ -33,9 +21,6 @@ fn num(value: &Value) -> f64 { value.as_num().unwrap_or(0.0) } -/// §4.3's six probes. Their behaviour is as much the contract as the -/// runner is - this is where twenty implementations of `noisy` are made to -/// fail at the same callback in the same way. pub fn probes() -> Vec { let mut out = Vec::new(); @@ -47,8 +32,6 @@ pub fn probes() -> Vec { } let band = i.options().get("band"); - // One hook binding (`p`) and one chain wrap (`c`) - the workhorse - // shape DOCS.md §4.3 specifies. let hook = i.clone(); let hookfn: BindFn = Rc::new(move |_next, _args| { let n = num(&hook.state_get("count")); @@ -112,9 +95,6 @@ pub fn probes() -> Vec { boom(i, "define") })); noisy.activate = Some(Rc::new(|i: &Inst| { - // Acquire BEFORE the raise, so a failing activate has something to - // leak if the scope does not unwind - which is the whole point of - // the entry that asserts open == 0 afterwards. i.acquire()?; reenter(i, "activate")?; boom(i, "activate") @@ -127,9 +107,6 @@ pub fn probes() -> Vec { let mut greedy = Definition::named("greedy"); greedy.define = Some(Rc::new(|i: &Inst| { i.state_set("count", Value::Num(0.0)); - // §8.1 puts resource capture in `activate`. `early` NAMES the call - // that reaches for it in `define`, because `acquire` and `release` - // carry the guard separately. let early = i.options().get("early"); match early.as_str() { Some("acquire") => { @@ -202,7 +179,6 @@ pub fn probes() -> Vec { })); out.push(greedy); - // -- dep: declares requirements ------------------------------------ let mut dep = Definition::named("dep"); dep.define = Some(Rc::new(|i: &Inst| { i.state_set("count", Value::Num(0.0)); @@ -257,7 +233,6 @@ pub fn probes() -> Vec { })); out.push(provider); - // -- the recorders: `slow` and the three ordering extras ------------ for name in ["slow", "other", "adapter", "late"] { let mut d = Definition::named(name); d.define = Some(Rc::new(|i: &Inst| { @@ -324,11 +299,6 @@ fn reenter(inst: &Inst, callback: &str) -> Result<(), PluginError> { Ok(()) } -/// The points every driver host declares. DOCS.md §4.3 defines `probe` as -/// binding one hook point (`p`) and wrapping one chain point (`c`), so a -/// host without them cannot load the probe at all - they are part of the -/// contract's baseline rather than a fixture convenience. `v` is the -/// provider point the `provider` probe defaults to. pub fn withpoints(extra: &Value) -> Value { let mut out = Value::map(); @@ -423,23 +393,7 @@ fn docmd(host: &Host, cmd: &Value) -> Step { match verb { "host" => Ok((newhost(cmd)?, None)), - // §10.1's static registration: the definition ENTERS THE CATALOG - // here, and registration is where its option shape is validated - // (§9.4) - before any load, so a malformed shape fails at one - // moment in every host rather than whenever a document happens - // to exercise the key. - // - // The catalog is pre-seeded with the probe set, so re-registering - // a probe by name is the identity this command has always been; - // `shape` is what makes it do work. A name the probe set does not - // hold registers a bare definition - enough to reach the catalog, - // and never loaded. "define" => { - // §4.2's three keys, all of them live. `probe` names the - // PROBE whose callbacks back the definition and `name` is what - // the definition is called - two keys that ten entries passed - // as equal strings, so a driver ignoring `probe` passed them - // all. let name = cmd.get("name"); let name = name.as_str().unwrap_or("").to_string(); let source = cmd.get("probe"); @@ -628,13 +582,8 @@ fn docall(host: &Host, cmd: &Value, eref: &Value, point: Option<&str>) -> Step { }), )) } - // Reached through the instance api, which is where §6.6 puts it - - // a plugin asks about itself. "position" => Ok((host.clone(), Some(host.positionof(name, point)?))), "stray" => { - // A release from OUTSIDE a lifecycle callback. THIS BRANCH - // USED TO DO NOTHING, and its corpus row stayed green whatever - // `release` did with its guard. let exported = host.exports(&format!("{}/inst", name))?; match exported { Value::Opaque(o) => match o.downcast_ref::() { diff --git a/rust/tests/support/mod.rs b/rust/tests/support/mod.rs index 4c9fc38..f6fe467 100644 --- a/rust/tests/support/mod.rs +++ b/rust/tests/support/mod.rs @@ -1,9 +1,3 @@ -//! The corpus runner. -//! -//! Reads spec/plugin.json - the COMMITTED artifact, not the aontu source - -//! exactly as every other port's runner does. No port needs a Node -//! toolchain to run its tests, and this one does not get a private door -//! into the source either. pub mod driver; @@ -51,13 +45,6 @@ pub enum Got { Present(Value), } -/// Deep equality over spec values. Key order never matters; list order -/// always does. -/// -/// AGENTS.md section 1: "The plugin library must never be used to implement -/// its own tests." A shared comparison lets a broken implementation and its -/// oracle be wrong together and stay green, so the corpus's equality is -/// written here rather than imported. pub fn same(a: &Value, b: &Value) -> bool { match (a, b) { (Value::Null, Value::Null) => true, @@ -137,14 +124,6 @@ pub fn matches(expect: &Value, actual: &Got) -> bool { same(expect, &actual) } -/// A LITERAL-WITH-ANCHORS MATCHER, NOT A REGEX ENGINE. -/// -/// The standard library has no regex and §16 permits no crate to supply -/// one. Every pattern the corpus writes is a literal, optionally anchored: -/// `/^plugin\/plugin_not_loaded: /`, `/cycle=\[/`, `/retry\$fast/`. So -/// this unescapes and compares - and PANICS on any unescaped -/// metacharacter, because the one thing a hand-rolled matcher must never -/// do is quietly report a mismatch it could not evaluate. pub fn regex_lite(pattern: &str, text: &str) -> bool { let chars: Vec = pattern.chars().collect(); let mut literal = String::new(); @@ -188,11 +167,6 @@ pub fn regex_lite(pattern: &str, text: &str) -> bool { } } -/// Run one entry against a subject and report the disagreement, if any. -/// -/// The three combinations the spec format allows are enforced here as well -/// as at build time, because a runner that quietly accepted `err` beside -/// `out` would let a contradictory entry pass. pub fn check(entry: &Value, subject: F) -> Option where F: FnOnce(&Value) -> Result, @@ -211,9 +185,6 @@ where let want = entry.get("err"); if !matches!(want, Value::Bool(true)) { - // Errors compare by CODE (§12). Message wording is a port's - // own business, and pinning it would make every translation a - // corpus change. let got = codeof(&raised); if Some(got) != want.as_str() { return Some(format!( diff --git a/spec/def/plugin-spec.aon b/spec/def/plugin-spec.aon index 678d08c..f69a224 100644 --- a/spec/def/plugin-spec.aon +++ b/spec/def/plugin-spec.aon @@ -1,63 +1,6 @@ -# The spec-format shape, written in aontu — the same language the corpus -# itself is written in. -# -# This is used by tools/check-spec-shape.js, which unifies it with each -# spec SOURCE and discards the result: the question asked is only "does -# the corpus unify with the shape", and a violation is an ordinary aontu -# conflict, reported with an error code, the exact spec path, and the -# source file it came from. -# -# It is deliberately NOT unified into the corpus during `make spec`. Under -# aontu 0.48.2 an optional key holding an empty container (`out: []`) is -# treated as unresolved and DROPPED from the output, which would silently -# rewrite an entry into a different test. Keeping the check separate from -# the build keeps the artifact exact. -# -# Inherited from omni's spec shape, because the corpus format is omni's -# and a second description of one format is a second thing to drift. -# -# WHAT THIS ENFORCES: -# - entry fields limited to the defined ones — the typo class, where -# `matches:` silently replaces `match:` and the assertion disappears -# - field types: id and client are strings, doc is boolean, err is a -# boolean or a string; `id: null` is rejected here too -# -# WHAT IT DOES NOT ENFORCE, and why: -# - at most one of in/args/ctx, and `err` never beside `out`: cross-field -# rules needing `must()`, which aontu 0.48.2 does not implement -# - a non-empty `set`: needs the `length()` sizing atom, likewise absent -# The runner enforces all three at run time in every port, and remains the -# authority on spec semantics. When aontu gains `must` and `length` they -# belong here, so that a malformed spec fails at build rather than at test. -# -# NO FIELD IS PLUGIN'S OWN ANY MORE. Driver sections (§15.3) drive a host -# through a command vocabulary rather than calling one function, and used -# to carry that list in a `cmd` field omni does not define — which put the -# corpus outside omni's format and outside its strict entry validation, -# where an unrecognised key is an assertion that stopped asserting. -# -# The command list is now the entry's `in`, which is what it always was: -# the single argument the driver subject is called with. The nine fields -# below are omni's nine, and this corpus is now readable by omni's runner -# in any of its twenty-four ports. -# The corpus metadata block. `version` is what turns on strict entry -# validation in every runner, so a corpus that loses or mistypes it does -# not fail loudly — it silently downgrades the checking in every port at -# once. Closed, so a misspelled `versoin:` is an unknown key rather than a -# second marker nobody reads. -# -# `version` is pinned to the one defined version rather than to `integer`: -# a bump is a decision about every runner's baseline, and it should have -# to be made here as well as in the corpus. Update both when version 2 -# exists. -# -# ABSENCE is NOT caught here, and cannot be: unifying `{}` with -# `{version: 1}` fills the key in rather than objecting. That case is -# checked in tools/build-spec.js against the built artifact, which is what -# every port actually reads. Meta: hide(close({ version: 1 requires?: top diff --git a/spec/plugin.aon b/spec/plugin.aon index a230691..dc9786d 100644 --- a/spec/plugin.aon +++ b/spec/plugin.aon @@ -1,137 +1,21 @@ -# The plugin conformance corpus — SOURCE OF TRUTH. -# -# plugin.json beside this file is a COMMITTED build artifact compiled from -# here by tools/build-spec.js (@voxgig/model). Every port's test runner -# reads that JSON directly, so no port needs a Node toolchain to run its -# tests. Edit this file, run `npm run build-spec` in tools/, and commit -# both. Never hand-edit the JSON (§16, prime directive 5). -# -# STATUS: P5 complete. The corpus is COMPLETE against §15.3 — all -# nineteen sections, 572 entries (417 at P2, when this line was first -# written), every one running green against the canonical TypeScript -# port AND the sixteen ports of it. -# -# P0 built the machinery and proved it turns over on an EMPTY corpus, -# which was its exit criterion: `make spec` compiles this file and -# `make spec-check` proves the committed JSON matches it. Both worked -# before there was anything to compile, because a corpus pipeline first -# exercised on real data is one whose failures arrive mixed up with the -# data's. -# -# P1 fills it in, and the order is an obligation rather than a preference -# (see station's station-and-plugin-plan.md §3): -# -# C1 — WRITTEN. Owed before station's Stage 2, earlier than P1's exit: -# ref name/tag grammar, parse, format, canonicalization, -# auto-tag, `pos` vs `seq` across a redeclaration -# config document normalization, array/map forms, the `default` -# map, the ten-level precedence ladder, profile overlay, -# defaults-after-merge, shape-declared merge depth, -# list-replace -# -# C2 — WRITTEN, with the driver contract in DOCS.md §4. Owed before -# P1's exit, together with that contract in -# DOCS.md (§15.2 — probes, command vocabulary, canonical observable), -# because these two are DRIVER sections and a port cannot run them from -# corpus files alone: -# lifecycle the state machine, idempotence, illegal transitions, -# failure paths -# order topological sort, bands, ties, vacuous constraints, -# recomputation -# -# P2's PURE sections are now written too — env, version, capability, -# graph, resolve. Nine sections, and every one runs against the canonical -# with no driver where §15.3 marks it pure. -# -# ALL NINETEEN SECTIONS §15.3 NAMES NOW EXIST — none missing, none -# extra. A guard in the typescript suite asserts every corpus group has -# a subject, so a section added here without a runner is a failure -# rather than a silence. -# -# Adding a section here is not enough on its own — §15.3's table records -# whether it is pure data or driver-based, and a driver section without -# its contract in DOCS.md is a suite no port but this one can implement. - - -# Spec format version. Version 1 turns on strict entry validation in every -# runner: unknown fields, multiple argument sources, `err` beside `out`, -# and empty sets all fail rather than passing silently. + + PLUGIN: version: 1 -# The omni spec-format version. `primary`, the group `set` lists and every -# entry field here are omni's, so this corpus is readable by omni's runner -# in any of its twenty-four ports; version 1 turns on its strict entry -# validation (unknown fields, several argument sources, `err` beside -# `out`, an empty `set`). OMNI: version: 1 -# --------------------------------------------------------------------------- -# ref — name/tag identity. THE FIRST SECTION A PORT PASSES. -# -# Pure (§15.3): every entry calls one of the four pure functions, so a port -# runs this with no host and no driver contract. That is what makes it -# deliverable to station ahead of its Stage 2 (C1) — see doc/plan/contracts.md. -# -# The group name selects the subject: -# parse / parsebad parseref(str) -> {name, tag} -# format / formatbad formatref(name,tag)-> str (positional args) -# canon formatref(parseref(str)) -> str -# name checkname(str) -> boolean -# tag checktag(str) -> boolean -# bound the 1024-character limit, both functions -# -# GRAMMAR (§4), and the asymmetry between the two is deliberate: -# name ^[a-zA-Z@][a-zA-Z0-9.~_\-/]*$ max 1024 -# tag ^[a-zA-Z0-9.~_-]+$ max 1024, or empty -# A tag may START WITH A DIGIT and a name may not, because auto-tagging -# assigns integer tags (stripe$1). A tag admits neither `@` nor `/`, because -# a name is a package specifier and a tag is not. -# -# TWO THINGS THIS SECTION PINS THAT THE PROSE DOES NOT STATE: -# -# 1. `a$b$c` splits on the FIRST `$`, giving name `a` and tag `b$c`, so it -# fails as plugin_bad_tag rather than plugin_bad_name. Splitting on the -# last `$` would be equally consistent with §4 and would blame the name. -# Neither reading is derivable from the grammar — `$` is in neither -# character class — so the corpus is the arbiter (§4 rule 5), and it -# chooses the error that names the part actually at fault. -# -# 2. The empty string is plugin_bad_name, not a name with an empty tag. -# -# WHAT THIS SECTION DOES NOT PIN, and where those things now live. -# An earlier §15.3 assigned **auto-tag** and **`pos` vs `seq`** to `ref` -# as well, and marked the section pure. Nothing in the pure surface — -# parseref, formatref, checkname, checktag — can reach any of them, so -# §15.3 was corrected rather than this section stretched: -# -# pos `config`, assigned by document normalization (pure) -# seq `declare`, a counter the host holds (driver) -# auto-tag `declare`, since it must know what is already -# declared (driver) -# pos STABILITY across a redeclaration — `order`'s tie group, which -# is where getting it wrong actually shows (driver) -# -# Keeping them here would have made a section marked `pure` require a -# driver, putting C1 behind C2 and inverting the ordering station's -# Stage 2 depends on. See doc/plan/handover.md §5. -# --------------------------------------------------------------------------- primary: ref: { - # The pair, from the written form. The tag is '' when absent — never null, - # never missing, because a port that returns three shapes for two states - # makes every downstream comparison a special case. parse: set: [ { id: 'ref/parse#bare', doc: true, in: 'stripe', out: { name: 'stripe', tag: '' } } { in: 'stripe$test', out: { name: 'stripe', tag: 'test' } } { in: 'retry$fast', out: { name: 'retry', tag: 'fast' } } - # Canonicalization: a trailing `$` is an empty tag written out loud. { in: 'stripe$', out: { name: 'stripe', tag: '' } } - # A name is a package specifier, so it carries @ / . - _ ~ { in: '@voxgig/plugin-retry', out: { name: '@voxgig/plugin-retry', tag: '' } } { in: '@voxgig/plugin-retry$eu', out: { name: '@voxgig/plugin-retry', tag: 'eu' } } { in: 'a.b.c', out: { name: 'a.b.c', tag: '' } } @@ -150,8 +34,6 @@ primary: ref: { ] - # Grammar violations. The code says which HALF is wrong, which is the - # whole reason there are two codes rather than one plugin_bad_ref. parsebad: set: [ { id: 'ref/parsebad#empty', doc: true, in: '', err: true, match: { err: { code: 'plugin_bad_name' } } } { in: '$test', err: true, match: { err: { code: 'plugin_bad_name' } } } @@ -170,24 +52,17 @@ primary: ref: { { id: 'ref/parsebad#trailing-newline', in: "abc\n", err: true, match: { err: { code: 'plugin_bad_name' } } } { in: "a$b\n", err: true, match: { err: { code: 'plugin_bad_tag' } } } - # Splits on the FIRST `$`, so the name is `a` and the tag is `b$c`. - # See the header note: this is a corpus decision, not a grammar one. { in: 'a$b$c', err: true, match: { err: { code: 'plugin_bad_tag' } } } { in: 'a$b/c', err: true, match: { err: { code: 'plugin_bad_tag' } } } { in: 'a$@b', err: true, match: { err: { code: 'plugin_bad_tag' } } } { in: 'a$b c', err: true, match: { err: { code: 'plugin_bad_tag' } } } { in: 'a$b!c', err: true, match: { err: { code: 'plugin_bad_tag' } } } - # The 1024 maximum applies to the PUBLIC REFERENCE OPERATIONS, not - # only to the predicates. A port implementing checkname/checktag - # separately from parseref would otherwise pass `bound` while its - # parser happily accepted a 1025-character component. { id: 'ref/parsebad#name-overlong', in: 'abbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', err: true, match: { err: { code: 'plugin_bad_name' } } } { id: 'ref/parsebad#tag-overlong', in: 'x$ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', err: true, match: { err: { code: 'plugin_bad_tag' } } } ] - # The written form, from the pair. Positional args: [name, tag]. format: set: [ { id: 'ref/format#bare', doc: true, args: ['stripe', ''], out: 'stripe' } { args: ['stripe', 'test'], out: 'stripe$test' } @@ -256,16 +131,6 @@ primary: ref: { { in: '/a', out: false } { in: 'a$b', out: false } { in: 'a b', out: false } - # A TRAILING NEWLINE IS NOT A NAME, and this entry exists because - # four languages' regex idioms disagree about that. `^` and `$` in - # the grammar mean STRING start and end. Python's `$` also matches - # BEFORE a trailing newline, so `^...$` there admits "abc\n"; ruby's - # `^`/`$` match at every LINE boundary, so they admit worse. Both - # need `\A`/`\Z` (or `fullmatch`), and neither is caught by any - # other entry in this section. - # - # Found while writing the ruby port; the python port had shipped - # with the hole (P5, handover.md §15). { id: 'ref/name#trailing-newline', in: "abc\n", out: false } { in: 'a!b', out: false } { in: 'a%b', out: false } @@ -286,7 +151,6 @@ primary: ref: { { in: 'a~b', out: true } { in: 'A1._~-', out: true } - # A tag is not a package specifier: no @, no /. { in: '@x', out: false } { in: 'a/b', out: false } { in: 'a$b', out: false } @@ -299,15 +163,6 @@ primary: ref: { ] - # The 1024-character bound, pinned at the boundary in both directions. - # A bound stated in prose and absent from the corpus is not a contract, - # and off-by-one at a length limit is the classic per-port divergence. - # - # THESE TWO GROUPS ARE NOT THE WHOLE BOUND. They exercise the - # predicates; `parsebad` and `formatbad` carry the matching over-limit - # cases for the public reference operations, because a port may - # implement the four functions independently and only the predicates - # would be constrained here. bound: set: [ { id: 'ref/bound#name-max', doc: true, in: 'abbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', out: true } { in: 'abbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', out: false } @@ -323,60 +178,6 @@ primary: ref: { } -# --------------------------------------------------------------------------- -# config — the declarative document, and the precedence ladder. -# -# Pure (§15.3): normalization is a pure function of the document, and option -# resolution is a pure function of its layers. No host, no driver. The other -# half of C1 — see doc/plan/contracts.md. -# -# TWO SUBJECTS, selected by group prefix, and BOTH TAKE ONE NAMED-LAYER MAP -# as `in`: -# -# norm* normalizeconfig({ doc, profile?, keys?, reserved? }) -# -> { instance, order, default, ... } -# `order` is the load order: array position for the array form, -# sorted refs for the map form. -# -# NORMALIZATION DOES NOT MERGE OPTIONS. It merges the ENTRY -# keys (`active`, `start`, `order`) across base and profile, -# because §9.3's defaults-after-merge rule is about those; it -# leaves option data as `optionlayers`, the levels 3-6 that are -# present, IN LADDER ORDER. -# -# That split is forced rather than chosen. §9.4 makes merge -# behaviour a property of the definition's option SHAPE, which -# normalization has never seen — a normalizer that flattened -# the layers would make `$MERGE: append` unimplementable at -# load time, since the layers it needed to concatenate are -# already collapsed. Preserving them is what keeps -# normalize-then-resolve equivalent to resolve-on-raw. -# -# opt* resolveoptions({ ref, shape?, hostdefaults?, doc?, profile?, -# env?, hostoptions?, loadoptions?, patch? }) -# -> the resolved option map -# -# mapping onto §9.3's levels 1-10 as -# shape(1) hostdefaults(2) doc.default(3) doc.instance(4) -# doc.profile.

.default(5) doc.profile.

.instance(6) -# env(7) hostoptions(8) loadoptions(9) patch(10). -# -# An entry supplies only the layers it exercises, so the ladder is visible in -# the test data rather than implied by call order. -# -# THE INPUT SHAPE IS A CORPUS DECISION. §9.3 fixes the ORDER of the ten -# levels and §9.2 the API's existence, but not how a caller hands them over. -# One named map keeps every entry self-describing, lets a port implement -# resolveoptions without also implementing a host, and keeps the host -# settings (`keys`, `reserved`, `profile`) inside `in` where inputs belong — -# the shared Entry shape is CLOSED and stays omni's, because a second -# description of one format is a second thing to drift. -# -# ONE AONTU CONSTRAINT WORTH KNOWING: a bare key cannot contain `$`, so -# every ref used as a map key here is quoted ('stripe$test': …), as is -# '$MERGE'. Unquoted, it is a parse error rather than a subtly different -# key, so this is a build-time failure and not a silent one. -# --------------------------------------------------------------------------- primary: config: { @@ -392,10 +193,6 @@ primary: config: { in: { doc: { plugin: 1, instance: { 'stripe$test': {}, stripe: {}, 'stripe$eu': {} } } } match: { out: { order: ['stripe', 'stripe$eu', 'stripe$test'] } } } - # BYTEWISE, not locale-aware and not case-folded. All-lowercase - # cases pass under all three comparators, so they pin nothing; refs - # admit uppercase and `@`, and those are where the three disagree. - # '@' is 0x40, uppercase 0x41-0x5A, lowercase 0x61-0x7A. { id: 'config/normmap#bytewise' in: { doc: { plugin: 1, instance: { a: {}, A: {}, '@scope/x': {}, B: {}, b: {} } } } @@ -420,30 +217,12 @@ primary: config: { ] - # The normalized instance's ORDER BLOCK, which nothing else asserts. - # - # Every other section reads `order` for its EFFECT on the sort. These - # pin the block's own SHAPE in the normalized document, and that gap is - # exactly what let a port rewrite the block while staying green: three - # separate parity breaks reached review because no entry could see one. - # Normalization does not interpret the block (§9.1) — it carries the - # authored value through — so whatever spelling was written must come - # back unchanged, in every port. - # - # Every ABSENT key is asserted `__UNDEF__` rather than left out. `match` - # is partial, so an omitted key is an UNCHECKED key: without this a port - # could materialize its zero value — `band: 0`, an empty `before` — and - # still pass every entry here while handing back a block nobody wrote. - # Synthesizing a key is the same defect as dropping one. normorder: set: [ { id: 'config/normorder#scalar', doc: true in: { doc: { plugin: 1, instance: { retry: {}, alpha: { order: { after: 'retry' } } } } } match: { out: { instance: { alpha: { order: { after: 'retry', before: '__UNDEF__', band: '__UNDEF__' } } } } } } - # A ONE-ELEMENT LIST IS NOT THE SCALAR FORM. A port that keeps both - # as a list and re-emits the scalar collapses these two documents - # into one, so this entry and the one above must disagree. { id: 'config/normorder#one-element-list' in: { doc: { plugin: 1, instance: { retry: {}, alpha: { order: { after: ['retry'] } } } } } @@ -454,9 +233,6 @@ primary: config: { in: { doc: { plugin: 1, instance: { retry: {}, zeta: {}, alpha: { order: { after: ['retry', 'zeta'] } } } } } match: { out: { instance: { alpha: { order: { after: ['retry', 'zeta'], before: '__UNDEF__', band: '__UNDEF__' } } } } } } - # An AUTHORED EMPTY LIST constrains nothing, but it is not the same - # document as an absent key and must survive as written. Length alone - # cannot tell a typed port which of the two it holds. { id: 'config/normorder#empty-list-survives' in: { doc: { plugin: 1, instance: { alpha: { order: { after: [] } } } } } @@ -494,15 +270,10 @@ primary: config: { in: { doc: { plugin: 1, instance: [ { ref: 'retry', options: { retries: 3 } } ] } } match: { out: { instance: { retry: { optionlayers: [ { retries: 3 } ] } } } } } - # The ref is canonicalized on the way in, exactly as §4 rule 5 requires. { in: { doc: { plugin: 1, instance: [ { ref: 'stripe$' } ] } } match: { out: { order: ['stripe'] } } } - # `pos` in the ARRAY form is the array INDEX, a different rule from - # the map form's sorted index — a port implementing one and reusing - # it for the other gets this backwards, and the two entries disagree - # by construction: the same three refs, opposite orders. { id: 'config/normarray#pos' in: { doc: { plugin: 1, instance: [ { ref: 'zeta' }, { ref: 'alpha' }, { ref: 'retry' } ] } } @@ -536,7 +307,6 @@ primary: config: { } match: { out: { order: ['zeta', 'alpha', 'retry'] } } } - # Nothing is dropped: the instance map still carries all three. { in: { profile: 'dev' @@ -589,10 +359,6 @@ primary: config: { # A safety rule, not a tidiness one, and the first entry is the case # station supplied that shows why. normdefaults: set: [ - # A base that BARS an instance, and an overlay that only moves a URL. - # If the overlay had its defaults filled in before merging it would - # carry a synthesized active:true and overwrite the base's false — - # silently re-enabling a deliberately disabled integration in prod. { id: 'config/normdefaults#bar', doc: true in: { @@ -606,28 +372,14 @@ primary: config: { match: { out: { instance: { 'pad$a': { active: false optionlayers: [ { base: 'https://prod.example' } ] } } } } } - # The defaults themselves, on a merged entry that states neither. { in: { doc: { plugin: 1, instance: { retry: {} } } } match: { out: { instance: { retry: { active: true, start: 'eager' } } } } } - # An authored value is never overwritten by its own default. { in: { doc: { plugin: 1, instance: { retry: { active: false, start: 'lazy' } } } } match: { out: { instance: { retry: { active: false, start: 'lazy' } } } } } - # The same rule one level down, for a host nesting an instance map - # inside an option (§6.5) — the identical defect with a different key. - # - # NOTE WHAT THIS ASSERTS AND WHAT IT CANNOT. normalizeconfig does not - # merge options, so it does not apply the rule to the NESTED map — - # the nested host does, when it normalizes that map in turn. What - # this pins is that the information the nested host needs SURVIVES: - # both layers present, in ladder order, with the base's `active: - # false` intact and the overlay carrying only what it authored. A - # normalizer that flattened them here would hand the nested host a - # single pre-merged map and the one-level-down defect would be - # unavoidable rather than merely possible. { in: { profile: 'prod' @@ -645,8 +397,6 @@ primary: config: { ] - # Host key renaming (§9.1). Applied at TWO PLACES AND NO OTHERS: the - # document root, and every profile. overlay root. Neither recurses. normkeys: set: [ { id: 'config/normkeys#root', doc: true @@ -681,9 +431,6 @@ primary: config: { } match: { out: { instance: { retry: { optionlayers: [ { t: 1 }, { t: 2 } ] } } } } } - # It does NOT recurse: option data belongs to the definition, and a - # host's key map has no business rewriting keys inside it. An option - # literally named `sdk` survives untouched. { in: { keys: { instance: 'sdk', default: 'api' } @@ -711,8 +458,6 @@ primary: config: { in: { reserved: ['station'], doc: { plugin: 1, default: { station: {} } } } err: true, match: { err: { code: 'plugin_ref_reserved' } } } - # In a profile overlay too — §9.1 lists every input layer, and a - # guarantee that holds only at the root is trivially bypassable. { in: { reserved: ['station'], profile: 'prod' @@ -720,7 +465,6 @@ primary: config: { } err: true, match: { err: { code: 'plugin_ref_reserved' } } } - # A name that merely starts with the reserved one is NOT reserved. { in: { reserved: ['station'], doc: { plugin: 1, instance: { stationary: {} } } } match: { out: { order: ['stationary'] } } @@ -728,19 +472,12 @@ primary: config: { ] - # `default` DECLARES NOTHING (§9.3), and only normalizeconfig can show - # it. The optdefault group calls resolveoptions, which verifies - # inheritance but cannot observe that no instance was created — a - # normalizer that copied `default.stripe` into `instance.stripe` would - # pass every one of those and silently manufacture an integration from - # a defaults-only document. normdefaultmap: set: [ { id: 'config/normdefaultmap#inert', doc: true in: { doc: { plugin: 1, default: { stripe: { options: { t: 5000 } } } } } match: { out: { order: [], instance: {} } } } - # The default itself survives normalization — inert is not dropped. { in: { doc: { plugin: 1, default: { stripe: { options: { t: 5000 } } } } } match: { out: { default: { stripe: { options: { t: 5000 } } } } } @@ -762,8 +499,6 @@ primary: config: { ] - # §9.3's ten levels, lowest to highest. Each entry adds one level on top - # of the one below and expects it to win. optladder: set: [ { id: 'config/optladder#1', doc: true in: { ref: 'r', shape: { t: 1 } }, out: { t: 1 } } @@ -783,13 +518,6 @@ primary: config: { { in: { ref: 'r', hostoptions: { t: 8 }, loadoptions: { t: 9 } }, out: { t: 9 } } { in: { ref: 'r', loadoptions: { t: 9 }, patch: { t: 10 } }, out: { t: 10 } } - # ADJACENT PAIRS ALONE DO NOT PIN A TOTAL ORDER. The chain above - # constrains 1<2<3<...<10 only if the relation is known transitive, - # and a resolver that applies the layers in the wrong sequence can - # satisfy every adjacent pair while inverting a non-adjacent one. - # The env/profile boundary is the one that actually bites: a - # resolver merging `env` BEFORE the profile overlay passes the - # level-7 case above, because that case only compares env with 4. { id: 'config/optladder#7-beats-6' in: { ref: 'r', profile: 'p' doc: { profile: { p: { instance: { r: { options: { t: 6 } } } } } } @@ -797,7 +525,6 @@ primary: config: { { in: { ref: 'r', profile: 'p' doc: { profile: { p: { default: { r: { options: { t: 5 } } } } } } env: { t: 7 } }, out: { t: 7 } } - # Sparse non-adjacent pairs across the whole ladder. { in: { ref: 'r', shape: { t: 1 }, patch: { t: 10 } }, out: { t: 10 } } { in: { ref: 'r', hostdefaults: { t: 2 }, hostoptions: { t: 8 } }, out: { t: 8 } } { in: { ref: 'r', doc: { default: { r: { options: { t: 3 } } } }, loadoptions: { t: 9 } }, out: { t: 9 } } @@ -805,7 +532,6 @@ primary: config: { doc: { default: { r: { options: { t: 3 } } } profile: { p: { instance: { r: { options: { t: 6 } } } } } } }, out: { t: 6 } } { in: { ref: 'r', shape: { t: 1 }, env: { t: 7 } }, out: { t: 7 } } - # All ten at once: the highest present wins, whatever else is set. { id: 'config/optladder#all-ten' in: { ref: 'r', profile: 'p' shape: { t: 1 }, hostdefaults: { t: 2 } @@ -815,8 +541,6 @@ primary: config: { instance: { r: { options: { t: 6 } } } } } } env: { t: 7 }, hostoptions: { t: 8 }, loadoptions: { t: 9 }, patch: { t: 10 } } out: { t: 10 } } - # And with the top layer removed, level 9 wins — proving the stack - # is ordered rather than "patch always wins". { in: { ref: 'r', profile: 'p' shape: { t: 1 }, hostdefaults: { t: 2 } doc: { default: { r: { options: { t: 3 } } } @@ -831,9 +555,6 @@ primary: config: { # Levels 3-6 are ONE ordered merge, and the order is the point (§9.3): # PROFILE SPECIFICITY OUTRANKS DEFINITION SPECIFICITY. optprofile: set: [ - # A prod per-definition default BEATS a base instance value — that is - # what an environment overlay is for. Collapsing the two namespaces - # and composing them afterwards would invert this. { id: 'config/optprofile#specificity', doc: true in: { ref: 'stripe$test', profile: 'prod' @@ -849,14 +570,12 @@ primary: config: { instance: { 'stripe$test': { options: { t: 'prod-instance' } } } } } } } out: { t: 'prod-instance' } } - # With no `default` entries it degenerates to base then overlay. { in: { ref: 'r', profile: 'p' doc: { instance: { r: { options: { a: 1, b: 1 } } } profile: { p: { instance: { r: { options: { b: 2 } } } } } } } out: { a: 1, b: 2 } } - # An unselected profile contributes nothing. { in: { ref: 'r' doc: { instance: { r: { options: { t: 1 } } } @@ -866,9 +585,7 @@ primary: config: { ] - # `default` is keyed by NAME and declares nothing (§9.3). optdefault: set: [ - # A base for every instance of that definition. { id: 'config/optdefault#byname', doc: true in: { ref: 'stripe$eu', doc: { default: { stripe: { options: { t: 5000 } } } } } @@ -876,13 +593,8 @@ primary: config: { } { in: { ref: 'stripe$test', doc: { default: { stripe: { options: { t: 5000 } } } } }, out: { t: 5000 } } { in: { ref: 'stripe', doc: { default: { stripe: { options: { t: 5000 } } } } }, out: { t: 5000 } } - # It does not reach a DIFFERENT definition. { in: { ref: 'other', doc: { default: { stripe: { options: { t: 5000 } } } } }, out: {} } - # THE TWO MAPS ARE SEPARATE NAMESPACES, NOT SEPARATE SPELLINGS. An - # untagged ref IS a bare name, so `default.stripe` and - # `instance.stripe` are the same key string in two maps; the map - # decides the meaning. Both may exist for one name. { in: { ref: 'stripe', doc: { default: { stripe: { options: { a: 'from-default', b: 1 } } } instance: { stripe: { options: { a: 'from-instance' } } } } } @@ -899,9 +611,6 @@ primary: config: { ] - # A LIST REPLACES (§9.4). struct.merge is element-wise by index, which - # for option maps is nearly always wrong: ["a"] over ["x","y","z"] - # yielding ["a","y","z"] is the defect station hit on secrets.providers. optlist: set: [ { id: 'config/optlist#replace', doc: true @@ -910,8 +619,6 @@ primary: config: { out: { p: ['a'] } } { in: { ref: 'r', doc: { instance: { r: { options: { p: ['x', 'y'] } } } }, patch: { p: [] } }, out: { p: [] } } - # A map next to it still deep-merges: the rule is about lists, not - # about turning every merge into a replace. { in: { ref: 'r', doc: { default: { r: { options: { p: ['x'], m: { a: 1 } } } } instance: { r: { options: { p: ['a'], m: { b: 2 } } } } } } @@ -925,8 +632,6 @@ primary: config: { # the one place the question is answered, and it travels with the # definition rather than living as a table in the host. optmerge: set: [ - # replace — station declares `policy` this way to keep an egress - # allowlist from silently widening because two levels merged. { id: 'config/optmerge#replace', doc: true in: { ref: 'r', shape: { policy: { '$MERGE': 'replace' } } @@ -934,7 +639,6 @@ primary: config: { instance: { r: { options: { policy: { hosts: ['b.example'] } } } } } } out: { policy: { hosts: ['b.example'] } } } - # append — concatenates, and is the ONLY way to get list append. { in: { ref: 'r', shape: { p: { '$MERGE': 'append' } } doc: { default: { r: { options: { p: ['x'] } } } @@ -952,7 +656,6 @@ primary: config: { instance: { r: { options: { feature: { retry: { headers: { c: 3 } } } } } } } } out: { feature: { retry: { n: 3, headers: { c: 3 } } } } } - # deep:1 merges one level and replaces below it. { in: { ref: 'r', shape: { m: { '$MERGE': { deep: 1 } } } doc: { default: { r: { options: { m: { a: { x: 1 }, b: 2 } } } } @@ -967,17 +670,6 @@ primary: config: { out: { m: { a: { x: 1, y: 2 }, b: 2 } } } - # THE DIRECTIVE HOLDS AT EVERY PRECEDENCE LEVEL, not only between - # document levels 3 and 4. §9.4 makes it a property of the shape, - # which does not know which layer a value arrived from — so a port - # honouring `replace` in the document while deep-merging `patch`, - # `env`, `hostoptions` or `loadoptions` is wrong, and every case - # above would still pass it. - # - # This is the one that bites in production: patching `policy` with a - # new host list must REPLACE it, not retain `strict` from below. An - # egress allowlist that silently widens because the top of the ladder - # merged is exactly the failure `replace` exists to prevent. { id: 'config/optmerge#replace-at-patch' in: { ref: 'r', shape: { policy: { '$MERGE': 'replace' } } @@ -1003,42 +695,16 @@ primary: config: { doc: { instance: { r: { options: { p: ['x'] } } } } patch: { p: ['y'] } } out: { p: ['x', 'y'] } } - # and deep: N, which must count levels the same way wherever the - # overlay came from. { in: { ref: 'r', shape: { feature: { '$MERGE': { deep: 2 } } } doc: { instance: { r: { options: { feature: { retry: { n: 3, headers: { a: 1 } } } } } } } patch: { feature: { retry: { headers: { c: 3 } } } } } out: { feature: { retry: { n: 3, headers: { c: 3 } } } } } - # The bare list-replace default likewise, at the top of the ladder. { in: { ref: 'r', doc: { instance: { r: { options: { p: ['x', 'y', 'z'] } } } } patch: { p: ['a'] } } out: { p: ['a'] } } ] - # N IS AN INTEGER OF AT LEAST 1, AND EVERYTHING ELSE IS AN ERROR (§9.4). - # - # WHAT THIS GROUP PINS AND WHAT IT DOES NOT. It pins the DOMAIN — which - # values are rejected — and nothing about WHEN. §9.4 also requires the - # raise to happen when the shape ENTERS THE CATALOG rather than when a - # document happens to exercise the key, so that a malformed shape fails - # once and in the same place everywhere. Every entry here calls - # resolveoptions with the shape already in hand, so a port that accepts - # an invalid shape into its catalog and raises only at resolution time - # passes all of them. - # - # That timing is a CATALOG behaviour and needs a driver to observe. - # `declare/shape` is where it lives, and it is written the other way - # round: every entry there stops at `define`, so a port that defers the - # check raises nothing and fails. The two groups are the domain and the - # moment, and neither implies the other. - # - # Stating the domain is not pedantry: without it each port picks its own - # reading of {"deep": 0} — reject, replace, unlimited merge, or clamp to - # 1 — and the same document resolves differently per language. deep:0 is - # rejected DESPITE having an obvious reading, because "replace at this - # key" already has a spelling and two spellings for one behaviour is the - # defect class this repo exists to avoid. optmergebad: set: [ { id: 'config/optmergebad#zero', doc: true in: { ref: 'r', shape: { m: { '$MERGE': { deep: 0 } } } }, err: true, match: { err: { code: 'plugin_shape_invalid' } } } @@ -1050,11 +716,6 @@ primary: config: { { in: { ref: 'r', shape: { m: { '$MERGE': 'REPLACE' } } }, err: true, match: { err: { code: 'plugin_shape_invalid' } } } { in: { ref: 'r', shape: { m: { '$MERGE': true } } }, err: true, match: { err: { code: 'plugin_shape_invalid' } } } { in: { ref: 'r', shape: { m: { '$MERGE': {} } } }, err: true, match: { err: { code: 'plugin_shape_invalid' } } } - # The error names the key AND the directive (§9.4), so a shape with - # two problems is diagnosable without bisecting it. `/m/` alone - # would accept "invalid shape at m", which never mentions $MERGE and - # leaves a reader guessing which of the key's rules was broken — so - # both halves are asserted, separately. { id: 'config/optmergebad#names-key' in: { ref: 'r', shape: { m: { '$MERGE': { deep: 0 } } } } err: true, match: { err: { message: '/m/' } } } @@ -1065,17 +726,6 @@ primary: config: { } -# --------------------------------------------------------------------------- -# lifecycle — the state machine (§5). -# -# DRIVER section (§15.3): entries carry `cmd`, and a port needs DOCS.md §4 -# to run them — the probe catalog, the command vocabulary, and the canonical -# observable {status, open, log, result}. Corpus files alone are not enough, -# which is why C2 ships both together (doc/plan/contracts.md). -# -# `log` order IS the assertion. A set would pass a port that unwinds in the -# wrong direction, which is the one thing §8.3 exists to guarantee. -# --------------------------------------------------------------------------- primary: lifecycle: { @@ -1097,7 +747,6 @@ primary: lifecycle: { in: [ { do: 'define', name: 'probe' }, { do: 'load', ref: 'probe$a' } ] out: { status: { 'probe$a': 'loaded' }, open: 0, log: ['probe$a:define'], result: null } } - # `ready` walks the whole staircase in one call (§5.1). { in: [ { do: 'define', name: 'probe' }, { do: 'ready', ref: 'probe$a' } ] match: { out: { status: { 'probe$a': 'live' }, open: 1 } } @@ -1159,7 +808,6 @@ primary: lifecycle: { out: { status: { 'probe$a': 'live' }, open: 1 log: ['probe$a:define', 'probe$a:activate'], result: null } } - # Likewise load on a loaded instance: `define` fires once. { in: [ { do: 'define', name: 'probe' } @@ -1168,7 +816,6 @@ primary: lifecycle: { ] out: { status: { 'probe$a': 'loaded' }, open: 0, log: ['probe$a:define'], result: null } } - # And deactivate on a loaded instance. { in: [ { do: 'define', name: 'probe' } @@ -1224,12 +871,6 @@ primary: lifecycle: { # There is no second, softer failure state: the point of `failed` is # that it is not trustworthy. fail: set: [ - # `catch: true` (DOCS.md §4.1) records the raise and lets the run - # continue. Without it this whole group is unwritable: §5.2's claim - # is that a failed instance REMAINS REGISTERED AND INSPECTABLE, and - # a run that stops at the raise can never see it. The corpus could - # pin that activation raises, or that the instance survives, but not - # both — and it is the pair that matters. { id: 'lifecycle/fail#activate', doc: true in: [ @@ -1239,7 +880,6 @@ primary: lifecycle: { ] match: { out: { status: { 'noisy$a': 'failed' } } } } - # The raise itself, uncaught, so the code is pinned too. { in: [ { do: 'define', name: 'noisy', probe: 'noisy' } @@ -1259,7 +899,6 @@ primary: lifecycle: { ] match: { out: { result: { 'noisy$a': 'failed' } } } } - # It accepts ONLY unload. Any other transition raises. { in: [ { do: 'define', name: 'noisy', probe: 'noisy' } @@ -1304,15 +943,6 @@ primary: lifecycle: { ] - # A FAILING `deactivate`, which nothing exercised until the go port - # went looking. §5.2: "Any failure during a transition — a lifecycle - # callback raising, or a ledger entry raising — lands the instance in - # `failed`", with the scope still FULLY unwound. - # - # The `unload` case is the one that was actually broken in the - # canonical: the raise propagated straight out, leaving the instance - # `live`, its scope untouched and its bindings still live — reporting - # a failure while leaking exactly the resources the failure was about. faildown: set: [ { id: 'lifecycle/faildown#deactivate', doc: true @@ -1329,11 +959,6 @@ primary: lifecycle: { ] err: true, match: { err: { code: 'probe_boom' } } } - # ...and through `unload`, which §5.2 defines as "deactivate first, - # then close". The deactivate half failing is still a transition - # failure, so the same three things hold — INCLUDING that the - # instance stays registered, because `failed` is a state an operator - # has to be able to see. { id: 'lifecycle/faildown#unload' in: [ @@ -1367,25 +992,6 @@ primary: lifecycle: { ] - # §12's `plugin__failed` codes are "a callback raised; wraps the - # cause" - and nothing wrapped anything, because every probe raise - # carried a code of its own. An ORDINARY error escaping a callback (a - # socket that would not open, a library that threw) reached the caller - # with no code at all, so it took part in no cross-port error contract. - # - # AN ERROR THAT ALREADY CARRIES A CODE KEEPS IT: the code is the - # error's identity, and a plugin raising `store_unreachable` must not - # have it rewritten. Only the code-less case is wrapped. - # `slow` is the sixth probe DOCS.md §4.3 names, and until this entry - # NOTHING IN THE CORPUS EVER LOADED IT. `check_probes.py` asserted the - # definition exists; no run ever asked it to do anything, so a port - # could ship a `slow` that raised on activate and stay green. - # - # It behaves as `probe` does, deliberately: §18 makes the host - # synchronous, so a yielding callback would not "complete later", it - # would abandon everything past its first suspension point. The open - # question - whether a host should ever await - is recorded in - # DOCS.md §4.3 rather than guessed at here. slow: set: [ { id: 'lifecycle/slow#is-a-probe', doc: true @@ -1429,7 +1035,6 @@ primary: lifecycle: { ] err: true, match: { err: { code: 'plugin_deactivate_failed' } } } - # ...and the instance still lands in `failed`, wrapped or not. { in: [ { do: 'load', ref: 'noisy$a', options: { fail: 'activate', bare: true } } @@ -1437,7 +1042,6 @@ primary: lifecycle: { ] match: { out: { status: { 'noisy$a': 'failed' }, open: 0 } } } - # A code the plugin chose survives untouched. { id: 'lifecycle/wrap#own-code-kept' in: [ @@ -1449,11 +1053,6 @@ primary: lifecycle: { ] - # `failed` HAS ONE EXIT, AND IT IS `unload` (§5.2). `deactivate` fell - # through its state checks, ran the definition's `deactivate` on an - # instance that never completed activation, and - if that callback - # happened to succeed - returned it to `loaded`, from where it could be - # activated again. Found by review of the go port. failedexit: set: [ { id: 'lifecycle/failedexit#deactivate', doc: true @@ -1464,7 +1063,6 @@ primary: lifecycle: { ] err: true, match: { err: { code: 'plugin_bad_state' } } } - # ...and it stays `failed` rather than being quietly rehabilitated. { in: [ { do: 'load', ref: 'noisy$a', options: { fail: 'activate' } } @@ -1499,14 +1097,10 @@ primary: lifecycle: { ] err: true, match: { err: { code: 'plugin_ref_duplicate' } } } - # An unknown definition, distinct from an unknown ref. { in: [ { do: 'load', ref: 'nosuch$a' } ], err: true, match: { err: { code: 'plugin_unknown_definition' } } } ] - # INTROSPECTION NEVER ADVANCES THE STATE (§5.2). A status page must not - # be a way to accidentally import twenty packages, so `list` over a - # `declared` instance leaves it `declared` and fires no callback. introspect: set: [ { id: 'lifecycle/introspect#free', doc: true @@ -1518,8 +1112,6 @@ primary: lifecycle: { out: { status: { 'probe$a': 'declared' }, open: 0, log: [] result: { 'probe$a': 'declared' } } } - # `declared` costs nothing: twenty lazy instances are twenty map - # entries and no executed code, which is the property station needs. { in: [ { do: 'define', name: 'probe' } @@ -1547,15 +1139,6 @@ primary: lifecycle: { ] - # The synthetic resource counter is the whole test for §8.3's unwind. - # `greedy` acquires N and releases some explicitly; the difference is - # what the instance scope must unwind by itself. - # §12 has carried `plugin_bind_scope` - "binding declared outside - # `define`" - since before anything raised it. §8.1 puts binding - # DECLARATION in `define` and INSERTION at a successful activate; the - # guard was the half nobody wrote, so a binding added from `activate` - # went live without being part of the loaded definition and every - # deactivate/activate cycle appended another copy. bindscope: set: [ { id: 'lifecycle/bindscope#activate', doc: true @@ -1640,25 +1223,9 @@ primary: lifecycle: { } -# --------------------------------------------------------------------------- -# order — the resolved order of bindings on a point (§7). -# -# DRIVER section: needs DOCS.md §4, and §4.4's ordering block in particular. -# -# ONE RULE, ONE PLACE. sdkgen grew two special cases in makeOptions (`test`, -# then `station`) and the third was not far off; this sort is the whole -# replacement. Three tiers, in this order: -# -# 1 constraints before/after edges, matched by ref or by name -# 2 bands integer, lower first, default 0 -# 3 declaration ties break by `pos`, NOT by load order and NOT by `seq` -# -# `result` is the ref list from an `order` command. -# --------------------------------------------------------------------------- primary: order: { - # Constraints are edges, and they win over everything below them. constraint: set: [ { id: 'order/constraint#after', doc: true @@ -1681,8 +1248,6 @@ primary: order: { ] match: { out: { result: ['probe$b', 'probe$a'] } } } - # Matching BY NAME orders behind every instance of that definition - # (§7), which is the whole reason the two spellings exist. { in: [ { do: 'define', name: 'probe' } @@ -1768,14 +1333,6 @@ primary: order: { ] - # `before`/`after` take ONE spelling or a LIST of them. plugin typed this - # as a bare string and matched with `===`, so a list matched nothing and - # was SILENTLY DROPPED - the sort came out as if no constraint had been - # declared, with nothing raised. station has declared and validated the - # list form since Stage 3b, so the two levels of one rule disagreed about - # what the rule accepts. Every entry here carries a band that CONTRADICTS - # the constraint: without one, `pos` alone would produce the wanted order - # and the entry would pass whether or not the list was read. list: set: [ { id: 'order/list#after-one', doc: true @@ -1787,7 +1344,6 @@ primary: order: { ] match: { out: { result: ['probe$a', 'probe$b'] } } } - # A list means after ALL of them, not after the first one read. { id: 'order/list#after-many', doc: true in: [ @@ -1799,7 +1355,6 @@ primary: order: { ] match: { out: { result: ['probe$a', 'probe$c', 'probe$b'] } } } - # The `before` spelling takes a list on the same terms. { id: 'order/list#before-many', doc: true in: [ @@ -1824,14 +1379,6 @@ primary: order: { ] match: { out: { result: ['probe$b', 'probe$a'] } } } - # A list may name the same instance twice - once by NAME, once by REF. - # That is one edge, not two, and must not deadlock or double-count. - # - # The declaring instance is deliberately a DIFFERENT definition. A bare - # name fans out to every instance of that definition, so a member naming - # the declarer's own definition would include the declarer itself - a - # self-edge, which is a cycle of one and correctly raises. That is §7 - # working, not a list defect, and it cost this entry one rewrite. { id: 'order/list#name-and-ref-are-one-edge', doc: true in: [ @@ -1858,19 +1405,6 @@ primary: order: { ] - # TIES BREAK BY `pos` — the instance's position in the DECLARATION - # order — not by the order instances happened to load, and not by the - # incarnation counter `seq` (§4 rule 4). Both distinctions are - # load-bearing. - # §7 breaks a tie by `pos` - and TWO INSTANCES CAN SHARE ONE. `declare` - # defaults `pos` to the registry size, so unloading one and declaring - # another reuses a surviving instance's position. Past that the - # canonical fell through to its map's insertion order, which a language - # whose maps have none cannot reproduce: go ordered by ref and got the - # opposite answer from the same corpus. - # - # DECLARATION SEQUENCE is that order made explicit. Found by review of - # the go port. seqtie: set: [ { id: 'order/seqtie#shared-pos', doc: true @@ -1881,15 +1415,6 @@ primary: order: { { do: 'ready', ref: 'probe$a' } { do: 'order', point: 'p' } ] - # `probe$b` has pos 1 and `probe$a` re-used pos 1; `b` was declared - # first, so it runs first. A port breaking the tie by REF would - # answer ['probe$a', 'probe$b']. - # - # Like `pinorder`, this bites only where maps have no order. A - # delete-then-insert moves a key to the END of an insertion-ordered - # map, which is also where its new `seq` puts it, so registry order - # and `seq` order coincide there and cannot be told apart. go is - # what this entry is for. match: { out: { result: ['probe$b', 'probe$a'] } } } ] @@ -1997,7 +1522,6 @@ primary: order: { ] match: { out: { result: ['probe$a', 'probe$b'] } } } - # An empty live set is an empty order, not an error. { in: [ { do: 'define', name: 'probe' }, { do: 'order', point: 'p' } ] match: { out: { result: [] } } @@ -2040,23 +1564,6 @@ primary: order: { ] - # A PIN IS NOT A CONSTRAINT (§7). Constraints and bands are negotiable - # by definition — they are what plugins and documents say they WANT, - # and the sort's job is to satisfy them all. A pin is the host stating - # a structural invariant of its own architecture, so an ordering that - # would move it is REJECTED rather than honoured. - # - # The vocabulary is POSITIONAL, not ordinal: §6.2 composes - # b1(b2(b3(base))) with the FIRST binding outermost, so `first` and - # `innermost` are opposites. A pin spelled in sort terms would be read - # backwards by exactly the people it exists to protect. - # A PIN MAP IS DATA, AND ITS ITERATION ORDER IS PART OF THE ANSWER. - # Two names pinned to the same end are order-sensitive: - # `{b:'first', a:'first'}` and `{a:'first', b:'first'}` give different - # results. JavaScript's `Object.keys` is insertion order and a go map - # has none at all, so leaving it unstated made the same declaration - # mean different things per port. SORTED is the one order every - # language agrees on. Found by review of the go port. pinorder: set: [ { id: 'order/pinorder#two-names', doc: true @@ -2066,19 +1573,6 @@ primary: order: { { do: 'ready', ref: 'noisy$b' } { do: 'order', point: 'p' } ] - # Each pinned name is moved to the front in turn, so the one - # applied LAST ends up outermost: sorted order applies `noisy` then - # `probe`, leaving `probe` outermost. - # - # THIS ENTRY CAN ONLY FAIL IN A PORT WHOSE MAPS HAVE NO ORDER - - # go, and every port whose map type is a hash. `make spec` emits - # the JSON with SORTED KEYS, so a decoder that preserves file order - # (JavaScript, Python, Ruby) hands the implementation a sorted map - # whatever it then does, and the two readings coincide. That is a - # limit of the corpus, not of the rule: an embedding host builds - # its pin map in code, in any order it likes, which is why every - # port sorts explicitly. Recorded in doc/plan/handover.md so the - # next port does not read the green as coverage. match: { out: { result: ['probe$a', 'noisy$b'] } } } ] @@ -2128,43 +1622,23 @@ primary: order: { } -# --------------------------------------------------------------------------- -# env — VOXGIG_PLUGIN_* parsing (§9.5). -# -# PURE: a function over a string map and a ref set, so the corpus tests it -# without touching a real environment. -# -# in: { env: {...}, refs?: [...], reserved?: [...] } -# -> { profile?, options: {ref -> map}, active: [], inactive: [] } -# -# THE REF SET IS NOT OPTIONAL DECORATION. The encoding is LOSSY — `$` -> `__` -# and `.` -> `_`, case-folded — so RETRY__FAST_MIN cannot be split into ref -# and path without knowing which refs exist. A port that guesses at the -# split will guess differently from the next one. -# --------------------------------------------------------------------------- primary: env: { - # One option, on one ref. option: set: [ { id: 'env/option#simple', doc: true in: { refs: ['retry'], env: { VOXGIG_PLUGIN_RETRY_RETRIES: '3' } } out: { options: { retry: { retries: 3 } }, active: [], inactive: [] } } - # A tag: `$` encodes as `__`. { in: { refs: ['retry$fast'], env: { VOXGIG_PLUGIN_RETRY__FAST_RETRIES: '3' } } match: { out: { options: { 'retry$fast': { retries: 3 } } } } } - # A dotted path: `.` encodes as `_`, and nests. { in: { refs: ['retry'], env: { VOXGIG_PLUGIN_RETRY_MIN_DELAY: '500' } } match: { out: { options: { retry: { min: { delay: 500 } } } } } } - # LONGEST ENCODED REF WINS. With both `retry` and `retry$fast` held, - # RETRY__FAST_MIN belongs to the tagged one — shortest-first would - # read `__fast` as part of the path and write it to the wrong ref. { id: 'env/option#longest' in: { refs: ['retry', 'retry$fast'], env: { VOXGIG_PLUGIN_RETRY__FAST_MIN: '1' } } @@ -2176,7 +1650,6 @@ primary: env: { in: { refs: ['retry'], env: { VOXGIG_PLUGIN_OTHER_X: '1' } } out: { options: {}, active: [], inactive: [] } } - # Keys without the prefix are not ours. { in: { refs: ['retry'], env: { PATH: '/usr/bin', HOME: '/root' } } out: { options: {}, active: [], inactive: [] } @@ -2204,11 +1677,6 @@ primary: env: { { in: { refs: ['r'], env: { VOXGIG_PLUGIN_R_N: 'null' } } match: { out: { options: { r: { n: '__NULL__' } } } } } - # `NaN` and `Infinity` ARE NOT JSON. `JSON.parse` rejects them and - # the documented fallback keeps the string - but python's - # `json.loads` accepts them as floats, silently changing the option's - # type to a value strict JSON serializers cannot represent. Found by - # review of the python port. { id: 'env/value#not-json-constants' in: { refs: ['r'], env: { VOXGIG_PLUGIN_R_N: 'NaN' } } match: { out: { options: { r: { n: 'NaN' } } } } } @@ -2227,19 +1695,15 @@ primary: env: { match: { out: { active: ['a', 'b'] } } } { in: { refs: ['a'], env: { VOXGIG_PLUGIN_INACTIVE: 'a' } } match: { out: { inactive: ['a'] } } } - # Whitespace around a comma is an operator typing a list, not an error. { in: { refs: ['a', 'b'], env: { VOXGIG_PLUGIN_ACTIVE: ' a , b ' } } match: { out: { active: ['a', 'b'] } } } - # Refs canonicalize here as everywhere (§4 rule 5). { in: { refs: ['a'], env: { VOXGIG_PLUGIN_INACTIVE: 'a$' } } match: { out: { inactive: ['a'] } } } - # An empty value yields nothing rather than one empty ref. { in: { refs: ['a'], env: { VOXGIG_PLUGIN_ACTIVE: '' } } match: { out: { active: [] } } } ] - # THE PROFILE, which selects the overlay §9.3's levels 5 and 6 come from. profile: set: [ { id: 'env/profile#set', doc: true in: { refs: [], env: { VOXGIG_PLUGIN_PROFILE: 'prod' } } @@ -2247,38 +1711,21 @@ primary: env: { ] - # THE COLLISION IS DETECTED, NOT AVOIDED (§9.5). `_` is legal in a name - # and in a tag and the mapping folds case, so several distinct refs can - # encode to one key. Rather than restrict a grammar the rest of the - # stack already uses, the host encodes every ref it holds and refuses a - # key two of them claim, NAMING BOTH. - # - # Reported from the REF SET ALONE — no environment variable need - # exercise it. A latent ambiguity is still an ambiguity, and finding it - # at deploy time is the failure this exists to prevent. ambiguous: set: [ { id: 'env/ambiguous#tag-vs-underscore', doc: true in: { refs: ['retry$fast', 'retry__fast'], env: {} } err: true, match: { err: { code: 'plugin_env_ambiguous' } } } - # Case folding collides too. { in: { refs: ['retry$fast', 'Retry$fast'], env: {} }, err: true, match: { err: { code: 'plugin_env_ambiguous' } } } { in: { refs: ['retry$fast', 'retry$Fast'], env: {} }, err: true, match: { err: { code: 'plugin_env_ambiguous' } } } - # The message names BOTH refs — one name leaves the reader bisecting. { in: { refs: ['retry$fast', 'retry__fast'], env: {} } err: true, match: { err: { message: '/retry\\$fast/' } } } { in: { refs: ['retry$fast', 'retry__fast'], env: {} } err: true, match: { err: { message: '/retry__fast/' } } } - # Refs that do NOT collide are fine, including near-misses. { in: { refs: ['retry', 'retry$fast', 'other'], env: {} } match: { out: { options: {} } } } ] - # Reserved refs are barred HERE TOO (§9.1). Stating the guard for - # documents alone would leave the guarantee trivially bypassable: - # VOXGIG_PLUGIN_INACTIVE=station is easier to set than editing a config - # file, and INACTIVE has the final word — so the one lever this - # mechanism exists to deny would have been the one left open. reserved: set: [ { id: 'env/reserved#inactive', doc: true in: { refs: ['station'], reserved: ['station'], env: { VOXGIG_PLUGIN_INACTIVE: 'station' } } @@ -2289,27 +1736,16 @@ primary: env: { err: true, match: { err: { code: 'plugin_ref_reserved' } } } { in: { refs: ['station'], reserved: ['station'], env: { VOXGIG_PLUGIN_STATION_X: '1' } } err: true, match: { err: { code: 'plugin_ref_reserved' } } } - # A name merely starting with the reserved one is not reserved. { in: { refs: ['stationary'], reserved: ['station'], env: { VOXGIG_PLUGIN_INACTIVE: 'stationary' } } match: { out: { inactive: ['stationary'] } } } ] } -# --------------------------------------------------------------------------- -# version — the range grammar and the ONE satisfaction predicate (§11.2). -# -# satisfies: in: { version, range } -> boolean -# range: in: -> { lo, hi } -# --------------------------------------------------------------------------- primary: version: { - # TWO FORMS AND NO MORE. - # '2.1' >= 2.1.0 and < 3.0.0 - # '~2.1' >= 2.1.0 and < 2.2.0 range: set: [ - # ...and the boundary itself is admitted, in both directions. { id: 'version/range#component-max' in: '2147483647', out: { lo: [2147483647, 0, 0], hi: [2147483648, 0, 0] } } { id: 'version/range#caret', doc: true, in: '2.1', out: { lo: [2, 1, 0], hi: [3, 0, 0] } } @@ -2331,22 +1767,7 @@ primary: version: { { in: '2.1.3.4', err: true, match: { err: { code: 'plugin_bad_range' } } } { in: '~', err: true, match: { err: { code: 'plugin_bad_range' } } } { in: '-1', err: true, match: { err: { code: 'plugin_bad_range' } } } - # THE SAME ANCHOR TRAP AS THE REF GRAMMAR, in the one other place - # this library holds a regex. Python's `$` matches before a trailing - # newline and ruby's matches at every line boundary, so `^...$` - # accepts "2.1\n" as a range in both. Its own entry because a port - # that fixed the ref grammar and left the version grammar alone - # would pass the whole `ref` section. { id: 'version/rangebad#trailing-newline', in: "2.1\n", err: true, match: { err: { code: 'plugin_bad_range' } } } - # A COMPONENT IS BOUNDED, like a ref is (§4's 1024). The grammar - # admits an unbounded digit sequence and every language then - # disagrees past its integer range: JS silently loses precision, - # go's Atoi errors (and a port ignoring that gets 0), C overflows, - # python is exact. `satisfies('0', '9223372036854775808')` was false - # in the canonical and TRUE in go, from this same corpus. - # - # 2^31-1, because every port has a signed 32-bit integer and no real - # version has ever needed more. Found by review of the go port. { id: 'version/rangebad#component-bound', in: '9223372036854775808', err: true, match: { err: { code: 'plugin_bad_range' } } } { in: '2147483648', err: true, match: { err: { code: 'plugin_bad_range' } } } { in: '1.2147483648', err: true, match: { err: { code: 'plugin_bad_range' } } } @@ -2354,10 +1775,6 @@ primary: version: { ] - # THE WHOLE RULE: the provider's `version` falls inside the - # requirement's `range`. There is no third field and no second - # comparison — an earlier draft added a provider-side `compat` range - # and left three defensible readings of one declaration. satisfies: set: [ { id: 'version/satisfies#in', doc: true, in: { version: '2.3', range: '2.1' }, out: true } { in: { version: '2.1', range: '2.1' }, out: true } @@ -2374,7 +1791,6 @@ primary: version: { { in: { version: '2.2.0', range: '~2.1' }, out: false } { in: { version: '2.2', range: '2.1' }, out: true } - # Boundaries, both inclusive-low and exclusive-high. { id: 'version/satisfies#lo-inclusive', in: { version: '2.1.0', range: '~2.1' }, out: true } { in: { version: '2.0.99', range: '~2.1' }, out: false } { in: { version: '3.0.0', range: '2.1' }, out: false } @@ -2382,22 +1798,9 @@ primary: version: { } -# --------------------------------------------------------------------------- -# capability — provides/requires matching and the SELECTION RANK (§11.1). -# -# in: { req: {...}, candidates: [ { ref, pos, provides } ] } -# -> the matching candidates, BEST FIRST -# -# WITHOUT A TOTAL RANK, "any provider satisfies" is true of the GRAPH and -# useless to the PLUGIN: two ports could bind different `store` instances, -# both resolve green, and behave differently — precisely the divergence a -# shared corpus exists to catch. So the rank is pinned, not left to -# whichever order a language's sort happens to produce. -# --------------------------------------------------------------------------- primary: capability: { - # Matching: name, then range, then `match` against `attrs`. match: set: [ { id: 'capability/match#name', doc: true @@ -2409,10 +1812,6 @@ primary: capability: { candidates: [ { ref: 'a', pos: 0, provides: { name: 'store' } } ] } out: [] } - # `match` is checked KEY BY KEY against the provider's attrs, and a - # key the provider never declared is a MISS. A requirement asking - # for `transactional: true` must not be satisfied by a provider that - # never said whether it is. { id: 'capability/match#attrs' in: { req: { name: 'store', match: { transactional: true } } @@ -2428,21 +1827,10 @@ primary: capability: { { in: { req: { name: 'store', match: { transactional: true } } candidates: [ { ref: 'a', pos: 0, provides: { name: 'store' } } ] } out: [] } - # Every key must pass, not merely one. { in: { req: { name: 'store', match: { a: 1, b: 2 } } candidates: [ { ref: 'x', pos: 0, provides: { name: 'store', attrs: { a: 1, b: 3 } } } ] } out: [] } - # EQUALITY IS BY JSON TYPE AS WELL AS VALUE, and this is the entry - # that says so. The canonical compares with `===`, under which `true` - # and `1` are different values — but half the ports are written in - # languages where the default comparison says they are the same - # (python's `True == 1`, php's `==`, perl, lua), so a port taking its - # language's `==` passes every other entry in this section and - # silently satisfies a requirement no one asked for. - # - # Found by the python port (§18, P4): a mutation deleting its - # bool/int guard survived the whole suite. { in: { req: { name: 'store', match: { transactional: true } } candidates: [ { ref: 'a', pos: 0, provides: { name: 'store', attrs: { transactional: 1 } } } ] } out: [] } @@ -2454,9 +1842,6 @@ primary: capability: { { in: { req: { name: 'store', match: { transactional: false } } candidates: [ { ref: 'a', pos: 0, provides: { name: 'store', attrs: { transactional: 0 } } } ] } out: [] } - # ...and the STRING/NUMBER half, which is the one php's `==`, perl's - # `==`/`eq` split and lua's automatic coercion each get wrong in - # their own way. `1` is not `"1"`. { in: { req: { name: 'store', match: { n: 1 } } candidates: [ { ref: 'a', pos: 0, provides: { name: 'store', attrs: { n: '1' } } } ] } out: [] } @@ -2469,34 +1854,18 @@ primary: capability: { candidates: [ { ref: 'a', pos: 0, provides: { name: 'store', attrs: { transactional: true, n: 1 } } } ] } match: { out: [ { ref: 'a' } ] } } - # The range participates in matching, not only in diagnosis. { in: { req: { name: 'store', range: '2.1' } candidates: [ { ref: 'a', pos: 0, provides: { name: 'store', version: '2.3' } } ] } match: { out: [ { ref: 'a' } ] } } { in: { req: { name: 'store', range: '2.1' } candidates: [ { ref: 'a', pos: 0, provides: { name: 'store', version: '3.0' } } ] } out: [] } - # A requirement with a range is not satisfied by a provider with no - # version: "unversioned" is not "any version". { in: { req: { name: 'store', range: '2.1' } candidates: [ { ref: 'a', pos: 0, provides: { name: 'store' } } ] } out: [] } ] - # `match` IS A PARTIAL MATCH AND IT RECURSES (§11.1): "every leaf in - # the requirement must be present and equal in the capability, keys not - # mentioned are not checked" — struct's and omni's `match`, not a flat - # key-by-key comparison of top-level values. - # - # THE DISTINCTION IS INVISIBLE WHILE EVERY ENTRY IS SCALAR, which is - # exactly how the canonical came to implement the flat reading: it - # compared `attrs[k] !== req.match[k]`, and in JavaScript that is - # REFERENCE identity for a map. A requirement and a capability are - # written in different places and are never the same object, so - # `match: { limits: { max: 5 } }` matched NOTHING — including a - # provider declaring exactly that. Found by the go port (§18, P4), and - # fixed in the canonical rather than worked around there. nested: set: [ { id: 'capability/nested#leaf', doc: true @@ -2518,17 +1887,11 @@ primary: capability: { candidates: [ { ref: 'a', pos: 0 provides: { name: 'store', attrs: { limits: {} } } } ] } out: [] } - # ...and a scalar where the requirement wants a map does not match, - # rather than being coerced into one. { in: { req: { name: 'store', match: { limits: { max: 5 } } } candidates: [ { ref: 'a', pos: 0 provides: { name: 'store', attrs: { limits: 5 } } } ] } out: [] } - # A LIST IS COMPARED LEAF-WISE AT THE SAME LENGTH, NOT AS A SUBSET. - # "the first two of your three regions" is not something `match` can - # say, and giving it a spelling would be inventing the filter - # language §11.1 explicitly declines to add. { in: { req: { name: 'store', match: { regions: ['eu', 'us'] } } candidates: [ { ref: 'a', pos: 0 provides: { name: 'store', attrs: { regions: ['eu', 'us'] } } } ] } @@ -2537,7 +1900,6 @@ primary: capability: { candidates: [ { ref: 'a', pos: 0 provides: { name: 'store', attrs: { regions: ['eu', 'us'] } } } ] } out: [] } - # Order is part of a list, here as everywhere else in the corpus. { in: { req: { name: 'store', match: { regions: ['us', 'eu'] } } candidates: [ { ref: 'a', pos: 0 provides: { name: 'store', attrs: { regions: ['eu', 'us'] } } } ] } @@ -2556,8 +1918,6 @@ primary: capability: { ] } match: { out: [ { ref: 'hi' }, { ref: 'lo' } ] } } - # `priority` breaks a version tie, and LOWER wins — the opposite of - # what the word suggests to most readers, which is why it is pinned. { id: 'capability/rank#priority' in: { req: { name: 's' }, candidates: [ @@ -2566,7 +1926,6 @@ primary: capability: { ] } match: { out: [ { ref: 'a' }, { ref: 'b' } ] } } - # Default priority is 0, so an unset one beats an explicit 1. { in: { req: { name: 's' }, candidates: [ { ref: 'set', pos: 0, provides: { name: 's', version: '2.1', priority: 1 } } @@ -2574,8 +1933,6 @@ primary: capability: { ] } match: { out: [ { ref: 'unset' }, { ref: 'set' } ] } } - # `pos` breaks the remaining tie — declaration order, the same - # number §7 uses, so ordering and selection cannot disagree. { in: { req: { name: 's' }, candidates: [ { ref: 'second', pos: 1, provides: { name: 's', version: '2.1' } } @@ -2593,7 +1950,6 @@ primary: capability: { ] } match: { out: [ { ref: 'newer' }, { ref: 'older' } ] } } - # And PRIORITY OUTRANKS pos, likewise. { in: { req: { name: 's' }, candidates: [ { ref: 'late', pos: 9, provides: { name: 's', version: '2.0', priority: 0 } } @@ -2605,43 +1961,24 @@ primary: capability: { } -# --------------------------------------------------------------------------- -# graph — whole-graph resolution (§11.4). -# -# in: [ { ref, pos, provides?, requires? } ] -# -> { resolved: [refs], blocked: [ { ref, unmet, why } ] } -# -# PURE: no callbacks run, no state changes, nothing is touched. -# -# THE `why` IS PART OF THE CONTRACT, not a nicety. The failure being -# designed against is famous: OSGi's resolver is correct and its -# diagnostics are legendarily unusable. A resolver that says "blocked" -# without saying WHY has moved the problem rather than solved it — and -# twenty ports each inventing their own explanation is the same problem -# again, twenty times. -# --------------------------------------------------------------------------- primary: graph: { resolve: set: [ - # Nothing required: everything resolves. { id: 'graph/resolve#trivial', doc: true in: [ { ref: 'a', pos: 0 }, { ref: 'b', pos: 1 } ] out: { resolved: ['a', 'b'], blocked: [] } } - # A satisfied requirement. { in: [ { ref: 'p', pos: 0, provides: [ { name: 'clock' } ] } { ref: 'c', pos: 1, requires: [ { name: 'clock' } ] } ] out: { resolved: ['c', 'p'], blocked: [] } } - # An OPTIONAL requirement never blocks. { in: [ { ref: 'c', pos: 0, requires: [ { name: 'clock', optional: true } ] } ] out: { resolved: ['c'], blocked: [] } } - # A chain resolves to a fixed point rather than in one pass. { id: 'graph/resolve#chain' in: [ { ref: 'c', pos: 0, requires: [ { name: 'b' } ] } @@ -2649,19 +1986,12 @@ primary: graph: { { ref: 'a', pos: 2, provides: [ { name: 'a' } ] } ] out: { resolved: ['a', 'b', 'c'], blocked: [] } } - # A REF SATISFIES HERE TOO (§11.1), and it did not until this entry. - # `resolve()` considered only DECLARED CAPABILITIES, so it answered - # `absent` about a provider the runtime activates against — §11.4's - # whole job is explaining the graph the runtime reconciles, and it - # was explaining a different one. `depend/byref` pins the runtime - # half; without this the two disagree. { id: 'graph/resolve#byref' in: [ { ref: 'p', pos: 0 } { ref: 'c', pos: 1, requires: [ { name: 'p' } ] } ] out: { resolved: ['c', 'p'], blocked: [] } } - # ...canonically (§4 rule 5), same as everywhere else. { id: 'graph/resolve#byref-canonical' in: [ { ref: 'p', pos: 0 } @@ -2681,11 +2011,7 @@ primary: graph: { ] - # ONE BLOCKED INSTANCE, AND THE SPECIFIC REASON. Apply twenty - # instances against a registry missing one thing and the answer must - # be that one thing — not nineteen pending rows. blocked: set: [ - # No provider at all. { id: 'graph/blocked#absent', doc: true in: [ { ref: 'c', pos: 0, requires: [ { name: 'clock' } ] } ] @@ -2708,7 +2034,6 @@ primary: graph: { out: { resolved: ['p'], blocked: [ { ref: 'c', unmet: 's', why: { kind: 'version', range: '2.1', found: ['1.0'] } } ] } } - # A provider whose attributes fail the match — WITH THE FAILING LEAF. { id: 'graph/blocked#match' in: [ { ref: 'p', pos: 0, provides: [ { name: 's', attrs: { transactional: false } } ] } @@ -2717,13 +2042,6 @@ primary: graph: { { ref: 'c', unmet: 's' why: { kind: 'match', failing: 'transactional', want: true, found: false } } ] } } - # A missing attribute reports `found: null` rather than omitting the - # key — "the provider does not say" is an answer, and a port that - # drops the field makes the two cases indistinguishable. - # - # A REAL null, not the `__NULL__` marker: this is an `out` comparison - # and the value genuinely is null. The marker belongs to `match`, - # where it means "no value" rather than "the value null". { in: [ { ref: 'p', pos: 0, provides: [ { name: 's' } ] } { ref: 'c', pos: 1, requires: [ { name: 's', match: { transactional: true } } ] } ] @@ -2731,10 +2049,6 @@ primary: graph: { { ref: 'c', unmet: 's' why: { kind: 'match', failing: 'transactional', want: true, found: null } } ] } } - # A NESTED requirement reports the TOP-LEVEL key that failed, and - # `found` is the whole value the provider declared under it — the - # reader needs to see what is actually there, not the one leaf the - # comparison happened to stop at. { id: 'graph/blocked#match-nested' in: [ { ref: 'p', pos: 0, provides: [ { name: 's', attrs: { limits: { max: 9 } } } ] } @@ -2767,17 +2081,6 @@ primary: graph: { } -# --------------------------------------------------------------------------- -# resolve — name to candidate module ids (§10.2). -# -# in: { name, sources? } -> [ids] -# -# PURE, and deliberately so: it returns the ids a host WOULD try, in -# order, and loads nothing. That separation is what lets the corpus pin -# resolution in every language INCLUDING those with no dynamic loading at -# all, and it is why §15.4 puts real module loading in per-port -# integration tests rather than here. -# --------------------------------------------------------------------------- primary: resolve: { @@ -2800,7 +2103,6 @@ primary: resolve: { } { in: { name: 'r', sources: [ { kind: 'path', dir: './plugin' } ] }, out: ['./plugin/r'] } { in: { name: 'r', sources: [ { kind: 'path', dir: './plugin/' } ] }, out: ['./plugin/r'] } - # Several sources concatenate in declared order. { in: { name: 'r', sources: [ { kind: 'path', dir: './p' }, { kind: 'module', prefix: ['m-'] } ] } out: ['./p/r', 'm-r'] @@ -2811,12 +2113,6 @@ primary: resolve: { in: { name: 'r', sources: [ { kind: 'module', prefix: ['a-', 'a-'] } ] } out: ['a-r'] } - # A SCOPED NAME RESOLVES VERBATIM ONLY (§10.2), whatever the - # sources say — it is already a package id, and prefixing it - # produces `@voxgig/plugin-@acme/thing`, which is not a thing that - # can exist. The default sources are the case that matters: a port - # honouring this only when the prefix list happens to be empty - # passes the second entry and fails the first. { id: 'resolve/candidates#scoped-verbatim' in: { name: '@acme/thing' } @@ -2833,12 +2129,6 @@ primary: resolve: { ] - # `from` BYPASSES CANDIDATE GENERATION ENTIRELY (§10.2). A MODULE PATH - # IS NOT A NAME: the ref grammar starts a name with a letter or `@`, - # so `./local/thing` is not a ref and never reaches this function at - # all. seneca allows a path where a plugin name goes; this design - # deliberately does not, because a ref is an ADDRESS WITHIN A HOST and - # a path is a LOCATION ON A DISK. from: set: [ { id: 'resolve/from#verbatim', doc: true, in: './local/thing', out: ['./local/thing'] } { in: '/abs/path', out: ['/abs/path'] } @@ -2847,15 +2137,6 @@ primary: resolve: { } -# --------------------------------------------------------------------------- -# point — the three kinds, the four hook modes, composition, shadowing -# and exclusivity (§6). -# -# DRIVER. A PLUGIN NEVER MUTATES THE HOST — that inversion is what makes -# deactivation possible, and every entry here is really a test of it: -# sdkgen's `utility.fetcher = wrapped` is not undoable, but "this instance -# holds slot 3 of the request chain" is undoable in O(1). -# --------------------------------------------------------------------------- primary: point: { @@ -2888,7 +2169,6 @@ primary: point: { ] match: { out: { result: 0 } } } - # An unknown point is an error, not a silent no-op. { in: [ { do: 'host', points: {} }, { do: 'emit', point: 'nope' } ] err: true, match: { err: { code: 'plugin_point_unknown' } } @@ -2919,7 +2199,6 @@ primary: point: { ] match: { out: { result: 'A' } } } - # With no bindings, nothing is returned — absent, not an error. { in: [ { do: 'host', points: { v: { kind: 'hook', mode: 'bail' } } } @@ -2927,13 +2206,6 @@ primary: point: { ] match: { out: { result: '__NULL__' } } } - # A NULL RETURN DECLINES, and the next binding gets its turn. - # - # §6.1: JavaScript can tell `null` from `undefined` and almost - # nothing else in the target set can, so a canonical that stopped on - # `null` had a rule go, python and ruby could not implement - and - # each of them had already, silently, implemented the other one. This - # entry is what makes the two readings disagree out loud. { id: 'point/bail#null-declines', doc: true in: [ @@ -2944,9 +2216,6 @@ primary: point: { ] match: { out: { result: 'B' } } } - # ...and a FALSE is a value. The rule is about absence, not about - # falsiness, which is where a port reaching for its own truthiness - # would land. { in: [ { do: 'host', points: { v: { kind: 'hook', mode: 'bail' } } } @@ -2973,8 +2242,6 @@ primary: point: { ] match: { out: { result: 'ABx' } } } - # Band order decides nesting: lower band is OUTERMOST, the same - # direction §7 gives, so one rule covers hooks and chains. { id: 'point/chain#band' in: [ @@ -2985,7 +2252,6 @@ primary: point: { ] match: { out: { result: 'BAx' } } } - # With no bindings the base is called directly. { in: [ { do: 'host', points: { c: { kind: 'chain' } } } @@ -3009,13 +2275,6 @@ primary: point: { ] - # `provider` — at most one live implementation, and THE LOSERS ARE - # VISIBLE rather than silently ignored (§6.3). - # - # The winner is the HIGHEST band, ties by ref sort — the opposite - # direction from hook and chain, which run lowest first. Two rules, - # kept visibly different rather than one derived from the other by a - # reader who then gets it backwards. provider: set: [ { id: 'point/provider#one', doc: true @@ -3026,7 +2285,6 @@ primary: point: { ] match: { out: { result: 'A' } } } - # Highest band wins. { id: 'point/provider#highest' in: [ @@ -3037,7 +2295,6 @@ primary: point: { ] match: { out: { result: 'B' } } } - # And the loser is LISTED, not dropped. { id: 'point/provider#shadowed' in: [ @@ -3048,15 +2305,6 @@ primary: point: { ] match: { out: { result: ['provider$a'] } } } - # AT EQUAL BANDS THE TIE BREAKS BY REF SORT, NOT BY DECLARATION - # ORDER — the one place §6.3 does not fall through to `pos`, because - # a provider is picked from a set rather than composed into a - # sequence, and "whichever loaded first wins" is not a rule anyone - # can reason about across twenty ports. - # - # `provider$b` is declared FIRST here on purpose: a port breaking the - # tie by declaration order answers 'B' and passes every other entry - # in this group. Found by the python port (§18, P4). { id: 'point/provider#tie-by-ref' in: [ @@ -3076,7 +2324,6 @@ primary: point: { ] match: { out: { result: ['provider$b'] } } } - # With no binding the host's default answers. { in: [ { do: 'host', points: { v: { kind: 'provider', default: 'D' } } } @@ -3096,7 +2343,6 @@ primary: point: { ] err: true, match: { err: { code: 'plugin_point_exclusive' } } } - # One binding is fine on an exclusive point. { in: [ { do: 'host', points: { v: { kind: 'provider', exclusive: true } } } @@ -3108,23 +2354,6 @@ primary: point: { ] - # POSITION VERIFICATION (§6.6). Station found that a plugin can need - # to KNOW it is in the right place: its middleware must sit - # immediately outside the base transport or its "wire truth" events - # are fiction. - # - # `outermost` and `innermost` are NOT index 0 and index count-1 read - # the other way round — §6.2 composes b1(b2(b3(base))) with the FIRST - # binding OUTERMOST. Getting that backwards is the exact error the - # positional pin vocabulary exists to prevent, so both flags are - # pinned against a chain with a known composition. - # - # THE HOST DOES NOT POLICE THIS; it makes the fact available. A plugin - # that requires a position it did not get fails loudly rather than - # reporting nonsense, and that is the plugin's call because only it - # knows what its position means. Verification tells a plugin it was - # misplaced; a pin stops the misplacement from being expressible. The - # two are not substitutes. position: set: [ { id: 'point/position#outermost', doc: true @@ -3144,8 +2373,6 @@ primary: point: { ] match: { out: { result: { index: 1, count: 2, outermost: false, innermost: true } } } } - # A LONE binding is both, which a port special-casing one flag gets - # wrong. { in: [ { do: 'ready', ref: 'probe$a' } @@ -3193,9 +2420,6 @@ primary: point: { } -# --------------------------------------------------------------------------- -# export — keying, aliasing, ambiguity (§11). -# --------------------------------------------------------------------------- primary: export: { @@ -3208,7 +2432,6 @@ primary: export: { ] match: { out: { result: 'probe$a' } } } - # A key nothing exports is absent, not an error. { in: [ { do: 'ready', ref: 'probe$a' }, { do: 'export', key: 'probe$a/nope' } ] match: { out: { result: '__NULL__' } } @@ -3222,14 +2445,6 @@ primary: export: { match: { out: { result: 'probe$a' } } } - # A SECOND KEY OFF ONE INSTANCE, which is what makes the map a MAP. - # - # Every other entry here reads `client`, so a port whose `exports` - # ignored the key and answered with the instance's first export - # passed all of them. `probe` exports `client` (its ref), `mark` (a - # constant) and `inst` (its own api) -- and `inst` cannot close this, - # because it is shaped differently in every port and no entry can - # assert on it. A constant can. { id: 'export/key#second-key', doc: true in: [ { do: 'ready', ref: 'probe$a' }, { do: 'export', key: 'probe$a/mark' } ] @@ -3259,13 +2474,6 @@ primary: export: { match: { out: { result: 'probe$a' } } } - # A SCOPED DEFINITION NAME, and the reason the spec splits at the - # LAST `/`. §4's `checkname` permits `/` in a NAME — `@acme/store` is - # legal, and §10.2 resolves a scoped name verbatim — so splitting at - # the FIRST one read this as the ref `@acme` with the key - # `probe$a/client`, matched nothing, and answered absent. A scoped - # definition had no spelling for its exports at all, and every entry - # in this section used `probe`, which has no slash, so nothing saw it. { id: 'export/key#scoped', doc: true in: [ @@ -3275,7 +2483,6 @@ primary: export: { ] match: { out: { result: '@acme/probe$a' } } } - # Untagged, so the ref IS the name: the exact branch answers. { id: 'export/key#scoped-untagged' in: [ @@ -3309,12 +2516,6 @@ primary: export: { ] - # THE UNQUALIFIED ALIAS, and the case seneca gets wrong. `probe/client` - # resolves to the UNTAGGED instance if one exists; if not, and exactly - # one tagged instance exports the key, to that one; if two do, it is - # an ERROR rather than silent last-wins — with multi-instance as a - # headline feature, an ambiguous alias is a defect waiting for - # production. alias: set: [ { id: 'export/alias#untagged-wins', doc: true @@ -3325,7 +2526,6 @@ primary: export: { ] match: { out: { result: 'probe' } } } - # No untagged one, exactly one tagged: that one. { in: [ { do: 'ready', ref: 'probe$a' }, { do: 'export', key: 'probe/client' } ] match: { out: { result: 'probe$a' } } @@ -3337,7 +2537,6 @@ primary: export: { in: [ { do: 'ready', ref: 'probe$a' }, { do: 'export', key: 'probe/mark' } ] match: { out: { result: 'marked' } } } - # Two tagged and no untagged: ERROR, not last-wins. { id: 'export/alias#ambiguous' in: [ @@ -3361,13 +2560,6 @@ primary: export: { } -# --------------------------------------------------------------------------- -# depend — requirements, pending, and the reactive half (§11.3). -# -# ACTIVATION IS A STANDING REQUEST, NOT A ONE-SHOT EVENT. That single -# sentence is why `pending` is a state rather than a flag, and every -# entry here is a consequence of it. -# --------------------------------------------------------------------------- primary: depend: { @@ -3380,8 +2572,6 @@ primary: depend: { ] match: { out: { status: { 'dep$a': 'pending' } } } } - # THE REQUIREMENT IS ON A CAPABILITY, not on a ref: whichever - # instance provides `clock` satisfies it. { id: 'depend/pending#capability' in: [ @@ -3391,7 +2581,6 @@ primary: depend: { ] match: { out: { status: { 'dep$c': 'live' } } } } - # AN OPTIONAL REQUIREMENT NEVER BLOCKS. { in: [ { do: 'load', ref: 'dep$a', options: { requires: ['clock'], optional: ['clock'] } } @@ -3402,13 +2591,7 @@ primary: depend: { ] - # `inst.capability(name)` — WHICH provider this instance is bound to. bound: set: [ - # WHICH PROVIDER THIS INSTANCE TOOK (§11.1). The host's own - # `capability` answers with the live providers RANKED; this answers - # with the one THIS instance is bound to, which §11.4's reluctant - # rebinding makes a different question. `dep` exports the answer as - # `cap` when `capof` names one of its requirements. { id: 'depend/bound#selected', doc: true in: [ @@ -3419,9 +2602,6 @@ primary: depend: { ] match: { out: { result: 'dep$p' } } } - # RANK DECIDES WHICH ONE, and the answer is the instance's, not the - # first declared: `dep$hi` provides version 2 and wins over `dep$lo` - # at version 1 though it was declared second. { id: 'depend/bound#ranked' in: [ @@ -3435,10 +2615,6 @@ primary: depend: { ] match: { out: { result: 'dep$hi' } } } - # AN OPTIONAL REQUIREMENT WITH NOBODY PROVIDING IT is the case §11.3 - # names: a plugin that works without metrics and uses metrics when - # it is there has to be able to ask, and gets absent rather than a - # refusal. { id: 'depend/bound#optional-absent' in: [ @@ -3461,12 +2637,6 @@ primary: depend: { ] match: { out: { result: '__NULL__' } } } - # THE NAME IS READ, not assumed. An instance with TWO satisfied - # requirements is the only shape that can tell a lookup from a - # lookup-shaped `requirements[0]`, and every other entry here has - # exactly one — so the shortest wrong implementation passed the lot - # of them. Both directions, because taking the LAST is as wrong as - # taking the first and neither entry alone says so. { id: 'depend/bound#named-second' in: [ @@ -3489,14 +2659,6 @@ primary: depend: { ] match: { out: { result: 'dep$p' } } } - # A REF REQUIREMENT ANSWERS WITH THE REF (§11.1's "a ref satisfies - # too"), and this is the pair that makes the instance-side question - # a DIFFERENT question rather than a spelling of the host's. The - # next entry asks `host.capability` for the same name and gets - # NOTHING: a ref is not a declared capability, so the host has no - # provider record to rank. Answering this one by ranking, the - # shortest wrong implementation there is, passes every other entry - # in this group. { id: 'depend/bound#by-ref', doc: true in: [ @@ -3517,12 +2679,6 @@ primary: depend: { ] match: { out: { result: [] } } } - # AND THEY PART COMPANY THE OTHER WAY TOO, which is §11.4's - # reluctance seen from the outside: `dep$c` binds `dep$lo`, then - # `dep$hi` arrives outranking it, and `dep$c` is NOT re-bound — so - # what this instance took and what the host ranks first are two - # different refs at the same instant. The next entry is the same - # run, asking the host. { id: 'depend/bound#reluctant', doc: true in: [ @@ -3536,11 +2692,6 @@ primary: depend: { ] match: { out: { result: 'dep$lo' } } } - # AND `dep` HOLDS ONE RESOURCE PER ACTIVATION, like every other - # probe. Nothing said so: five ports gave `dep` no `activate` at - # all, and the corpus could not tell, because no entry with a live - # `dep` had ever asserted `open`. That is the probe catalog - # diverging silently, which is what DOCS.md §4.3 exists to stop. { id: 'depend/bound#dep-holds' in: [ @@ -3567,24 +2718,6 @@ primary: depend: { ] - # A REF SATISFIES TOO (§11.1), AND THE COMPARISON IS CANONICAL - # (§4 rule 5). - # - # §11.1 opens by saying a dependency is on a capability and NOT on a - # ref, and then makes this one narrow exception: a host that genuinely - # needs a specific instance should not have to invent a capability for - # it. Until this group the rule lived only in a comment in the - # canonical's source — the design did not state it and no entry - # reached it, so the whole branch was DEAD CODE against the corpus and - # deleting it outright passed all 552 entries. Seventeen ports carried - # it because they copied the canonical, not because anything checked. - # - # The canonicalization is the half that was named as a gap, and it is - # the smaller half: `dep$` and `dep` are the same instance, so a port - # comparing the requirement string against the registry key without - # canonicalizing first answers `pending` where every other port - # answers `live`. Every requirement name elsewhere in the corpus is - # already canonical, which is why nothing saw it. byref: set: [ { id: 'depend/byref#satisfies', doc: true @@ -3594,10 +2727,6 @@ primary: depend: { ] match: { out: { status: { 'dep$p': 'live', 'dep$c': 'live' } } } } - # THE NON-CANONICAL SPELLING OF THE SAME REF. `dep$` parses to the - # empty tag and formats back to `dep`, which is the only way two - # spellings of one ref can differ (§4) — so this is the entry, and - # there is no other, that a port dropping the canonicalization fails. { id: 'depend/byref#canonical-comparison' in: [ @@ -3606,9 +2735,6 @@ primary: depend: { ] match: { out: { status: { 'dep': 'live', 'dep$c': 'live' } } } } - # LIVE, exactly as a capability is. A declared instance is a map - # entry and no executed code (§5.1), and it can no more satisfy a - # requirement than a capability nobody has provided yet. { in: [ { do: 'declare', ref: 'dep$p', definition: 'dep' } @@ -3625,8 +2751,6 @@ primary: depend: { ] match: { out: { status: { 'dep$p': 'loaded', 'dep$c': 'pending' } } } } - # LOSING IT CASCADES, like any other requirement. Without this the - # ref path could be satisfied once at activation and never rechecked. { id: 'depend/byref#lost' in: [ @@ -3636,11 +2760,6 @@ primary: depend: { ] match: { out: { status: { 'dep$p': 'loaded', 'dep$c': 'pending' } } } } - # ...AND IT CASCADES UNDER THE ORDINARY CARDINALITY AND POLICY RULES - # (§11.3), not always to `pending`. The entry above is the `static` - # mandatory default; a `dynamic` consumer is re-pointed in place and - # STAYS LIVE, because `dynamic` is the plugin saying it can survive a - # provider going away. A ref requirement is not a third axis. { id: 'depend/byref#lost-dynamic' in: [ @@ -3651,8 +2770,6 @@ primary: depend: { ] match: { out: { status: { 'dep$p': 'loaded', 'dep$c': 'live' } } } } - # ...and an OPTIONAL ref requirement never gated activation in the - # first place, so losing it changes nothing. { id: 'depend/byref#lost-optional' in: [ @@ -3663,15 +2780,6 @@ primary: depend: { ] match: { out: { status: { 'dep$p': 'loaded', 'dep$c': 'live' } } } } - # A NAME NO REF COULD HAVE IS A CAPABILITY NAME, AND ONLY THAT. - # - # The design puts a grammar on plugin NAMES (§4) and none at all on - # capability names, so `2fa` and `my cap` are perfectly good - # capabilities. Asking `canonref` whether such a name is a ref does - # not answer no - it RAISES `plugin_bad_name` - so a legal document - # killed the host at the first requirement whose name no ref could - # have. Every capability name in the corpus happened to be a - # well-formed ref, which is why seventeen implementations carried it. { id: 'depend/byref#capability-not-a-ref', doc: true in: [ @@ -3681,7 +2789,6 @@ primary: depend: { ] match: { out: { status: { 'dep$p': 'live', 'dep$c': 'live' } } } } - # A space is not in the name grammar either, and is likewise fine. { in: [ { do: 'ready', ref: 'dep$p', definition: 'dep' @@ -3690,8 +2797,6 @@ primary: depend: { ] match: { out: { status: { 'dep$p': 'live', 'dep$c': 'live' } } } } - # An UNMET one of those still just goes pending - "not a ref" must - # mean "compare as a capability", not "skip the requirement". { in: [ { do: 'ready', ref: 'dep$c', definition: 'dep', options: { requires: ['2fa'] } } @@ -3701,9 +2806,6 @@ primary: depend: { ] - # THE HOST ACTIVATES IT THE MOMENT THE REQUIREMENT ARRIVES, WITHOUT - # BEING ASKED AGAIN. Reconciliation is EAGER — it settles by running - # to a fixed point, not by scheduling. arrive: set: [ { id: 'depend/arrive#auto', doc: true @@ -3714,7 +2816,6 @@ primary: depend: { ] match: { out: { status: { 'dep$c': 'live', 'dep$p': 'live' } } } } - # A CHAIN settles in one pass rather than needing N calls. { id: 'depend/arrive#chain' in: [ @@ -3729,9 +2830,6 @@ primary: depend: { ] - # REACTIVE DEACTIVATION, and its recursion. A live instance whose - # requirement is LOST goes back to `pending` — and so do its own - # consumers, because the loss propagates. lost: set: [ { id: 'depend/lost#back-to-pending', doc: true @@ -3742,7 +2840,6 @@ primary: depend: { ] match: { out: { status: { 'dep$c': 'pending', 'dep$p': 'loaded' } } } } - # Recursively, through the consumer's own consumers. { id: 'depend/lost#recursive' in: [ @@ -3780,7 +2877,6 @@ primary: depend: { ] match: { out: { status: { 'dep$c': 'live' } } } } - # An OPTIONAL requirement never causes a loss either. { in: [ { do: 'ready', ref: 'dep$p', definition: 'dep', options: { provides: [ { name: 'clock' } ] } } @@ -3805,8 +2901,6 @@ primary: depend: { ] match: { out: { result: ['dep$hi', 'dep$lo'] } } } - # Deactivating one of two providers moves nobody: ANY live instance - # providing it satisfies a requirement for it. { in: [ { do: 'ready', ref: 'dep$p1', definition: 'dep', options: { provides: [ { name: 'clock' } ] } } @@ -3816,18 +2910,6 @@ primary: depend: { ] match: { out: { status: { 'dep$c': 'live' } } } } - # §11.4: ALWAYS-RELUCTANT. "A satisfied requirement is not re-bound - # while it stays satisfied" - so a BETTER provider arriving after - # the consumer is live does not take it, and losing the one it - # actually selected still restarts it. - # - # THE LOG IS THE DISCRIMINATOR. A host that re-ranks on every - # question treats `dep$c` as bound to `dep$hi` the moment `dep$hi` - # goes live, so deactivating `dep$lo` moves nothing and the log ends - # at `dep$lo:deactivate` - the consumer silently keeps a reference - # to a provider it was never activated against. The statuses are - # identical in both readings, which is why this cannot be asserted - # on `status`. { id: 'depend/select#reluctant', doc: true in: [ @@ -3875,23 +2957,6 @@ primary: depend: { } ] - # CONSUMERS GO DOWN FIRST, NOT AFTERWARDS (§11.3). The cascade is part - # of the provider's own deactivation and runs BEFORE the provider's - # `deactivate` callback and scope unwind, so a consumer's teardown can - # still call the thing it depends on — flushing a buffer to the store - # it is about to lose is exactly what a `deactivate` callback is for. - # - # The callback log is what makes this checkable rather than asserted: - # a port that cascades afterwards produces the same STATUSES and a - # different ORDER, so only the log can tell the two apart. - # A CASCADED CONSUMER THAT FAILS ITS OWN TEARDOWN LANDS IN `failed`. - # - # §5.2: "Any failure during a transition - a lifecycle callback - # raising, or a ledger entry raising - lands the instance in `failed`." - # The cascade discarded the error and marked the consumer `pending`, - # which handed it straight back to `reconcile`: the moment the provider - # returned, an instance whose teardown had failed was activated again. - # Found by review of the go port. cascadefail: set: [ { id: 'depend/cascadefail#consumer', doc: true @@ -3964,10 +3029,6 @@ primary: depend: { } ] - # POLICY IS PER REQUIREMENT (§11.3), and the instance-level spelling is - # a document-side default for all of them. Only the per-requirement - # form can say "static on the store, dynamic on the metrics", which is - # the ordinary case rather than an exotic one. policy: set: [ { id: 'depend/policy#per-requirement-dynamic', doc: true @@ -3978,9 +3039,6 @@ primary: depend: { ] match: { out: { status: { 'dep$c': 'live' } } } } - # THE CASE THE INSTANCE-LEVEL SPELLING CANNOT EXPRESS: one instance, - # two requirements, different policies. Losing the dynamic one leaves - # it live. { id: 'depend/policy#mixed-keeps-live-on-dynamic-loss', doc: true in: [ @@ -3994,9 +3052,6 @@ primary: depend: { ] match: { out: { status: { 'dep$c': 'live' } } } } - # ...and losing the STATIC one restarts it, same instance, same run - # shape. The pair is the point: one entry alone cannot show that the - # two requirements are read separately. { id: 'depend/policy#mixed-restarts-on-static-loss', doc: true in: [ @@ -4010,11 +3065,6 @@ primary: depend: { ] match: { out: { status: { 'dep$c': 'pending' } } } } - # CARDINALITY ALONE GATES ACTIVATION, NOT POLICY. `dynamic` is a - # statement about surviving a SWAP, not about starting with nothing - # to call, so a mandatory-dynamic consumer still waits in `pending` - # for its first provider. A port that conflated the two would let it - # activate holding no reference at all. { id: 'depend/policy#dynamic-still-gates-first-activation', doc: true in: [ @@ -4022,15 +3072,6 @@ primary: depend: { ] match: { out: { status: { 'dep$c': 'pending' } } } } - # An OPTIONAL STATIC requirement never gates activation, but a change - # still DEACTIVATES AND REACTIVATES — the third cell of §11.3's - # matrix, and the one that is neither "gates" nor "ignores". - # - # The reactivation is the half that is easy to miss, and it is not - # cosmetic: the consumer released the old provider's resources on the - # way down and takes fresh ones on the way back up, which is the - # whole reason the cell restarts rather than merely notifying. It - # ends `live` with its scope reopened, having been down in between. { id: 'depend/policy#optional-static-restarts', doc: true in: [ @@ -4062,10 +3103,6 @@ primary: depend: { } ] - # A cycle through RESTART-CAUSING requirements is - # `plugin_dependency_cycle`, detected AT LOAD — because the failure it - # describes is a non-terminating reconcile, and the only safe time to - # report that is before it starts. cycle: set: [ { id: 'depend/cycle#mutual-mandatory', doc: true @@ -4095,14 +3132,6 @@ primary: depend: { ] err: true, match: { err: { code: 'plugin_dependency_cycle' } } } - # ...AND THE SPELLING DOES NOT DECIDE WHETHER IT IS ONE. The detector - # indexed providers by canonical ref and looked them up with the raw - # requirement name, so the SAME CYCLE written `dep$`/`other$` found no - # providers, produced no edges, and sailed past a check whose entire - # purpose is to refuse a non-terminating reconcile BEFORE anything - # runs. Two spellings of one graph, two answers - which is the - # divergence §4 rule 5 exists to prevent, reached through a door - # `depend/byref` does not open. { id: 'depend/cycle#through-refs-noncanonical', doc: true in: [ @@ -4111,10 +3140,6 @@ primary: depend: { ] err: true, match: { err: { code: 'plugin_dependency_cycle' } } } - # STATIC OPTIONAL EDGES ARE RESTART-CAUSING, so they cycle too. An - # earlier draft of §11.3 excluded every optional edge and thereby - # admitted the non-terminating case it was trying to permit — this - # entry is the one that catches that reading. { id: 'depend/cycle#static-optional-still-cycles', doc: true in: [ @@ -4123,10 +3148,6 @@ primary: depend: { ] err: true, match: { err: { code: 'plugin_dependency_cycle' } } } - # ONLY DYNAMIC OPTIONAL EDGES ARE EXCLUDED, and they are the ones the - # exclusion was for: two plugins that optionally and dynamically - # consume each other both activate happily, neither gates on the - # other, nothing restarts, so nothing oscillates. { id: 'depend/cycle#dynamic-optional-is-permitted', doc: true in: [ @@ -4145,8 +3166,6 @@ primary: depend: { ] err: true, match: { err: { code: 'plugin_dependency_cycle' } } } - # A chain is not a cycle, and this is the entry that stops a port - # from passing the four above by rejecting every requirement graph. { id: 'depend/cycle#chain-is-not-a-cycle', doc: true in: [ @@ -4172,8 +3191,6 @@ primary: depend: { ] err: true, match: { err: { code: 'plugin_dependency_held' } } } - # The error names the HOLDERS, because "something needs this" without - # saying what is the diagnostic §12 exists to prevent. { id: 'depend/hold#names-the-holders', doc: true in: [ @@ -4185,12 +3202,6 @@ primary: depend: { err: true match: { err: { message: '/holders=\\["dep\\$c"\\]/' } } } - # A MANDATORY-DYNAMIC consumer HOLDS. §11.3's word is `required`, - # which is cardinality, not policy: `dynamic` promises the consumer - # survives a SWAP, and under `hold` there is no swap - the provider - # goes and the consumer falls back to `pending`, which is exactly - # what the policy exists to prevent. Reading the holders off the - # cascade's restart-causing set excluded this consumer entirely. { id: 'depend/hold#mandatory-dynamic-holds', doc: true in: [ @@ -4201,12 +3212,6 @@ primary: depend: { ] err: true, match: { err: { code: 'plugin_dependency_held' } } } - # ...and an OPTIONAL consumer does NOT hold, whatever its policy. It - # said in writing that it does not need the thing; `hold` refusing a - # deactivation on its behalf is the policy speaking for a plugin - # against that plugin's own declaration. Optional-STATIC is the case - # that changed: it restarts, so the cascade's set contained it, and - # `hold` inherited a holder the design never called required. { id: 'depend/hold#optional-does-not-hold', doc: true in: [ @@ -4217,9 +3222,6 @@ primary: depend: { ] match: { out: { status: { 'dep$p': 'loaded', 'dep$c': 'live' } } } } - # An instance nothing holds deactivates normally under `hold`: the - # policy is a guard on removing something depended upon, not a - # freeze. { id: 'depend/hold#unheld-deactivates-normally' in: [ @@ -4229,13 +3231,6 @@ primary: depend: { ] match: { out: { status: { 'dep$p': 'loaded' } } } } - # THE HOLD CHECK IS A GUARD ON AD-HOC DEACTIVATION, NOT ON - # COORDINATED TEARDOWN. `close()` is removing the holders too, so it - # is suspended for exactly those holders — otherwise `close()` under - # `hold` would raise on the first provider it reached whenever a - # document happened to list a consumer after it, which is the policy - # refusing to allow the one teardown it has no reason to object to. - # The consumers-first order still holds. { id: 'depend/hold#close-is-coordinated', doc: true in: [ @@ -4249,9 +3244,6 @@ primary: depend: { 'dep$z:deactivate', 'dep$z:close', 'dep$a:deactivate', 'dep$a:close' ] } } } - # Under the DEFAULT policy the same ad-hoc deactivation succeeds and - # cascades. Without this, every `hold` entry above would pass on a - # port that refused the deactivation unconditionally. { id: 'depend/hold#restart-is-the-default', doc: true in: [ @@ -4265,25 +3257,9 @@ primary: depend: { } -# --------------------------------------------------------------------------- -# declare — `declared` costs nothing, plus AUTO-TAG and `seq` (§5.1, §9.1). -# -# The last two arrived here by correction: §15.3 originally assigned them to -# `ref` and marked that section pure, and neither is reachable from the four -# pure functions. Auto-tagging must know what is already declared and `seq` -# is a counter the host holds, so both need a driver (doc/plan/handover.md §5). -# --------------------------------------------------------------------------- primary: declare: { - # `declared` COSTS NOTHING: twenty lazy instances are twenty map - # entries and no executed code, which is the property station needs - # and the reason the state exists at all. - # `options: {}` CLEARS, rather than being read as "no options given". - # `load` replaces the declared options when the spec carries them, and - # a port testing TRUTHINESS rather than PRESENCE cannot express - # "clear them all" - python's `if spec.get('options')` is false for an - # empty dict. Found by review of the python port. clear: set: [ { id: 'declare/clear#empty-options', doc: true @@ -4293,7 +3269,6 @@ primary: declare: { { do: 'activate', ref: 'probe$a' } { arg: 'x', do: 'chain', point: 'c' } ] - # The wrap default is ':' - so the cleared options are in force. match: { out: { result: ':x' } } } # ...while OMITTING options leaves the declared ones alone, which is @@ -4310,11 +3285,6 @@ primary: declare: { ] - # A MALFORMED REF IS `plugin_bad_name` EVERYWHERE, including on the - # introspection path. `host.instance()` canonicalizes, so it raises - - # and a port whose lookup swallowed the parse failure reported an - # ordinary "no such instance" instead, which is a different answer to a - # different question. Found by review of the go port. lookup: set: [ { id: 'declare/lookup#malformed', doc: true @@ -4341,7 +3311,6 @@ primary: declare: { out: { status: { 'probe$a': 'declared', 'probe$b': 'declared' } open: 0, log: [], result: null } } - # `start: eager` (the default) does run it. { in: [ { do: 'apply', doc: { plugin: 1, instance: { 'probe$a': {} } } } ] match: { out: { status: { 'probe$a': 'live' } } } @@ -4355,11 +3324,6 @@ primary: declare: { ] match: { out: { status: { 'probe$a': 'declared' }, log: [] } } } - # ...AND THE ENTRY ABOVE NEVER CALLED `ready`. Its comment claimed - # the bar held and only asserted that one apply skipped the ref, so - # a host keeping the flag nowhere passed it — and the config switch - # §17.1 exists for (stripe$test off in prod) could be turned back on - # by any later `ready`. { id: 'declare/free#barred-stays-barred', doc: true in: [ @@ -4394,8 +3358,6 @@ primary: declare: { } { in: [ { do: 'declare', ref: 'probe', tag: '?' }, { do: 'declare', ref: 'probe', tag: '?' } ] match: { out: { result: 'probe$2' } } } - # LOWEST UNUSED, not next-highest: a gap is filled. A counter-based - # implementation passes the two entries above and fails this one. { id: 'declare/autotag#fills-gap' in: [ @@ -4405,7 +3367,6 @@ primary: declare: { ] match: { out: { result: 'probe$2' } } } - # It skips an existing explicit tag rather than colliding with it. { in: [ { do: 'declare', ref: 'probe$1' }, { do: 'declare', ref: 'probe', tag: '?' } ] match: { out: { result: 'probe$2' } } @@ -4413,10 +3374,6 @@ primary: declare: { ] - # `seq` DISTINGUISHES ONE INCARNATION FROM THE NEXT, and `pos` is the - # document position. COLLAPSING THEM WOULD LET A RE-APPLIED DOCUMENT - # SILENTLY REORDER A CHAIN (§4 rule 4), which is why they are two - # numbers and why this group asserts them separately. seq: set: [ { id: 'declare/seq#advances', doc: true @@ -4425,7 +3382,6 @@ primary: declare: { } { in: [ { do: 'declare', ref: 'probe$a' }, { do: 'seq', ref: 'probe$a' } ] match: { out: { result: 0 } } } - # A RE-DECLARED instance gets a FRESH seq — it is a new incarnation. { id: 'declare/seq#fresh-on-reload' in: [ @@ -4437,9 +3393,6 @@ primary: declare: { ] match: { out: { result: 2 } } } - # And `pos` DOES NOT MOVE with it. This pair is the whole reason the - # two numbers exist: a counter-based tie-break would sort the - # re-declared instance behind everything that never moved. { id: 'declare/seq#pos-stable' in: [ @@ -4453,13 +3406,6 @@ primary: declare: { ] - # `define` REGISTERS, AND THE DEFINITION NEED NOT BE NAMED AFTER THE - # PROBE BACKING IT (DOCS.md §4.2). `probe` names the catalog entry whose - # callbacks are used; `name` is what the definition is called, and a ref - # resolves by NAME. Ten entries already passed `probe` beside an - # IDENTICAL `name`, so nothing distinguished the two keys and a driver - # ignoring `probe` outright passed every one of them — which every - # driver did, `define` having been a no-op until this group. register: set: [ { id: 'declare/register#probe-differs-from-name', doc: true @@ -4494,29 +3440,12 @@ primary: declare: { ] - # THE OPTION SHAPE IS VALIDATED WHEN IT ENTERS THE CATALOG (§10.1), - # not when a document exercises the key. - # - # `config/optmergebad` pins the DOMAIN — which shapes are rejected — - # and says in its own comment that it cannot pin the TIMING, because - # every entry there hands `resolveoptions` a shape already in hand. A - # port that took an invalid shape into its catalog and raised only at - # resolution passed all eleven. This group closes that: every entry - # below stops at `define`, with NO load and NO document, so a port - # that defers raises nothing and fails rather than passing quietly. - # - # Timing is the whole of §9.4's claim. A shape checked at resolution - # fails in a different place in every host — under the first document - # that touches the key, which in station is a deploy rather than a - # startup — and "fails once, and in the same place everywhere" is the - # property the stated domain exists to deliver. shape: set: [ { id: 'declare/shape#at-registration', doc: true in: [ { do: 'define', name: 'probe', shape: { m: { '$MERGE': { deep: 0 } } } } ] err: true, match: { err: { code: 'plugin_shape_invalid' } } } - # The word form too, so the raise is not one special case of `deep`. { in: [ { do: 'define', name: 'probe', shape: { m: { '$MERGE': 'merge' } } } ] err: true, match: { err: { code: 'plugin_shape_invalid' } } @@ -4534,14 +3463,6 @@ primary: declare: { ] err: true, match: { err: { code: 'plugin_unknown_definition' } } } - # ...AND A VALID SHAPE IS KEPT, not merely inspected and dropped. - # §10.1: "a catalog entry carries the definition's option shape, and - # it is readable before the definition is loaded". Validating at - # registration and discarding would pass every entry above while - # making the check pointless, so the shape is followed all the way - # to a resolved option: the shape's non-directive values are the - # level-1 defaults (§9.3, `config/optladder#1`), and the probe wraps - # the `c` chain with `options.wrap`. { id: 'declare/shape#reaches-resolution', doc: true in: [ @@ -4605,8 +3526,6 @@ primary: state: { ] match: { out: { result: 1 } } } - # Deactivating a rate limiter and reactivating it ten seconds later - # must not reset its window counters — several cycles, one state. { in: [ { do: 'ready', ref: 'probe$a' } @@ -4633,7 +3552,6 @@ primary: state: { ] match: { out: { result: 0 } } } - # Two instances of one definition do not share state. { in: [ { do: 'ready', ref: 'probe$a' } @@ -4647,16 +3565,6 @@ primary: state: { } -# --------------------------------------------------------------------------- -# resource — the instance scope (§8.3). -# -# THE CORPUS PROVES THE HOST UNWOUND WHAT THE INSTANCE REGISTERED. IT -# CANNOT PROVE THE INSTANCE STOPPED RUNNING (§15.4). A probe that -# squirrels a reference away in a global and keeps using it after -# deactivation would pass every entry here. That is a limit of the -# architecture, not a gap in the corpus, and no amount of test design -# closes it — said here so a green suite cannot be read as claiming it. -# --------------------------------------------------------------------------- primary: resource: { @@ -4698,7 +3606,6 @@ primary: resource: { ] match: { out: { open: 0 } } } - # Closing the host unwinds everything still held. { in: [ { do: 'ready', ref: 'greedy$a', definition: 'greedy', options: { acquire: 3 } } @@ -4707,13 +3614,6 @@ primary: resource: { ] match: { out: { open: 0, status: {} } } } - # §8.3 unwinds the scope IN REVERSE REGISTRATION ORDER, "whether an - # entry came from a host call or from `release`" — and until this - # entry, NOTHING PINNED THE DIRECTION. An acquired handle is an - # idempotent counter decrement, so unwinding the handles forwards - # leaves the same `open` and passes every other entry in this - # section; the order is observable only through releases that record - # themselves. Found by the go port (§18, P4). { id: 'resource/scope#reverse' in: [ @@ -4723,16 +3623,6 @@ primary: resource: { ] match: { out: { result: [2, 1, 0] } } } - # A `release` ENTRY IS A HELD RESOURCE while it is registered and not - # one afterwards, exactly like an acquired handle: `open` counts what - # is CURRENTLY held. Registering three and unwinding them must leave - # the count where it started. - # - # The canonical incremented on registration and never decremented, - # which made every `release` a permanent leak in the counter — and - # `close()` on a plugin that used one could never reach `open: 0`. - # Invisible until an entry used `release` at all, which is what these - # two do. { id: 'resource/scope#release-counts' in: [ @@ -4756,15 +3646,6 @@ primary: resource: { ] match: { out: { open: 0, status: {} } } } - # `release` outside a lifecycle callback is an error: the scope is - # the activation's, and a call from anywhere else has no scope to - # belong to. - # - # THIS ROW USED TO ASSERT NOTHING. Every port's `stray` command was a - # no-op with a comment claiming it called `release`, so the entry - # stayed green whatever the guard did — or did not do. The probe now - # exports its own instance api and `stray` calls through it. Found by - # review of the go port. { id: 'resource/scope#stray' in: [ @@ -4779,12 +3660,6 @@ primary: resource: { ] match: { out: { open: 1 } } } - # ...and `acquire` in `define` is the same error, which the guard did - # NOT catch: it tested "a callback is running" rather than "activate - # is running", and §8.1 puts capture in `activate`. A scope entry - # registered in `define` is never unwound at all - `unload` on a - # merely `loaded` instance does not call it - so this was a - # permanent leak reachable from ordinary plugin code. { id: 'resource/scope#capture-phase' in: [ @@ -4801,15 +3676,6 @@ primary: resource: { err: true, match: { err: { code: 'plugin_release_scope' } } } - # A FAILING RELEASE. §8.3: "A failing release does not stop the rest. - # Every entry runs, in reverse order, whatever any of them does; the - # errors are collected and raised as one `plugin_release_failed`" - - # and "A failed release ends the instance in `failed`". - # - # None of that was implemented, and nothing exercised it: every - # release in the probe catalog was infallible, so `unwind` swallowing - # errors was invisible. The host reported a clean `loaded` for an - # instance that had failed to give back what it held. { id: 'resource/scope#release-failed' in: [ @@ -4833,14 +3699,6 @@ primary: resource: { } -# --------------------------------------------------------------------------- -# nest — an instance that is itself a host (§6.5). -# -# THE OUTER OWNS THE INNER'S LIFETIME. Station declares SDK instances as -# plugins, and every generated SDK carries its own plugin system — so a -# station plugin instance is itself a host of feature plugins. Two levels, -# and the outer one owns the inner one. -# --------------------------------------------------------------------------- primary: nest: { @@ -4865,8 +3723,6 @@ primary: nest: { ] match: { out: { result: {} } } } - # The inner host's instances do not appear in the outer's status — - # two levels, not one flattened namespace. { id: 'nest/lifetime#isolated' in: [ { do: 'ready', ref: 'probe$outer', options: { nest: ['probe$inner'] } } ] @@ -4875,23 +3731,6 @@ primary: nest: { ] - # A NESTED HOST IS NOT ITSELF AN OPEN RESOURCE, AND THE INNER HOST'S - # RESOURCES ARE ITS OWN. - # - # §6.5 says the outer instance's scope holds the inner host's - # teardown, and every port implements that. Whether the nest ALSO - # counts toward the outer's `open` was a free choice nothing pinned: - # the three entries above assert `result` and `status`, never `open`, - # so a port that counted the nest reported one more open resource than - # its neighbours for the same document and stayed green. `open` is the - # synthetic counter DOCS.md §4.5 puts in the observable precisely so - # "what is still held" is data rather than a sentence each port words - # differently, and two ports disagreeing about it is exactly the - # divergence it exists to expose. - # - # It counts NOTHING. The scope entry is a teardown, not an - # acquisition, and the inner host keeps its own counter — so the - # outer's `open` is its own `acquire` and no more. open: set: [ { id: 'nest/open#nest-is-not-a-resource', doc: true @@ -4905,9 +3744,6 @@ primary: nest: { in: [ { do: 'ready', ref: 'probe$a' } ] match: { out: { open: 1 } } } - # AND IT DOES NOT SCALE WITH THE INNER HOST'S CONTENTS. Two inner - # instances acquire two resources IN THE INNER HOST; a port - # accumulating them into the outer's counter answers 3. { id: 'nest/open#inner-resources-stay-inner' in: [ { do: 'ready', ref: 'probe$outer', options: { nest: ['probe$i1', 'probe$i2'] } } ] @@ -4967,13 +3803,6 @@ primary: trace: { } -# --------------------------------------------------------------------------- -# apply — idempotent document application (§9.6). -# -# `apply` DECLARES EVERYTHING AND ACTIVATES ONLY WHAT ASKED FOR IT, and it -# is RE-RUNNABLE: applying the same document twice changes nothing, which -# is what lets a host reload config without tracking what it already did. -# --------------------------------------------------------------------------- primary: apply: { @@ -4996,20 +3825,6 @@ primary: apply: { ] match: { out: { log: [] } } } - # AND THE OPTIONS REACH THE INSTANCE ON THE FIRST APPLY. - # - # No entry anywhere observed an option that arrived through `apply`, - # and the canonical was silently dropping every one of them: `apply` - # resolves the options and hands the map to `declare`, which for a - # ref declared for the first time ADOPTS that map — so the refill - # afterwards cleared its own source and left the instance with - # nothing. Applying the same document a SECOND time filled them in, - # because by then the two maps were distinct. Every existing apply - # entry passed, because each asserted status, log or open and none - # asserted a value the options decide. - # - # `wrap` is read by the probe's `c` binding at call time, so the - # chain result is the option, not a copy of it. { id: 'apply/idempotent#options-first-time', doc: true in: [ @@ -5042,13 +3857,6 @@ primary: apply: { ] - # ADD, REMOVE, PATCH, TOGGLE — the four things a second document can do. - # §9.6's FIRST CLAUSE, which nothing implemented: "load what is - # missing, UNLOAD WHAT IS GONE, patch what changed". `apply` walked - # only the document's own refs, so an instance the new document - # DROPPED stayed live, with its bindings in every chain and its - # resources held - in the config-reload case the method exists for. - # Found by review of the go port. gone: set: [ { id: 'apply/gone#dropped', doc: true @@ -5068,7 +3876,6 @@ primary: apply: { ] match: { out: { result: { 'probe$b': '__UNDEF__' }, open: 1 } } } - # ...and `close` ran, which is what makes "as if never loaded" true. { in: [ { do: 'apply', doc: { plugin: 1, instance: { 'probe$a': {} } } } @@ -5092,10 +3899,6 @@ primary: apply: { ] - # §9.6's ORDERING: "deactivations and unloads first (reverse load - # order), then loads, then activations in load order". THREE PHASES, - # not one interleaved walk - and the log is the only thing that can - # tell them apart, which is why nothing caught it. phases: set: [ { id: 'apply/phases#declare-all-then-activate', doc: true @@ -5122,7 +3925,6 @@ primary: apply: { ] change: set: [ - # ADD: a new ref appears. { id: 'apply/change#add', doc: true in: [ @@ -5131,7 +3933,6 @@ primary: apply: { ] match: { out: { status: { 'probe$a': 'live', 'probe$b': 'live' } } } } - # PATCH: options change without the instance restarting from scratch. { in: [ { do: 'apply', doc: { plugin: 1, instance: { 'probe$a': { options: { wrap: 'A' } } } } } @@ -5140,12 +3941,6 @@ primary: apply: { ] match: { out: { result: 'Zx' } } } - # TOGGLE to inactive RETURNS IT TO `declared`, BY UNLOADING IT - # (§9.6). There is no loaded->declared transition and there should - # not be one: going back to `declared` means "as if never loaded", - # and an instance that has run `define` has state and bindings only - # `close` can properly undo. Its state is DESTROYED, which is the - # honest meaning of the change. { id: 'apply/change#toggle-off' in: [ @@ -5154,7 +3949,6 @@ primary: apply: { ] match: { out: { status: { 'probe$a': 'declared' }, open: 0 } } } - # And the unload is VISIBLE in the log — `close` ran. { in: [ { do: 'apply', doc: { plugin: 1, instance: { 'probe$a': {} } } } @@ -5163,7 +3957,6 @@ primary: apply: { match: { out: { log: [ 'probe$a:define', 'probe$a:activate', 'probe$a:deactivate', 'probe$a:close' ] } } } - # Toggling to LAZY does the same thing, for the same reason. { in: [ { do: 'apply', doc: { plugin: 1, instance: { 'probe$a': {} } } } @@ -5171,7 +3964,6 @@ primary: apply: { ] match: { out: { status: { 'probe$a': 'declared' } } } } - # And BACK ON again, from a clean slate. { id: 'apply/change#toggle-back' in: [ @@ -5203,38 +3995,15 @@ primary: apply: { } -# --------------------------------------------------------------------------- -# error — the codes of §12 and the MESSAGE FORMAT. -# -# plugin/: [= …] -# -# The bracketed detail carries the fields that apply, IN A FIXED ORDER — -# host, ref, name, tag, point, key, capability, range, version, match, -# candidates, cycle, holders, refs, path, cause — omitting those that do -# not, and absent entirely when none do. -# -# THE ORDER IS CONTRACT, not formatting. An earlier draft named six -# fields while other sections promised diagnostics that had nowhere to -# go, which would have left each port inventing its own order and -# breaking message parity. Values render as COMPACT JSON so a value -# containing a space or a bracket cannot break the parse. -# -# Ports compare by CODE, never by wording — a port's language is its own -# business, and pinning the words would make every translation a corpus -# change. The FORMAT is what is pinned, because a parseable message is -# what makes a log searchable across twenty languages. -# --------------------------------------------------------------------------- primary: error: { format: set: [ - # The prefix and code, verbatim. { id: 'error/format#prefix', doc: true in: [ { do: 'activate', ref: 'nope$a' } ] err: true, match: { err: { message: '/^plugin\\/plugin_not_loaded: /' } } } - # The detail bracket, with the field that applies. { in: [ { do: 'activate', ref: 'nope$a' } ] err: true, match: { err: { message: '/\\[ref="nope\\$a"\\]/' } } @@ -5246,7 +4015,6 @@ primary: error: { in: [ { do: 'emit', point: 'nope' } ] err: true, match: { err: { message: '/\\[point="nope"\\]/' } } } - # A list field renders as a JSON ARRAY. { id: 'error/format#list-field' in: [ @@ -5259,7 +4027,6 @@ primary: error: { ] - # The codes themselves, each reached through the path that raises it. codes: set: [ { id: 'error/codes#not-loaded', doc: true in: [ { do: 'activate', ref: 'nope$a' } ], err: true, match: { err: { code: 'plugin_not_loaded' } } } @@ -5299,13 +4066,6 @@ primary: error: { { do: 'declare', ref: 'station$a' } ], err: true, match: { err: { code: 'plugin_ref_reserved' } } } - # ...but THE HOST ITSELF MUST STILL BE ABLE TO DECLARE IT. §9.1: - # "The host declares those instances itself, after the user merge, - # and always wins." Every path to `declare` was barred, including the - # embedding host's, so `reserved: ['station']` made it impossible for - # station to install the adapter it had reserved the name for - - # a feature that defeated its own purpose. Found by review of the go - # port. { id: 'error/codes#reserved-host-path' in: [ { do: 'host', reserved: ['probe'] } diff --git a/tools/comment-gate.cjs b/tools/comment-gate.cjs new file mode 100644 index 0000000..f2b68ff --- /dev/null +++ b/tools/comment-gate.cjs @@ -0,0 +1,459 @@ + +'use strict' + +const Fs = require('node:fs') +const Path = require('node:path') + +const REPO = Path.join(__dirname, '..') +const Child = require('node:child_process') +const CONFIG = require('./comment-scope.json') + +const SOURCE_EXTS = ['.ts', '.go', '.rs', '.aon', '.aontu'] +const SKIP_DIRS = new Set([ + 'node_modules', 'dist', 'dist-test', 'covdata', 'target', 'vendor', '.git', +]) + +const MAX_BLOCK_LINES = 5 +const MAX_DENSITY = 0.12 +const DENSITY_FLOOR_LINES = 8 + +const LICENSE_RE = /\b(?:Copyright|SPDX-License-Identifier)\b/i +const DIRECTIVE_RE = + /^(#!|\/\/\s*(EJECT-(?:START|END)|<\[|#[A-Z][\w-]*\b|go:|nolint|@ts-|eslint|prettier|c8 |istanbul|coverage:ignore|covergate:allow|\/\s* rel.endsWith(ext)) && + !rel.split('/').some((part) => SKIP_DIRS.has(part) && !(model && part === 'target' && /(^|\/)model\/target\//.test(rel))) && + !Object.keys({...CONFIG.exclude, ...(model ? CONFIG.excludeModels : {})}).some((prefix) => rel === prefix || rel.startsWith(prefix + '/')) +} + +function sourceFiles() { + const names = Child.execFileSync('git', + ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], + { cwd: REPO, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).split('\0') + return [...new Set(names)].filter((f) => f && gated(f) && + Fs.existsSync(Path.join(REPO, f)) && Fs.lstatSync(Path.join(REPO, f)).isFile()).sort() +} + +function language(file) { + return /\.(aon|aontu)$/.test(file) ? 'aon' : file.endsWith('.go') ? 'go' : file.endsWith('.rs') ? 'rs' : 'ts' +} + +// Track literals so their contents cannot become comment findings. +function lex(text, lang) { + const comments = [], codeLine = new Set() + let line = 1, i = 0, prev = '' + const n = text.length + function string(quote, raw = false) { + codeLine.add(line); i++ + while (i < n) { + if (!raw && text[i] === '\\') { + if (text[i + 1] === '\n') line++ + i += 2; continue + } + if (text[i] === '\n') line++ + if (text[i++] === quote) break + } + prev = quote + } + function template() { + codeLine.add(line); i++ + while (i < n) { + if (text[i] === '\\') { + if (text[i + 1] === '\n') line++ + i += 2; continue + } + if (text[i] === '`') { i++; break } + if (text[i] === '$' && text[i + 1] === '{') { + i += 2; prev = '{'; code(true); continue + } + if (text[i] === '\n') line++ + i++ + } + prev = '`' + } + function code(interpolation = false) { + let depth = 0 + while (i < n) { + const c = text[i], c2 = text[i + 1] + if (c === '\n') { line++; i++; continue } + if (lang === 'rs' && (c === 'r' || (c === 'b' && c2 === 'r'))) { + const match = text.slice(i).match(/^(?:br|r)(#*)"/) + if (match) { + codeLine.add(line) + const end = text.indexOf('"' + match[1], i + match[0].length) + const stop = end < 0 ? n : end + 1 + match[1].length + line += (text.slice(i, stop).match(/\n/g) || []).length + i = stop; prev = '"'; continue + } + } + if (lang === 'rs' && c === "'" && + !/^'(?:[^'\\\n]|\\(?:u\{[0-9a-fA-F]+\}|x[0-9a-fA-F]{2}|.))'/.test(text.slice(i))) { + codeLine.add(line); i++; prev = c; continue + } + if ((lang === 'aon' && c === '#') || (lang !== 'aon' && c === '/' && c2 === '/')) { + const start = i + while (i < n && text[i] !== '\n') i++ + comments.push({kind:'line', start:line, end:line, from:start, to:i, text:text.slice(start,i)}) + continue + } + if (lang !== 'aon' && c === '/' && c2 === '*') { + const start = i, startLine = line + i += 2 + let nesting = 1 + while (i < n && nesting) { + if (lang === 'rs' && text[i] === '/' && text[i + 1] === '*') { nesting++; i += 2 } + else if (text[i] === '*' && text[i + 1] === '/') { nesting--; i += 2 } + else { if (text[i] === '\n') line++; i++ } + } + comments.push({kind:'block',start:startLine,end:line,from:start,to:i,text:text.slice(start,i)}) + continue + } + if (lang === 'ts' && c === '`') { template(); continue } + if (c === '"' || c === "'" || ((lang === 'go' || lang === 'aon') && c === '`')) { + string(c, lang === 'go' && c === '`'); continue + } + const regexStart = lang === 'ts' && (!/[\w$)\]]/.test(prev) || + /\b(?:return|throw|yield|case)\s*$/.test(text.slice(Math.max(0,i-30),i))) + if (c === '/' && regexStart) { + let j=i+1, inClass=false, closed=false + while (j 0 + + const directive = DIRECTIVE_RE.test(c.text.trim()) + + if (c.kind === 'line' && !inline && !directive && open && !open.directive + && open.end === c.start - 1 && !open.inline) { + open.end = c.end + open.to = c.to + open.text += '\n' + c.text + continue + } + + open = { + kind: c.kind, + start: c.start, + end: c.end, + from: c.from, + to: c.to, + text: c.text, + inline, + directive, + } + out.push(open) + } + + return out +} + + +function blockLines(block) { + return block.end - block.start + 1 +} + + +function prose(block) { + return block.text + .split('\n') + .map((l) => l.replace(/^\s*(#+|\/\/+|\/\*+|\*+\/?)/, '').replace(/\*\/\s*$/, '').trim()) + .join('\n') + .trim() +} + + +function exempt(block) { + const first = block.text.trim() + return LICENSE_RE.test(first) || DIRECTIVE_RE.test(first) +} + + +let symbolIndex = null + +function codeOnly(text, lang) { + const { comments } = lex(text, lang) + let out = '' + let at = 0 + for (const c of comments) { out += text.slice(at, c.from); at = c.to } + return out + text.slice(at) +} + + +// Indexed from code alone: a symbol index built over comment prose too +// would confirm every name a comment invents. +function symbols() { + if (symbolIndex) return symbolIndex + symbolIndex = new Set() + for (const file of sourceFiles()) { + const text = Fs.readFileSync(Path.join(REPO, file), 'utf8') + const code = codeOnly(text, language(file)) + for (const word of code.match(WORD_RE) || []) symbolIndex.add(word) + } + return symbolIndex +} + + +let adrIndex = null + +function adrs() { + if (adrIndex) return adrIndex + adrIndex = new Set() + const path = Path.join(REPO, 'ADR.md') + const text = Fs.existsSync(path) ? Fs.readFileSync(path, 'utf8') : '' + for (const m of text.matchAll(/^## ADR-(\d{3})\b/gm)) adrIndex.add(m[1]) + return adrIndex +} + + +// A path in a comment may be written from the repository root, from +// the package root, or beside the file. +function resolves(ref, file) { + const bases = [REPO, Path.join(REPO, file.split('/')[0]), + Path.dirname(Path.join(REPO, file))] + return bases.some((base) => + Fs.existsSync(Path.join(base, ref)) || Fs.existsSync(Path.join(base, '.' + ref))) +} + + +function checkFile(file, opts = {}) { + return checkText(file, Fs.readFileSync(Path.join(REPO, file), 'utf8'), opts) +} + + +function checkText(file, text, opts = {}) { + const lines = text.split('\n') + const scan = lex(text, + language(file)) + const all = blocks(scan, lines) + const kept = all.filter((b) => !exempt(b)) + + const commentLines = kept.reduce((sum, b) => sum + blockLines(b), 0) + const findings = [] + + const add = (block, rule, detail) => findings.push({ + file, + line: block.start, + rule, + detail, + excerpt: prose(block).split('\n')[0].slice(0, 72), + }) + + for (const block of kept) { + const body = prose(block) + const flat = body.replace(/\s+/g, ' ') + + if (blockLines(block) > MAX_BLOCK_LINES) { + add(block, 'long-block', `${blockLines(block)} lines (max ${MAX_BLOCK_LINES})`) + } + + for (const re of NARRATIVE_RE) { + const m = flat.match(re) + if (m) { add(block, 'narrative', `"${m[0]}"`); break } + } + + for (const re of REQUIREMENT_RE) { + const m = flat.match(re) + if (m) { add(block, 'requirements', `"${m[0]}"`); break } + } + + for (const { rule, re } of UNVERIFIABLE_RE) { + const m = flat.match(re) + if (m) add(block, rule, `"${m[0].trim()}"`) + } + + for (const raw of body.split('\n')) { + const line = raw.replace(/^\s*(?:\/\/+\s*)+/, '') + if ((CODE_SHAPED_RE.test(line) || (language(file) === 'aon' && + /^(?:[\w.$-]+\s*:\s*)+(?:[\[{'"`*@]|-?\d|true\b|false\b|null\b|(?:string|number|boolean|integer)\b)/.test(line))) && line.length > 3) { + add(block, 'commented-code', `"${line.slice(0, 48)}"`) + break + } + } + + for (const m of body.matchAll(PATH_RE)) { + if (!resolves(m[1], file)) add(block, 'stale-path', m[1]) + } + + for (const m of body.matchAll(ADR_RE)) { + if (!adrs().has(m[1])) add(block, 'stale-adr', m[0]) + } + + if (!opts.skipSymbols) { + for (const m of body.matchAll(SYMBOL_RE)) { + const head = m[1].split('.')[0] + if (!symbols().has(head)) add(block, 'stale-symbol', m[1]) + } + } + } + + const density = scan.codeLines === 0 ? 0 : commentLines / scan.codeLines + if (commentLines > DENSITY_FLOOR_LINES && density > MAX_DENSITY) { + findings.push({ + file, + line: 1, + rule: 'dense-file', + detail: `${commentLines} comment lines over ${scan.codeLines} code lines` + + ` = ${(density * 100).toFixed(1)}% (max ${(MAX_DENSITY * 100).toFixed(0)}%)`, + excerpt: '', + }) + } + + return { + file, + codeLines: scan.codeLines, + commentLines, + blocks: kept.length, + longest: kept.reduce((m, b) => Math.max(m, blockLines(b)), 0), + words: kept.reduce((sum, b) => sum + (prose(b).match(/\S+/g) || []).length, 0), + findings, + } +} + + +function checkAll(files = sourceFiles(), opts = {}) { + return files.map((f) => checkFile(f, opts)) +} + + +function totals(reports) { + const t = { + files: reports.length, + codeLines: 0, + commentLines: 0, + commentWords: 0, + blocks: 0, + longest: 0, + findings: 0, + byRule: {}, + filesWithFindings: 0, + } + for (const r of reports) { + t.codeLines += r.codeLines + t.commentLines += r.commentLines + t.commentWords += r.words + t.blocks += r.blocks + t.longest = Math.max(t.longest, r.longest) + t.findings += r.findings.length + if (r.findings.length > 0) t.filesWithFindings++ + for (const f of r.findings) t.byRule[f.rule] = (t.byRule[f.rule] || 0) + 1 + } + t.density = t.codeLines === 0 ? 0 : t.commentLines / t.codeLines + return t +} + + +module.exports = { + sourceFiles, checkFile, checkText, checkAll, totals, lex, blocks, prose, + MAX_BLOCK_LINES, MAX_DENSITY, exempt, language, gated, +} + + +if (require.main === module) { + const args = process.argv.slice(2) + const only = args.filter((a) => !a.startsWith('--')) + const files = only.length > 0 ? only : sourceFiles() + const opts = { skipSymbols: args.includes('--no-symbols') } + const reports = checkAll(files, opts) + const t = totals(reports) + + if (args.includes('--json')) { + process.stdout.write(JSON.stringify({ totals: t, reports }, null, 2) + '\n') + process.exitCode = t.findings === 0 ? 0 : 1; return + } + + if (args.includes('--measure')) { + process.stdout.write( + `files ${t.files} code ${t.codeLines} comment ${t.commentLines}` + + ` words ${t.commentWords} blocks ${t.blocks}` + + ` density ${(t.density * 100).toFixed(1)}% longest ${t.longest}\n`) + for (const [rule, count] of Object.entries(t.byRule).sort((a, b) => b[1] - a[1])) { + process.stdout.write(` ${rule}: ${count}\n`) + } + return + } + + const limit = Number((args.find((a) => a.startsWith('--limit=')) || '--limit=40').slice(8)) + let shown = 0 + for (const r of reports) { + for (const f of r.findings) { + if (shown++ < limit) { + process.stdout.write(`${f.file}:${f.line}: ${f.rule}: ${f.detail}\n`) + if (f.excerpt) process.stdout.write(` ${f.excerpt}\n`) + } + } + } + if (t.findings > limit) process.stdout.write(`... ${t.findings - limit} more\n`) + process.stdout.write(t.findings === 0 + ? `comment gate: clean (${t.commentLines} comment lines, ${(t.density * 100).toFixed(1)}%)\n` + : `comment gate: ${t.findings} finding(s) in ${t.filesWithFindings} file(s) — COMMENT-POLICY.md\n`) + process.exitCode = t.findings === 0 ? 0 : 1; return +} diff --git a/tools/comment-gate.test.cjs b/tools/comment-gate.test.cjs new file mode 100644 index 0000000..dc283b9 --- /dev/null +++ b/tools/comment-gate.test.cjs @@ -0,0 +1,152 @@ +'use strict' +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const os = require('node:os') +const cp = require('node:child_process') +const gate = require('./comment-gate.cjs') +const rules = (text, file = 'probe.ts') => gate.checkText(file, text).findings.map(f => f.rule) + +test('repository satisfies the policy', () => { + const findings = gate.checkAll().flatMap(r => r.findings) + assert.deepEqual(findings, [], JSON.stringify(findings.slice(0, 20))) +}) + +test('short explanation passes and each prohibited form fails', () => { + assert.deepEqual(rules('// The sentinel distinguishes absence from null.\nconst sentinel = null'), []) + for (const [rule, text] of [ + ['long-block', Array(6).fill('// explanation').join('\n')], + ['narrative', '// We fixed the old implementation.'], + ['requirements', '// The business requirement is here.'], + ['commented-code', '// const removed = fn();'], + ['count-claim', '// There are three passes.'], + ['dated-claim', '// Updated 2026-09-18.'], + ['issue-ref', '// See #123.'], + ['version-claim', '// Available in v1.2.3.'], + ['stale-path', '// See missing-comment-gate-probe/no-file.ts.'], + ['stale-symbol', '// Uses `CommentGateNonexistentSymbolXyz`.'], + ['stale-adr', '// See ADR-999.'], + ]) assert.ok(rules(text).includes(rule), rule) +}) + +test('density boundary and small-file floor', () => { + const comments = Array.from({length: 9}, (_, i) => '// explanation\n\n').join('') + const code = n => Array.from({length: n}, (_, i) => `const x${i} = ${i}`).join('\n') + assert.ok(rules(comments + code(74)).includes('dense-file')) + assert.ok(!rules(comments + code(75)).includes('dense-file')) + assert.ok(!rules('// explanation\n'.repeat(8) + code(1)).includes('dense-file')) +}) + +test('licenses and tool directives survive', () => { + for (const text of ['/* Copyright 2026 Example, MIT License */', + '// SPDX-License-Identifier: MIT', '//go:build linux', '//go:embed data.json', + '//covergate:allow unreachable state', '// @ts-expect-error intentional invalid input', + '/* node:coverage ignore next */', '// eslint-disable-next-line rule', '//nolint:errcheck', '// EJECT-START', '// EJECT-END', '// <[SLOT]>', '// #SecretsImport', '/*#__PURE__*/']) { + assert.deepEqual(rules(text), [], text) + } +}) + +test('strings, regular expressions, Rust lifetimes and raw strings are code', () => { + const fixtures = [ + ['ts', 'const url = "http://host/path"; const r = /[/]/; // explanation'], + ['ts', 'function f() { return /https?:\\/\\// } // explanation'], + ['ts', 'const nested = `outer ${`inner ${value}`} text`; // explanation'], + ['ts', 'const template = `// not prose\n/* not prose */`; // explanation'], + ['go', 'var text = `// not prose\n/* not prose */` // explanation'], + ['rs', 'fn f<\'a>(x: &\'a str) {} // explanation'], + ['rs', 'let text = r##"// not prose " /* not prose */"##; // explanation'], + ['rs', 'let text = br#"// not prose"#; let c = \'x\'; // explanation'], + ['rs', '/* outer /* nested */ outer */ // explanation'], + ] + for (const [lang, text] of fixtures) { + const comments = gate.lex(text, lang).comments + assert.equal(comments.at(-1).text, '// explanation', text) + assert.equal(comments.length, text.startsWith('/* outer') ? 2 : 1, text) + } +}) + +test('scope includes authored implementation files and excludes output', () => { + for (const file of ['src/probe.ts', 'go/probe.go', 'rust/src/probe.rs', '.sdk/src/probe.ts']) + assert.equal(gate.gated(file), true, file) + for (const file of ['tools/probe.cjs', 'dist/probe.ts', 'rust/target/probe.rs', 'node_modules/probe.ts', 'vendor/probe.go']) + assert.equal(gate.gated(file), false, file) +}) + +test('CLI and pre-push reject violations, discover new files, and emit complete JSON', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'comment-gate-test-')) + try { + fs.mkdirSync(path.join(dir, 'tools')) + fs.copyFileSync(path.join(__dirname, 'comment-gate.cjs'), path.join(dir, 'tools/comment-gate.cjs')) + fs.writeFileSync(path.join(dir, 'tools/comment-scope.json'), '{"exclude":{}}') + cp.execFileSync('git', ['init', '-q'], {cwd: dir}) + fs.writeFileSync(path.join(dir, '.gitignore'), 'ignored/\n') + fs.mkdirSync(path.join(dir, 'ignored')) + fs.writeFileSync(path.join(dir, 'ignored/probe.ts'), '// TODO: ignored\n') + const invoke = args => cp.spawnSync(process.execPath, ['tools/comment-gate.cjs', ...args], {cwd: dir, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024}) + assert.equal(invoke([]).status, 0) + fs.writeFileSync(path.join(dir, 'probe.ts'), '// TODO: fix this\n\n'.repeat(1000)) + const failed = invoke(['--json']) + assert.equal(failed.status, 1) + assert.ok(JSON.parse(failed.stdout).totals.findings > 0) + const hook = path.join(__dirname, '..', '.githooks/pre-push') + if (process.platform !== 'win32') { + assert.equal(cp.spawnSync('sh', [hook], {cwd: dir}).status, 1) + } + fs.writeFileSync(path.join(dir, 'probe.ts'), 'const ready = true\n') + assert.equal(invoke([]).status, 0) + } finally { fs.rmSync(dir, {recursive: true, force: true}) } +}) + +test('both Aontu extensions enforce hash comments and preserve model literals', () => { + for (const ext of ['aon', 'aontu']) { + const file = `model/target/probe.${ext}` + assert.equal(gate.gated(file), true) + assert.equal(gate.language(file), 'aon') + assert.deepEqual(rules('# The sentinel distinguishes absence from null.\nvalue: null', file), []) + for (const [rule, text] of [ + ['narrative', '# TODO: restore this'], + ['long-block', '# explanation\n'.repeat(6)], + ['requirements', '# The business requirement is here.'], + ['commented-code', '# value: true'], + ['commented-code', '# nested: value: { enabled: false }'], + ['stale-path', '# See missing-comment-gate-probe/schema.aontu.'], + ]) assert.ok(rules(text, file).includes(rule), `${ext}: ${rule}`) + const literal = 'single: \'# TODO: literal\'\ndouble: "# TODO: literal"\nmulti: `# TODO: literal\n${value} // literal\n\\`# still literal`\nurl: https://example.test/path\n# explanation' + const comments = gate.lex(literal, gate.language(file)).comments + assert.deepEqual(comments.map(c => c.text), ['# explanation']) + assert.deepEqual(rules(literal, file), []) + assert.deepEqual(rules('# Copyright 2026 Example, MIT License', file), []) + const dense = '# explanation\n\n'.repeat(9) + 'value: true\n' + assert.ok(rules(dense, file).includes('dense-file')) + } +}) + +test('CLI and hook reject new models in target directories under either extension', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'comment-model-test-')) + try { + fs.mkdirSync(path.join(dir, 'tools')) + fs.mkdirSync(path.join(dir, 'model/target'), {recursive: true}) + fs.copyFileSync(path.join(__dirname, 'comment-gate.cjs'), path.join(dir, 'tools/comment-gate.cjs')) + fs.writeFileSync(path.join(dir, 'tools/comment-scope.json'), JSON.stringify({exclude: {}, excludeModels: {'golden': 'Byte-exact generated model output.'}})) + cp.execFileSync('git', ['init', '-q'], {cwd: dir}) + fs.mkdirSync(path.join(dir, 'golden')) + fs.writeFileSync(path.join(dir, 'golden/output.aontu'), '# TODO: golden\n') + const invoke = () => cp.spawnSync(process.execPath, ['tools/comment-gate.cjs', '--json'], {cwd: dir, encoding: 'utf8'}) + assert.equal(invoke().status, 0) + fs.writeFileSync(path.join(dir, 'golden/implementation.ts'), '// TODO: still checked\n') + assert.equal(invoke().status, 1) + fs.unlinkSync(path.join(dir, 'golden/implementation.ts')) + for (const ext of ['aon', 'aontu']) { + const file = path.join(dir, `model/target/probe.${ext}`) + fs.writeFileSync(file, '# TODO: remove this\nvalue: true\n') + const bad = invoke() + assert.equal(bad.status, 1) + assert.ok(JSON.parse(bad.stdout).totals.findings > 0) + if (process.platform !== 'win32') + assert.equal(cp.spawnSync('sh', [path.join(__dirname, '..', '.githooks/pre-push')], {cwd: dir}).status, 1) + fs.writeFileSync(file, 'value: "# TODO: literal"\n') + assert.equal(invoke().status, 0) + } + } finally { fs.rmSync(dir, {recursive: true, force: true}) } +}) diff --git a/tools/comment-scope.json b/tools/comment-scope.json new file mode 100644 index 0000000..4c66878 --- /dev/null +++ b/tools/comment-scope.json @@ -0,0 +1,3 @@ +{ + "exclude": {} +} diff --git a/typescript/package.json b/typescript/package.json index 7147160..67b4ee0 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -24,7 +24,7 @@ "build": "tsc --build src test", "watch": "tsc --build src test -w", "typecheck": "tsc --build src test --force", - "test": "node --enable-source-maps --test \"dist-test/**/*.test.js\"", + "test": "node ../tools/comment-gate.cjs && node --enable-source-maps --test \"dist-test/**/*.test.js\"", "test-some": "node --enable-source-maps --test-name-pattern=\"$TEST_PATTERN\" --test \"dist-test/**/*.test.js\"", "test-cov": "rm -rf ./coverage && mkdir -p ./coverage && node --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --enable-source-maps --test \"dist-test/**/*.test.js\" && genhtml ./coverage/lcov.info -o ./coverage && open ./coverage/index.html", "doc": "echo doc", diff --git a/typescript/src/Capability.ts b/typescript/src/Capability.ts index 08d8f28..c2fddb1 100644 --- a/typescript/src/Capability.ts +++ b/typescript/src/Capability.ts @@ -1,13 +1,3 @@ -/* Capabilities (§11.1). - * - * A DEPENDENCY IS ON A CAPABILITY, NOT ON A REF — because it is a - * dependency on something that can do the job, and which instance is - * doing it is exactly the configuration detail a plugin must not care - * about. - * - * But A BINDING IS TO AN INSTANCE, not to a capability, which is what - * decides behaviour when the bound provider leaves while another match - * remains. */ import { satisfies } from './Version' @@ -37,19 +27,6 @@ export type Candidate = { provides: Provided } -/** Rank the matching live providers and return them best-first: - * highest `version`, then LOWEST `priority` (default 0), then - * declaration position `pos` ascending. - * - * `priority` is a field on the capability rather than §7's `order` - * band, because bands live on POINT BINDINGS: a provider may have - * several bindings with different bands, or none at all, so a rank - * reaching for one would be undefined in the common case. - * - * Without a total rank, "any provider satisfies" is true of the GRAPH - * and useless to the PLUGIN — two ports could bind different `store` - * instances, both resolve green, and behave differently, which is - * precisely the divergence a shared corpus exists to catch. */ export function resolvecapability(req: Required, candidates: Candidate[]): Candidate[] { const hits = candidates.filter((c) => matches(req, c.provides)) hits.sort((a, b) => { @@ -76,10 +53,6 @@ export function matches(req: Required, prov: Provided): boolean { if (!satisfies(prov.version, req.range)) return false } - // `match` is checked against the provider's `attrs`, key by key. A - // key the provider does not carry is a miss, not a pass: a - // requirement asking for `transactional: true` must not be satisfied - // by a provider that never said. if (undefined !== req.match) { const attrs: any = prov.attrs || {} for (const k of Object.keys(req.match)) { @@ -91,26 +64,6 @@ export function matches(req: Required, prov: Provided): boolean { return true } -/** PARTIAL MATCH, RECURSING INTO MAPS (§11.1). - * - * §11.1 defines `match` as "a partial match against `attrs`, with - * exactly the semantics voxgig/struct and the omni corpus already - * define for `match` — every leaf in the requirement must be present - * and equal in the capability, keys not mentioned are not checked." - * - * THIS FUNCTION IS WHAT "EVERY LEAF" MEANS, and an earlier draft did - * not have it: the check was `attrs[k] !== req.match[k]`, which for any - * compound value is JavaScript REFERENCE IDENTITY. A requirement and a - * capability are declared in different places and are never the same - * object, so `match: {limits: {max: 5}}` could not be satisfied by any - * provider at all — including one declaring exactly that. The flat - * reading is invisible while every corpus entry is scalar, which is why - * the go port found it and P2 did not. - * - * A LIST IS COMPARED LEAF-WISE AT THE SAME LENGTH, not as a subset. - * "the first two of your three regions" is not something `match` can - * say, and inventing a spelling for it would be inventing the filter - * language §11.1 explicitly declines to add. */ export function matchvalue(want: any, got: any): boolean { if (isMap(want)) { if (!isMap(got)) return false diff --git a/typescript/src/Catalog.ts b/typescript/src/Catalog.ts index f4255f8..c7c2d27 100644 --- a/typescript/src/Catalog.ts +++ b/typescript/src/Catalog.ts @@ -1,9 +1,3 @@ -/* The definition catalog (§10.1). - * - * A definition is registered once and may back many instances. Option - * shapes are validated AT REGISTRATION, not when a document happens to - * exercise a key — so a malformed shape fails once, and in the same - * place everywhere (§9.4). */ import { fail } from './Types' import { checkname } from './Ref' diff --git a/typescript/src/Config.ts b/typescript/src/Config.ts index b9ce77a..08062e9 100644 --- a/typescript/src/Config.ts +++ b/typescript/src/Config.ts @@ -1,17 +1,3 @@ -/* The declarative document (§9): normalization, and the ten-level - * precedence ladder. - * - * TWO FUNCTIONS, AND THE SPLIT BETWEEN THEM IS FORCED. - * - * `normalizeconfig` normalizes STRUCTURE and ENTRY KEYS. It does not - * merge options, and cannot: §9.4 makes merge behaviour a property of - * the definition's option SHAPE, which normalization has never seen. A - * normalizer that flattened the option layers would make - * `$MERGE: append` unimplementable at load time, because the layers it - * must concatenate would already be collapsed. - * - * `resolveoptions` applies the ladder, and it is the only place that - * knows the shape. */ import { Normalized, Instance, fail } from './Types' import { canonref, parseref } from './Ref' @@ -23,10 +9,7 @@ import { canonref, parseref } from './Ref' export type NormalizeInput = { doc: any profile?: string - /** §9.1: a host may rename `instance` and `default` into its own - * vocabulary. */ keys?: { instance?: string, default?: string } - /** §9.1: refs the host declares itself and always wins on. */ reserved?: string[] } @@ -38,11 +21,6 @@ export function normalizeconfig(input: NormalizeInput): Normalized { const reserved = (input && input.reserved) || [] const profile = input && input.profile - // The rename is applied at TWO PLACES AND NO OTHERS: the document - // root, and every profile. overlay root (§9.1). A rename applied - // only at the root would leave `profile.prod.sdk` untranslated and - // silently drop every environment override the host depends on. - // Recursing further would be worse: option data is the definition's. const baseinst = doc[ikey] const basedef = doc[dkey] || {} @@ -50,8 +28,6 @@ export function normalizeconfig(input: NormalizeInput): Normalized { const overinst = overlay ? overlay[ikey] : undefined const overdef = (overlay && overlay[dkey]) || {} - // Entry layers, base then overlay, each as {ref -> entry} plus the - // order the form implies. const base = entries(baseinst) const over = entries(overinst) @@ -79,16 +55,10 @@ export function normalizeconfig(input: NormalizeInput): Normalized { const b = base.map[ref] const o = over.map[ref] - // MERGE THE ENTRIES AS AUTHORED, THEN APPLY DEFAULTS TO THE RESULT - // (§9.3). A safety rule, not a tidiness one: if the overlay had its - // defaults filled in before merging it would carry a synthesized - // active:true and overwrite a base's false — silently re-enabling a - // deliberately disabled integration in production. const active = pick(o, 'active', pick(b, 'active', true)) const start = pick(o, 'start', pick(b, 'start', 'eager')) const ord = pick(o, 'order', pick(b, 'order', undefined)) - // Option layers, levels 3-6, IN LADDER ORDER. Never merged here. const layers: any[] = [] const nm = parseref(ref).name if (basedef[nm] && undefined !== basedef[nm].options) layers.push(basedef[nm].options) @@ -123,9 +93,6 @@ function pick(src: any, key: string, dflt: any): any { return src && undefined !== src[key] ? src[key] : dflt } -/** Both document forms reduce to {ref -> entry} plus the order the form - * implies: array POSITION for the array form, sorted refs for the map - * form. */ function entries(src: any): { map: { [ref: string]: any }, order: string[] } { const map: { [ref: string]: any } = {} const order: string[] = [] @@ -139,8 +106,6 @@ function entries(src: any): { map: { [ref: string]: any }, order: string[] } { } } else { - // Map-form refs arrive as KEYS, through a different path than an - // array element's `ref` field — and must canonicalize the same way. for (const key of Object.keys(src)) { map[canonref(key)] = src[key] } @@ -160,22 +125,17 @@ function checkreserved(ref: string, reserved: string[]): void { } } -// --------------------------------------------------------------------- -// resolveoptions — §9.3's ten levels, and §9.4's merge directives -// --------------------------------------------------------------------- export type ResolveInput = { ref: string - /** Level 1 — the definition's option shape. Also carries the $MERGE - * directives, which is why merging cannot happen without it. */ shape?: any - hostdefaults?: any // 2 - doc?: any // 3-6 + hostdefaults?: any + doc?: any profile?: string - env?: any // 7 - hostoptions?: any // 8 - loadoptions?: any // 9 - patch?: any // 10 + env?: any + hostoptions?: any + loadoptions?: any + patch?: any } export function resolveoptions(input: ResolveInput): any { @@ -187,22 +147,17 @@ export function resolveoptions(input: ResolveInput): any { const doc = input.doc || {} const overlay = input.profile && doc.profile && doc.profile[input.profile] - // ONE ordered merge, lowest to highest. Levels 3-6 are not two - // namespaces collapsed separately and composed afterwards: that - // inverts the rule that PROFILE SPECIFICITY OUTRANKS DEFINITION - // SPECIFICITY, so a prod per-definition default would lose to a base - // instance value. const layers = [ - defaultsof(shape), // 1 - input.hostdefaults, // 2 - optsof(doc.default, name), // 3 - optsof(doc.instance, ref), // 4 - optsof(overlay && overlay.default, name), // 5 - optsof(overlay && overlay.instance, ref), // 6 - input.env, // 7 - input.hostoptions, // 8 - input.loadoptions, // 9 - input.patch, // 10 + defaultsof(shape), + input.hostdefaults, + optsof(doc.default, name), + optsof(doc.instance, ref), + optsof(overlay && overlay.default, name), + optsof(overlay && overlay.instance, ref), + input.env, + input.hostoptions, + input.loadoptions, + input.patch, ] let out: any = {} @@ -213,7 +168,6 @@ export function resolveoptions(input: ResolveInput): any { return out } -/** The shape's non-directive values are the level-1 defaults. */ function defaultsof(shape: any): any { const out: any = {} for (const k of Object.keys(shape)) { @@ -226,7 +180,6 @@ function defaultsof(shape: any): any { function optsof(src: any, key: string): any { if (null == src) return undefined - // The array form is equivalent to the map form (§9.1). if (Array.isArray(src)) { for (const item of src) { if (canonref(item.ref) === key) return item.options @@ -239,10 +192,6 @@ function optsof(src: any, key: string): any { return undefined } -/** Merge ONE layer onto the accumulator, honouring the shape's - * directives. The directive holds at EVERY precedence level, not only - * between document levels — §9.4 makes it a property of the shape, - * which does not know which layer a value arrived from. */ function mergeone(base: any, over: any, shape: any): any { if (null == over) return base if (!isMap(base) || !isMap(over)) return clone(over) @@ -277,7 +226,6 @@ function mergeone(base: any, over: any, shape: any): any { return out } -/** Merge N levels below this key, replace below that. */ function deepto(base: any, over: any, n: number): any { if (n <= 0) return clone(over) if (!isMap(base) || !isMap(over)) return clone(over) @@ -289,14 +237,6 @@ function deepto(base: any, over: any, n: number): any { return out } -/** §9.4: N is an integer of at least 1, and everything else is an error. - * - * `{"deep": 0}` is rejected DESPITE having an obvious reading, because - * "replace at this key" already has a spelling and two spellings for one - * behaviour is the defect class this repo exists to avoid. Without the - * stated domain each port picks its own reading — reject, replace, - * unlimited merge, or clamp to 1 — and the same document resolves - * differently per language. */ const MERGE_WORDS = ['replace', 'append'] export function checkshape(shape: any): void { diff --git a/typescript/src/Depend.ts b/typescript/src/Depend.ts index a7650ad..7fdcfbc 100644 --- a/typescript/src/Depend.ts +++ b/typescript/src/Depend.ts @@ -1,27 +1,3 @@ -/* Dependency cardinality, policy, and the restart graph (§11.3). - * - * TWO AXES, BOTH DECLARED BY THE DEFINITION THAT HAS THE REQUIREMENT, - * because only it knows what it can cope with: - * - * | static (default) | dynamic - * -------------|---------------------------|-------------------------- - * mandatory | unmet -> pending; | unmet -> pending; - * (default) | lost -> pending, | lost -> STAYS LIVE, - * | recursively | notified - * -------------|---------------------------|-------------------------- - * optional:true| never gates activation; | never gates activation; - * | a change deactivates and | a change is a - * | reactivates | notification, nothing else - * - * `dynamic` means the plugin has said, IN WRITING, that it can survive - * its provider being swapped underneath it. It is not the default - * because most plugins cannot, and the cost of wrongly assuming they - * can is a live instance holding a dead reference. - * - * The rebinding-preference axis is deliberately omitted. OSGi has - * reluctant vs greedy and it is a knob every author must understand to - * read anyone else's component; we take always-reluctant. Three axes - * were more than the model can carry across twenty ports. */ import { Required } from './Capability' import { tryref } from './Ref' @@ -32,21 +8,6 @@ export function normrequire(r: any): Required { return ('string' === typeof r ? { name: r } : (r || {})) as Required } -/** The requirements a definition declared, normalized. - * - * BOTH AXES ARE READ AT TWO LEVELS, AND THE PER-REQUIREMENT ONE WINS. - * - * The instance-level `policy` and `optional` list are how a DOCUMENT - * states the axis without editing the definition, and they apply to - * every requirement. The per-requirement form is the one §11.1's object - * syntax exists for, and it is strictly more expressive: an instance - * that is `static` on its store and `dynamic` on its metrics cannot be - * written at all at the instance level, and that is the ordinary case - * rather than an exotic one. - * - * `optional` unions rather than overriding — both spellings are - * statements that this requirement need not gate activation, and there - * is no reading under which one of them means "actually, mandatory". */ export function requirements(options: any): Required[] { const raw: any[] = (options && options.requires) || [] const marked: string[] = (options && options.optional) || [] @@ -61,44 +22,14 @@ export function requirements(options: any): Required[] { }) } -/** Does losing this requirement's SELECTED provider restart the - * consumer? The mandatory ones under `static`, and the `static` - * optional ones — both make a capability change deactivate and - * reactivate. `dynamic` never restarts: mandatory-dynamic stays live - * and is notified, optional-dynamic is a notification and nothing - * else. */ export function restartsonloss(r: Required): boolean { return 'dynamic' !== (r.policy || 'static') } -/** Does an unmet requirement keep the consumer out of `live`? - * - * Cardinality alone decides this, NOT policy. `dynamic` is a statement - * about surviving a SWAP, not about starting without the thing at all — - * a mandatory-dynamic consumer still waits in `pending` for its first - * provider. Conflating the two would let a plugin that declared it can - * cope with replacement activate with nothing to call. */ export function gatesactivation(r: Required): boolean { return true !== r.optional } -/** Edges that can cause a restart, which is exactly the set a cycle - * must be detected over (§11.3). - * - * Those are the mandatory requirements AND THE `static` OPTIONAL ONES, - * because both make a capability change deactivate and reactivate the - * consumer — and a cycle of restarts does not settle: A comes up, B - * restarts, which changes B's capability, which restarts A, - * indefinitely. - * - * ONLY `dynamic` OPTIONAL EDGES ARE EXCLUDED, and they are the ones the - * exclusion was for: two plugins that optionally and dynamically - * consume each other's capabilities both activate happily, neither - * gates on the other, and each is merely notified when the other - * appears. Nothing restarts, so nothing oscillates. - * - * An earlier draft of §11.3 excluded EVERY optional edge and thereby - * admitted the non-terminating case it was trying to permit. */ export function restartcausing(r: Required): boolean { return gatesactivation(r) || restartsonloss(r) } @@ -109,24 +40,7 @@ export type Node = { requires: Required[] } -/** A cycle through restart-causing requirements is - * `plugin_dependency_cycle`, detected AT LOAD — before anything runs, - * because the failure it describes is a non-terminating reconcile and - * the only safe time to report that is before it starts. - * - * The graph is over capabilities, not refs: an edge runs from a - * consumer to EVERY node that provides what it needs, because any of - * them could be the one selected and a cycle through any is a cycle. - * A node also satisfies its own name as a ref (§11.1), which is why the - * ref is a provider of itself here. */ export function dependencycycle(nodes: Node[]): string[] | null { - // TWO INDEXES, NOT ONE MERGED MAP. Capability names and refs are - // matched differently — a capability by its exact name, a ref through - // the canonical spelling (§4 rule 5) — and a single map keyed by both - // can only do one of them. Keyed by both and looked up raw, as this - // was, a cycle spelled `a$`/`b$` found no providers and EVADED the - // load-time check that exists to catch a non-terminating reconcile: - // the same graph, written two ways, gave two answers. const bycap: { [cap: string]: string[] } = {} const isref: { [ref: string]: boolean } = {} for (const n of nodes) { @@ -156,8 +70,6 @@ export function dependencycycle(nodes: Node[]): string[] | null { edges[n.ref] = out.sort() } - // Iterative DFS with an explicit stack: twenty ports, and several of - // them have no recursion budget worth relying on. const WHITE = 0, GREY = 1, BLACK = 2 const colour: { [ref: string]: number } = {} for (const n of nodes) colour[n.ref] = WHITE diff --git a/typescript/src/Env.ts b/typescript/src/Env.ts index 4cd2f10..668d459 100644 --- a/typescript/src/Env.ts +++ b/typescript/src/Env.ts @@ -1,25 +1,3 @@ -/* Environment overrides (§9.5) — level 7 of the ladder. - * - * One prefix, so nothing drifts: `VOXGIG_PLUGIN_*`. - * - * VOXGIG_PLUGIN_PROFILE the profile name - * VOXGIG_PLUGIN__ one option - * VOXGIG_PLUGIN_ACTIVE/INACTIVE comma-separated refs, INACTIVE wins - * - * THE ENCODING IS LOSSY, AND THIS SAYS SO RATHER THAN PRETENDING - * OTHERWISE. Ref and path are upper-snake with `$` -> `__` and `.` -> - * `_`. But `_` is legal in a name and in a tag, and the mapping folds - * case, so `retry$fast` and `retry__fast` both encode to `RETRY__FAST`, - * as do `Retry$fast` and `retry$Fast`. - * - * Rather than restrict a grammar the rest of the stack already uses, - * the host DETECTS THE COLLISION: it encodes every ref it holds, and a - * key two refs claim is `plugin_env_ambiguous`, naming both. The - * affected pair stays configurable by document and by API, just not by - * environment — which is the honest trade. - * - * Pure: a function over a string map and a ref set. The corpus tests it - * without touching a real environment. */ import { fail } from './Types' import { canonref, parseref } from './Ref' @@ -35,14 +13,10 @@ export type EnvResult = { export type EnvInput = { env: { [k: string]: string } - /** Every ref the host holds. Needed because the encoding is lossy: - * without the set there is no way to know where the ref ends and the - * path begins in `RETRY__FAST_MIN_DELAY`. */ refs?: string[] reserved?: string[] } -/** `retry$fast` -> `RETRY__FAST`. */ export function encoderef(ref: string): string { return ref.replace(/\$/g, '__').replace(/\./g, '_').toUpperCase() } @@ -72,8 +46,6 @@ export function applyenv(input: EnvInput): EnvResult { } } - // Longest encoded ref first, so `retry$fast` wins over `retry` on - // `RETRY__FAST_MIN`. Shortest-first would read the tag as a path. const encoded = Object.keys(byencoded).sort((a, b) => b.length - a.length) for (const key of Object.keys(env).sort()) { @@ -85,11 +57,6 @@ export function applyenv(input: EnvInput): EnvResult { if ('ACTIVE' === rest || 'INACTIVE' === rest) { for (const r of split(env[key])) { const c = canonref(r) - // The reservation covers EVERY input layer (§9.1). - // VOXGIG_PLUGIN_INACTIVE=station is easier to set than editing - // a config file, and INACTIVE has the final word — so guarding - // documents alone would leave the one lever this mechanism - // exists to deny wide open. checkreserved(c, reserved) if ('ACTIVE' === rest) out.active.push(c) else out.inactive.push(c) @@ -126,9 +93,6 @@ function checkreserved(ref: string, reserved: string[]): void { } } -/** Values parse as JSON, FALLING BACK TO STRING — so `8080` is a - * number, `true` is a boolean, `{"a":1}` is a map, and `hello` is the - * string it looks like rather than a parse error. */ function parsevalue(v: string): any { try { return JSON.parse(v) } catch (err) { return v } diff --git a/typescript/src/Export.ts b/typescript/src/Export.ts index bcefdcf..61744ce 100644 --- a/typescript/src/Export.ts +++ b/typescript/src/Export.ts @@ -1,14 +1,3 @@ -/* Exports (§11). - * - * An instance publishes values for other plugins and for the - * application. Read with `host.exports('retry$fast/client')`. - * - * THE UNQUALIFIED ALIAS IS THE INTERESTING PART. `retry/client` - * resolves to the UNTAGGED instance if one exists; if not, and exactly - * one tagged instance exports that key, it resolves to that one; if two - * do, it is `plugin_export_ambiguous` — deliberately diverging from - * seneca's silent last-wins, because with multi-instance as a headline - * feature an ambiguous alias is a defect waiting for production. */ import { fail } from './Types' import { parseref, canonref } from './Ref' @@ -16,17 +5,6 @@ import { parseref, canonref } from './Ref' export type Exported = { ref: string, key: string, value: any } export function resolveexport(spec: string, exported: Exported[]): any { - // THE LAST SLASH, not the first. §4's `checkname` permits `/` in a - // NAME — `@acme/store` is legal, and `resolvecandidates` has a branch - // for resolving a scoped name verbatim — so splitting at the first one - // read `@acme/store/client` as the ref `@acme` with the key - // `store/client`, which matches nothing. A scoped definition had no - // spelling for its exports at all, and said so by answering absent. - // - // The cost is that an export KEY may not contain `/`. Nothing declares - // one, no port shipped one, and the two cannot both be reachable with - // one separator: a name may hold slashes because §4 says so, a key - // holds them because nobody stopped it. const cut = spec.lastIndexOf('/') if (-1 === cut) { fail('plugin_export_ambiguous', 'export spec needs a key: ' + spec, { spec }) diff --git a/typescript/src/FeatureHost.ts b/typescript/src/FeatureHost.ts index 6b00691..11fc439 100644 --- a/typescript/src/FeatureHost.ts +++ b/typescript/src/FeatureHost.ts @@ -1,57 +1,7 @@ -/* The sdkgen bridge (§17.2, P3 item 2). - * - * THE DELIVERABLE IS A BRIDGE THAT RUNS AN UNMODIFIED SDKGEN FEATURE - * CLASS AS A PLUGIN, proving the two vocabularies map — and leaving - * whether sdkgen's generated code adopts the model as a separate, - * sdkgen-side decision with its own cost across 23 template trees. - * - * A generated SDK is not natively a host. Wrapping one in `inst.host()` - * directly would be a host-shaped object around a non-host, so the - * INNER HOST IS THE BRIDGE, NOT THE SDK: it declares points for the - * SDK's hook vocabulary and its `request` chain, and a feature's own - * method names become its bindings. - * - * WHAT THE BRIDGE BUYS, IN ONE SENTENCE: sdkgen can activate a feature - * and cannot deactivate one, because its transport wrap is an - * irreversible assignment — `ctx.utility.fetcher = wrapped` — and its - * hook methods are read off a fixed array. Both become bindings here, - * and bindings come out. - * - * - * THIS FILE IS OUTSIDE THE PORTABILITY BUDGET, DELIBERATELY, AND IT IS - * THE ONLY ONE. - * - * `typescript/AGENTS.md` forbids dynamic property interception, and the - * `utility.fetcher` getter/setter below is exactly that. The budget - * governs the PORTABLE LIBRARY — the thing twenty languages implement - * from one corpus — and it is not negotiable there. - * - * A BRIDGE TO SDKGEN CANNOT BE PORTED AND IS NOT MEANT TO BE. sdkgen - * generates SDKs in 23 languages, each feature written in that - * language's idiom; a Go SDK's feature does not assign - * `ctx.utility.fetcher`, so there is nothing for a Go translation of - * this file to intercept. Each language that wants the bridge writes - * its own, against its own generated code, and the thing they share is - * the plugin model underneath — not this mechanism. - * - * Said here because the contradiction is otherwise live and invisible: - * `Config.ts` cites the same budget as the reason `apply` refills an - * options map instead of installing a getter. That reasoning is correct - * and unchanged; this file is a different kind of thing. */ import { Definition } from './Catalog' import { PointSpec } from './Host' -/** sdkgen's declared hook vocabulary (`main.kit.feature.&.hook` in - * `model/sdkgen.aon`). - * - * §17.2 says "13 hook points, named exactly as today". The model - * declares ELEVEN; the three station's own feature adds — `PrePoint`, - * `PreDone`, `PreUnexpected` — are declared by that feature rather than - * by the core, because `hook: &:` admits any name. So the count depends - * on which features are installed, which is why the bridge takes the - * extra names rather than this list pretending to be closed. - * Recorded in doc/plan/handover.md rather than silently resolved. */ export const SDK_HOOKS = [ 'PostConstruct', 'PostConstructEntity', @@ -66,30 +16,12 @@ export const SDK_HOOKS = [ 'PostOperation', ] -/** The hooks sdkgen-station's feature declares beyond the core set. - * Named here so the bridge's default vocabulary covers the one feature - * this repo's first consumer actually ships. */ export const STATION_HOOKS = ['PrePoint', 'PreDone', 'PreUnexpected'] -/** The one chain point: the SDK's transport. Its base is - * `utility.fetcher`, which is exactly what a wrapping feature captures - * and replaces today. */ export const REQUEST_POINT = 'request' -/** What a particular SDK adds to the default vocabulary. - * - * All three fields exist because §17.2's mapping is stated in terms of - * a specific SDK's declarations, and a bridge that hard-coded them - * would be right for exactly one SDK. */ export type BridgeOptions = { - /** Hook names this SDK's installed features declare beyond the core - * set. */ hooks?: string[] - /** §17.2: "`provider` points for the seams `__replace__` currently - * serves." A replacement seam is a PROVIDER point and not a chain — - * at most one implementation wins, the losers are visible, and the - * host keeps a default — which is precisely what `__replace__` means - * and what a chain cannot express. */ replace?: string[] /** The SDK's REAL ctx. A feature's `init` may read `ctx.client`, * `ctx.utility.log` or anything else the SDK hands it, and a @@ -99,9 +31,6 @@ export type BridgeOptions = { ctx?: any } -/** Points for a bridge host: every hook name as a `hook` point, each - * replacement seam as a `provider` point, plus `request` as a `chain` - * whose base is the SDK's own fetcher. */ export function featurepoints( fetcher: (...args: any[]) => any, options?: BridgeOptions ): { [point: string]: PointSpec } { @@ -112,35 +41,18 @@ export function featurepoints( if (undefined === points[h]) { points[h] = { kind: 'hook' } } } for (const r of opts.replace || []) { - // A seam declared BOTH ways is a contradiction in the SDK's own - // declarations, and the provider reading wins loudly rather than - // the map's iteration order deciding. points[r] = { kind: 'provider' } } points[REQUEST_POINT] = { kind: 'chain', base: fetcher } return points } -/** The ctx an sdkgen feature's `init` expects. - * - * `utility.fetcher` is a GETTER/SETTER PAIR rather than a field, and - * that is the whole trick: a feature that writes - * `ctx.utility.fetcher = wrapped` is expressing a chain binding, and - * the bridge records it as one instead of letting it overwrite the - * slot. Reading it back returns the marker the feature should call, - * which is `next` — so the feature's own `const inner = utility.fetcher` - * still gives it something to call through to. - * - * This is the single place the irreversible assignment becomes - * reversible; everything else about the feature is untouched. */ type Captured = { wrap?: (...args: any[]) => any inner: any } function makectx(base: any, captured: Captured, options: any): any { - // Layer onto the SDK's REAL utility, so a feature reading anything - // else off it still finds what the SDK put there. const source = (base && base.utility) || {} const utility: any = {} for (const k of Object.keys(source)) { utility[k] = source[k] } @@ -157,41 +69,6 @@ export type FeatureClass = { new(...args: any[]): any } -/** Turn an sdkgen `Feature` class into a plugin definition, MECHANICALLY - * (§17.2): - * - * - `name` and `version` come off the instance, as today; - * - `init(ctx, options)` runs in `define`, where reading options and - * declaring bindings belong; - * - a method named after a hook point IS a binding to that point — - * "a feature's method names are its bindings"; - * - a method named after a replacement seam is a `provider` binding; - * - an assignment to `ctx.utility.fetcher` becomes a `request` chain - * binding rather than an irreversible overwrite. - * - * The feature class is not modified, subclassed or inspected beyond its - * own public surface. That is the claim being proved. - * - * - * WHAT "AND DEACTIVATES IT" CLAIMS, EXACTLY. - * - * §17.2 says `init` "splits into `define` (read options, declare - * bindings) and `activate` (capture)". THAT SPLIT IS SDKGEN'S TO MAKE. - * An unmodified feature has one `init` and no teardown method, so the - * bridge runs `init` in `define` and there is nothing it could call to - * undo a side effect `init` performed — a connection opened there stays - * open, and no amount of bridging changes that. - * - * So the claim is precisely: THE FEATURE'S BINDINGS BECOME REVERSIBLE. - * Its hooks stop firing and its transport wrap leaves the chain, with - * no cooperation from the feature — which is the thing sdkgen cannot do - * at all, because it assigns the slot and has nowhere to put the old - * value back. It is not a claim that arbitrary `init` side effects are - * undone. - * - * A feature that DOES carry `activate` / `deactivate` / `close` methods - * gets them wired below, which is the same mapping applied to the - * methods §17.2 expects an adopting sdkgen to add. */ export function featuredefinition( name: string, Feature: FeatureClass, options?: BridgeOptions ): Definition { @@ -214,12 +91,6 @@ export function featuredefinition( define: (inst: any) => { const feature: any = new (Feature as any)() - // THE DEFINITION NAME AND THE FEATURE'S OWN NAME MUST AGREE. - // §17.2 maps a feature's `name` to the definition's, so a caller - // passing a different string leaves configuration addressed by - // the SDK's feature name unable to resolve the definition, while - // the exported object reports a third identity. Loud, because the - // failure it prevents is silent. if (null != feature.name && feature.name !== name) { const err: any = new Error( 'plugin/plugin_definition_name: feature name does not match the ' + @@ -230,28 +101,6 @@ export function featuredefinition( const captured: Captured = { inner: undefined } - // `inner` is what the feature reads back from `utility.fetcher`. - // In a chain the value to call through to is `next`, which is not - // known until the binding runs — so the feature is handed a - // trampoline that forwards to whatever `next` is at call time. - // - // ONE SHARED SLOT, AND THE LIMIT IS REAL. An sdkgen feature - // stashes `inner` once, at init; §6.2 names that exact pattern as - // "a bug the host cannot prevent" and declines to pretend - // otherwise. Sequential and nested calls are correct — the - // binding sets `current` immediately before the wrap runs — but - // if the wrap awaits and a SECOND request begins before it - // resumes, both share this slot and the resumed wrap calls - // through the second request's chain. - // - // Restoring the previous value in a `finally` makes it worse, not - // better: the finally runs when the wrap returns its promise, - // before the feature ever calls `inner`. Fixing it properly needs - // either a per-invocation channel the feature would have to be - // modified to accept — which is the thing this bridge exists to - // avoid — or async-context storage, which the portability budget - // forbids and which does not exist in most of the 23 languages. - // Stated rather than papered over. let current: any = null captured.inner = (...args: any[]) => null == current ? undefined : current(...args) @@ -259,20 +108,15 @@ export function featuredefinition( const ctx = makectx( { client: inst, ...(opts.ctx || {}), feature }, captured, inst.options) - // The SDK calls `init(ctx, options)`; so do we, unchanged. if ('function' === typeof feature.init) { feature.init(ctx, inst.options) } - // A method named after a hook point IS a binding to it. for (const h of hooknames) { if ('function' !== typeof feature[h]) { continue } inst.bind(h, (...args: any[]) => feature[h](...args)) } - // ...and a method named after a replacement seam is a PROVIDER - // binding (§17.2), which is what `__replace__` means: at most one - // wins, the losers are visible, and the host keeps a default. for (const r of seams) { if ('function' !== typeof feature[r]) { continue } inst.bind(r, (...args: any[]) => feature[r](...args)) @@ -294,21 +138,9 @@ export function featuredefinition( inst.export('version', feature.version) } - // ...and the instance's OWN state, which is where the three later - // callbacks read it from. The export is the public handle; it is - // not a channel back to the definition, because §11 hides the - // exports of a `failed` instance — and `unload` on a failed - // instance still runs `close` (§5.2). Reading the feature back - // through `exports` therefore lost it in exactly the case where a - // feature holding a connection most needs its `close` to run. inst.state.feature = feature }, - // §17.2's `activate` (capture) half, for a feature that has one. - // Today's sdkgen features do not — which is why the bridge's claim - // is about bindings and not about side effects — but a feature that - // grows the methods §17.2 expects gets them called, in the phase - // the model puts them in. activate: (inst: any) => { const feature: any = inst && featureof(inst) if (feature && 'function' === typeof feature.activate) { @@ -332,15 +164,6 @@ export function featuredefinition( } } -/** The feature object `define` built, read back from the instance's own - * state rather than closed over, so the three later callbacks do not - * each need their own capture. - * - * NOT from `exports`. §11 hides a `failed` instance's exports, and - * `unload` on a failed instance still runs `close` — so an exports read - * returned undefined precisely when a feature holding a connection - * needed its `close` to run. `state` is the instance's own and survives - * every status. */ function featureof(inst: any): any { return inst.state && inst.state.feature } diff --git a/typescript/src/Graph.ts b/typescript/src/Graph.ts index 8d3bf91..de0154b 100644 --- a/typescript/src/Graph.ts +++ b/typescript/src/Graph.ts @@ -1,21 +1,3 @@ -/* Whole-graph resolution (§11.4) — a phase, not a discovery. - * - * "Activate, and wait in `pending` if you must" is correct and, on its - * own, produces a terrible experience: apply twenty instances against a - * registry missing one thing and you get NINETEEN pending rows and no - * statement of what is actually wrong. - * - * `resolvegraph` is a PURE FUNCTION of the registry and the intended - * activation set. No callbacks run, no state changes, nothing is - * touched. It answers for the whole graph at once which instances can be - * live, and for each blocked one THE SPECIFIC REQUIREMENT that is - * unmet, and why. - * - * The failure mode being designed against is a famous one: OSGi's - * resolver is correct and its diagnostics are legendarily unusable. A - * resolver that says "blocked" without saying WHY has moved the problem - * rather than solved it, so `why` is part of the contract and the - * corpus pins its shape. */ import { Provided, Required, Candidate, resolvecapability, matchvalue, @@ -52,10 +34,6 @@ export function resolvegraph(nodes: Node[]): Resolution { const resolved = new Set() const blocked: { [ref: string]: Blocked } = {} - // Fixed point: a node resolves when every mandatory requirement is - // met by an ALREADY-RESOLVED provider. Iterating to a fixed point is - // what makes a provider that is itself blocked propagate, rather than - // each node being judged against the raw registry. let moved = true while (moved) { moved = false @@ -78,10 +56,6 @@ export function resolvegraph(nodes: Node[]): Resolution { } } -/** The FIRST unmet requirement, with the most specific explanation - * available. Order matters: "no provider at all" and "a provider at the - * wrong version" are different problems and a reader must not have to - * guess which they have. */ function firstunmet( n: Node, byref: { [ref: string]: Node }, @@ -125,9 +99,6 @@ function firstunmet( for (const c of all) { const attrs = c.provides.attrs || {} for (const k of Object.keys(req.match).sort()) { - // The same recursive partial match `matches` applies, so a - // nested requirement that FAILED the selection is also the - // one the diagnosis names (§11.4). if (!(k in attrs) || !matchvalue(req.match[k], attrs[k])) { return { ref: n.ref, unmet: req.name, @@ -149,23 +120,9 @@ function firstunmet( function candidates(byref: { [ref: string]: Node }, name: string): Candidate[] { const out: Candidate[] = [] - // A NODE SATISFIES ITS OWN REF (§11.1), and this is where the graph - // learned it. Considering only declared capabilities made `resolve()` - // answer `absent` about a provider sitting right there and live — - // §11.4's whole job is explaining the graph the runtime reconciles, - // and it was explaining a different one. Canonical (§4 rule 5), and - // tolerant, because a capability name need not be a well-formed ref. const asref = tryref(name) for (const ref of Object.keys(byref).sort()) { const n = byref[ref] - // Synthesized exactly as `providersof` synthesizes it, so the two - // answer the same question the same way — including that a bare ref - // carries no version, and so cannot satisfy a `range`. - // AND THE REF MATCH WINS OUTRIGHT for that node, as it does at - // runtime: `providersof` pushes the synthesized candidate and moves - // to the next instance. A node both named `b` and providing a - // capability `b` is ONE candidate, not two — without the skip the - // blocked-chain explanation named it twice. if (ref === asref) { out.push({ ref: n.ref, pos: n.pos, provides: { name } }) continue diff --git a/typescript/src/Host.ts b/typescript/src/Host.ts index d2dfaad..2417a13 100644 --- a/typescript/src/Host.ts +++ b/typescript/src/Host.ts @@ -1,18 +1,3 @@ -/* The host: the lifecycle state machine (§5), extension points (§6), - * and resource capture (§8). - * - * TWO RULES SHAPE EVERY METHOD BELOW. - * - * Transitions are SEQUENTIAL (§5.2). One at a time, in call order, - * never interleaved; a transition triggered from inside a lifecycle - * callback is `plugin_reentrant`. A hard rule, because it is the only - * way the semantics can be identical in Go, in Ruby and in - * single-threaded JavaScript. - * - * Reconciliation is EAGER (§18's portability budget). A transition - * settles by running the state machine to a fixed point, not by - * suspending on a promise. Every port must be able to do the same, and - * fourteen of them will not have JavaScript's event loop. */ import { Status, Instance, OrderBlock, fail } from './Types' import { canonref, tryref, parseref, formatref } from './Ref' @@ -35,15 +20,6 @@ export type HostOptions = { defaults?: { [name: string]: any } profile?: string points?: { [point: string]: PointSpec } - /** §11.3. `restart` (the default) treats provider replacement as an - * ordinary runtime operation: deactivate the old store, activate the - * new one, and everything that depended on it rides through, having - * released the old one's resources in between. - * - * `hold` is the strict reading — deactivating a required instance is - * `plugin_dependency_held`, naming the holders. NOT the default, - * because a station that cannot swap a provider without a restart - * has lost the argument for having a plugin system. */ dependency?: 'restart' | 'hold' } @@ -56,23 +32,8 @@ type Live = { options: any state: any order?: OrderBlock - /** §11.4's ALWAYS-RELUCTANT rebinding, made concrete: the provider - * ref this instance's activation actually selected, per requirement - * name. "A satisfied requirement is not re-bound while it stays - * satisfied" is a statement about a REMEMBERED choice — recomputing - * `providersof(r)[0]` on every question silently re-points a live - * consumer at any better-ranked newcomer, and then losing the - * provider it was really using does not restart it. Captured at - * activate, cleared on the way out. */ selected: { [name: string]: string } - /** §9.6's `active: false` — "declares it and bars it: it appears in - * `host.list()`, and `activate` and `ready` on it fail rather than - * quietly doing nothing". THE BAR OUTLIVES THE APPLY THAT SET IT: a - * flag consulted only while `apply` ran let a later direct `ready` - * bring the instance live, which is the config-switch it exists to - * be silently ignored. */ barred?: boolean - /** Requirements this instance declared but has not been given. */ unmet: string[] /** Resources the instance scope holds, newest last — unwound in * REVERSE, because that is the only order in which teardown mirrors @@ -82,7 +43,6 @@ type Live = { * (§8.1). Holding them until then is what makes a failed activate * leave nothing behind. */ bindings: Bound[] - /** Set when this instance is itself a host (§6.5). */ inner?: any /** Declared in `define`, and VISIBLE while merely `loaded` (§11): * they are data, and hiding them would make the loaded state useless @@ -113,17 +73,10 @@ export function makehost(options?: HostOptions) { let seqn = 0 let open = 0 let intransition = false - /** WHICH callback is running, not merely that one is. §8.1 puts - * resource capture in `activate` and §8.3 says `inst.release` outside - * `activate` is `plugin_release_scope` — and `intransition` alone - * cannot tell `activate` from `define`, so it admitted an acquire in - * `define` whose scope `unload` would never unwind. */ let phase: string | null = null // --- observation ------------------------------------------------- - /** Introspection NEVER advances the state (§5.2). A status page must - * not be a way to accidentally import twenty packages. */ const list = (): { [ref: string]: Status } => { const out: { [ref: string]: Status } = {} for (const r of Object.keys(inst).sort()) out[r] = inst[r].status @@ -172,12 +125,6 @@ export function makehost(options?: HostOptions) { fn(api(e)) } catch (err: any) { - // §12: `plugin_define_failed` and its three siblings are "a - // callback raised; wraps the cause". AN ERROR THAT ALREADY - // CARRIES A CODE KEEPS IT — the code is the error's identity, and - // a plugin that raised `store_unreachable` must not have it - // rewritten. Only a code-less error is wrapped, which is the - // ordinary case for a callback that let a library error escape. if (err && err.code) throw err fail('plugin_' + at + '_failed', e.ref + ' raised in ' + at + ': ' + (err && err.message), @@ -189,8 +136,6 @@ export function makehost(options?: HostOptions) { } } - /** What a definition's callbacks see. Deliberately not the internal - * record: a plugin that could reach `status` could also write it. */ function api(e: Live) { return { ref: e.ref, @@ -209,23 +154,10 @@ export function makehost(options?: HostOptions) { if ('activate' !== phase) { fail('plugin_release_scope', 'release called outside activate') } - // SYMMETRIC WITH `acquire`, and it has to be: `open` counts the - // resources CURRENTLY HELD, so an entry that is registered and - // then unwound must leave the count where it found it. - // Incrementing on registration and never decrementing made - // every `release` a permanent leak in the counter — invisible - // only because no corpus entry used `release` at all, which is - // the gap `resource/scope#release-counts` now closes. let done = false e.scope.push(() => { if (!done) { done = true; open -= 1; fn() } }) open += 1 }, - /** The synthetic counter the driver owns, so "what is open" is - * data rather than an assertion each port words differently. - * - * Returns its own release, so a plugin can hand one back early. - * The scope still holds the entry and unwinding it twice is a - * no-op — releasing early must not make teardown wrong. */ acquire: (): (() => void) => { // §8.1: resources are "acquired during `activate` — the scope's // actual job". Same reason as `release` above. @@ -240,17 +172,7 @@ export function makehost(options?: HostOptions) { }, host: () => self, - /** Bind into a host point. Declared in `define`; the host inserts - * it only after `activate` returns successfully (§8.1), which is - * why a failing activate leaves no live binding behind. */ bind: (point: string, fn: any, band?: number) => { - // §12's `plugin_bind_scope`: "binding declared outside - // `define`". §8.1 puts binding declaration in `define` and - // insertion at a SUCCESSFUL activate, and the guard was the - // half that never got written — so a binding added from - // `activate` went live without being part of the loaded - // definition, and a deactivate/activate cycle appended it - // again. The code was in the table before anything raised it. if ('define' !== phase) { fail('plugin_bind_scope', 'bind called outside define: ' + point, { ref: e.ref, point }) @@ -261,61 +183,22 @@ export function makehost(options?: HostOptions) { e.bindings.push({ ref: e.ref, point, fn, band: band || 0 }) }, - /** Published for other plugins and for the application (§11). */ export: (key: string, value: any) => { e.exports[key] = value }, - /** WHICH provider this instance is bound to for `name` (§11.1), - * as a ref, or undefined when nothing provides it. - * - * §11.1 says "the first is bound, and `inst.capability(name)` - * returns it", and §11.3 gives the case that needs it: a plugin - * that works without metrics and uses metrics when it is there - * has to be able to ask. Every port had `host.capability(name)`, - * which answers with the live providers RANKED — not with the one - * THIS instance actually took. §11.4's reluctant rebinding makes - * those differ: a better-ranked newcomer tops the ranking while - * the consumer keeps what it had. - * - * A REF, not the instance. `host.capability` answers in refs, - * every port can return a string, and a corpus entry can assert - * on one — an instance api is shaped differently in each and - * cannot be asserted at all. - * - * The selection is REMEMBERED, because this is the instance - * asking. `chosen(..., false)` is for questions asked ABOUT an - * instance, where answering must not create a binding. */ capability: (name: string): string | undefined => { const req = requirements(e.options).find((r) => r.name === name) if (undefined === req) return undefined return chosen(e, req, true) }, - /** What this instance can do for others (§11.1). */ provides: (p: Provided) => { e.provides.push(p) }, - /** Where this binding landed (§6.6) — the plugin-side counterpart - * to a host pin. Station found that a plugin can need to KNOW it - * is in the right place: its middleware must sit immediately - * outside the base transport or its "wire truth" events are - * fiction. - * - * THE HOST DOES NOT POLICE THIS; it just makes the fact - * available. A plugin that requires a position it did not get - * fails loudly rather than reporting nonsense — and that is the - * plugin's call, because only it knows what its position means. - * Verification tells a plugin it was misplaced; a pin (§7) stops - * the misplacement from being expressible at all. The two are not - * substitutes. */ position: (point: string) => { const ranked = order(point) const index = ranked.indexOf(e.ref) return { index, count: ranked.length, - // §6.2 composes b1(b2(b3(base))) with the FIRST binding - // OUTERMOST, so these are not index 0 and index count-1 the - // other way round. Getting this backwards is the exact error - // the positional pin vocabulary exists to prevent. outermost: 0 === index, innermost: index === ranked.length - 1, } @@ -338,13 +221,6 @@ export function makehost(options?: HostOptions) { } } - /** AUTO-TAGGING IS EXPLICIT (§4 rule 3). `declare('stripe', {tag: - * '?'})` assigns the LOWEST UNUSED POSITIVE INTEGER tag and returns - * the assigned pair. Without `'?'`, a collision is an error. - * - * It needs a host because it must know what is already declared, - * which is why it cannot live in the pure `ref` section — the - * correction P1.7 made to §15.3. */ function autotag(name: string): string { for (let n = 1; ; n++) { const cand = formatref(name, String(n)) @@ -400,7 +276,7 @@ export function makehost(options?: HostOptions) { function load(ref: string, spec?: any): Live { guard() const e = declare(ref, spec) - if ('declared' !== e.status) return e // idempotent in the trivial direction + if ('declared' !== e.status) return e if (spec && spec.options) e.options = spec.options try { run(e, 'define', 'define') @@ -411,11 +287,6 @@ export function makehost(options?: HostOptions) { } e.status = 'loaded' - // AT LOAD, and before anything runs: a cycle through - // restart-causing requirements does not settle, and the only safe - // time to report a non-terminating reconcile is before it starts - // (§11.3). `provides` is populated by `define`, which has just run, - // so this is the first moment the graph is complete. try { checkcycle(graphnodes()) } catch (err: any) { e.status = 'failed' @@ -424,7 +295,6 @@ export function makehost(options?: HostOptions) { return e } - /** The requirement graph as plain data, for the pure detector. */ function graphnodes(): Node[] { return Object.keys(inst).sort().map((r) => ({ ref: r, @@ -436,22 +306,16 @@ export function makehost(options?: HostOptions) { function activate(ref: string): Live { guard() const e = need(ref) - if ('live' === e.status) return e // no-op returning success + if ('live' === e.status) return e if ('failed' === e.status) { fail('plugin_bad_state', 'instance has failed: ' + e.ref, { ref: e.ref }) } - // §9.6: `active: false` bars the instance from running, and the bar - // is on the INSTANCE rather than on the apply that set it. `ready` - // reaches this through `activate`, which is why one guard covers - // both verbs the design names. if (e.barred) { fail('plugin_inactive', 'instance is barred by active: false: ' + e.ref, { ref: e.ref }) } if ('declared' === e.status) load(e.ref) - // A declared requirement that is not live means `pending`: - // activation is a STANDING REQUEST, not a one-shot event. if (0 < unmetof(e).length) { e.unmet = unmetof(e) e.status = 'pending' @@ -462,7 +326,6 @@ export function makehost(options?: HostOptions) { run(e, 'activate', 'activate') } catch (err: any) { - // Unwind whatever the partial activation captured, in reverse. unwind(e) e.status = 'failed' throw err @@ -481,23 +344,11 @@ export function makehost(options?: HostOptions) { const e = need(ref) if ('loaded' === e.status || 'declared' === e.status) return e - // §5.2: `unload` is THE ONLY TRANSITION OUT OF `failed`. Falling - // through here ran the definition's `deactivate` on an instance that - // never completed activation and, if that callback happened to - // succeed, returned it to `loaded` — from where it could be - // activated again, which is precisely what `failed` exists to - // prevent. if ('failed' === e.status) { fail('plugin_bad_state', 'instance has failed: ' + e.ref, { ref: e.ref }) } if ('pending' === e.status) { - // DEACTIVATING A PENDING INSTANCE RUNS NO CALLBACK (§5.2). It - // never reached activate, so it holds no scope and no live - // bindings; running the definition's deactivate there would be - // teardown without matching setup, which plugins are not written - // to survive and which could fail an instance that had done - // nothing wrong. It cannot fail. e.status = 'loaded' e.unmet = [] return e @@ -531,13 +382,6 @@ export function makehost(options?: HostOptions) { run(e, 'deactivate', 'deactivate') } catch (err: any) { - // §5.2: ANY failure during a transition lands the instance in - // `failed`, with the scope STILL FULLY UNWOUND. An earlier - // draft let the raise propagate straight out of `unload`, - // which left the instance `live` and its scope untouched — - // reporting a failure while leaking exactly the resources the - // failure was about, and leaving an instance whose bindings - // were never removed still participating in every point. unwind(e) e.status = 'failed' throw err @@ -555,10 +399,6 @@ export function makehost(options?: HostOptions) { } function ready(ref: string): Live { - // Runs the whole forward path in one call (§5.1). §15.2's verb list - // omits this; §5.1 defines it and §15.3's `declare` row requires the - // corpus to pin it, so the list was incomplete rather than - // excluding it (DOCS.md §4.2). guard() const r = canonref(ref) if (!inst[r]) declare(r) @@ -566,15 +406,6 @@ export function makehost(options?: HostOptions) { return activate(r) } - /** Bindings go live only when activation succeeds (§8.1), so the - * teardown is the exact inverse: reverse order, always. - * - * Returns the errors the scope raised. §8.3: "A failing release does - * not stop the rest. Every entry runs, in reverse order, whatever any - * of them does; the errors are collected and raised as one - * `plugin_release_failed`." An earlier draft swallowed them, which - * left the host reporting a clean `loaded` for an instance that may - * still be holding what it failed to give back. */ /** A selection belongs to ONE activation (§11.4). Leaving `live` by * any door drops it, so the next activation ranks afresh — keeping it * would make a consumer prefer a provider it never actually ran @@ -602,13 +433,6 @@ export function makehost(options?: HostOptions) { { ref: e.ref, cause: causes }) } - /** A REQUIREMENT IS ON A CAPABILITY, not on a ref (§11.1) — it is a - * dependency on something that can do the job, and which instance is - * doing it is exactly the configuration detail a plugin must not care - * about. A bare string is shorthand for `{name}`. - * - * A ref satisfies too, because a host that genuinely needs a specific - * instance should not have to invent a capability for it. */ function unmetof(e: Live): string[] { return requirements(e.options) .filter(gatesactivation) @@ -616,17 +440,6 @@ export function makehost(options?: HostOptions) { .map((r) => r.name) } - /** §11.4's always-reluctant selection, and the ONE place a provider - * is chosen for a live instance. - * - * "A satisfied requirement is not re-bound while it stays satisfied." - * So: if this instance already selected a provider for `req` and that - * provider is STILL among the candidates, it keeps it — a - * better-ranked newcomer does not take it. Otherwise the rank - * decides, and the choice is remembered. - * - * `remember` is false for the questions asked ABOUT an instance - * rather than BY it — introspection must not create a binding. */ function chosen(e: Live, req: Required, remember: boolean): string | undefined { const cands = providersof(req) if (0 === cands.length) return undefined @@ -636,14 +449,6 @@ export function makehost(options?: HostOptions) { return cands[0].ref } - /** The instance currently SELECTED for each of this one's - * restart-causing requirements. A BINDING IS TO AN INSTANCE, not to a - * capability (§11.1), and that is what decides behaviour when the - * bound provider leaves while another match remains: the selected one - * going away restarts a `static` consumer even though a survivor is - * available. It is not silently re-pointed — `static` is the plugin - * saying in writing that it cannot survive a provider swap, and a - * survivor being available does not make the swap survivable. */ function boundproviders(e: Live): string[] { const out: string[] = [] for (const r of requirements(e.options)) { @@ -664,27 +469,6 @@ export function makehost(options?: HostOptions) { }) } - /** §11.3's `hold` asks a DIFFERENT question from the cascade, and - * reading it off `consumersof` answered the cascade's. - * - * The cascade wants the edges that RESTART — mandatory-static and - * optional-static — because that is what it has to walk. `hold` says - * "deactivating a REQUIRED instance is `plugin_dependency_held`", and - * `required` is cardinality: `gatesactivation`, not - * `restartsonloss`. The two sets differ in both directions and each - * difference was a real bug. - * - * A MANDATORY-DYNAMIC consumer was excluded, so the strictest policy - * let a provider go that a live consumer could not do without — - * `dynamic` promises the consumer survives a SWAP, and under `hold` - * there is no swap, so it goes back to `pending`, which is precisely - * what `hold` exists to prevent. - * - * An OPTIONAL-STATIC consumer was included, so `hold` refused a - * deactivation on behalf of an instance that had said in writing it - * does not need the thing. Disruptive, yes — it restarts — but the - * policy's word is `required`, and an optional requirement is the - * plugin declaring the provider is not. */ function holdersof(ref: string): string[] { return Object.keys(inst).sort().filter((r) => { const c = inst[r] @@ -699,18 +483,10 @@ export function makehost(options?: HostOptions) { function providersof(req: Required): Candidate[] { const cands: Candidate[] = [] - // ASK WHETHER THE NAME IS A REF, do not assume it. A requirement - // name is a CAPABILITY name first (§11.1) and capability names are - // free-form, so `2fa` and `my cap` are legal ones that no ref could - // be called — and `canonref` RAISES on those, which made a perfectly - // legal document kill the host right here. `tryref` answers - // `undefined` instead, and still canonicalizes when it is a ref - // (§4 rule 5), which is what lets `dep$` find `dep`. const asref = tryref(req.name) for (const ref of Object.keys(inst).sort()) { const t = inst[ref] if ('live' !== t.status) continue - // A ref satisfies directly. if (ref === asref) { cands.push({ ref, pos: t.pos, provides: { name: req.name } }) continue @@ -722,19 +498,6 @@ export function makehost(options?: HostOptions) { return resolvecapability(req, cands) } - /** CONSUMERS GO DOWN FIRST, NOT AFTERWARDS (§11.3). - * - * The cascade is part of the provider's own deactivation and runs - * BEFORE the provider's `deactivate` callback and scope unwind, so a - * consumer's teardown can still call the thing it depends on — - * flushing a buffer to the store it is about to lose is exactly what - * a `deactivate` callback is for, and a cascade that fired after the - * provider was already gone would make that impossible. - * - * Order: consumers deepest-first, then the provider. `unload` and - * `close` inherit it, UNDER EITHER DEPENDENCY POLICY, which is what - * makes apply's reverse-load-order teardown safe even when a document - * happens to list a consumer before its provider. */ function cascade(provider: Live, seen?: { [ref: string]: true }): void { const done = seen || {} if (done[provider.ref]) return @@ -743,7 +506,7 @@ export function makehost(options?: HostOptions) { for (const r of consumersof(provider.ref)) { const c = inst[r] if ('live' !== c.status) continue - cascade(c, done) // deepest-first + cascade(c, done) let bad = false try { run(c, 'deactivate', 'deactivate') } catch (err) { bad = true } const errors = unwind(c) @@ -761,16 +524,6 @@ export function makehost(options?: HostOptions) { } } - /** The hold check is A GUARD ON AD-HOC DEACTIVATION, NOT ON - * COORDINATED TEARDOWN. In a bulk operation that is removing the - * holders too — `close()`, or an `apply` plan whose own steps - * deactivate them — it is suspended for exactly those holders, and - * the teardown still runs consumers before providers. - * - * Otherwise `close()` under `hold` would raise on the first provider - * it reached whenever a document happened to list a consumer after - * it, which is the policy refusing to allow the one teardown it has - * no reason to object to. */ function held(e: Live): void { if ('hold' !== dependency) return if (coordinated) return @@ -781,13 +534,6 @@ export function makehost(options?: HostOptions) { { ref: e.ref, holders }) } - /** EAGER reconciliation: run to a fixed point rather than scheduling. - * - * Two directions, and both are the reason `pending` exists. - * Activation is a STANDING REQUEST, not a one-shot event: a pending - * instance whose requirement arrives activates without being asked - * again, and a LIVE instance whose requirement is lost goes back to - * pending — recursively, through its own consumers. */ function reconcile(): void { let moved = true let rounds = 0 @@ -804,12 +550,6 @@ export function makehost(options?: HostOptions) { .filter(gatesactivation) .filter((q) => 0 === providersof(q).length) if (0 === lost.length) continue - // POLICY IS PER REQUIREMENT, not per instance (§11.3): only the - // definition that has the requirement knows what it can cope - // with, and one instance may hold both a `static` and a - // `dynamic` one. A `dynamic` requirement whose provider is gone - // leaves the consumer LIVE and notified; it is a statement - // about surviving a swap, so it does not restart here. if (lost.every((q) => !restartsonloss(q))) continue let bad = false try { run(e, 'deactivate', 'deactivate') } catch (err) { bad = true } @@ -846,14 +586,6 @@ export function makehost(options?: HostOptions) { // --- ordering ---------------------------------------------------- function order(point?: string): string[] { - // Sorted by declaration SEQUENCE, which is what makes the §7 sort's - // fall-through deterministic in a language whose maps have no - // insertion order. §7 breaks ties by `pos`; two instances CAN share - // one — `declare` defaults `pos` to the registry size, so an unload - // followed by a fresh declare reuses a surviving instance's — and - // past that the canonical was falling through to `Object.keys`. - // `seq` is that order, made explicit. Found by review of the go - // port. const bindings: Binding[] = Object.keys(inst) .filter((r) => 'live' === inst[r].status) .sort((a, b) => inst[a].seq - inst[b].seq) @@ -913,7 +645,6 @@ export function makehost(options?: HostOptions) { return pick.winner.fn(...args) } - /** The losers are VISIBLE rather than silently ignored (§6.3). */ function shadowed(point: string): string[] { const spec = points[point] if (undefined === spec) return [] @@ -924,14 +655,12 @@ export function makehost(options?: HostOptions) { const all: Exported[] = [] for (const ref of Object.keys(inst).sort()) { const e = inst[ref] - // Exports of a `loaded` (not live) instance are VISIBLE (§11). if ('declared' === e.status || 'failed' === e.status) continue for (const k of Object.keys(e.exports)) all.push({ ref, key: k, value: e.exports[k] }) } return resolveexport(spec, all) } - /** The live providers of a capability, best-first (§11.1). */ function capability(name: string): string[] { const cands: Candidate[] = [] for (const ref of Object.keys(inst).sort()) { @@ -946,18 +675,6 @@ export function makehost(options?: HostOptions) { // --- documents --------------------------------------------------- - /** §9.6: "load what is missing, UNLOAD WHAT IS GONE, patch what - * changed, and move activation state to match", with the stated - * ordering — "deactivations and unloads first (reverse load order), - * then loads, then activations in load order". - * - * THREE PHASES, NOT ONE INTERLEAVED LOOP, and both halves matter. An - * earlier draft walked the document once, unloading and activating per - * ref, which (a) never looked at instances the new document had - * DROPPED, so an integration removed from a config reload stayed live - * with its bindings and resources — the case this method exists for — - * and (b) activated the first instance before the second was declared, - * which is not the order §9.6 states. */ function apply(doc: any, profile?: string): void { guard() const norm = normalizeconfig({ @@ -984,15 +701,6 @@ export function makehost(options?: HostOptions) { return undefined !== ent && ent.active && 'eager' === ent.start } - // --- phase 1: deactivations and unloads, in REVERSE load order --- - // - // Two populations, and the second is the one that was missing: refs - // the document no longer names at all, and refs it now names as lazy - // or inactive. Toggling back to lazy or inactive returns an instance - // to `declared` BY UNLOADING IT (§9.6) — there is no loaded->declared - // transition and there should not be one, because an instance that - // has run `define` has state and bindings that only `close` can - // properly undo. const drop: string[] = [] for (const ref of Object.keys(inst)) { if ('declared' === inst[ref].status) continue @@ -1006,51 +714,20 @@ export function makehost(options?: HostOptions) { // --- phase 2: declare and patch EVERYTHING, in load order -------- for (const ref of want) { const ent: Instance = norm.instance[ref] - // NO OPTIONS HERE, and the omission is the fix rather than an - // oversight. `declare` ADOPTS the options map it is handed as the - // instance's own, so passing the resolved map made target and - // source THE SAME MAP in the refill three lines below — which - // cleared its own source and left a first-time instance with no - // options at all. A second apply of the same document filled them - // in, because by then `declare` returned the existing entry and - // the two maps were distinct. `declare` makes its own empty map - // and the refill fills it, so both paths are now one path. declare(ref, { order: ent.order, pos: ent.pos }) // The bar is REASSERTED ON EVERY APPLY, in both directions — a // document that turns the instance back on clears it, which is // the whole point of a config switch. inst[ref].barred = !ent.active - // REFILL rather than REBIND. A definition's callbacks close over - // the options map they were handed at `define`; replacing the - // reference here would leave every binding reading the values the - // first apply gave it, and a re-applied document would silently do - // nothing. Clearing and refilling the same map is portable to every - // language, unlike a getter or an interception hook — which the - // §18 portability budget forbids anyway. refill(inst[ref].options, optionsof[ref]) inst[ref].order = ent.order inst[ref].pos = ent.pos } - // --- phase 3: loads, in load order ------------------------------- - // - // ONLY THE EAGER, ACTIVE ONES. §9.6: "`apply` declares everything - // and activates only what asked for it… A document of twenty lazy - // instances is therefore twenty map entries and no executed code." for (const ref of want) { if (wantlive(ref)) load(ref) } - // --- phase 4: activations, in load order ------------------------- - // - // Separate from the loads because §9.6 names them separately, and - // the difference is observable: every `define` runs before the first - // `activate`, so a plugin cannot see a half-built registry. - // - // Activation order does not have to be dependency-sorted (§9.6): - // under §11 activation is a standing request, so a consumer - // activated before its provider sits in `pending` until the provider - // arrives a few lines later in the same plan. for (const ref of want) { if (wantlive(ref)) activate(ref) } @@ -1083,8 +760,6 @@ export function makehost(options?: HostOptions) { } } - /** Empty the target and refill it, so callers holding the reference - * see the new values. */ function refill(target: any, source: any): void { for (const k of Object.keys(target)) delete target[k] for (const k of Object.keys(source || {})) target[k] = source[k] @@ -1098,16 +773,11 @@ export function makehost(options?: HostOptions) { } function close(): void { - // A bulk teardown removing the holders too, so `hold` is suspended - // for exactly those holders (§11.3) - while the consumers-first - // cascade still runs, which is the half that matters. coordinated = true try { for (const r of Object.keys(inst).sort().reverse()) unload(r) } finally { coordinated = false } } - /** The same record §6.6 gives a plugin about itself, reachable from - * outside for the corpus. A plugin asks via `inst.position(point)`. */ function positionof(ref: string, point: string): any { const e = inst[canonref(ref)] if (!e) fail('plugin_not_loaded', 'no such instance: ' + ref, { ref }) @@ -1120,23 +790,6 @@ export function makehost(options?: HostOptions) { } } - /** §9.1: a host that reserves a name MUST still be able to declare - * the instance it reserved — "The host declares those instances - * itself, after the user merge, and always wins." - * - * Without this, `reserved` was a feature that made its own purpose - * impossible: every path to `declare` was barred, including the - * embedding host's, so reserving `station` meant station could never - * install the adapter it had reserved the name for. - * - * THE BOUNDARY IS BY METHOD, NOT BY CALLER, and that is a real limit - * rather than an oversight. No language here can tell the embedding - * host from a plugin holding the same host object — and a plugin that - * holds it can already call `close()`. What reservation protects is - * CONFIGURATION: documents, profile overlays, `VOXGIG_PLUGIN_*`, - * construction options and ordinary `declare`/`load`/`options` calls. - * That is what §9.1 lists, and all of it still goes through the - * check. */ function hostdeclare(ref: string, spec?: DeclareSpec): Live { guard() return declare(ref, { ...(spec || {}), hostowned: true }) diff --git a/typescript/src/Order.ts b/typescript/src/Order.ts index db1ba62..b356ba2 100644 --- a/typescript/src/Order.ts +++ b/typescript/src/Order.ts @@ -1,18 +1,3 @@ -/* Ordering (§7) — one rule, one place. - * - * sdkgen grew two special cases in `makeOptions` (`test`, then - * `station`) and the third was not far off. This sort is the whole - * replacement, and the tiers are in this order for a reason: - * - * 1 constraints before/after edges, by ref or by name - * 2 bands integer, lower first, default 0 - * 3 declaration ties break by `pos` - * - * CONSTRAINTS BEAT BANDS precisely so the correct tool wins when both - * are present. A band expresses a genuine cross-cutting layer; a - * constraint expresses a relationship between two specific things; and - * a band chosen by trial and error to fix an ordering bug is a bug - * wearing a number. */ import { OrderBlock, OrderRef, OrderSpec, fail } from './Types' import { parseref } from './Ref' @@ -23,10 +8,6 @@ export type Binding = { order?: OrderBlock } -/** Where a host has pinned a binding. Positional, not ordinal: §6.2 - * composes b1(b2(b3(base))) with the FIRST binding OUTERMOST, so - * `first` and `innermost` are opposites, and a pin spelled in sort - * terms would be read backwards by exactly the people it protects. */ export type Pin = { [name: string]: 'outermost' | 'innermost' | 'first' | 'last' } export function resolveorder(bindings: Binding[], pin?: Pin): string[] { @@ -48,9 +29,6 @@ export function resolveorder(bindings: Binding[], pin?: Pin): string[] { if (declared(o.before)) for (const t of targets(o.before!, nodes)) edges[b.ref].push(t) } - // Stable topological sort. Among ready nodes, band first (lower runs - // first), then `pos` — the position the DOCUMENT visibly states, not - // the order instances happened to load and not the incarnation `seq`. const indeg: { [ref: string]: number } = {} for (const b of nodes) indeg[b.ref] = 0 for (const from of Object.keys(edges)) { @@ -90,18 +68,12 @@ function band(b: Binding): number { return 'number' === typeof o.band ? o.band : 0 } -/** Was a constraint actually declared? An ABSENT one and an EMPTY LIST - * are both "no constraint"; only a non-empty spelling is an edge. */ function declared(spec?: OrderSpec): spec is OrderRef { return Array.isArray(spec) ? 0 < spec.length : null != spec && '' !== spec } -/** Matching is by REF, or by NAME across all of that definition's - * instances (§7) — which is the whole reason the two spellings exist. */ function targets(spec: OrderRef, nodes: Binding[]): string[] { const hit: string[] = [] - // One spelling or a list of them; a list fans out to the union of what - // each names, so `after: ['a', 'b']` means after BOTH. const specs = Array.isArray(spec) ? spec : [spec] for (const one of specs) { for (const b of nodes) { @@ -113,39 +85,15 @@ function targets(spec: OrderRef, nodes: Binding[]): string[] { return hit } -/** A PIN IS NOT A CONSTRAINT (§7). - * - * Constraints and bands are negotiable by definition — they are what - * plugins and documents say they want, and the sort's job is to satisfy - * them all. A pin is the host stating a structural invariant of its own - * architecture, which is a different kind of claim and must not lose a - * tie to a document. - * - * So a pin PLACES the binding at the named end, and an ordering that - * would move it away is `plugin_order_pinned` — rejected, not honoured - * into a broken wrap. Station's transport adapter must sit immediately - * outside the base transport; an `order` list that moves it has to be - * an error rather than a preference. */ function applypin(order: string[], edges: { [from: string]: string[] }, pin?: Pin): string[] { if (null == pin) return order let out = order.slice() - // SORTED, not insertion order. A pin map is data — it can arrive from - // a host's own construction options in any order, and two names pinned - // to the same end are order-sensitive (`{b:'first', a:'first'}` and - // `{a:'first', b:'first'}` give different results). JavaScript's - // `Object.keys` is insertion order and a Go map has none at all, so - // leaving it unstated made the same declaration mean different things - // in different ports. Sorted is the one order every language agrees - // on, and `order/pin#two-names` pins it. for (const name of Object.keys(pin).sort()) { const want = pin[name] const idx = out.findIndex((r) => parseref(r).name === name) if (-1 === idx) continue - // `first`/`outermost` is index 0; `last`/`innermost` is the end. - // §6.2 makes the first chain binding outermost, which is why the - // vocabulary is positional and why the two spellings pair this way. const wantfirst = 'first' === want || 'outermost' === want const ref = out[idx] out.splice(idx, 1) diff --git a/typescript/src/Point.ts b/typescript/src/Point.ts index 2d47d4f..df7c114 100644 --- a/typescript/src/Point.ts +++ b/typescript/src/Point.ts @@ -1,23 +1,8 @@ -/* Extension points (§6). Three kinds, chosen because they are what the - * two existing systems actually needed, and no more. - * - * A PLUGIN NEVER MUTATES THE HOST. That inversion is what makes - * deactivation possible: sdkgen's `utility.fetcher = wrapped` is not - * undoable, but "this instance holds slot 3 of the request chain" is - * undoable in O(1). OSGi named it the whiteboard pattern in 2004, in a - * paper called *Listeners Considered Harmful*, and for exactly this - * reason. */ import { fail } from './Types' export type Kind = 'hook' | 'chain' | 'provider' -/** §6.1: "fan-out" is not one answer but four. In a language with - * asynchrony, "call every binding" hides a decision — start them all - * and wait, await each in turn, or do not wait — and a design that - * leaves it unsaid gets four different answers from four ports, in the - * concurrency behaviour of production code no corpus entry happens to - * cover. */ export type Mode = 'emit' | 'parallel' | 'serial' | 'bail' export type Spec = { @@ -39,25 +24,12 @@ export type Bound = { ref: string point: string fn: any - /** `provider` ranks by HIGHEST band, unlike hook and chain which run - * lowest first. Kept as declared so the two rules stay visibly - * different rather than one being derived from the other by a reader - * who then gets it backwards. */ band: number } /** Fan-out. Return values are ignored except in `bail`. */ export function emit(bindings: Bound[], mode: Mode, arg: any): any { if ('bail' === mode) { - // Stops at the first binding that RETURNS A VALUE — the - // "handled, stop" case. A NULL DECLINES, and so does `undefined`. - // - // JavaScript can tell the two apart and almost nothing else in the - // target set can — Go, Python, Ruby, PHP, Lua, Java and C# each have - // exactly one way to say nothing. Making the distinction - // load-bearing would cost every one of them a wrapper type carried - // through the whole dispatch path, to express a difference their - // plugin authors cannot write. §18's budget settles it (§6.1). for (const b of bindings) { const v = b.fn(arg) if (null != v) return v @@ -77,13 +49,6 @@ export function emit(bindings: Bound[], mode: Mode, arg: any): any { return 'emit' === mode ? undefined : errors } -/** Composition: b1(b2(b3(base))), FIRST BINDING OUTERMOST (§6.2). - * - * Recomputed by the host whenever the live set changes, and cached - * between changes. Plugins receive `next` as an argument; they never - * see or store the previous value of anything. A plugin that stashes - * `next` and calls it after deactivation is a bug the host cannot - * prevent, and this says so rather than pretending otherwise. */ export function compose(bindings: Bound[], base: (...args: any[]) => any): (...args: any[]) => any { let next = base for (let i = bindings.length - 1; 0 <= i; i--) { @@ -94,9 +59,6 @@ export function compose(bindings: Bound[], base: (...args: any[]) => any): (...a return next } -/** At most one live implementation (§6.3). The winner is the highest - * band, ties broken by ref sort, and THE LOSERS ARE VISIBLE rather than - * silently ignored. */ export function provider(bindings: Bound[], spec: Spec): { winner?: Bound, shadowed: string[] } { if (0 === bindings.length) return { shadowed: [] } @@ -108,7 +70,7 @@ export function provider(bindings: Bound[], spec: Spec): { winner?: Bound, shado } const ranked = bindings.slice().sort((a, b) => { - if (a.band !== b.band) return b.band - a.band // HIGHEST band wins + if (a.band !== b.band) return b.band - a.band return a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0 }) diff --git a/typescript/src/Ref.ts b/typescript/src/Ref.ts index b6c8cc7..86a2bda 100644 --- a/typescript/src/Ref.ts +++ b/typescript/src/Ref.ts @@ -1,19 +1,8 @@ -/* Identity: name+tag, written `name$tag` (§4). - * - * The four pure functions, and the whole of what `ref` pins. They are - * the first thing a new port implements and the first corpus section it - * passes. */ import { Ref, fail } from './Types' /** §4: `^[a-zA-Z@][a-zA-Z0-9.~_\-/]*$`, max 1024. */ const NAME_RE = /^[a-zA-Z@][a-zA-Z0-9.~_\-\/]*$/ -/** §4: `^[a-zA-Z0-9.~_-]+$`, max 1024, or empty. - * - * The asymmetry with a name is deliberate: a tag MAY start with a digit - * because auto-tagging assigns integer tags (`stripe$1`), and a tag - * admits neither `@` nor `/` because a name is a package specifier and - * a tag is not. */ const TAG_RE = /^[a-zA-Z0-9.~_-]+$/ const MAX = 1024 @@ -33,17 +22,11 @@ export function checktag(tag: string): boolean { return TAG_RE.test(tag) } -/** `name$tag` -> the pair. Canonicalizing: `stripe$` and `stripe` both - * give tag ''. */ export function parseref(str: string): Ref { if ('string' !== typeof str) { fail('plugin_bad_name', 'ref must be a string') } - // Split on the FIRST `$`. Nothing in the grammar decides this — `$` is - // in neither character class — so the corpus is the arbiter (§4 rule - // 5), and it picks the split that blames the part actually at fault: - // `a$b$c` is a good name with a bad tag, not the reverse. const cut = str.indexOf('$') const name = -1 === cut ? str : str.substring(0, cut) const tag = -1 === cut ? '' : str.substring(cut + 1) @@ -58,9 +41,6 @@ export function parseref(str: string): Ref { return { name, tag } } -/** The pair -> `name$tag`. An empty tag NEVER writes the separator, - * which is the half of canonicalization formatref owns: parse tolerates - * `stripe$`, format never produces it, so a round trip is idempotent. */ export function formatref(name: string, tag?: string): string { const t = null == tag ? '' : tag if (!checkname(name)) { @@ -79,16 +59,6 @@ export function canonref(str: string): string { return formatref(r.name, r.tag) } -/** The canonical ref this string denotes, or `undefined` if it denotes - * none — the TOLERANT half of `canonref`, and the one a requirement - * name needs. - * - * A REQUIREMENT NAME IS A CAPABILITY NAME FIRST (§11.1), and capability - * names are free-form: the design puts no grammar on them, so `2fa` and - * `my cap` are perfectly good ones and neither is a well-formed ref. - * `canonref` RAISES on those, so asking it "is this a ref?" made a legal - * document kill the host at the first requirement whose name no ref - * could have. Answering `undefined` is the whole difference. */ export function tryref(str: string): string | undefined { if ('string' !== typeof str) return undefined const cut = str.indexOf('$') diff --git a/typescript/src/Resolve.ts b/typescript/src/Resolve.ts index 0a3cab6..67ca6bb 100644 --- a/typescript/src/Resolve.ts +++ b/typescript/src/Resolve.ts @@ -1,12 +1,3 @@ -/* Dynamic resolution (§10.2) — name to candidate module ids. - * - * PURE. It returns the ids a host WOULD try, in order; it does not load - * anything. That separation is what lets the corpus pin resolution in - * every language including those with no dynamic loading at all, and it - * is why §15.4 puts real module loading in per-port integration tests - * rather than here. - * - * The corpus `resolve` section arrives with P2. */ export type Source = | { kind: 'module', prefix?: string[] } @@ -43,16 +34,6 @@ const DEFAULT_SOURCES: Source[] = [ { kind: 'module', prefix: ['@voxgig/plugin-', 'voxgig-plugin-', 'plugin-', ''] }, ] -/** A MODULE PATH IS NOT A NAME (§10.2). The ref grammar starts a name - * with a letter or `@`, so `./local/thing` is not a ref and never - * reaches candidate generation — seneca allows a path where a plugin - * name goes, and this design deliberately does not, because a ref is an - * ADDRESS WITHIN A HOST and a path is a LOCATION ON A DISK. - * - * Loading from an explicit location is a separate field that bypasses - * candidate generation entirely: `from` is passed to the resolver - * verbatim, and a resolver that cannot honour a location raises - * plugin_resolve_failed. */ export function resolvefrom(from: string): string[] { return [from] } diff --git a/typescript/src/Types.ts b/typescript/src/Types.ts index b49aff1..b994445 100644 --- a/typescript/src/Types.ts +++ b/typescript/src/Types.ts @@ -1,15 +1,6 @@ -/* Shared types. Deliberately small: the design's §19 budget says the - * library owns naming, configuration, lifecycle, ordering, binding and - * teardown, and nothing else. */ -/** The two halves of an identity (§4). `tag` is '' when absent — never - * null and never missing, because a port returning three shapes for two - * states makes every downstream comparison a special case. */ export type Ref = { name: string, tag: string } -/** §5.1's seven statuses, and no more. A port that adds an eighth is - * diverging. `loading` and `closing` are observable only from inside a - * callback or from another thread. */ export type Status = 'declared' | 'loaded' | 'pending' | 'live' | 'failed' | 'loading' | 'closing' @@ -20,34 +11,11 @@ export type Instance = { active: boolean start: 'eager' | 'lazy' order?: OrderBlock - /** Levels 3-6 that are present, IN LADDER ORDER (§9.3). - * - * Normalization does not merge these, and cannot: §9.4 makes merge - * behaviour a property of the definition's option shape, which - * normalization has never seen. Flattening them here would make - * `$MERGE: append` unimplementable at load time, because the layers - * it must concatenate would already be collapsed. */ optionlayers: any[] } -/** §4.4 of DOCS.md — `band` rather than a nested `order`, because - * `order.order` needs explaining every time it is read. */ -/** `before`/`after` accept ONE spelling or a LIST of them. The list form - * is not decoration: a host may need a binding after two unrelated others, - * and station's per-feature `order` has carried string lists since Stage 3b. - * Plugin typed this as a bare string and matched with `===`, so a list was - * SILENTLY DROPPED - the sort came out exactly as if no constraint had been - * declared, with nothing raised. */ export type OrderRef = string | string[] -/** What a NORMALIZED block may hold, which is wider than what constrains - * anything. Normalization is a carrier, not an interpreter (§9.1): the - * block comes back exactly as authored, so a value written `null` is still - * `null` here, and `config/normorder#null-survives` pins that. Typing it - * as `OrderRef | undefined` told callers a runtime null was impossible - * while `Config.ts` assigned one straight through `any`. `declared()` is - * the gate that turns a spec into a constraint; nothing else may assume - * one. */ export type OrderSpec = OrderRef | null export type OrderBlock = { before?: OrderSpec, after?: OrderSpec, band?: number } @@ -58,12 +26,6 @@ export type Normalized = { default: { [name: string]: any } } -/** §12's detail fields, IN THIS FIXED ORDER. - * - * The order is part of the contract, not a formatting preference. An - * earlier draft named six fields while other sections promised - * diagnostics that had nowhere to go, which would have left each port - * inventing its own order and breaking message parity. */ export const DETAIL_ORDER = [ 'host', 'ref', 'name', 'tag', 'point', 'key', 'capability', 'range', 'version', 'match', 'candidates', 'cycle', 'holders', @@ -86,11 +48,6 @@ export function formaterror(code: string, text: string, details?: { [k: string]: return 'plugin/' + code + ': ' + text + tail } -/** Every error carries a §12 code. Ports compare by CODE and never by - * message: wording is a port's own business, and pinning the words - * would make every translation a corpus change. The FORMAT, however, is - * pinned — a parseable message is what makes a log searchable across - * twenty languages. */ export class PluginError extends Error { code: string text: string diff --git a/typescript/src/Version.ts b/typescript/src/Version.ts index 6787b05..df94996 100644 --- a/typescript/src/Version.ts +++ b/typescript/src/Version.ts @@ -1,16 +1,3 @@ -/* Versions and ranges (§11.2). - * - * TWO FIELDS AND ONE PREDICATE. A capability declares `version`, a - * concrete version. A requirement declares `range`. A requirement is - * satisfied when the names match, the `match` passes, and: - * - * the provider's `version` falls inside the requirement's `range`. - * - * That is the whole rule. There is no third field and no second - * comparison — an earlier draft added a provider-side `compat` range, - * which left three values and no statement of how they combine, and - * three defensible readings of one declaration is worse than the - * ambiguity it was introduced to fix. */ import { fail } from './Types' @@ -18,25 +5,8 @@ export type Range = { lo: number[], hi: number[] } const VERSION_RE = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/ -/** A COMPONENT IS BOUNDED, like a ref is (§4's 1024). - * - * The grammar admits an unbounded digit sequence, and every language - * then disagrees about what happens past its integer range: JavaScript - * silently loses precision, Go's `Atoi` errors (and a port ignoring that - * gets 0), C overflows, Python is exact. `satisfies("0", - * "9223372036854775808")` was false in the canonical and true in go — - * from the same corpus. - * - * 2^31-1 because every port has a signed 32-bit integer, and no real - * version has ever needed more. Stated rather than left to arithmetic - * nobody agrees on. Found by review of the go port. */ const COMPONENT_MAX = 2147483647 -/** Two forms and no more (§11.2): - * - * '2.1' >= 2.1.0 and < 3.0.0 - * '~2.1' >= 2.1.0 and < 2.2.0 - */ export function parserange(range: string): Range { if ('string' !== typeof range || 0 === range.length) { fail('plugin_bad_range', 'invalid range: ' + range, { range }) @@ -69,9 +39,6 @@ export function parseversion(version: string): number[] { ] } -/** One component, bounded. `plugin_bad_range` either way — the same code - * the rest of the grammar's failures use, because "this is not a version - * I can compare" is one fact however it went wrong. */ function component(digits: string, whole: string, field: string): number { const n = Number(digits) if (!Number.isInteger(n) || COMPONENT_MAX < n) { diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 83add06..7897963 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -1,7 +1,3 @@ -/* The canonical surface `make parity` checks (AGENTS.md §4). Small on - * purpose (§19): everything else is methods on the host and instance - * types, because a library that grows a second public entry point per - * feature is a library twenty ports pay for twice. */ export { makehost } from './Host' export { makecatalog } from './Catalog' diff --git a/typescript/test/corpus.ts b/typescript/test/corpus.ts index 1694b3d..d69da12 100644 --- a/typescript/test/corpus.ts +++ b/typescript/test/corpus.ts @@ -1,15 +1,3 @@ -/* The corpus runner. - * - * Reads spec/plugin.json — the COMMITTED artifact, not the aontu source - * — exactly as every other port's runner does. No port needs a Node - * toolchain to run its tests, and this one does not get a private door - * into the source either. - * - * A group name selects the subject. That is the whole dispatch, and it - * is deliberately dumb: a runner that inferred the subject from the - * entry's shape would silently run the wrong function when an entry was - * mistyped, which is the failure the closed Entry shape exists to catch - * at build time. */ import * as Fs from 'node:fs' import * as Path from 'node:path' @@ -103,11 +91,6 @@ function isMap(v: any): boolean { return null != v && 'object' === typeof v && !Array.isArray(v) } -/** Run one entry against a subject and report the disagreement, if any. - * - * The three combinations the spec format allows are enforced here as - * well as at build time, because a runner that quietly accepted `err` - * beside `out` would let a contradictory entry pass. */ export function check(e: Entry, subject: (e: Entry) => any): string | null { if (undefined !== e.err && undefined !== e.out) { return 'entry has both err and out' @@ -125,9 +108,6 @@ export function check(e: Entry, subject: (e: Entry) => any): string | null { if (undefined !== e.err) { if (null == raised) return 'expected a raise, got: ' + JSON.stringify(value) if (true !== e.err) { - // Errors compare by CODE (§12). Message wording is a port's own - // business, and pinning it would make every translation a corpus - // change. if (raised.code !== e.err) { return 'expected code ' + e.err + ', got ' + raised.code + ' (' + raised.message + ')' } diff --git a/typescript/test/driver.ts b/typescript/test/driver.ts index 3a532da..81a58fd 100644 --- a/typescript/test/driver.ts +++ b/typescript/test/driver.ts @@ -7,9 +7,6 @@ import { makehost, makecatalog } from '../dist/index' import type { Definition } from '../dist/index' -/** §4.3's six probes. Their behaviour is as much the contract as the - * runner is — this is where twenty implementations of `noisy` are made - * to fail at the same callback in the same way. */ export function probes(): Definition[] { const record = (name: string): Definition => ({ name, @@ -21,8 +18,6 @@ export function probes(): Definition[] { name: 'probe', define: (i: any) => { i.state.count = i.state.count || 0 - // One hook binding (`p`) and one chain wrap (`c`) — the workhorse - // shape DOCS.md §4.3 specifies. i.bind('p', () => { i.state.count = (i.state.count || 0) + 1 }, i.options && i.options.band) // Wrap AFTER next, so the result spells the nesting left to right: @@ -31,11 +26,6 @@ export function probes(): Definition[] { i.bind('c', (next: any, v: any) => (i.options && i.options.wrap ? i.options.wrap : ':') + next(v), i.options && i.options.band) i.export('client', i.ref) - // A SECOND SCALAR KEY. Every `export` entry used to read - // `client`, so a port whose `exports` ignored the key and - // answered with the instance's first export passed all of them. - // `inst` below cannot close that: it is an instance api, shaped - // differently in every port, and no entry can assert on it. i.export('mark', 'marked') // The instance api itself, so the driver's `stray` command can // call `release` from OUTSIDE a lifecycle callback — which is the @@ -66,9 +56,6 @@ export function probes(): Definition[] { boom(i, 'define') }, activate: (i: any) => { - // Acquire BEFORE the raise, so a failing activate has something - // to leak if the scope does not unwind — which is the whole point - // of the entry that asserts open === 0 afterwards. i.acquire() reenter(i, 'activate') boom(i, 'activate') @@ -81,12 +68,6 @@ export function probes(): Definition[] { name: 'greedy', define: (i: any) => { i.state.count = 0 - // §8.1 puts resource capture in `activate`. `early` reaches for it - // in `define`, where the scope does not exist yet — a `loaded` - // instance is not supposed to hold anything, and `unload` on one - // never unwinds. It NAMES the call, because `acquire` and - // `release` carry the guard separately and an entry that exercised - // only one would leave the other's mutation alive. if (i.options && 'acquire' === i.options.early) i.acquire() if (i.options && 'release' === i.options.early) i.release(() => undefined) }, @@ -95,36 +76,14 @@ export function probes(): Definition[] { const rel = i.options.release || 0 const handles: (() => void)[] = [] for (let k = 0; k < n; k++) handles.push(i.acquire()) - // Release some explicitly; the DIFFERENCE is what the instance - // scope must unwind by itself (§8.3), and that difference is the - // whole test. for (let k = 0; k < rel; k++) handles[k]() - // `mark` registers N FOREIGN releases — §8.3's `release`, the - // half `acquire` cannot exercise — each recording its own index - // as it runs. - // - // THE RECORDED LIST IS THE ONLY THING THAT DISTINGUISHES A - // REVERSE UNWIND FROM A FORWARD ONE. `acquire`'s handles are - // idempotent counter decrements, so running them in either - // direction leaves the same `open`, and a port unwinding forwards - // passed every other entry in this section. - // `bind` is `early`'s counterpart for §8.1's OTHER half. Binding - // declaration belongs in `define`; this names the callback that - // tries it from somewhere else, because §12 has carried - // `plugin_bind_scope` since before anything raised it and a - // binding added in `activate` went live without being part of the - // loaded definition. if ('activate' === i.options.bind) i.bind('p', () => undefined) const mark = i.options.mark || 0 i.state.unwound = [] for (let k = 0; k < mark; k++) { i.release(() => { - // `markfail` makes the release RAISE. §8.3 says every entry - // still runs, the errors are collected, and the instance ends - // in `failed` — none of which any entry exercised while every - // release was infallible. if (i.options.markfail) throw new Error('release failed at ' + k) i.state.unwound.push(k) }) @@ -132,9 +91,6 @@ export function probes(): Definition[] { }, } - // `deactivate` completes the pair: the guard is on the phase, not on - // "not define", and an entry exercising only one leaves the other's - // mutation alive. greedy.deactivate = (i: any) => { if (i.options && 'deactivate' === i.options.bind) i.bind('p', () => undefined) } @@ -152,16 +108,6 @@ export function probes(): Definition[] { }, activate: (i: any) => { i.acquire() - // WHICH provider this instance took, when the entry asks for it. - // `options.capof` names one of this instance's requirements and - // the answer is exported as `cap`, which is the only way a corpus - // entry can see `inst.capability` at all: the host's own - // `capability` answers with the RANKING, not with the choice this - // instance made, and §11.4 makes those differ. - // - // From `activate` rather than `define`, because the selection is - // made at activation — and so a deactivate/reactivate cycle - // re-exports whatever the second activation chose. if (i.options && i.options.capof) { i.export('cap', i.capability(i.options.capof)) } @@ -201,20 +147,12 @@ function boom(i: any, cb: string): void { function reenter(i: any, cb: string): void { if (i.options && cb === i.options.reenter) { - // A transition from inside a lifecycle callback (§5.2). i.host().activate(i.ref) } } export type Cmd = { do: string, [k: string]: any } -/** Run a command list and return §4.5's observable. Stops at the first - * raise; the entry's `err` matches its code. */ -/** The points every driver host declares. DOCS.md §4.3 defines `probe` - * as binding one hook point (`p`) and wrapping one chain point (`c`), so - * a host without them cannot load the probe at all — they are part of - * the contract's baseline rather than a fixture convenience. `v` is the - * provider point the `provider` probe defaults to. */ const BASEPOINTS: { [k: string]: any } = { p: { kind: 'hook' }, c: { kind: 'chain', base: (v: any) => v }, @@ -225,9 +163,6 @@ function withpoints(extra?: { [k: string]: any }): { [k: string]: any } { const out: { [k: string]: any } = {} for (const k of Object.keys(BASEPOINTS)) out[k] = BASEPOINTS[k] for (const k of Object.keys(extra || {})) { - // A `host` command REPLACES a base point rather than merging into - // it, so an entry can redeclare `c` with its own base or `v` as - // exclusive without inheriting the default's shape. out[k] = (extra as any)[k] } return out @@ -258,19 +193,6 @@ export function drive(cmds: Cmd[]): any { }) break case 'define': { - // §10.1's static registration: the definition ENTERS THE CATALOG - // here, and registration is where its option shape is validated - // (§9.4) — before any load, so a malformed shape fails at one - // moment in every host rather than whenever a document happens - // to exercise the key. - // - // §4.2's three keys, all of them live. `probe` names the PROBE - // whose callbacks back the definition and `name` is what the - // definition is called — two keys that ten entries passed as - // equal strings, so a driver ignoring `probe` passed them all. - // `shape` is the option shape, and its arrival is what makes - // this command more than a re-registration of the pre-seeded - // probe set. const from = undefined === c.probe ? c.name : c.probe let def: any = { name: c.name } for (const d of probes()) if (from === d.name) def = { ...d, name: c.name } @@ -282,9 +204,6 @@ export function drive(cmds: Cmd[]): any { host.load(c.ref, { options: c.options, order: c.order, definition: c.definition }) break case 'ready': - // declare FIRST, so the ordering block and definition reach the - // instance — `ready` walks the staircase, it does not carry - // configuration of its own. host.declare(c.ref, { options: c.options, order: c.order, definition: c.definition }) host.ready(c.ref) break @@ -303,8 +222,6 @@ export function drive(cmds: Cmd[]): any { case 'capability': last = host.capability(c.name); break case 'trace': last = host.trace(); break case 'hostdeclare': - // §9.1's host-owned path: the embedding host installing the - // instance whose name it reserved. last = (host as any).hostdeclare(c.ref, { tag: c.tag, options: c.options, order: c.order, definition: c.definition, }).ref @@ -335,20 +252,10 @@ export function drive(cmds: Cmd[]): any { if ('count' === c.method) { last = e.state.count || 0; break } if ('unwound' === c.method) { last = e.state.unwound || []; break } if ('position' === c.method) { - // Reached through the instance api, which is where §6.6 puts - // it — a plugin asks about itself. last = (host as any).positionof(c.ref, c.point) break } if ('stray' === c.method) { - // A release from OUTSIDE a lifecycle callback. The scope - // belongs to the activation; a call from anywhere else has no - // scope to belong to, so it raises. - // - // THIS BRANCH USED TO DO NOTHING, and its corpus row stayed - // green whatever `release` did with its guard. The probe - // exports its own instance api precisely so the call can be - // made from here. const strayapi: any = host.exports(c.ref + '/inst') strayapi.release(() => undefined) break @@ -360,9 +267,6 @@ export function drive(cmds: Cmd[]): any { } } catch (err: any) { - // §4.1: `catch` records the raise and lets the run continue, which - // is the only way to observe a `failed` instance — §5.2's whole - // claim is that it stays registered and inspectable. if (true !== c.catch) throw err } } diff --git a/typescript/test/featurehost.test.ts b/typescript/test/featurehost.test.ts index b1bf4f6..1a25bbb 100644 --- a/typescript/test/featurehost.test.ts +++ b/typescript/test/featurehost.test.ts @@ -1,18 +1,3 @@ -// RUN: npm test -// -// P3 item 2 (§17.2): the bridge runs an UNMODIFIED sdkgen feature class -// as a plugin. -// -// The feature classes below are written the way sdkgen generates them — -// `init(ctx, options)`, hook methods named after hook points, and a -// transport wrap done by assigning `ctx.utility.fetcher`. Nothing in -// them knows about plugin. That is the claim: the vocabularies map, and -// the mapping is mechanical. -// -// P3's exit names the property that makes the bridge worth having: the -// bridge "runs RetryFeature unmodified, AND DEACTIVATES IT — which -// sdkgen alone cannot do", because sdkgen's wrap is an irreversible -// assignment to a slot. import { describe, test } from 'node:test' import { deepStrictEqual, equal, ok, throws } from 'node:assert' @@ -66,8 +51,6 @@ class LogFeature { } } -// An sdkgen-shaped feature that serves a `__replace__` seam: one -// method, named for the seam, returning the replacement. class CodecFeature { name = 'codec' Encode(v: any) { return 'encoded:' + v } @@ -86,8 +69,6 @@ class CtxFeature { } } -// ...and one carrying the lifecycle methods §17.2 expects an adopting -// sdkgen to add, which today's generated features do not have. class PhasedFeature { name = 'phased' log: string[] = [] @@ -140,10 +121,8 @@ describe('featurehost-bridge', () => { const ctx: any = {} host.emit('PreRequest', ctx) - // Both features' PreRequest ran, in resolved order. deepStrictEqual(ctx.seen, ['retry', 'log']) - // A hook only one of them declares reaches only that one. host.emit('PostOperation', {}) const log: any = host.exports('log$a/feature') deepStrictEqual(log.lines, ['init:info', 'done']) @@ -174,10 +153,6 @@ describe('featurehost-bridge', () => { host.deactivate('retry$a') equal('loaded', host.list()['retry$a']) - // The wrap is GONE from the chain - the base answers directly - and - // the hook binding is gone with it. sdkgen assigns - // `ctx.utility.fetcher` and has nowhere to put the old value back; - // a binding just comes out. equal('base:2', host.call(REQUEST_POINT, 2)) equal(1, feature.attempts, 'the wrap must not run after deactivate') @@ -210,9 +185,6 @@ describe('featurehost-bridge', () => { }) test('the declared hook vocabulary is the SDK s, named as today', () => { - // §17.2: "13 hook points, named exactly as today". The core model - // declares eleven; the rest come from the features installed, which - // is why featurepoints takes them rather than hard-coding a count. equal(11, SDK_HOOKS.length) for (const h of ['PostConstruct', 'PreRequest', 'PreResponse', 'PostOperation']) { ok(-1 !== SDK_HOOKS.indexOf(h), h + ' is part of the vocabulary') @@ -224,7 +196,6 @@ describe('featurehost-bridge', () => { equal('hook', points['Custom'].kind, 'an SDK s own extra hook is declarable') }) - // ---- what review found the first version got wrong ---------------- test('a repeated hook name binds ONCE, not twice', () => { // `hooks` is "what this SDK's features declare", and a feature may @@ -240,10 +211,6 @@ describe('featurehost-bridge', () => { }) test('a replacement seam is a PROVIDER point, not a hook', () => { - // §17.2: "`provider` points for the seams `__replace__` currently - // serves". At most one wins, the losers are visible, the host keeps - // a default - which is what a replacement means and what a chain - // cannot express. const points = featurepoints((n: number) => n, { replace: ['Encode'] }) equal('provider', points['Encode'].kind) @@ -270,7 +237,6 @@ describe('featurehost-bridge', () => { const feature: any = host.exports('ctxread$a/feature') equal(client, feature.sawclient, 'the SDK s own client, not the instance') deepStrictEqual(log, ['init'], 'the SDK s own utility survives the trap') - // ...and the fetcher trap still works alongside it. equal('base:4', host.call(REQUEST_POINT, 4)) }) @@ -283,10 +249,6 @@ describe('featurehost-bridge', () => { }) test('a feature s own activate/deactivate are wired, in phase', () => { - // §17.2 splits `init` into define (declare bindings) and activate - // (capture). An unmodified feature has no such split - which is why - // the bridge's claim is about BINDINGS - but one that grows the - // methods gets them called where the model puts them. const host = bridge([{ name: 'phased', cls: PhasedFeature }]) host.ready('phased$a') const feature: any = host.exports('phased$a/feature') @@ -304,17 +266,10 @@ describe('featurehost-bridge', () => { }) test('a FAILED instance still gets its feature s close on unload', () => { - // §5.2: `unload` is the only exit from `failed`, and it runs - // `close`. The bridge read the feature back through `exports`, - // which §11 hides for a failed instance — so `close` saw undefined - // and did nothing, in exactly the case where a feature holding a - // connection most needs it. The feature lives in the instance's own - // state, which survives every status. const host = bridge([{ name: 'broken', cls: BrokenFeature }]) throws(() => host.ready('broken$a'), /activate failed/) equal('failed', host.list()['broken$a']) - // The export is gone, as §11 says it must be... equal(undefined, host.exports('broken$a/feature')) // ...and `close` still reaches the feature. THE FEATURE'S OWN LOG @@ -329,12 +284,6 @@ describe('featurehost-bridge', () => { }) test('sequential and nested calls each reach their own next', () => { - // The shared `current` slot is correct for every synchronous path: - // the binding sets it immediately before the wrap runs. The known - // limit is an AWAITING wrap overtaken by a second request, which - // needs a per-invocation channel the feature would have to be - // modified to accept - stated in FeatureHost.ts rather than - // pretended away. const host = bridge([ { name: 'retry', cls: RetryFeature }, { name: 'log', cls: LogFeature }, @@ -347,7 +296,6 @@ describe('featurehost-bridge', () => { const feature: any = host.exports('retry$a/feature') equal(2, feature.attempts) - // ...and the chain recomposes under it between calls. host.deactivate('retry$a') equal('base:3', host.call(REQUEST_POINT, 3)) equal(2, feature.attempts)