diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index d638901..6997d7f 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -15,4 +15,11 @@ jobs: - name: Build run: swift build - name: Test - run: swift test \ No newline at end of file + run: swift test + + - name: Emit IR v2 from examples + run: | + .build/debug/hypercode emit Examples/service.hc --hcs Examples/service.hcs --format json > /tmp/ir-dev.json + .build/debug/hypercode emit Examples/service.hc --hcs Examples/service.hcs --ctx env=production --format json > /tmp/ir-prod.json + - name: Validate IR v2 against schema + run: npm exec --yes --package=ajv-cli@5 -- ajv validate --spec=draft2020 -s Schema/hypercode-ir-v2.schema.json -d /tmp/ir-dev.json -d /tmp/ir-prod.json \ No newline at end of file diff --git a/DOCS/Workplan.md b/DOCS/Workplan.md index 944155c..fea727d 100644 --- a/DOCS/Workplan.md +++ b/DOCS/Workplan.md @@ -20,7 +20,7 @@ PR-1 substrate ──▶ PR-2 HC-112 IR v2 ──▶ PR-3 HC-110 explain ──▶ PR-4 HC-111 contracts (also needs PR-1+PR-2) ``` -## PR-1 — Substrate (`feat/hc-112-substrate`) +## PR-1 — Substrate (`feat/hc-112-substrate`, [#19](https://github.com/0al-spec/Hypercode/pull/19) open) All three features share these foundations; merges first with zero user-visible change. @@ -86,7 +86,7 @@ Cascade semantics are **unchanged** — only retention of losing matches is adde --- -## PR-2 — HC-112 IR v2 (`feat/hc-112-ir-v2`) +## PR-2 — HC-112 IR v2 (`feat/hc-112-ir-v2`, [#20](https://github.com/0al-spec/Hypercode/pull/20) open) Depends on PR-1. @@ -138,7 +138,7 @@ validates `Examples/` IR output against `Schema/hypercode-ir-v2.schema.json`. --- -## PR-3 — HC-110 `hypercode explain` (`feat/hc-110-explain`) +## PR-3 — HC-110 `hypercode explain` (`feat/hc-110-explain`, [#21](https://github.com/0al-spec/Hypercode/pull/21) open) Depends on PR-1 and PR-2. @@ -178,7 +178,7 @@ Golden-file tests against `Examples/service.{hc,hcs}` for: --- -## PR-4 — HC-111 Monotonic contracts (`feat/hc-111-contracts`) +## PR-4 — HC-111 Monotonic contracts (`feat/hc-111-contracts`, [#22](https://github.com/0al-spec/Hypercode/pull/22) open) Depends on PR-1, PR-2, and PR-3. @@ -239,7 +239,7 @@ Update `EBNF/Hypercode_Syntax.md` with `.hcs` contract block grammar. | File | PR-1 | PR-2 | PR-3 | PR-4 | |---|---|---|---|---| | `Sources/Hypercode/HCS/CascadeSheet.swift` | TypedValue, Match, Rule.file | — | — | Contract types | -| `Sources/Hypercode/HCS/Resolver.swift` | Provenance.file, losers retained | — | — | contract eval | +| `Sources/Hypercode/HCS/Resolver.swift` | Provenance.file, losers retained | — | — | — (value validation → PR-5, see R9) | | `Sources/Hypercode/HCS/CascadeSheetReader.swift` | parseProperty types, read(file:) | — | — | @contract: block | | `Sources/Hypercode/Emit/Emitter.swift` | TypedValue render | v2 emitter | — | contracts in v2 | | `Sources/HypercodeCLI/main.swift` | pass file to reader | --ir-version flag | explain subcommand | — | @@ -250,3 +250,72 @@ Update `EBNF/Hypercode_Syntax.md` with `.hcs` contract block grammar. | `Tests/ContractTests.swift` | — | — | — | new | | `RFC/Hypercode.md` | — | — | — | v0.2 bump | | `EBNF/Hypercode_Syntax.md` | — | — | — | @contract syntax | + +--- + +## Review follow-ups (strict review 2026-06-11, PR-1..PR-4) + +All findings reproduced against `feat/hc-111-contracts` (69a38f0). R1–R8 block merging +[#22](https://github.com/0al-spec/Hypercode/pull/22); R9–R12 need an owner decision first. + +### A — Blocking #22 + +- ✅ **R1 — IR v2 violates its own schema: child nodes have no `hash`.** + `Emitter.intermediateV2` post-inserts `hash` only into root forest nodes; the schema + requires it on every `resolvedNode`. Fix: compute/pass the hash inside `nodeV2` + recursion instead of the zip-insert. Add a nested-document schema-shape test. +- ✅ **R2 — emitter crash on large numerals.** `Emitter.json` `.double` whole-number + branch does `String(Int(number))`; a 26-digit `.hcs` value parses as `Double(1e26)` + and traps (`exit 133`). Render without `Int` conversion; add a regression test. + YAML path is unaffected. +- ✅ **R3 — ContractValidator false positive on disjoint selectors.** Pairs are compared + purely by specificity; `Service { timeout <= 100 }` vs `.slow { timeout <= 500 }` + errors (HC2102) even when no node matches both. Gate the pairwise check on + "∃ node in forest matched by both selectors" — `Validator.validate(_:against:)` + already has the forest. +- ✅ **R4 — equal-specificity contract conflicts pass silently.** Two `Service:` blocks + with `timeout: int` vs `timeout: float` produce no diagnostic (guard skips + `specificity ==`). At minimum flag type conflicts at equal specificity. +- ✅ **R5 — Foundation leak in core.** `Explainer.renderMatch` uses + `padding(toLength:withPad:startingAt:)` (Foundation/NSString) with no import in file — + compiles only via Swift 5 leaky member lookup; breaks under `MemberImportVisibility`. + Hand-roll the padding; core stays Foundation-free. +- ✅ **R6 — compiler warning.** `Explainer.swift:113` `var line1` never mutated → `let`. +- ✅ **R7 — test framework consistency.** `ContractTests.swift` uses Swift Testing; + the other 14 test files (incl. SHA256/Explain/Emitter tests from this same chain) + use XCTest. Convert to XCTest (or record a deliberate migration decision). +- ✅ **R8 — undelivered plan items.** Root `workplan.md` M8: HC-110/111/112 still ⬜ + (mark only on merge); `RFC/Hypercode.md` not bumped (PR-4 promised v0.2; §Limitations + "untyped strings in IR v1" needs a v2 note); IR `contracts[]` not sorted by ascending + specificity as specified; `Package.swift` `0.5.0-dev` version comment missing (PR-1); + CI `ajv` schema-validation step missing (PR-2); EBNF v0.2: `` wrongly requires + quoted strings (bare `driver: sqlite` is valid), header `Date:` stale, and the HC21xx + semantics table belongs in `Hypercode_Resolution.md`. + +### B — Decided 2026-06-11, pending implementation + +- ⬜ **R9 — contract value validation is not implemented.** Values are never checked + against contracts (`timeout: 999` under `int <= 300` passes; wrong-type values pass). + **Decision:** separate **PR-5** — value validation against contracts with a new + **HC2104** diagnostic (type mismatch, bounds violation, missing required property). + #22 stays scoped to grammar + monotonicity; files table fixed (`Resolver` row moved + to PR-5). +- ✅ **R10 — v1 emitter is now lossy for numeric-looking strings.** `version: 1.10` → + `"1.1"`, `build: 0123` → `"123"` under `--ir-version 1` (regression vs pre-PR-1 raw + strings). **Decision:** store the source lexeme alongside the typed value; v1 emits + the lexeme byte-for-byte, v2 keeps typed values. Implement in the open chain before + merge. +- ✅ **R11 — D4 deviation.** SHA-256 shipped as a CryptoKit wrapper, not the approved + vendored pure-Swift implementation; core is now Apple-only (no Linux). + **Decision:** switch to `swift-crypto` (same API surface, Linux-capable); accept it + as the project's second dependency after SpecificationCore. D4 row updated. +- ✅ **R12 — absent-bound semantics underspecified.** A more-specific contract that + omits `min`/`max` is not flagged as widening. **Decision:** omitted bound = inherited + via interval intersection — the effective contract for a node is the intersection of + all applicable contracts. Current validator behavior is correct; fix is to specify + this normatively in the RFC contracts section (bundle with the R8 RFC update). +- ✅ **R13 — public API construction gaps.** `Match`, `PropertyTrace`, `NodeTrace` have + no public inits, so external consumers cannot build `ResolvedValue`; `ResolvedValue` + duplicates winner data with no invariant. **Decision:** add explicit `public init` to + all three (project practice) and add `ResolvedValue.init(winner:losers:)` deriving + `value`/`provenance` from `winner` so inconsistent construction is impossible. diff --git a/EBNF/Hypercode_Resolution.md b/EBNF/Hypercode_Resolution.md index 0c2b11e..ac99a23 100644 --- a/EBNF/Hypercode_Resolution.md +++ b/EBNF/Hypercode_Resolution.md @@ -2,9 +2,9 @@ **Status:** Draft -**Version:** 0.1 +**Version:** 0.2 -**Date:** June 2, 2026 +**Date:** June 11, 2026 **Author:** Egor Merkushev @@ -101,7 +101,65 @@ ctx ⊢ n ⇓ ⟨ n.type, n.class, n.id, The whole document resolves by applying this to each top-level node. -## 7. Conformance +## 7. Contracts (HC-111) + +A `@contract:` block (syntax: [Hypercode_Syntax.md §6.3](Hypercode_Syntax.md)) +attaches property constraints to selectors. Values cascade (last sufficiently +specific writer wins); contracts **accumulate** — every applicable contract +governs the node simultaneously. + +### 7.1 Effective contract — intersection + +For a node `n` and property key `k`, the effective contract is the +**intersection** of all contracts whose selector matches `n` and that +constrain `k`: + +```text +applicable(n, k) = { c ∈ contracts | match(c.selector, n) ∧ k ∈ c.properties } + +effective(n, k).type = the common type of all applicable (must agree) +effective(n, k).min = max over declared lower bounds +effective(n, k).max = min over declared upper bounds +effective(n, k).required = true if any applicable contract requires k +``` + +An **omitted bound is not a statement** — it inherits through the +intersection. A more specific contract that re-declares `k` without `min` +keeps the inherited lower bound; it does not lift it. + +### 7.2 Monotonicity validation + +Specificity relates two contracts only when they can govern the same node — +exactly as in the CSS cascade. The validator therefore checks a pair of +contracts only if **at least one node in the document matches both +selectors**. For such a pair where `spec(A) < spec(B)`: + +| Violation | Code | Description | +|-----------|------|-------------| +| Type changed | HC2101 | `B` declares a different type than `A` for the same key | +| Interval widened | HC2102 | `B` lowers a declared `min` or raises a declared `max` of `A` | +| Required → optional | HC2103 | `B` marks `[?]` a key that `A` requires | + +At **equal specificity** both contracts apply with equal force; a type +conflict makes the intersection unsatisfiable and is reported as HC2101. +Bounds at equal specificity simply intersect and are not a conflict. + +All three are `error`-severity diagnostics; `hypercode validate` exits +non-zero. + +### 7.3 Value validation (planned) + +Checking the resolved values themselves against the effective contract +(type conformance, bounds, required presence) is diagnostic **HC2104**, +scheduled as PR-5 in [DOCS/Workplan.md](../DOCS/Workplan.md). + +### 7.4 IR + +IR v2 echoes the applicable contracts per property, sorted by ascending +specificity (declaration order as tie-breaker), so a consumer can re-derive +the effective contract without re-parsing the sheet. + +## 8. Conformance The reference fixtures are [`Examples/service.hc`](../Examples/service.hc) and [`service.hcs`](../Examples/service.hcs), resolved in @@ -114,12 +172,15 @@ and provenance. Any conforming resolver must reproduce those results, e.g.: hypercode resolve Examples/service.hc --hcs Examples/service.hcs --ctx env=production ``` -## 8. Deferred +## 9. Deferred - **Origin / importance.** RFC §4.2.3 lists origin/importance above specificity in the precedence order. There is no syntax for it yet (no `!important`, no override-file origin), so the current precedence key is `(specificity, source-order)`. When syntax is introduced, it becomes the most-significant component of `precedence`. -- **Typed scalars.** Property values are currently raw strings (quotes stripped). - Typed scalars (bool/int/…) arrive with real YAML input, if ever needed. +- **Contract value validation.** §7.3 — HC2104, planned as PR-5. + +*(Typed scalars, previously deferred, landed with IR v2: bare scalars are +type-inferred at parse time, with the source lexeme preserved for v1 +round-tripping.)* diff --git a/EBNF/Hypercode_Syntax.md b/EBNF/Hypercode_Syntax.md index cf03b7f..c46c9e1 100644 --- a/EBNF/Hypercode_Syntax.md +++ b/EBNF/Hypercode_Syntax.md @@ -2,9 +2,9 @@ **Status:** Draft -**Version:** 0.1 +**Version:** 0.2 -**Date:** July 12, 2025 +**Date:** June 11, 2026 **Author:** Egor Merkushev @@ -108,20 +108,99 @@ Root - Indentation is significant (off-side rule): a nested `` must be indented deeper than its parent ``. The lexer reads each line's leading ``, tracks it on an indentation stack, and emits the synthetic `` / `` tokens when the depth increases or decreases. Because this context-sensitive relationship cannot be expressed in pure BNF, indentation handling is delegated to the lexer (see `HypercodeLexer.g4`). - No support for inline attributes or arguments in `.hc` files (these belong in `.hcs`). -## 6. Future Work +## 6. `.hcs` Cascade Sheet Syntax + +A `.hcs` file contains cascade rules and optional contract blocks. + +### 6.1 Cascade Rule + +``` + ::= { } + ::= | | + + ::= "@" "[" "]" ":" + { } + ::= + + ::= ":" + { } + ::= ":" + ::= | + ::= '"' { } '"' | "'" { } "'" + ::= any run of characters up to end of line (trimmed) +``` + +A bare scalar is type-inferred by the reader: `true`/`false` → bool, integral +form → int, decimal form without letters → double, anything else → string +(e.g. `driver: sqlite` is a valid bare string). Quoting forces string. The +source lexeme is preserved verbatim for v1 IR round-tripping. + +### 6.2 Selectors + +``` + ::= { ">" } + ::= | | + ::= + ::= "." + ::= "#" +``` + +Specificity (highest to lowest): id `#x` > class `.x` > type `x`. + +### 6.3 `@contract:` Block (HC-111) + +A `@contract:` block declares property constraints for nodes matching a selector. +More-specific selectors may only **narrow** constraints — never widen them (monotonicity invariant). + +``` + ::= "@contract:" + { } + + ::= ":" + { } + + ::= ":" + [ ">=" ] [ "<=" ] + + ::= [ "[?]" ] // "[?]" marks optional property + ::= "string" | "int" | "float" | "bool" +``` + +#### Constraint syntax example + +```hcs +@contract: + service: + timeout[?]: int >= 1 <= 300 + name: string + .primary: + timeout: int >= 10 <= 200 # narrows — allowed +``` + +#### Semantics + +How contracts accumulate, intersect, and which monotonicity violations are +diagnostics (HC2101–HC2103) is defined in the +[resolution semantics](Hypercode_Resolution.md) — this document covers syntax +only. + +## 7. Future Work - Define EBNF with optional comments, arguments, and macro support. - Add parser conformance test suite. - Define formal AST schema (YAML or JSON). -## 7. Change Log +## 8. Change Log + +**Version 0.2** (2026-06-11) + +* Added Section 6: `.hcs` cascade sheet syntax (rules, selectors, dimension blocks). +* Added `@contract:` block grammar (HC-111): constraint-line syntax, `[?]` optional marker, bounds `>=`/`<=`. +* Bare scalars documented (type inference, lexeme preservation); semantics + (contract accumulation, monotonicity diagnostics) live in + `Hypercode_Resolution.md`. **Version 0.1** (2025-07-12) * Initial public draft of the Hypercode grammar in BNF. * Describes core structural elements: command, class, ID, indentation-based hierarchy. -* Includes: - - BNF grammar for `.hc` files - - Visual AST example - - Positive and negative test cases - - Notes on scope and grammar limitations diff --git a/Package.resolved b/Package.resolved index c85780c..691cd46 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,6 +9,24 @@ "version" : "1.0.0" } }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, { "identity" : "swift-docc-plugin", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index e19c4a9..adb2a59 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,7 @@ // swift-tools-version: 5.9 import PackageDescription +// Package version: 0.5.0-dev (the IR v2 emitter echoes 0.5.0 as resolver.version) let package = Package( name: "Hypercode", platforms: [ @@ -14,6 +15,9 @@ let package = Package( // SpecificationCore — the 0AL Specification-pattern foundation; the // Hypercode grammar and cascade rules are expressed as composable specs. .package(url: "https://github.com/SoundBlaster/SpecificationCore", from: "1.0.0"), + // swift-crypto — SHA-256 for IR v2 node hashes; same API as CryptoKit, + // works on Linux (decision R11 in DOCS/Workplan.md). + .package(url: "https://github.com/apple/swift-crypto", from: "3.0.0"), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), ], targets: [ @@ -21,6 +25,7 @@ let package = Package( name: "Hypercode", dependencies: [ .product(name: "SpecificationCore", package: "SpecificationCore"), + .product(name: "Crypto", package: "swift-crypto"), ] ), .executableTarget( diff --git a/RFC/Hypercode.md b/RFC/Hypercode.md index e7afcba..7b720f5 100644 --- a/RFC/Hypercode.md +++ b/RFC/Hypercode.md @@ -2,9 +2,9 @@ **Status:** Draft -**Version:** 0.1.1 +**Version:** 0.2 -**Date:** June 10, 2026 +**Date:** June 11, 2026 **Author:** Egor Merkushev @@ -273,13 +273,15 @@ Hypercode is therefore a new *combination* and a new *layer* — a context-resol The strongest objection to Hypercode's design comes from the configuration-language community itself. Drawing on Google's experience with GCL — where inheritance and overrides became a chronic source of configuration bugs — the designers of CUE made unification order-independent and *forbade* overrides entirely: in CUE, the origin of a value is never in doubt precisely because no rule can silently replace another. Hypercode deliberately reintroduces overriding, so the choice requires a defense. It has four parts: 1. **Determinism is machine-checked, not promised.** Cascade resolution (specificity, then source order) is specified operationally ([resolution semantics](../EBNF/Hypercode_Resolution.md)) and cross-checked by an executable [Lean 4 oracle](../SPEC/lean/). Resolution is independent of rule application and traversal order: the outcome is fully determined by the pair (specificity, source order). -2. **Provenance is core semantics, not optional tooling.** Every resolved property records its winning selector and source line as part of the [IR contract](../Schema/hypercode-ir-v1.schema.json), not as an add-on. The CSS cascade became manageable the day developer tools showed where each style came from; Hypercode bakes that affordance into the format. A planned `hypercode explain` command will surface the full match trace, including losing rules. -3. **Values cascade; contracts only narrow.** The planned contract layer (property schemas attached via selectors) is monotonic in CUE's spirit. Although that layer is not yet implemented, its governing rule is fixed normatively now: +2. **Provenance is core semantics, not optional tooling.** Every resolved property records its winning selector and source line as part of the [IR contract](../Schema/hypercode-ir-v1.schema.json), not as an add-on. The CSS cascade became manageable the day developer tools showed where each style came from; Hypercode bakes that affordance into the format. The `hypercode explain` command surfaces the full match trace, including losing rules with their specificity and source order. +3. **Values cascade; contracts only narrow.** The contract layer (property schemas attached via selectors in `@contract:` blocks) is monotonic in CUE's spirit. Its governing rule: > A more specific selector **MAY** override a value. > A more specific selector **MUST NOT** weaken a contract established by a less specific rule; weakening is a resolution error. For example, given a base contract `Database: pool_size: int >= 1`, a more specific `Database#main: pool_size: int >= 10` is valid (narrowing), while `Database#main: pool_size: int >= 0` is rejected (weakening). Behavior cascades; safety does not. This asymmetry is the design's direct answer to the GCL lesson. + + Two refinements make the rule precise (normative semantics in the [resolution specification](../EBNF/Hypercode_Resolution.md)). First, contracts **accumulate by intersection**: every contract matching a node governs it simultaneously, and an omitted bound is not a statement — it inherits through the intersection. Second, as in the CSS cascade, specificity relates two contracts only when at least one node in the document is matched by both selectors; contracts on disjoint parts of the tree are independent. Monotonicity violations are resolution errors (HC2101 type change, HC2102 interval widening, HC2103 required→optional); validating resolved *values* against the effective contract is the next layer (HC2104, in progress). 4. **Known failure modes are acknowledged.** Specificity wars and selector escalation are real CSS pathologies at scale. Countermeasures — origin/layer control analogous to CSS `@layer`, dangling-selector validation (already in `hypercode validate`), and explain tooling — are sequenced in the [work plan](../workplan.md) ahead of language surface that would amplify them. ### 9.5. Why a Stable Topology @@ -295,14 +297,14 @@ In layered configuration systems the recurring operational question is "where di Spec-driven development is converging on the view that the specification is the durable artifact and code is increasingly a regenerated output. Today that movement runs almost entirely on natural-language Markdown, which neither resolves deterministically nor diffs semantically. Hypercode's intended role is the formal substrate underneath it, with three consequences: * **Confined nondeterminism.** The specification side resolves deterministically (machine-checked, §9.4); nondeterminism is confined to the generation step, where it can be validated against the resolved graph. -* **A fixed generated/durable boundary.** Classic MDA demanded complete models; an LLM generator tolerates incompleteness, so `.hc` can stay a skeleton. The node boundary fixes the division of labor: orchestration and wiring are derived from the resolved graph (mechanically where possible), while durable leaf implementations live behind generated interfaces and are never overwritten. Node-level hashes over the resolved IR — a planned `hypercode.ir/v2` addition (see the [work plan](../workplan.md)), not part of IR v1 — will provide the invalidation signal for incremental regeneration. +* **A fixed generated/durable boundary.** Classic MDA demanded complete models; an LLM generator tolerates incompleteness, so `.hc` can stay a skeleton. The node boundary fixes the division of labor: orchestration and wiring are derived from the resolved graph (mechanically where possible), while durable leaf implementations live behind generated interfaces and are never overwritten. Node-level hashes over the resolved IR (`hypercode.ir/v2`, [schema](../Schema/hypercode-ir-v2.schema.json)) provide the invalidation signal for incremental regeneration: the hash covers the stable resolved content — type, class, id, resolved values, child hashes — so a provenance-only change (a different rule winning with the same value) does not invalidate. * **Review compression.** The unit of human review shifts from generated code to the specification diff: humans approve a small, formally resolved change; machines expand it into code and validate the expansion against the same graph. ### 9.8. Acknowledged Limits * **Context binds at resolve time.** `--ctx` is supplied when resolving: Hypercode's default mode decides context at build/generation time. Runtime feature-flag systems (OpenFeature, LaunchDarkly) decide flag values per request at runtime — a different layer that composes with Hypercode rather than competing with it. Serving dynamic context from a single deployment (e.g., many tenants per process) would require embedding the resolver as a runtime library; that optional mode raises its own caching, latency, and provenance questions and is currently out of scope. * **No integrity chain yet.** As noted in §8, nothing verifies the chain end-to-end: signed `.hc`/`.hcs` → resolved-IR hash → generator identity and version → generated-artifact hashes → validator report. SLSA provides the reference vocabulary for such attestations. For the review-compression story to carry governance weight they are eventually required; they are deliberately deferred as future work. -* **Type-system maturity.** Resolved values are untyped strings in IR v1, and the contract layer (§9.4) is planned, not implemented. Until both land (an IR v2 concern), Hypercode does not compete with typed configuration languages on safety. +* **Type-system maturity.** IR v2 carries typed values (string/int/double/bool, inferred at parse time with the source lexeme preserved), and the contract layer (§9.4) ships declarations and monotonicity validation. Enforcement of resolved values against the effective contract (HC2104) is in progress; until it lands, Hypercode still does not compete with typed configuration languages on safety. IR v1 remains strings-only and is kept for backward compatibility. ## 10. Open Questions @@ -333,6 +335,13 @@ Prior art surveyed in §9: ## 12. Change Log +**Version 0.2** (2026-06-11): + +* Contracts are now part of the language model (HC-111): `@contract:` block syntax in `.hcs`, accumulation by intersection (an omitted bound inherits), specificity relating only contracts that can govern the same node, monotonicity diagnostics HC2101–HC2103 (§9.4); value-level enforcement (HC2104) declared in progress. +* `hypercode explain` (HC-110) shipped: full cascade trace with winner and losing rules (§9.4). +* `hypercode.ir/v2` (HC-112) shipped: typed values with lexeme preservation, winner/losers per property, contract echo, per-node and per-document SHA-256 hashes over stable resolved content (§9.7, §9.8). +* Normative contract semantics delegated to the resolution specification (`EBNF/Hypercode_Resolution.md` §7). + **Version 0.1.1** (2026-06-10): * Replaced §9 "Comparison to Existing Concepts" with "Novelty and Prior Art": the novelty claim stated as a falsifiable combination; non-goals; a prior-art map (typed configuration languages, deployment overlays, OAM/KubeVela, DI, architecture-as-code, interface contracts, agent protocols, AI spec-driven development, software product lines); the override objection and its answer, including the *values cascade, contracts only narrow* rule; rationale for stable topology, provenance, and AI code generation; acknowledged limits. diff --git a/Schema/hypercode-ir-v2.schema.json b/Schema/hypercode-ir-v2.schema.json index 281dcd3..2388ee4 100644 --- a/Schema/hypercode-ir-v2.schema.json +++ b/Schema/hypercode-ir-v2.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://0al-spec.github.io/Hypercode/schema/hypercode-ir-v2.schema.json", "title": "Hypercode IR v2", - "description": "Hypercode resolved-graph IR v2: typed values, cascade trace (winner + losers), per-node SHA-256 hashes, context echo, resolver metadata. Contracts field is empty until HC-111.", + "description": "Hypercode resolved-graph IR v2: typed values, cascade trace (winner + losers), per-node SHA-256 hashes, context echo, resolver metadata, and accumulated selector contracts (HC-111).", "type": "object", "required": ["version", "context", "resolver", "documentHash", "nodes"], "additionalProperties": false, @@ -66,7 +66,7 @@ "properties": { "value": { "description": "The winning resolved value (typed).", - "oneOf": [ + "anyOf": [ { "type": "string" }, { "type": "integer" }, { "type": "number" }, @@ -80,7 +80,7 @@ }, "contracts": { "type": "array", - "description": "Accumulated selector contracts (HC-111). Empty until contracts are added to .hcs files.", + "description": "Contracts governing this property (HC-111), ascending specificity — narrowest last. Empty when no @contract: block matches the node.", "items": { "$ref": "#/$defs/contractEntry" } } } @@ -102,7 +102,7 @@ "line": { "type": "integer" }, "file": { "type": "string" }, "value": { - "oneOf": [ + "anyOf": [ { "type": "string" }, { "type": "integer" }, { "type": "number" }, diff --git a/Sources/Hypercode/Crypto/SHA256.swift b/Sources/Hypercode/Crypto/SHA256.swift index ffa43a4..faa31d9 100644 --- a/Sources/Hypercode/Crypto/SHA256.swift +++ b/Sources/Hypercode/Crypto/SHA256.swift @@ -1,20 +1,27 @@ -import CryptoKit +import Crypto -// Thin wrapper around CryptoKit.SHA256 so the rest of the library uses a -// stable internal interface. CryptoKit is available on all supported targets -// (macOS 10.15+; this package requires macOS 13). +// Thin wrapper around swift-crypto's SHA256 so the rest of the library uses a +// stable internal interface. swift-crypto mirrors the CryptoKit API and works +// on Linux (decision R11); on Apple platforms it delegates to CryptoKit. struct SHA256Digest { let bytes: [UInt8] var hexString: String { - bytes.map { String(format: "%02x", $0) }.joined() + let digits = Array("0123456789abcdef") + var out = "" + out.reserveCapacity(bytes.count * 2) + for byte in bytes { + out.append(digits[Int(byte >> 4)]) + out.append(digits[Int(byte & 0x0F)]) + } + return out } } enum SHA256 { static func hash(_ input: [UInt8]) -> SHA256Digest { - let digest = CryptoKit.SHA256.hash(data: input) + let digest = Crypto.SHA256.hash(data: input) return SHA256Digest(bytes: Array(digest)) } diff --git a/Sources/Hypercode/Emit/Emitter.swift b/Sources/Hypercode/Emit/Emitter.swift index 5c5d6b2..e3024f1 100644 --- a/Sources/Hypercode/Emit/Emitter.swift +++ b/Sources/Hypercode/Emit/Emitter.swift @@ -21,12 +21,16 @@ public struct Emitter { _ forest: [ResolvedNode], version: EmitVersion = .v2, context: ResolutionContext = [:], + commands: [Command] = [], + contracts: [SelectorContract] = [], as format: EmitFormat ) -> String { let root: IR switch version { case .v1: root = Emitter.intermediateV1(forest) - case .v2: root = Emitter.intermediateV2(forest, context: context) + case .v2: root = Emitter.intermediateV2( + forest, context: context, commands: commands, contracts: contracts + ) } switch format { case .json: return Emitter.json(root, indent: 0) + "\n" @@ -83,49 +87,100 @@ public struct Emitter { // MARK: v2 - static func intermediateV2(_ forest: [ResolvedNode], context: ResolutionContext) -> IR { - let hashes = forest.map { nodeHash($0) } - let nodeIRs = forest.map { nodeV2($0) } - let docHash = documentHash(hashes) + static func intermediateV2( + _ forest: [ResolvedNode], + context: ResolutionContext, + commands: [Command] = [], + contracts: [SelectorContract] = [] + ) -> IR { + let rendered = zip(forest, padded(commands, to: forest.count)) + .map { node, cmd in nodeV2(node, command: cmd, ancestors: [], contracts: contracts) } + let docHash = documentHash(rendered.map(\.hash)) return .object([ ("version", .string("hypercode.ir/v2")), ("context", .object(context.keys.sorted().map { ($0, .string(context[$0]!)) })), ("resolver", .object([ ("name", .string("hypercode-swift")), - ("version", .string("0.5.0")), + ("version", .string(HypercodeVersion.current)), ])), ("documentHash", .string(docHash)), - ("nodes", .array(zip(nodeIRs, hashes).map { nodeIR, hash in - guard case var .object(pairs) = nodeIR else { return nodeIR } - let insertAt = pairs.firstIndex(where: { $0.0 == "properties" }) ?? pairs.endIndex - pairs.insert(("hash", .string(hash)), at: insertAt) - return .object(pairs) - })), + ("nodes", .array(rendered.map(\.ir))), ]) } - private static func nodeV2(_ node: ResolvedNode) -> IR { + /// Aligns the optional command array with the resolved-node array so a + /// caller-side mismatch can never silently drop nodes from the output. + private static func padded(_ commands: [Command], to count: Int) -> [Command?] { + var result: [Command?] = commands.prefix(count).map(Optional.init) + while result.count < count { result.append(nil) } + return result + } + + private static func nodeV2( + _ node: ResolvedNode, + command: Command?, + ancestors: [Command], + contracts: [SelectorContract] + ) -> (ir: IR, hash: String) { + let childCommands = command.map { $0.children } ?? [] + let childAncestors = command.map { ancestors + [$0] } ?? ancestors + let children = zip(node.children, padded(childCommands, to: node.children.count)) + .map { child, childCmd in + nodeV2(child, command: childCmd, ancestors: childAncestors, contracts: contracts) + } + let hash = stableHash(node, childHashes: children.map(\.hash)) + + // All contracts matching this node, narrowest last (ascending specificity, + // declaration order as the stable tie-breaker). + let nodeContracts: [SelectorContract] = command.map { cmd in + let ctx = NodeContext(node: cmd, ancestors: ancestors) + return contracts.enumerated() + .filter { _, sc in selectorSpec(sc.selector).isSatisfiedBy(ctx) } + .sorted { a, b in + a.element.selector.specificity != b.element.selector.specificity + ? a.element.selector.specificity < b.element.selector.specificity + : a.offset < b.offset + } + .map(\.element) + } ?? [] + var fields: [(String, IR)] = [("type", .string(node.type))] if let className = node.className { fields.append(("class", .string(className))) } if let id = node.id { fields.append(("id", .string(id))) } + fields.append(("hash", .string(hash))) let properties = node.properties.keys.sorted().map { key -> (String, IR) in let rv = node.properties[key]! + let applicableContracts = nodeContracts.compactMap { sc -> IR? in + guard let pc = sc.properties[key] else { return nil } + return contractIR(pc, selector: sc.selector) + } let propFields: [(String, IR)] = [ ("value", typedValueIR(rv.value)), ("winner", matchIR(rv.winner)), ("losers", .array(rv.losers.map(matchIR))), - ("contracts", .array([])), + ("contracts", .array(applicableContracts)), ] return (key, .object(propFields)) } fields.append(("properties", .object(properties))) - fields.append(("children", .array(node.children.map(self.nodeV2)))) - return .object(fields) + fields.append(("children", .array(children.map(\.ir)))) + return (.object(fields), hash) + } + + private static func contractIR(_ pc: PropertyContract, selector: Selector) -> IR { + var pairs: [(String, IR)] = [ + ("selector", .string(selector.description)), + ("type", .string(pc.type.rawValue)), + ("required", .bool(pc.required)), + ] + if let min = pc.min { pairs.append(("min", .double(min))) } + if let max = pc.max { pairs.append(("max", .double(max))) } + return .object(pairs) } private static func typedValueIR(_ v: TypedValue) -> IR { - switch v { + switch v.kind { case .string(let s): return .string(s) case .int(let i): return .int(i) case .double(let d): return .double(d) @@ -155,8 +210,7 @@ public struct Emitter { /// type, class?, id?, resolved property values (not provenance), and child hashes. /// Changing a winning value or the tree structure changes the hash; /// changing which rule won (with the same value) does not. - private static func nodeHash(_ node: ResolvedNode) -> String { - let childHashes = node.children.map { nodeHash($0) } + private static func stableHash(_ node: ResolvedNode, childHashes: [String]) -> String { let stableProps = node.properties.keys.sorted().map { key -> (String, IR) in (key, typedValueIR(node.properties[key]!.value)) } @@ -183,10 +237,16 @@ public struct Emitter { case let .int(number): return String(number) case let .double(number): - // Emit without trailing ".0" when it's a whole number. - return number.truncatingRemainder(dividingBy: 1) == 0 - ? String(Int(number)) + ".0" - : String(number) + // Non-finite doubles cannot occur via the reader, but a library + // consumer can construct them — emit valid JSON, never trap. + guard number.isFinite else { return "null" } + // Whole numbers keep a ".0" marker; Int(exactly:) returns nil for + // magnitudes beyond Int.max instead of trapping like Int(_:). + if let whole = Int(exactly: number.rounded(.towardZero)), + number.truncatingRemainder(dividingBy: 1) == 0 { + return String(whole) + ".0" + } + return String(number) case let .bool(flag): return flag ? "true" : "false" case .null: diff --git a/Sources/Hypercode/Explain/Explainer.swift b/Sources/Hypercode/Explain/Explainer.swift index 49dd254..1fefbd1 100644 --- a/Sources/Hypercode/Explain/Explainer.swift +++ b/Sources/Hypercode/Explain/Explainer.swift @@ -3,6 +3,12 @@ public struct PropertyTrace: Sendable { public let key: String public let winner: Match public let losers: [Match] + + public init(key: String, winner: Match, losers: [Match]) { + self.key = key + self.winner = winner + self.losers = losers + } } /// All cascade traces for one node matched by the explain query. @@ -11,6 +17,11 @@ public struct NodeTrace: Sendable { public let nodePath: String /// One trace per property (filtered to the queried property if provided). public let properties: [PropertyTrace] + + public init(nodePath: String, properties: [PropertyTrace]) { + self.nodePath = nodePath + self.properties = properties + } } /// Walks a resolved tree to produce cascade traces for every node that matches @@ -111,10 +122,15 @@ extension NodeTrace { private func renderMatch(_ m: Match, role: String) -> String { // " WINNER selector { value: X }" // " file: f line: N specificity: (i,c,t) order: N" - let prefix = " " + role.padding(toLength: 7, withPad: " ", startingAt: 0) + " " + // Hand-rolled padding: `padding(toLength:)` is Foundation/NSString API + // and the core target stays Foundation-free. + let paddedRole = role.count >= 7 + ? String(role.prefix(7)) + : role + String(repeating: " ", count: 7 - role.count) + let prefix = " " + paddedRole + " " let cont = String(repeating: " ", count: prefix.count) let spec = "(\(m.specificity.ids),\(m.specificity.classes),\(m.specificity.types))" - var line1 = prefix + "\(m.selector) { value: \(m.value.rawString) }\n" + let line1 = prefix + "\(m.selector) { value: \(m.value.rawString) }\n" var line2 = cont if let f = m.file { line2 += "file: \(f) " } line2 += "line: \(m.line) specificity: \(spec) order: \(m.order)\n" diff --git a/Sources/Hypercode/HCS/CascadeSheet.swift b/Sources/Hypercode/HCS/CascadeSheet.swift index 715e690..e2ef0d7 100644 --- a/Sources/Hypercode/HCS/CascadeSheet.swift +++ b/Sources/Hypercode/HCS/CascadeSheet.swift @@ -1,19 +1,41 @@ /// A typed property value inferred at parse time from the raw scalar text. -public enum TypedValue: Equatable, Sendable { - case string(String) - case int(Int) - case double(Double) - case bool(Bool) - - /// String representation used by the v1 emitter and backward-compatible accessors. - public var rawString: String { - switch self { - case .string(let s): return s - case .int(let i): return String(i) - case .double(let d): return String(d) - case .bool(let b): return b ? "true" : "false" - } +/// Keeps the source lexeme alongside the typed kind so the v1 emitter +/// round-trips values byte-for-byte (`1.10` stays `1.10`, `0123` stays `0123`). +public struct TypedValue: Equatable, Sendable { + /// The inferred scalar kind with its canonical value. + public enum Kind: Equatable, Sendable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) } + + public let kind: Kind + /// The source text as written in the sheet (after unquoting). + public let lexeme: String + + public init(kind: Kind, lexeme: String) { + self.kind = kind + self.lexeme = lexeme + } + + // Factories with canonical lexemes, so call sites read like enum cases. + public static func string(_ value: String) -> TypedValue { + TypedValue(kind: .string(value), lexeme: value) + } + public static func int(_ value: Int) -> TypedValue { + TypedValue(kind: .int(value), lexeme: String(value)) + } + public static func double(_ value: Double) -> TypedValue { + TypedValue(kind: .double(value), lexeme: String(value)) + } + public static func bool(_ value: Bool) -> TypedValue { + TypedValue(kind: .bool(value), lexeme: value ? "true" : "false") + } + + /// Source-faithful string representation — what the author wrote. + /// Used by the v1 emitter and `explain` text output. + public var rawString: String { lexeme } } // String interpolation (e.g. the `hypercode resolve` tree rendering) must show @@ -74,13 +96,72 @@ public struct Match: Equatable, Sendable { public let line: Int public let specificity: Specificity public let order: Int + + public init( + value: TypedValue, + selector: Selector, + file: String? = nil, + line: Int, + specificity: Specificity, + order: Int + ) { + self.value = value + self.selector = selector + self.file = file + self.line = line + self.specificity = specificity + self.order = order + } +} + +// MARK: - Contract types + +/// The scalar type a property is constrained to in a `@contract:` block. +public enum ContractType: String, Equatable, Sendable { + case string, int, float, bool +} + +/// A constraint for one property key declared inside a `@contract:` selector block. +public struct PropertyContract: Equatable, Sendable { + public let type: ContractType + /// `true` = property must be present; `false` = property may be absent (`key[?]:` syntax). + public let required: Bool + /// Lower bound (inclusive). `nil` = no lower bound. + public let min: Double? + /// Upper bound (inclusive). `nil` = no upper bound. + public let max: Double? + + public init(type: ContractType, required: Bool = true, min: Double? = nil, max: Double? = nil) { + self.type = type + self.required = required + self.min = min + self.max = max + } +} + +/// A `@contract:` block entry: a selector and the property constraints it declares. +public struct SelectorContract: Equatable, Sendable { + public let selector: Selector + public let properties: [String: PropertyContract] + public let file: String? + public let line: Int + + public init(selector: Selector, properties: [String: PropertyContract], + file: String? = nil, line: Int) { + self.selector = selector + self.properties = properties + self.file = file + self.line = line + } } -/// A parsed `.hcs` cascade sheet: an ordered list of rules. +/// A parsed `.hcs` cascade sheet: an ordered list of rules and `@contract:` blocks. public struct CascadeSheet: Equatable, Sendable { public let rules: [Rule] + public let contracts: [SelectorContract] - public init(rules: [Rule]) { + public init(rules: [Rule], contracts: [SelectorContract] = []) { self.rules = rules + self.contracts = contracts } } diff --git a/Sources/Hypercode/HCS/CascadeSheetReader.swift b/Sources/Hypercode/HCS/CascadeSheetReader.swift index 0bedea1..a2e084c 100644 --- a/Sources/Hypercode/HCS/CascadeSheetReader.swift +++ b/Sources/Hypercode/HCS/CascadeSheetReader.swift @@ -32,11 +32,12 @@ public struct CascadeSheetReader { let outline = buildOutline(lines, &index, parentIndent: -1) var rules: [Rule] = [] + var contracts: [SelectorContract] = [] var order = 0 for node in outline { - try interpretTopLevel(node, file: file, into: &rules, order: &order) + try interpretTopLevel(node, file: file, into: &rules, contracts: &contracts, order: &order) } - return CascadeSheet(rules: rules) + return CascadeSheet(rules: rules, contracts: contracts) } // MARK: - Outline (group lines into a tree by indentation) @@ -61,9 +62,19 @@ public struct CascadeSheetReader { // MARK: - Interpretation - private func interpretTopLevel(_ node: Outline, file: String?, into rules: inout [Rule], order: inout Int) throws { + private func interpretTopLevel( + _ node: Outline, + file: String?, + into rules: inout [Rule], + contracts: inout [SelectorContract], + order: inout Int + ) throws { let content = String(node.line.trimmed) - if content.hasPrefix("@") { + if content == "@contract:" { + for child in node.children { + try interpretContractSelector(child, file: file, into: &contracts) + } + } else if content.hasPrefix("@") { guard let split = splitFirstColon(content), split.right.isEmpty else { throw HCSError(message: "expected '@dimension[value]:'", line: node.line.number) } @@ -76,6 +87,78 @@ public struct CascadeSheetReader { } } + private func interpretContractSelector(_ node: Outline, file: String?, into contracts: inout [SelectorContract]) throws { + let content = String(node.line.trimmed) + guard let split = splitFirstColon(content), split.right.isEmpty else { + throw HCSError(message: "expected a selector header ending with ':' in @contract block", line: node.line.number) + } + let selector = try parseSelector(String(split.left), line: node.line.number) + var properties: [String: PropertyContract] = [:] + for child in node.children { + let (key, contract) = try parseConstraintLine(child) + properties[key] = contract + } + contracts.append(SelectorContract(selector: selector, properties: properties, file: file, line: node.line.number)) + } + + private func parseConstraintLine(_ node: Outline) throws -> (String, PropertyContract) { + guard node.children.isEmpty else { + throw HCSError(message: "unexpected nested block in @contract constraint", line: node.line.number) + } + let content = String(node.line.trimmed) + guard let colonIdx = content.firstIndex(of: ":") else { + throw HCSError(message: "expected 'key[?]: type [>= n] [<= n]'", line: node.line.number) + } + var rawKey = String(trim(content[.. (ContractType, Double?, Double?) { + var tokens = text.split(separator: " ").map(String.init) + guard !tokens.isEmpty else { + throw HCSError(message: "expected type name in constraint", line: line) + } + let typeName = tokens.removeFirst() + guard let contractType = ContractType(rawValue: typeName.lowercased()) else { + throw HCSError(message: "unknown constraint type '\(typeName)'; expected string|int|float|bool", line: line) + } + var min: Double? + var max: Double? + var i = 0 + while i < tokens.count { + switch tokens[i] { + case ">=": + i += 1 + guard i < tokens.count, let n = Double(tokens[i]) else { + throw HCSError(message: "expected number after '>='", line: line) + } + min = n + case "<=": + i += 1 + guard i < tokens.count, let n = Double(tokens[i]) else { + throw HCSError(message: "expected number after '<='", line: line) + } + max = n + default: + throw HCSError(message: "unexpected token '\(tokens[i])' in constraint", line: line) + } + i += 1 + } + return (contractType, min, max) + } + private func interpretRule( _ node: Outline, condition: ContextGuard?, @@ -123,11 +206,13 @@ public struct CascadeSheetReader { } private func inferType(_ s: String) -> TypedValue { - if s == "true" { return .bool(true) } - if s == "false" { return .bool(false) } - if let i = Int(s) { return .int(i) } - if let d = Double(s), !s.contains(where: { $0.isLetter }) { return .double(d) } - return .string(s) + if s == "true" { return TypedValue(kind: .bool(true), lexeme: s) } + if s == "false" { return TypedValue(kind: .bool(false), lexeme: s) } + if let i = Int(s) { return TypedValue(kind: .int(i), lexeme: s) } + if let d = Double(s), !s.contains(where: { $0.isLetter }) { + return TypedValue(kind: .double(d), lexeme: s) + } + return TypedValue(kind: .string(s), lexeme: s) } private func isQuoted(_ s: Substring) -> Bool { diff --git a/Sources/Hypercode/HCS/Resolver.swift b/Sources/Hypercode/HCS/Resolver.swift index d349bff..506e497 100644 --- a/Sources/Hypercode/HCS/Resolver.swift +++ b/Sources/Hypercode/HCS/Resolver.swift @@ -17,17 +17,20 @@ public struct Provenance: Equatable, Sendable { } } -/// A resolved property value with its cascade winner, all losing matches, and provenance. +/// A resolved property value with its cascade winner and all losing matches. +/// `value` and `provenance` are derived from `winner`, so an inconsistent +/// instance cannot be constructed. public struct ResolvedValue: Equatable, Sendable { - public let value: TypedValue - public let provenance: Provenance public let winner: Match /// Losing candidates sorted descending by (specificity, source order). public let losers: [Match] - public init(value: TypedValue, provenance: Provenance, winner: Match, losers: [Match]) { - self.value = value - self.provenance = provenance + public var value: TypedValue { winner.value } + public var provenance: Provenance { + Provenance(selector: winner.selector, file: winner.file, line: winner.line) + } + + public init(winner: Match, losers: [Match]) { self.winner = winner self.losers = losers } @@ -91,32 +94,19 @@ private struct PropertyCascade: DecisionSpec { func decide(_ candidates: [Contribution]) -> ResolvedValue? { guard !candidates.isEmpty else { return nil } - let sorted = candidates.sorted { $0.precedence > $1.precedence } - let w = sorted[0] - let winner = Match( - value: w.value, - selector: w.provenance.selector, - file: w.provenance.file, - line: w.provenance.line, - specificity: w.precedence.specificity, - order: w.precedence.order - ) - let losers = sorted.dropFirst().map { c in - Match( - value: c.value, - selector: c.provenance.selector, - file: c.provenance.file, - line: c.provenance.line, - specificity: c.precedence.specificity, - order: c.precedence.order - ) - } - return ResolvedValue( - value: w.value, - provenance: w.provenance, - winner: winner, - losers: losers - ) + let matches = candidates + .sorted { $0.precedence > $1.precedence } + .map { c in + Match( + value: c.value, + selector: c.provenance.selector, + file: c.provenance.file, + line: c.provenance.line, + specificity: c.precedence.specificity, + order: c.precedence.order + ) + } + return ResolvedValue(winner: matches[0], losers: Array(matches.dropFirst())) } } diff --git a/Sources/Hypercode/Validation/ContractValidator.swift b/Sources/Hypercode/Validation/ContractValidator.swift new file mode 100644 index 0000000..41fd8c5 --- /dev/null +++ b/Sources/Hypercode/Validation/ContractValidator.swift @@ -0,0 +1,144 @@ +import SpecificationCore + +/// Validates monotonicity invariants across `@contract:` blocks in a `.hcs` sheet. +/// +/// Rule (RFC §9.4 / HC-111): A more specific selector MAY narrow constraints +/// (tighter interval, required instead of optional), but MUST NOT weaken them +/// (wider interval, demote required to optional, change type). +/// +/// Like the CSS cascade, specificity only relates rules that can govern the +/// same node — a pair of contracts is checked only when at least one node in +/// the document is matched by both selectors (review R3). An omitted bound is +/// not a violation: the effective contract for a node is the intersection of +/// all applicable contracts, so absent bounds inherit (decision R12). +/// +/// Diagnostic codes: +/// HC2101 — type mismatch (between specificities, or at equal specificity +/// where the intersection would be unsatisfiable) +/// HC2102 — interval widening (more specific selector widens lower or upper bound) +/// HC2103 — optional weakening (more specific selector makes a required property optional) +public struct ContractValidator { + public init() {} + + public func validate( + _ contracts: [SelectorContract], + against forest: [Command] + ) -> [Diagnostic] { + var diagnostics: [Diagnostic] = [] + for i in 0.. [Diagnostic] { + var diags: [Diagnostic] = [] + for key in less.properties.keys.sorted() { + guard let lessContract = less.properties[key], + let moreContract = more.properties[key] else { continue } + diags += check( + key: key, less: lessContract, lessSelector: less.selector, + more: moreContract, moreSelector: more.selector, + file: more.file, line: more.line + ) + } + return diags + } + + /// At equal specificity both contracts apply with equal force; their types + /// must agree or the intersection is unsatisfiable. + private func checkEqualSpecificity( + _ first: SelectorContract, _ second: SelectorContract + ) -> [Diagnostic] { + var diags: [Diagnostic] = [] + for key in first.properties.keys.sorted() { + guard let a = first.properties[key], let b = second.properties[key] else { continue } + if a.type != b.type { + diags.append(Diagnostic( + severity: .error, code: "HC2101", + message: "contract for '\(key)': selector '\(second.selector)' declares type '\(b.type.rawValue)' but '\(first.selector)' declares '\(a.type.rawValue)' at equal specificity — the effective contract is unsatisfiable", + file: second.file, + range: SourceRange(SourcePosition(line: second.line, column: 1)) + )) + } + } + return diags + } + + private func check( + key: String, + less: PropertyContract, lessSelector: Selector, + more: PropertyContract, moreSelector: Selector, + file: String?, line: Int + ) -> [Diagnostic] { + var diags: [Diagnostic] = [] + let range = SourceRange(SourcePosition(line: line, column: 1)) + + if more.type != less.type { + diags.append(Diagnostic( + severity: .error, code: "HC2101", + message: "contract for '\(key)': selector '\(moreSelector)' has type '\(more.type.rawValue)' but inherits type '\(less.type.rawValue)' from '\(lessSelector)' — type must be identical", + file: file, range: range + )) + } + + if let lessMin = less.min, let moreMin = more.min, moreMin < lessMin { + diags.append(Diagnostic( + severity: .error, code: "HC2102", + message: "contract for '\(key)': selector '\(moreSelector)' widens lower bound (\(moreMin) < \(lessMin) from '\(lessSelector)') — contracts must narrow", + file: file, range: range + )) + } + + if let lessMax = less.max, let moreMax = more.max, moreMax > lessMax { + diags.append(Diagnostic( + severity: .error, code: "HC2102", + message: "contract for '\(key)': selector '\(moreSelector)' widens upper bound (\(moreMax) > \(lessMax) from '\(lessSelector)') — contracts must narrow", + file: file, range: range + )) + } + + if less.required && !more.required { + diags.append(Diagnostic( + severity: .error, code: "HC2103", + message: "contract for '\(key)': selector '\(moreSelector)' makes required property optional — selector '\(lessSelector)' requires it", + file: file, range: range + )) + } + + return diags + } + + // MARK: - Selector overlap + + /// Whether at least one node in the forest is matched by both selectors. + private func overlaps(_ a: Selector, _ b: Selector, in forest: [Command]) -> Bool { + let specA = selectorSpec(a) + let specB = selectorSpec(b) + + func walk(_ nodes: [Command], ancestors: [Command]) -> Bool { + for node in nodes { + let context = NodeContext(node: node, ancestors: ancestors) + if specA.isSatisfiedBy(context) && specB.isSatisfiedBy(context) { return true } + if walk(node.children, ancestors: ancestors + [node]) { return true } + } + return false + } + return walk(forest, ancestors: []) + } +} diff --git a/Sources/Hypercode/Validation/Validator.swift b/Sources/Hypercode/Validation/Validator.swift index af67e40..57fab44 100644 --- a/Sources/Hypercode/Validation/Validator.swift +++ b/Sources/Hypercode/Validation/Validator.swift @@ -35,13 +35,15 @@ public struct Validator { } /// `.hcs` checks: flag rules whose selector matches no node in the forest - /// (likely a typo or a dead rule). + /// (likely a typo or a dead rule), plus contract monotonicity violations. public func validate(_ sheet: CascadeSheet, against forest: [Command]) -> [Diagnostic] { - sheet.rules.compactMap { rule in + let dangling = sheet.rules.compactMap { rule in anyNode(forest, ancestors: [], matches: selectorSpec(rule.selector)) ? nil : Diagnostic(severity: .warning, code: "HC3002", message: "selector '\(rule.selector)' matches no node", range: SourceRange(SourcePosition(line: rule.line, column: 1))) } + let contractDiags = ContractValidator().validate(sheet.contracts, against: forest) + return dangling + contractDiags } private func anyNode( diff --git a/Sources/Hypercode/Version.swift b/Sources/Hypercode/Version.swift new file mode 100644 index 0000000..0bcbe1c --- /dev/null +++ b/Sources/Hypercode/Version.swift @@ -0,0 +1,6 @@ +/// Single source of truth for the resolver version echoed in IR v2 +/// (`resolver.version`) and available to tooling. Bump together with +/// `CHANGELOG.md` and the `Package.swift` version comment. +public enum HypercodeVersion { + public static let current = "0.5.0-dev" +} diff --git a/Sources/HypercodeCLI/main.swift b/Sources/HypercodeCLI/main.swift index 76769b7..6abf00a 100644 --- a/Sources/HypercodeCLI/main.swift +++ b/Sources/HypercodeCLI/main.swift @@ -174,10 +174,11 @@ func runEmit(_ args: [String]) throws { guard let hcPath else { fail("error: emit needs a .hc file\n\n\(usage)", code: 64) } - let forest = try Parser(source: readSource(hcPath)).parse() + let commands = try Parser(source: readSource(hcPath)).parse() let sheet = try hcsPath.map { p in try CascadeSheetReader().read(readSource(p), file: p) } ?? CascadeSheet(rules: []) - let resolved = Resolver(sheet: sheet, context: context).resolve(forest) - print(Emitter().emit(resolved, version: irVersion, context: context, as: format), terminator: "") + let resolved = Resolver(sheet: sheet, context: context).resolve(commands) + print(Emitter().emit(resolved, version: irVersion, context: context, + commands: commands, contracts: sheet.contracts, as: format), terminator: "") } func runExplain(_ args: [String]) throws { diff --git a/Tests/HypercodeTests/ContractTests.swift b/Tests/HypercodeTests/ContractTests.swift new file mode 100644 index 0000000..840c748 --- /dev/null +++ b/Tests/HypercodeTests/ContractTests.swift @@ -0,0 +1,196 @@ +import XCTest +@testable import Hypercode + +// MARK: - CascadeSheetReader: @contract: parsing + +final class ContractParsingTests: XCTestCase { + private let reader = CascadeSheetReader() + + func testParsesContractBlock() throws { + let src = """ + @contract: + service: + timeout: int >= 1 <= 300 + name: string + """ + let sheet = try reader.read(src) + XCTAssertEqual(sheet.contracts.count, 1) + let sc = sheet.contracts[0] + XCTAssertEqual(sc.selector, .type("service")) + XCTAssertEqual(sc.properties["timeout"]?.type, .int) + XCTAssertEqual(sc.properties["timeout"]?.min, 1) + XCTAssertEqual(sc.properties["timeout"]?.max, 300) + XCTAssertEqual(sc.properties["timeout"]?.required, true) + XCTAssertEqual(sc.properties["name"]?.type, .string) + XCTAssertEqual(sc.properties["name"]?.required, true) + } + + func testParsesOptionalConstraint() throws { + let src = """ + @contract: + service: + timeout[?]: int >= 1 + """ + let sheet = try reader.read(src) + let sc = sheet.contracts[0] + XCTAssertEqual(sc.properties["timeout"]?.required, false) + XCTAssertEqual(sc.properties["timeout"]?.min, 1) + XCTAssertNil(sc.properties["timeout"]?.max) + } + + func testParsesMultipleContractSelectors() throws { + let src = """ + @contract: + service: + timeout: int + .primary: + replicas: int >= 2 + """ + let sheet = try reader.read(src) + XCTAssertEqual(sheet.contracts.count, 2) + XCTAssertEqual(sheet.contracts[0].selector, .type("service")) + XCTAssertEqual(sheet.contracts[1].selector, .klass("primary")) + } + + func testParsesContractAlongsideRules() throws { + let src = """ + service: + timeout: 30 + + @contract: + service: + timeout: int >= 1 + """ + let sheet = try reader.read(src) + XCTAssertEqual(sheet.rules.count, 1) + XCTAssertEqual(sheet.contracts.count, 1) + } + + func testRejectsUnknownConstraintType() { + let src = """ + @contract: + service: + x: colour + """ + XCTAssertThrowsError(try reader.read(src)) + } +} + +// MARK: - ContractValidator: monotonicity + +final class ContractValidatorTests: XCTestCase { + private let validator = ContractValidator() + + private func contract(_ selector: Hypercode.Selector, + _ properties: [String: PropertyContract], + line: Int = 1) -> SelectorContract { + SelectorContract(selector: selector, properties: properties, line: line) + } + + private func forest(_ source: String) -> [Command] { + try! Parser(source: source).parse() + } + + /// A document where `service` and `.primary` match the same node. + private var overlapping: [Command] { forest("App\n service.primary\n") } + + func testNoViolationIdenticalConstraints() { + let less = contract(.type("service"), ["timeout": PropertyContract(type: .int, required: true, min: 1, max: 300)]) + let more = contract(.klass("primary"), ["timeout": PropertyContract(type: .int, required: true, min: 1, max: 300)]) + XCTAssertTrue(validator.validate([less, more], against: overlapping).isEmpty) + } + + func testNoViolationMoreSpecificNarrows() { + let less = contract(.type("service"), ["timeout": PropertyContract(type: .int, required: false, min: 1, max: 300)]) + let more = contract(.klass("primary"), ["timeout": PropertyContract(type: .int, required: true, min: 10, max: 200)]) + XCTAssertTrue(validator.validate([less, more], against: overlapping).isEmpty) + } + + func testTypeMismatchHC2101() { + let less = contract(.type("service"), ["timeout": PropertyContract(type: .int)]) + let more = contract(.klass("primary"), ["timeout": PropertyContract(type: .float)]) + let diags = validator.validate([less, more], against: overlapping) + XCTAssertEqual(diags.count, 1) + XCTAssertEqual(diags[0].code, "HC2101") + } + + func testIntervalWideningLowerBoundHC2102() { + let less = contract(.type("service"), ["timeout": PropertyContract(type: .int, min: 5)]) + let more = contract(.klass("primary"), ["timeout": PropertyContract(type: .int, min: 1)]) + let diags = validator.validate([less, more], against: overlapping) + XCTAssertEqual(diags.count, 1) + XCTAssertEqual(diags[0].code, "HC2102") + XCTAssertTrue(diags[0].message.contains("lower bound")) + } + + func testIntervalWideningUpperBoundHC2102() { + let less = contract(.type("service"), ["timeout": PropertyContract(type: .int, max: 100)]) + let more = contract(.klass("primary"), ["timeout": PropertyContract(type: .int, max: 200)]) + let diags = validator.validate([less, more], against: overlapping) + XCTAssertEqual(diags.count, 1) + XCTAssertEqual(diags[0].code, "HC2102") + XCTAssertTrue(diags[0].message.contains("upper bound")) + } + + func testOptionalWeakeningHC2103() { + let less = contract(.type("service"), ["name": PropertyContract(type: .string, required: true)]) + let more = contract(.klass("primary"), ["name": PropertyContract(type: .string, required: false)]) + let diags = validator.validate([less, more], against: overlapping) + XCTAssertEqual(diags.count, 1) + XCTAssertEqual(diags[0].code, "HC2103") + } + + func testNoViolationUnsharedKeys() { + let less = contract(.type("service"), ["timeout": PropertyContract(type: .int)]) + let more = contract(.klass("primary"), ["replicas": PropertyContract(type: .int)]) + XCTAssertTrue(validator.validate([less, more], against: overlapping).isEmpty) + } + + // Review R3: specificity only relates contracts that can govern the same + // node — disjoint selectors must not be compared. + func testNoViolationDisjointSelectors() { + let doc = forest("App\n service\n cache.slow\n") + let a = contract(.type("service"), ["timeout": PropertyContract(type: .int, max: 100)]) + let b = contract(.klass("slow"), ["timeout": PropertyContract(type: .int, max: 500)]) + XCTAssertTrue(validator.validate([a, b], against: doc).isEmpty, + "selectors matching different nodes must not be related by specificity") + } + + // Review R4: at equal specificity both contracts apply with equal force — + // a type conflict makes the intersection unsatisfiable. + func testEqualSpecificityTypeConflictHC2101() { + let doc = forest("App\n service\n") + let a = contract(.type("service"), ["timeout": PropertyContract(type: .int)], line: 2) + let b = contract(.type("service"), ["timeout": PropertyContract(type: .float)], line: 4) + let diags = validator.validate([a, b], against: doc) + XCTAssertEqual(diags.count, 1) + XCTAssertEqual(diags[0].code, "HC2101") + XCTAssertTrue(diags[0].message.contains("equal specificity")) + } + + func testEqualSpecificityCompatibleContractsAreClean() { + let doc = forest("App\n service\n") + let a = contract(.type("service"), ["timeout": PropertyContract(type: .int, max: 100)]) + let b = contract(.type("service"), ["timeout": PropertyContract(type: .int, max: 200)]) + XCTAssertTrue(validator.validate([a, b], against: doc).isEmpty, + "same type at equal specificity intersects fine — bounds intersect, not conflict") + } +} + +// MARK: - Validator integration + +final class ValidatorContractIntegrationTests: XCTestCase { + func testValidatorRunsContractChecks() throws { + let src = """ + @contract: + service: + timeout: int + .primary: + timeout: float + """ + let sheet = try CascadeSheetReader().read(src) + let doc = try Parser(source: "App\n service.primary\n").parse() + let diags = Validator().validate(sheet, against: doc) + XCTAssertTrue(diags.map(\.code).contains("HC2101")) + } +} diff --git a/Tests/HypercodeTests/EmitterTests.swift b/Tests/HypercodeTests/EmitterTests.swift index 6d7c660..aa5150b 100644 --- a/Tests/HypercodeTests/EmitterTests.swift +++ b/Tests/HypercodeTests/EmitterTests.swift @@ -62,6 +62,59 @@ final class EmitterTests: XCTestCase { XCTAssertNotNil(hashRange) } + func testV2EveryNodeCarriesAHash() throws { + // Regression (review R1): child nodes used to miss the schema-required + // "hash" field — it was only post-inserted into root nodes. + let forest = try Parser(source: "App\n Service\n Database\n").parse() + let sheet = try CascadeSheetReader().read("Service:\n timeout: 30\n") + let resolved = Resolver(sheet: sheet).resolve(forest) + let json = Emitter().emit(resolved, version: .v2, as: .json) + + let hashCount = json.components(separatedBy: "\"hash\": \"").count - 1 + XCTAssertEqual(hashCount, 3, "all three nodes (App, Service, Database) must carry a hash") + } + + func testV2HugeNumberDoesNotCrash() throws { + // Regression (review R2): a numeral beyond Int.max parses as Double and + // used to trap in Int(_:) inside the whole-number JSON branch. + let forest = try Parser(source: "App\n").parse() + let sheet = try CascadeSheetReader().read("App:\n huge: 99999999999999999999999999\n") + let resolved = Resolver(sheet: sheet).resolve(forest) + let json = Emitter().emit(resolved, version: .v2, as: .json) + XCTAssertTrue(json.contains("1e+26"), "huge double must render in scientific notation") + } + + func testV2ContractsSortedBySpecificityAscending() throws { + // Review R8: contracts[] must accumulate narrowest-last. + let forest = try Parser(source: "App\n Service.primary\n").parse() + let sheet = try CascadeSheetReader().read(""" + Service: + timeout: 30 + + @contract: + .primary: + timeout: int <= 200 + Service: + timeout: int <= 300 + """) + let resolved = Resolver(sheet: sheet).resolve(forest) + let json = Emitter().emit( + resolved, version: .v2, commands: forest, contracts: sheet.contracts, as: .json + ) + // Compare positions inside the contracts array only — the winner entry + // also carries a "selector" key and must not skew the check. + guard let contractsStart = json.range(of: "\"contracts\": [") else { + return XCTFail("contracts array must be present") + } + let tail = json[contractsStart.upperBound...] + guard let serviceRange = tail.range(of: #""selector": "Service""#), + let classRange = tail.range(of: #""selector": ".primary""#) else { + return XCTFail("both contracts must appear in the IR") + } + XCTAssertTrue(serviceRange.lowerBound < classRange.lowerBound, + "ascending specificity: Service (0,0,1) before .primary (0,1,0)") + } + func testV2HashChangesWhenValueChanges() throws { let hc = "App\n" let hcsA = "App:\n x: 1\n" diff --git a/workplan.md b/workplan.md index 153d94d..0c0c31b 100644 --- a/workplan.md +++ b/workplan.md @@ -91,9 +91,9 @@ cascade sheets (`.hcs`). Companion to the [RFC](RFC/Hypercode.md) and the ## M8 — Spec-layer hardening (RFC §9 follow-through) P0 — what makes the novelty claim defensible: -- ⬜ HC-110 `hypercode explain . [--ctx …]` — full cascade trace: winner *and* losing rules with specificity/source-order, contract checks; requires the resolver to retain the matched-rule list (today only the winner survives into the IR) -- ⬜ HC-111 Monotonic selector contracts — property schemas attached via selectors; values cascade, contracts accumulate & narrow; weakening = resolution error (normative rule: RFC §9.4) -- ⬜ HC-112 `hypercode.ir/v2` (breaking) — typed values (v1 is strings-only), `file` alongside `line`, specificity + source order, losing rules, contract results, per-node and per-document hashes, context echo, resolver name/version — `Schema/` +- ⬜ HC-110 `hypercode explain [property] [--ctx …]` — full cascade trace: winner *and* losing rules with specificity/source-order; in review: [#21](https://github.com/0al-spec/Hypercode/pull/21) (stacked on #19/#20) +- ⬜ HC-111 Monotonic selector contracts — property schemas attached via selectors; values cascade, contracts accumulate by intersection & narrow; weakening = resolution error (normative: RFC §9.4 + `EBNF/Hypercode_Resolution.md` §7); in review: [#22](https://github.com/0al-spec/Hypercode/pull/22); value enforcement (HC2104) follows as PR-5 +- ⬜ HC-112 `hypercode.ir/v2` (breaking) — typed values (v1 is strings-only), `file` alongside `line`, specificity + source order, losing rules, contract results, per-node and per-document hashes, context echo, resolver name/version — `Schema/`; in review: [#19](https://github.com/0al-spec/Hypercode/pull/19) + [#20](https://github.com/0al-spec/Hypercode/pull/20) P1 — built on v2: - ⬜ HC-113 `hypercode diff ` — affected nodes/properties with reasons (which rule changed), node-hash based; the invalidation signal for incremental (re)generation