diff --git a/DOCS/Usage.md b/DOCS/Usage.md new file mode 100644 index 0000000..0f3e52f --- /dev/null +++ b/DOCS/Usage.md @@ -0,0 +1,213 @@ +# Using Hypercode + +A practical walkthrough of the toolchain, end to end. Every output below is +real — produced by the CLI from [`Examples/service.hc`](../Examples/service.hc) +and [`Examples/service.hcs`](../Examples/service.hcs). + +``` +.hc + .hcs + --ctx ──▶ resolve ──▶ validate (contracts) ──▶ emit (IR v2) ──▶ your generator + │ + explain (why is this value X?) +``` + +## The running example + +One structure, two contexts. `service.hc` is the *what*: + +```hypercode +Service + Logger.console + Database#main-db + Connect + APIServer + Listen +``` + +`service.hcs` is the *how* — defaults, a production override block, and +invariants the cascade must respect in **every** context: + +```hcs +Logger: + level: "debug" + +APIServer > Listen: + host: "127.0.0.1" + port: 5000 + +@env[production]: + Logger: + level: "info" + '#main-db': + driver: "postgres" + pool_size: 50 + APIServer > Listen: + host: "0.0.0.0" + port: 8080 + +@contract: + APIServer > Listen: + port: int >= 1 <= 65535 + host: string + '#main-db': + pool_size[?]: int >= 1 <= 100 +``` + +## 1. One structure, many builds (white-label / environments) + +```console +$ hypercode resolve Examples/service.hc --hcs Examples/service.hcs --ctx env=production +Service + Logger (class: console) + - format: json [.console] + - level: info [Logger] + Database (id: main-db) + - driver: postgres [#main-db] + - file: dev.sqlite3 [Database] + - pool_size: 50 [#main-db] + Connect + APIServer + Listen + - host: 0.0.0.0 [APIServer > Listen] + - port: 8080 [APIServer > Listen] +``` + +Drop `--ctx` and the same structure resolves to the development build +(`sqlite`, `127.0.0.1:5000`, `level: debug`). The `.hc` never changes — that +is the white-label guarantee. Every value carries `[the selector that won]`. + +## 2. Guardrails: contracts as a CI gate + +Contracts are invariants attached to selectors. Values cascade freely; +contracts only accumulate and narrow (RFC §9.4). Two layers of checking: + +**Sheet-level (static):** a more specific contract that *weakens* an inherited +one — wider interval (HC2102), changed type (HC2101), required→optional +(HC2103) — is rejected when the sheet is read. + +**Value-level (per context):** the resolved values are checked against every +applicable contract — type, bounds, required presence (HC2104): + +```console +$ hypercode validate app.hc --hcs app.hcs --ctx env=production +app.hcs:1:1: error[HC2104]: contract violation for 'port': 99999 exceeds upper bound 65535.0 from contract 'APIServer > Listen' +$ echo $? +1 +``` + +The classic failure — "the production override accidentally weakened a limit" — +becomes a build error instead of an incident. The CI recipe is one line per +context: + +```yaml +- name: Validate configuration for every context + run: | + hypercode validate app.hc --hcs app.hcs # development + hypercode validate app.hc --hcs app.hcs --ctx env=production + hypercode validate app.hc --hcs app.hcs --ctx env=staging +``` + +Diagnostics are also available LSP-shaped: `--diagnostics json`. + +## 3. Debugging the cascade: `explain` + +"Why is this value X in production?" — the eternal archaeology of layered +configuration, answered in one command: + +```console +$ hypercode explain Examples/service.hc --hcs Examples/service.hcs --ctx env=production Logger level +Matched 1 node for selector 'Logger' + +Node: Service > Logger.console + level + WINNER Logger { value: info } + file: Examples/service.hcs line: 16 specificity: (0,0,1) order: 4 + ──────────────────── + losing Logger { value: debug } + file: Examples/service.hcs line: 1 specificity: (0,0,1) order: 0 +``` + +Winner *and* every losing rule, each with its file, line, specificity and +source order. Selectors work the same as in sheets: `Logger`, `.console`, +`'#main-db'`, `APIServer > Listen`. Omit the property to trace all of them. + +## 4. Machine-readable output: IR v2 + +`emit` produces the canonical resolved-graph IR — the contract between +Hypercode and any consumer ([schema](../Schema/hypercode-ir-v2.schema.json), +ajv-validated in CI): + +```console +$ hypercode emit Examples/service.hc --hcs Examples/service.hcs --ctx env=production --format json +``` + +```jsonc +{ + "version": "hypercode.ir/v2", + "context": { "env": "production" }, // echo of --ctx + "resolver": { "name": "hypercode-swift", "version": "0.5.0-dev" }, + "documentHash": "3be098179523f21c…", + "nodes": [ /* … each node: */ + { + "type": "Listen", + "hash": "c4ba5d08dcfca81b…", // stable content hash + "properties": { + "port": { + "value": 8080, // typed, not stringly + "winner": { "selector": "APIServer > Listen", "line": 26, "specificity": [0,0,2], "…": "…" }, + "losers": [ { "value": 5000, "line": 11, "…": "…" } ], + "contracts": [ { "selector": "APIServer > Listen", "type": "int", "min": 1.0, "max": 65535.0, "required": true } ] + } + } + } + ] +} +``` + +What each piece is for: + +- **Typed values** — consumers get `8080`, not `"8080"`. +- **`hash`** covers only the *stable resolved content* (type/class/id, values, + child hashes). A different rule winning with the same value does **not** + change the hash — it is the invalidation signal for incremental + regeneration: re-generate only nodes whose hashes changed. +- **`winner` / `losers`** — full provenance; an auditor or codegen validator + can state *which rule* demanded a behavior. +- **`contracts`** — every contract governing the property, ascending + specificity, so a consumer can re-derive the effective constraint without + re-parsing the sheet. + +`--ir-version 1` keeps the legacy strings-only v1 for existing consumers; +v1 values round-trip byte-for-byte (`1.10` stays `1.10`). + +## 5. The target pipeline: specification for AI code generation + +The intended role ([RFC §9.7](../RFC/Hypercode.md)): `.hc`/`.hcs` is the durable +specification, code is regenerated output. + +``` +.hc + .hcs ──▶ resolved IR ──▶ LLM generates code per node + │ │ + node hashes validated against the same + (what changed?) contracts + provenance +``` + +- The generator consumes the IR — never the raw sheets — so it sees one + unambiguous, typed, context-resolved graph. +- Node hashes scope regeneration to what actually changed. +- Contracts give the validator formal grounds to reject a generated artifact. +- Humans review the *specification diff*; machines expand it into code + (review compression). + +The missing piece for this loop is `hypercode diff ` — +HC-113 in the [work plan](../workplan.md). + +## Scalar typing cheat-sheet + +| Written in `.hcs` | Resolved as | +|---|---| +| `port: 8080` | int `8080` | +| `ratio: 0.5` | float `0.5` | +| `active: true` | bool `true` | +| `driver: sqlite` | string `"sqlite"` (bare strings are fine) | +| `zip: "00123"` | string `"00123"` — **quoting forces string** | +| `version: 1.10` | float `1.1` in v2; v1 IR preserves the lexeme `1.10` | diff --git a/DOCS/Workplan.md b/DOCS/Workplan.md index fea727d..a66f6b6 100644 --- a/DOCS/Workplan.md +++ b/DOCS/Workplan.md @@ -294,12 +294,13 @@ All findings reproduced against `feat/hc-111-contracts` (69a38f0). R1–R8 block ### 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). +- ✅ **R9 — contract value validation was not implemented.** Values were never checked + against contracts (`timeout: 999` under `int <= 300` passed; wrong-type values passed). **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). + to PR-5). **Done:** `ContractValueValidator` on `feat/hc-111-value-validation`; + `validate` gained `--ctx`; violations point at the winning rule. - ✅ **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 diff --git a/EBNF/Hypercode_Resolution.md b/EBNF/Hypercode_Resolution.md index ac99a23..377be1e 100644 --- a/EBNF/Hypercode_Resolution.md +++ b/EBNF/Hypercode_Resolution.md @@ -147,11 +147,17 @@ 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.3 Value validation (HC2104) + +Resolved values are checked against every applicable contract +(`ContractValueValidator`): type conformance (an int value satisfies a +`float` contract, ℤ ⊂ ℝ; everything else must match exactly), declared +bounds, and required presence. Because resolution is context-dependent, +this check runs on the resolved graph — the same sheet can be clean under +one `--ctx` and violating under another, so `hypercode validate` accepts +`--ctx key=value`. Violations point at the winning rule (where the value +was written); a missing required property points at the contract that +demands it. ### 7.4 IR @@ -179,8 +185,6 @@ hypercode resolve Examples/service.hc --hcs Examples/service.hcs --ctx env=produ 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`. -- **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/Examples/service.hcs b/Examples/service.hcs index 90286fb..eb5b971 100644 --- a/Examples/service.hcs +++ b/Examples/service.hcs @@ -26,3 +26,14 @@ APIServer > Listen: APIServer > Listen: host: "0.0.0.0" port: 8080 + +# Invariants the cascade must respect in every context (HC-111). +# A more specific selector may narrow these, never weaken them. +@contract: + APIServer > Listen: + port: int >= 1 <= 65535 + host: string + '#main-db': + pool_size[?]: int >= 1 <= 100 + Logger: + level: string diff --git a/README.md b/README.md index 2150ebe..00bb979 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ build **without touching the structure**. 📖 **API documentation:** ``` -.hc + .hcs ──[resolve]──▶ resolved graph ──[emit]──▶ canonical IR (hypercode.ir/v1) +.hc + .hcs ──[resolve]──▶ resolved graph ──[validate contracts]──▶ canonical IR (hypercode.ir/v2) ``` ## Why @@ -18,8 +18,13 @@ build **without touching the structure**. context-aware `@rules`. - **Context switching / white-label** — one structure, many contexts. Swap `--ctx env=production` (or `client=acme`) → different output, same `.hc`. -- **Provenance** — every resolved value records the selector and source line it - came from. +- **Provenance** — every resolved value records the selector, file and source + line it came from; `hypercode explain` shows the winner *and* every losing rule. +- **Contracts that only narrow** — `@contract:` blocks attach invariants to + selectors; values cascade, safety doesn't. A production override that breaks + a bound is a build error (`HC2104`), not an incident. +- **Hashed, typed IR** — per-node SHA-256 over stable resolved content: the + invalidation signal for incremental (re)generation. ## Install @@ -50,9 +55,11 @@ swift run hypercode resolve Examples/service.hc --hcs Examples/service.hcs --ctx ``` hypercode parse -hypercode validate [--hcs ] +hypercode validate [--hcs ] [--ctx key=value]... # incl. contract checks hypercode resolve --hcs [--ctx key=value]... -hypercode emit [--hcs ] [--ctx key=value]... [--format json|yaml] +hypercode emit [--hcs ] [--ctx key=value]... [--format json|yaml] [--ir-version 1|2] +hypercode explain --hcs [--ctx key=value]... [property] +hypercode lsp # LSP over stdio ``` The same structure, two contexts: @@ -64,12 +71,13 @@ hypercode resolve Examples/service.hc --hcs Examples/service.hcs --ctx env=produ ## Documentation +- **[Usage guide](DOCS/Usage.md)** — every command with real outputs: contexts, contracts, explain, IR v2 - **API docs (DocC):** - [Conceptual overview](OVERVIEW.md) - [RFC — the paradigm](RFC/Hypercode.md) - Formal specs: [`.hc` syntax (BNF)](EBNF/Hypercode_Syntax.md) · [resolution semantics](EBNF/Hypercode_Resolution.md) - Architecture: [overview](DOCS/Architecture.md) · [backends & adapters](DOCS/Backends.md) · [core vs dialects](DOCS/Dialects.md) · [positioning](DOCS/Positioning.md) -- [Resolved-graph IR schema](Schema/hypercode-ir-v1.schema.json) — the cross-implementation contract +- Resolved-graph IR schemas — the cross-implementation contract: [v2](Schema/hypercode-ir-v2.schema.json) · [v1 (legacy)](Schema/hypercode-ir-v1.schema.json) - [Lean 4 cascade oracle](SPEC/lean/) — machine-checked agreement with the resolver - [Work plan](workplan.md) · [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) diff --git a/Sources/Hypercode/Validation/ContractValueValidator.swift b/Sources/Hypercode/Validation/ContractValueValidator.swift new file mode 100644 index 0000000..93350ad --- /dev/null +++ b/Sources/Hypercode/Validation/ContractValueValidator.swift @@ -0,0 +1,173 @@ +import SpecificationCore + +/// Validates resolved property values against the contracts that govern each +/// node (Resolution §7.3, diagnostic HC2104). Contracts accumulate by +/// intersection, so a value must satisfy **every** applicable contract +/// individually: type conformance, declared bounds, and required presence. +/// +/// Resolution is context-dependent, so unlike the static `ContractValidator` +/// this check runs on the resolved graph — the same `.hcs` can be clean under +/// one `--ctx` and violating under another. +public struct ContractValueValidator { + public init() {} + + public func validate( + resolved: [ResolvedNode], + commands: [Command], + contracts: [SelectorContract] + ) -> [Diagnostic] { + guard !contracts.isEmpty else { return [] } + var diagnostics: [Diagnostic] = [] + walk(commands: commands, resolved: resolved, ancestors: [], + contracts: contracts, into: &diagnostics) + return diagnostics + } + + // MARK: - Tree walk + + private func walk( + commands: [Command], + resolved: [ResolvedNode], + ancestors: [Command], + contracts: [SelectorContract], + into diagnostics: inout [Diagnostic] + ) { + // The resolver preserves tree shape, so the two arrays are parallel by + // construction — fail fast in debug builds rather than silently skip + // validation of trailing nodes. + assert(commands.count == resolved.count, + "command/resolved tree shape mismatch (\(commands.count) vs \(resolved.count))") + for (cmd, node) in zip(commands, resolved) { + let context = NodeContext(node: cmd, ancestors: ancestors) + let applicable = contracts.filter { selectorSpec($0.selector).isSatisfiedBy(context) } + for contract in applicable { + checkNode(cmd, node, against: contract, into: &diagnostics) + } + walk(commands: cmd.children, resolved: node.children, + ancestors: ancestors + [cmd], contracts: contracts, into: &diagnostics) + } + } + + private func checkNode( + _ cmd: Command, + _ node: ResolvedNode, + against contract: SelectorContract, + into diagnostics: inout [Diagnostic] + ) { + for key in contract.properties.keys.sorted() { + let constraint = contract.properties[key]! + guard let resolvedValue = node.properties[key] else { + if constraint.required { + diagnostics.append(Diagnostic( + severity: .error, code: "HC2104", + message: "node '\(label(cmd))' is missing required property '\(key)' — required by contract '\(contract.selector)'", + file: contract.file, + range: SourceRange(SourcePosition(line: contract.line, column: 1)) + )) + } + continue + } + diagnostics += check( + resolvedValue, key: key, + constraint: constraint, selector: contract.selector + ) + } + } + + // MARK: - Value checks + + private func check( + _ rv: ResolvedValue, + key: String, + constraint: PropertyContract, + selector: Selector + ) -> [Diagnostic] { + // Violations point at the winning rule — where the value was written — + // not at the contract declaration. + let winner = rv.winner + let range = SourceRange(SourcePosition(line: winner.line, column: 1)) + + // Type conformance. An int satisfies a float contract (ℤ ⊂ ℝ); + // everything else must match exactly. + let numeric: NumericValue? + switch (rv.value.kind, constraint.type) { + case (.int(let i), .int), (.int(let i), .float): + numeric = .int(i) + case (.double(let d), .float): + numeric = .double(d) + case (.string, .string), (.bool, .bool): + numeric = nil + default: + return [Diagnostic( + severity: .error, code: "HC2104", + message: "contract violation for '\(key)': value '\(rv.value.rawString)' (\(kindName(rv.value.kind))) does not satisfy type '\(constraint.type.rawValue)' from contract '\(selector)'", + file: winner.file, range: range + )] + } + + var diags: [Diagnostic] = [] + if let n = numeric { + if let min = constraint.min, n.isBelow(min) { + diags.append(Diagnostic( + severity: .error, code: "HC2104", + message: "contract violation for '\(key)': \(rv.value.rawString) is below lower bound \(min) from contract '\(selector)'", + file: winner.file, range: range + )) + } + if let max = constraint.max, n.isAbove(max) { + diags.append(Diagnostic( + severity: .error, code: "HC2104", + message: "contract violation for '\(key)': \(rv.value.rawString) exceeds upper bound \(max) from contract '\(selector)'", + file: winner.file, range: range + )) + } + } + return diags + } + + // MARK: - Helpers + + /// A resolved numeric value compared against `Double` bounds without + /// losing integer precision: `Double(i)` rounds beyond 2^53, which could + /// hide a violation like `9007199254740993` under `int <= 9007199254740992`. + /// Integer values compare in the `Int` domain whenever the bound is an + /// exactly representable integer. + private enum NumericValue { + case int(Int) + case double(Double) + + func isBelow(_ bound: Double) -> Bool { + switch self { + case .double(let d): return d < bound + case .int(let i): + if let exact = Int(exactly: bound) { return i < exact } + return Double(i) < bound + } + } + + func isAbove(_ bound: Double) -> Bool { + switch self { + case .double(let d): return d > bound + case .int(let i): + if let exact = Int(exactly: bound) { return i > exact } + return Double(i) > bound + } + } + } + + private func kindName(_ kind: TypedValue.Kind) -> String { + switch kind { + case .string: return "string" + case .int: return "int" + case .double: return "float" + case .bool: return "bool" + } + } + + private func label(_ cmd: Command) -> String { + var text = cmd.type + if let c = cmd.className { text += ".\(c)" } + if let i = cmd.id { text += "#\(i)" } + return text + } +} diff --git a/Sources/HypercodeCLI/main.swift b/Sources/HypercodeCLI/main.swift index 6abf00a..fcac06d 100644 --- a/Sources/HypercodeCLI/main.swift +++ b/Sources/HypercodeCLI/main.swift @@ -4,7 +4,7 @@ import Hypercode let usage = """ usage: hypercode parse - hypercode validate [--hcs ] + hypercode validate [--hcs ] [--ctx key=value]... hypercode resolve --hcs [--ctx key=value]... hypercode emit [--hcs ] [--ctx key=value]... [--format json|yaml] [--ir-version 1|2] hypercode explain --hcs [--ctx key=value]... [property] @@ -89,6 +89,7 @@ func runResolve(_ args: [String]) throws { func runValidate(_ args: [String]) throws { var hcPath: String? var hcsPath: String? + var context: ResolutionContext = [:] var index = 0 while index < args.count { @@ -97,6 +98,11 @@ func runValidate(_ args: [String]) throws { index += 1 guard index < args.count else { fail("error: --hcs needs a path") } hcsPath = args[index] + case "--ctx": + index += 1 + guard index < args.count else { fail("error: --ctx needs key=value") } + let (key, value) = parseContextAssignment(args[index]) + context[key] = value default: if hcPath == nil { hcPath = args[index] } else { fail("error: unexpected argument '\(args[index])'") @@ -121,6 +127,15 @@ func runValidate(_ args: [String]) throws { if let hcsPath { let sheet = try CascadeSheetReader().read(readSource(hcsPath), file: hcsPath) located += tagged(validator.validate(sheet, against: forest), file: hcsPath) + // Value-level contract checks run on the resolved graph (HC2104) — + // resolution is context-dependent, hence validate accepts --ctx. + let resolved = Resolver(sheet: sheet, context: context).resolve(forest) + located += tagged( + ContractValueValidator().validate( + resolved: resolved, commands: forest, contracts: sheet.contracts + ), + file: hcsPath + ) } switch diagnosticsFormat { case .json: diff --git a/Tests/HypercodeTests/ContractValueTests.swift b/Tests/HypercodeTests/ContractValueTests.swift new file mode 100644 index 0000000..5c199e7 --- /dev/null +++ b/Tests/HypercodeTests/ContractValueTests.swift @@ -0,0 +1,202 @@ +import XCTest +@testable import Hypercode + +/// HC2104 — resolved values checked against the contracts governing each node. +final class ContractValueTests: XCTestCase { + private func diagnostics( + hc: String, hcs: String, context: ResolutionContext = [:] + ) throws -> [Diagnostic] { + let forest = try Parser(source: hc).parse() + let sheet = try CascadeSheetReader().read(hcs) + let resolved = Resolver(sheet: sheet, context: context).resolve(forest) + return ContractValueValidator().validate( + resolved: resolved, commands: forest, contracts: sheet.contracts + ) + } + + func testUpperBoundViolation() throws { + let diags = try diagnostics( + hc: "App\n service\n", + hcs: """ + service: + timeout: 999 + + @contract: + service: + timeout: int <= 300 + """ + ) + XCTAssertEqual(diags.count, 1) + XCTAssertEqual(diags[0].code, "HC2104") + XCTAssertTrue(diags[0].message.contains("exceeds upper bound")) + } + + func testLowerBoundViolation() throws { + let diags = try diagnostics( + hc: "App\n service\n", + hcs: """ + service: + replicas: 1 + + @contract: + service: + replicas: int >= 2 + """ + ) + XCTAssertEqual(diags.count, 1) + XCTAssertTrue(diags[0].message.contains("below lower bound")) + } + + func testTypeMismatch() throws { + let diags = try diagnostics( + hc: "App\n service\n", + hcs: """ + service: + timeout: fast + + @contract: + service: + timeout: int + """ + ) + XCTAssertEqual(diags.count, 1) + XCTAssertEqual(diags[0].code, "HC2104") + XCTAssertTrue(diags[0].message.contains("does not satisfy type 'int'")) + } + + func testMissingRequiredProperty() throws { + let diags = try diagnostics( + hc: "App\n service\n", + hcs: """ + @contract: + service: + timeout: int + """ + ) + XCTAssertEqual(diags.count, 1) + XCTAssertTrue(diags[0].message.contains("missing required property 'timeout'")) + XCTAssertTrue(diags[0].message.contains("'service'")) + } + + func testMissingOptionalPropertyIsClean() throws { + let diags = try diagnostics( + hc: "App\n service\n", + hcs: """ + @contract: + service: + timeout[?]: int + """ + ) + XCTAssertTrue(diags.isEmpty) + } + + func testValueWithinBoundsIsClean() throws { + let diags = try diagnostics( + hc: "App\n service\n", + hcs: """ + service: + timeout: 30 + + @contract: + service: + timeout: int >= 1 <= 300 + """ + ) + XCTAssertTrue(diags.isEmpty) + } + + func testIntersectionOfAccumulatedContracts() throws { + // service caps at 300; .primary raises the floor to 10. + // A value of 5 satisfies the service contract but violates .primary's. + let diags = try diagnostics( + hc: "App\n service.primary\n", + hcs: """ + service: + timeout: 5 + + @contract: + service: + timeout: int <= 300 + .primary: + timeout: int >= 10 + """ + ) + XCTAssertEqual(diags.count, 1) + XCTAssertTrue(diags[0].message.contains("below lower bound 10.0 from contract '.primary'")) + } + + func testIntSatisfiesFloatContract() throws { + let diags = try diagnostics( + hc: "App\n service\n", + hcs: """ + service: + ratio: 2 + + @contract: + service: + ratio: float >= 0.5 + """ + ) + XCTAssertTrue(diags.isEmpty, "an int value satisfies a float contract") + } + + func testContextDependentViolation() throws { + let hc = "App\n service\n" + let hcs = """ + service: + timeout: 30 + + @env[production]: + service: + timeout: 999 + + @contract: + service: + timeout: int <= 300 + """ + let dev = try diagnostics(hc: hc, hcs: hcs) + XCTAssertTrue(dev.isEmpty, "development context resolves to 30 — clean") + + let prod = try diagnostics(hc: hc, hcs: hcs, context: ["env": "production"]) + XCTAssertEqual(prod.count, 1, "production context resolves to 999 — violation") + XCTAssertEqual(prod[0].code, "HC2104") + } + + func testIntPrecisionBeyondDoubleRange() throws { + // 9007199254740993 = 2^53 + 1 rounds to the same Double as 2^53, so a + // Double-domain comparison would miss the violation. Integer bounds + // must compare in the Int domain. + let diags = try diagnostics( + hc: "App\n service\n", + hcs: """ + service: + big: 9007199254740993 + + @contract: + service: + big: int <= 9007199254740992 + """ + ) + XCTAssertEqual(diags.count, 1, "2^53 + 1 must violate an upper bound of 2^53") + XCTAssertTrue(diags[0].message.contains("exceeds upper bound")) + } + + func testViolationPointsAtWinningRule() throws { + let diags = try diagnostics( + hc: "App\n service\n", + hcs: """ + service: + timeout: 999 + + @contract: + service: + timeout: int <= 300 + """ + ) + XCTAssertEqual(diags.count, 1) + // Provenance records the winning rule's header line (line 1: "service:"), + // consistent with v1 IR "line" — not the property line within the block. + XCTAssertEqual(diags.first?.range?.start.line, 1, + "diagnostic points at the winning rule, not the contract declaration") + } +} diff --git a/workplan.md b/workplan.md index 0c0c31b..8fe4e98 100644 --- a/workplan.md +++ b/workplan.md @@ -99,6 +99,7 @@ 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 - ⬜ HC-114 Runtime resolver boundary — document default build/generation-time mode vs. optional embedded runtime resolver (per-request context: caching, latency, provenance); decide library API or explicit out-of-scope — RFC §9.8 - 🅿️ HC-115 OpenFeature bridge for the runtime mode *(only if HC-114 decides "in scope")* +- ⬜ HC-116 `@import` for `.hcs` — sheet modularity; real configurations don't live in one file (resolution order, cycle detection, provenance keeps the importing file) ## M9 — Validation & adoption (DOCS/Positioning.md) @@ -106,8 +107,10 @@ P1 — built on v2: - ⬜ HC-121 Dogfooding as the primary adoption path — Hyperprompt / Ontology consume the resolved IR (consumer-side dialects & backends per `DOCS/Dialects.md` / `DOCS/Backends.md`) - 🅿️ HC-122 SLSA-like generation attestation chain — signed `.hc`/`.hcs` → IR hash → generator identity/version → artifact hashes → validator report (RFC §8, §9.8) - 🅿️ HC-123 Agent Passport / 0AL integration — attestation chain plugs into 0AL's signed-agent model +- ⬜ HC-124 End-to-end AI codegen demo — one `.hc`/`.hcs` service spec → IR v2 → LLM generates code per node → generated artifacts validated against the same contracts; node hashes scope regeneration after a spec edit; the "review compression" story (RFC §9.7) made runnable ## Cross-cutting - [x] HC-090 Swift CI workflow (build + test) — `.github/workflows/swift.yml` - [x] HC-091 Repo layout documented (root Swift package) — `DOCS/Architecture.md` +- [x] HC-092 Usage guide with real CLI outputs (contexts, contracts as a CI gate, `explain`, IR v2, AI-codegen pipeline) — `DOCS/Usage.md`; `Examples/service.hcs` gained a `@contract:` block so the reference fixture exercises HC-111 *(PR #23)* - [x] HC-092 ANTLR/Java relabeled as a conformance oracle — `DOCS/Architecture.md`