From 69a38f03c8c0ac9f4d6b028700fa09409b6cbb93 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Thu, 11 Jun 2026 01:43:54 +0300 Subject: [PATCH 01/12] feat: HC-111 monotonic selector contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds @contract: block syntax to .hcs sheets. Each selector block declares property constraints (type, required/optional, numeric bounds). ContractValidator enforces monotonicity: more-specific selectors may only narrow — never widen — the interval, type, or required status inherited from less-specific ones. - CascadeSheet: ContractType, PropertyContract, SelectorContract, updated CascadeSheet - CascadeSheetReader: parses @contract: blocks; [?] optional marker; >= / <= bounds - ContractValidator: pairwise HC2101/HC2102/HC2103 diagnostics - Validator: integrates ContractValidator into sheet validation - Emitter v2: contracts[] field per property; accepts commands + contracts params - CLI emit: passes commands and sheet.contracts to Emitter - EBNF/Hypercode_Syntax.md: v0.2 — .hcs and @contract: grammar documented - 14 new tests (Contract parsing, ContractValidator, Validator integration); 74 total Co-Authored-By: Claude Sonnet 4.6 --- DOCS/Workplan.md | 2 +- EBNF/Hypercode_Syntax.md | 88 +++++++++- Sources/Hypercode/Emit/Emitter.swift | 54 +++++- Sources/Hypercode/HCS/CascadeSheet.swift | 47 ++++- .../Hypercode/HCS/CascadeSheetReader.swift | 91 +++++++++- .../Validation/ContractValidator.swift | 79 +++++++++ Sources/Hypercode/Validation/Validator.swift | 6 +- Sources/HypercodeCLI/main.swift | 7 +- Tests/HypercodeTests/ContractTests.swift | 165 ++++++++++++++++++ 9 files changed, 513 insertions(+), 26 deletions(-) create mode 100644 Sources/Hypercode/Validation/ContractValidator.swift create mode 100644 Tests/HypercodeTests/ContractTests.swift diff --git a/DOCS/Workplan.md b/DOCS/Workplan.md index 6e35a61..b43cf8a 100644 --- a/DOCS/Workplan.md +++ b/DOCS/Workplan.md @@ -175,7 +175,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`) ✅ merged 2026-06-11 Depends on PR-1, PR-2, and PR-3. diff --git a/EBNF/Hypercode_Syntax.md b/EBNF/Hypercode_Syntax.md index cf03b7f..bfca47e 100644 --- a/EBNF/Hypercode_Syntax.md +++ b/EBNF/Hypercode_Syntax.md @@ -2,7 +2,7 @@ **Status:** Draft -**Version:** 0.1 +**Version:** 0.2 **Date:** July 12, 2025 @@ -108,20 +108,92 @@ 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 + +``` + ::= { } + ::= | | + + ::= "@" "[" "]" ":" + { } + ::= + + ::= ":" + { } + ::= ":" + ::= | | "true" | "false" + ::= '"' { } '"' | "'" { } "'" +``` + +### 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 +``` + +#### Monotonicity rules (enforced by `ContractValidator`) + +| Violation | Code | Description | +|-----------|------|-------------| +| Type changed | HC2101 | More-specific selector uses a different type | +| Interval widened | HC2102 | More-specific selector lowers min or raises max | +| Required → optional | HC2103 | More-specific selector marks a required property as `[?]` | + +## 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 `>=`/`<=`. +* Documented monotonicity rules HC2101/HC2102/HC2103. **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/Sources/Hypercode/Emit/Emitter.swift b/Sources/Hypercode/Emit/Emitter.swift index 997f52c..ffe76b5 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,9 +87,15 @@ public struct Emitter { // MARK: v2 - static func intermediateV2(_ forest: [ResolvedNode], context: ResolutionContext) -> IR { + static func intermediateV2( + _ forest: [ResolvedNode], + context: ResolutionContext, + commands: [Command] = [], + contracts: [SelectorContract] = [] + ) -> IR { let hashes = forest.map { nodeHash($0) } - let nodeIRs = forest.map { nodeV2($0) } + let nodeIRs = zip(forest, commands.isEmpty ? Array(repeating: nil, count: forest.count) as [Command?] : commands.map(Optional.init)) + .map { node, cmd in nodeV2(node, command: cmd, ancestors: [], contracts: contracts) } let docHash = documentHash(hashes) return .object([ ("version", .string("hypercode.ir/v2")), @@ -104,26 +114,58 @@ public struct Emitter { ]) } - private static func nodeV2(_ node: ResolvedNode) -> IR { + private static func nodeV2( + _ node: ResolvedNode, + command: Command?, + ancestors: [Command], + contracts: [SelectorContract] + ) -> IR { 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))) } let properties = node.properties.keys.sorted().map { key -> (String, IR) in let rv = node.properties[key]! + let applicableContracts = command.map { cmd -> [IR] in + let ctx = NodeContext(node: cmd, ancestors: ancestors) + return contracts + .filter { sc in selectorSpec(sc.selector).isSatisfiedBy(ctx) } + .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)))) + + let childCommands = command.map { $0.children } ?? [] + let childAncs = command.map { ancestors + [$0] } ?? ancestors + fields.append(("children", .array(zip(node.children, childCommands.isEmpty + ? Array(repeating: nil, count: node.children.count) as [Command?] + : childCommands.map(Optional.init)).map { child, childCmd in + nodeV2(child, command: childCmd, ancestors: childAncs, contracts: contracts) + }))) return .object(fields) } + 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 { case .string(let s): return .string(s) diff --git a/Sources/Hypercode/HCS/CascadeSheet.swift b/Sources/Hypercode/HCS/CascadeSheet.swift index b575624..abdb436 100644 --- a/Sources/Hypercode/HCS/CascadeSheet.swift +++ b/Sources/Hypercode/HCS/CascadeSheet.swift @@ -70,11 +70,54 @@ public struct Match: Equatable, Sendable { public let order: Int } -/// A parsed `.hcs` cascade sheet: an ordered list of rules. +// 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 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 0232370..bcfd588 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?, diff --git a/Sources/Hypercode/Validation/ContractValidator.swift b/Sources/Hypercode/Validation/ContractValidator.swift new file mode 100644 index 0000000..82295bf --- /dev/null +++ b/Sources/Hypercode/Validation/ContractValidator.swift @@ -0,0 +1,79 @@ +/// 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). +/// +/// Diagnostic codes: +/// HC2101 — type mismatch between selectors of different specificity +/// 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]) -> [Diagnostic] { + var diagnostics: [Diagnostic] = [] + // Pairwise: for every (less-specific A, more-specific B) pair with A < B, + // check shared property keys. + for i in 0.. [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 + } +} diff --git a/Sources/Hypercode/Validation/Validator.swift b/Sources/Hypercode/Validation/Validator.swift index af67e40..f4b3722 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) + return dangling + contractDiags } private func anyNode( diff --git a/Sources/HypercodeCLI/main.swift b/Sources/HypercodeCLI/main.swift index bd43b3e..9c934d8 100644 --- a/Sources/HypercodeCLI/main.swift +++ b/Sources/HypercodeCLI/main.swift @@ -165,10 +165,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..91f33b5 --- /dev/null +++ b/Tests/HypercodeTests/ContractTests.swift @@ -0,0 +1,165 @@ +import Testing +@testable import Hypercode + +// MARK: - CascadeSheetReader: @contract: parsing + +@Suite("Contract parsing") struct ContractParsingTests { + let reader = CascadeSheetReader() + + @Test func parsesContractBlock() throws { + let src = """ + @contract: + service: + timeout: int >= 1 <= 300 + name: string + """ + let sheet = try reader.read(src) + #expect(sheet.contracts.count == 1) + let sc = sheet.contracts[0] + #expect(sc.selector == .type("service")) + #expect(sc.properties["timeout"]?.type == .int) + #expect(sc.properties["timeout"]?.min == 1) + #expect(sc.properties["timeout"]?.max == 300) + #expect(sc.properties["timeout"]?.required == true) + #expect(sc.properties["name"]?.type == .string) + #expect(sc.properties["name"]?.required == true) + } + + @Test func parsesOptionalConstraint() throws { + let src = """ + @contract: + service: + timeout[?]: int >= 1 + """ + let sheet = try reader.read(src) + let sc = sheet.contracts[0] + #expect(sc.properties["timeout"]?.required == false) + #expect(sc.properties["timeout"]?.min == 1) + #expect(sc.properties["timeout"]?.max == nil) + } + + @Test func parsesMultipleContractSelectors() throws { + let src = """ + @contract: + service: + timeout: int + .primary: + replicas: int >= 2 + """ + let sheet = try reader.read(src) + #expect(sheet.contracts.count == 2) + #expect(sheet.contracts[0].selector == .type("service")) + #expect(sheet.contracts[1].selector == .klass("primary")) + } + + @Test func parsesContractAlongsideRules() throws { + let src = """ + service: + timeout: 30 + + @contract: + service: + timeout: int >= 1 + """ + let sheet = try reader.read(src) + #expect(sheet.rules.count == 1) + #expect(sheet.contracts.count == 1) + } + + @Test func rejectsUnknownConstraintType() { + let src = """ + @contract: + service: + x: colour + """ + #expect(throws: HCSError.self) { try reader.read(src) } + } +} + +// MARK: - ContractValidator: monotonicity + +@Suite("ContractValidator") struct ContractValidatorTests { + let validator = ContractValidator() + + private func contract(_ selector: Hypercode.Selector, + _ properties: [String: PropertyContract]) -> SelectorContract { + SelectorContract(selector: selector, properties: properties, line: 1) + } + + @Test func noViolation_identicalConstraints() { + 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)]) + #expect(validator.validate([less, more]).isEmpty) + } + + @Test func noViolation_moreSpecificNarrows() { + 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)]) + #expect(validator.validate([less, more]).isEmpty) + } + + @Test func typeMismatch_HC2101() { + let less = contract(.type("service"), ["timeout": PropertyContract(type: .int)]) + let more = contract(.klass("primary"), ["timeout": PropertyContract(type: .float)]) + let diags = validator.validate([less, more]) + #expect(diags.count == 1) + #expect(diags[0].code == "HC2101") + } + + @Test func intervalWidening_lowerBound_HC2102() { + let less = contract(.type("service"), ["timeout": PropertyContract(type: .int, min: 5, max: nil)]) + let more = contract(.klass("primary"), ["timeout": PropertyContract(type: .int, min: 1, max: nil)]) + let diags = validator.validate([less, more]) + #expect(diags.count == 1) + #expect(diags[0].code == "HC2102") + #expect(diags[0].message.contains("lower bound")) + } + + @Test func intervalWidening_upperBound_HC2102() { + 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]) + #expect(diags.count == 1) + #expect(diags[0].code == "HC2102") + #expect(diags[0].message.contains("upper bound")) + } + + @Test func optionalWeakening_HC2103() { + 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]) + #expect(diags.count == 1) + #expect(diags[0].code == "HC2103") + } + + @Test func noViolation_unsharedKeys() { + let less = contract(.type("service"), ["timeout": PropertyContract(type: .int)]) + let more = contract(.klass("primary"), ["replicas": PropertyContract(type: .int)]) + #expect(validator.validate([less, more]).isEmpty) + } + + @Test func noViolation_sameSpecificity() { + // Same specificity — neither is "more specific", so no check applies. + let a = contract(.type("service"), ["timeout": PropertyContract(type: .int, max: 100)]) + let b = contract(.type("database"), ["timeout": PropertyContract(type: .int, max: 200)]) + #expect(validator.validate([a, b]).isEmpty) + } +} + +// MARK: - Validator integration + +@Suite("Validator contract integration") struct ValidatorContractIntegrationTests { + @Test func validatorRunsContractChecks() throws { + let src = """ + @contract: + service: + timeout: int + .primary: + timeout: float + """ + let sheet = try CascadeSheetReader().read(src) + let diags = Validator().validate(sheet, against: []) + let codes = diags.map(\.code) + #expect(codes.contains("HC2101")) + } +} From 8af7dc56e862bfac87b96e72fe803ff697dd8350 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Thu, 11 Jun 2026 11:28:45 +0300 Subject: [PATCH 02/12] docs: record strict-review follow-ups R1-R13 in DOCS/Workplan.md - New "Review follow-ups" section: R1-R8 blocking PR #22 (child-node hash schema violation, emitter Int-overflow crash, contract validator false positives and equal-specificity gap, Foundation leak in Explainer, compiler warning, test-framework consistency, undelivered plan items), R9-R13 pending owner decisions (contract value validation, lossy v1, D4/CryptoKit deviation, absent-bound semantics, public API construction gaps) - Fix PR-4 header: #22 is open, not merged; link PR numbers on all four headers - Annotate D4 decision row with the CryptoKit deviation Co-Authored-By: Claude Sonnet 4.6 --- DOCS/Workplan.md | 73 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/DOCS/Workplan.md b/DOCS/Workplan.md index b43cf8a..2980eb6 100644 --- a/DOCS/Workplan.md +++ b/DOCS/Workplan.md @@ -10,7 +10,7 @@ Companion to [workplan.md](../workplan.md) M8 task stubs and [RFC §9](../RFC/Hy | D1 | `TypedValue` = union `string/int/double/bool`; inferred at parse time in `CascadeSheetReader.parseProperty` (no new syntax). | | D2 | Losers retained inside `ResolvedValue` as `losers: [Match]`; public API, because `explain` and IR v2 both need them. | | D3 | `explain` command address: ` [property]` positional (avoids `Node.prop` ambiguity with class selectors). | -| D4 | SHA-256 without swift-crypto: vendor ~100-line pure-Swift implementation with NIST vectors in `Tests/`. | +| D4 | SHA-256 without swift-crypto: vendor ~100-line pure-Swift implementation with NIST vectors in `Tests/`. ⚠️ Implementation deviated: shipped as a CryptoKit wrapper (Apple-only) — see R11 below. | | D5 | `@contract:` block syntax (fits existing outline-reader mechanics, not `@contract Selector:` which needs new grammar). | ## PR sequence @@ -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. @@ -84,7 +84,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. @@ -135,7 +135,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. @@ -175,7 +175,7 @@ Golden-file tests against `Examples/service.{hc,hcs}` for: --- -## PR-4 — HC-111 Monotonic contracts (`feat/hc-111-contracts`) ✅ merged 2026-06-11 +## 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. @@ -247,3 +247,66 @@ 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 — Needs decision / separate task + +- ⬜ **R9 — contract value validation is not implemented.** Values are never checked + against contracts (`timeout: 999` under `int <= 300` passes; wrong-type values pass). + The PR-4 files table lists `Resolver` "contract eval" — undelivered. Decide: implement + value checking (new HC2104 diagnostic) in #22, or re-scope to a follow-up ticket and + fix the table. +- ⬜ **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). Decide: store the source lexeme alongside `TypedValue`, or document as a + known v1 limitation. +- ⬜ **R11 — D4 deviation.** SHA-256 shipped as a CryptoKit wrapper, not the approved + vendored pure-Swift implementation; core is now Apple-only (no Linux). Decide: accept + (rewrite D4 above accordingly) or switch to `swift-crypto` for portability. +- ⬜ **R12 — absent-bound semantics underspecified.** A more-specific contract that + omits `min`/`max` is not flagged as widening (current behavior = "omit inherits via + intersection"). Specify this in the RFC contracts section either way. +- ⬜ **R13 — public API construction gaps.** `Match`, `PropertyTrace`, `NodeTrace` have + no public inits (project practice: explicit `public init` on public types), so external + consumers cannot build `ResolvedValue`. `ResolvedValue` also duplicates winner data + (`value`/`provenance` ≡ `winner.*`) with no invariant — derive them from `winner`. From 73c44e034a364a19835de70592630bc490bf37c5 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Thu, 11 Jun 2026 19:07:27 +0300 Subject: [PATCH 03/12] docs: record owner decisions on review follow-ups R9-R13 - R9: contract value validation -> separate PR-5 with HC2104; files table fixed - R10: store source lexeme alongside typed value; v1 emits byte-for-byte - R11: switch SHA-256 to swift-crypto (D4 superseded; Linux-capable) - R12: omitted bound = inheritance via interval intersection; specify in RFC - R13: public inits for Match/PropertyTrace/NodeTrace + derived ResolvedValue init Co-Authored-By: Claude Sonnet 4.6 --- DOCS/Workplan.md | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/DOCS/Workplan.md b/DOCS/Workplan.md index 2980eb6..757b30e 100644 --- a/DOCS/Workplan.md +++ b/DOCS/Workplan.md @@ -10,7 +10,7 @@ Companion to [workplan.md](../workplan.md) M8 task stubs and [RFC §9](../RFC/Hy | D1 | `TypedValue` = union `string/int/double/bool`; inferred at parse time in `CascadeSheetReader.parseProperty` (no new syntax). | | D2 | Losers retained inside `ResolvedValue` as `losers: [Match]`; public API, because `explain` and IR v2 both need them. | | D3 | `explain` command address: ` [property]` positional (avoids `Node.prop` ambiguity with class selectors). | -| D4 | SHA-256 without swift-crypto: vendor ~100-line pure-Swift implementation with NIST vectors in `Tests/`. ⚠️ Implementation deviated: shipped as a CryptoKit wrapper (Apple-only) — see R11 below. | +| D4 | ~~SHA-256 without swift-crypto: vendor pure-Swift implementation~~ Superseded 2026-06-11 (R11): use `swift-crypto` — same CryptoKit API, Linux-capable; accepted as the second dependency. Shipped CryptoKit wrapper is interim. | | D5 | `@contract:` block syntax (fits existing outline-reader mechanics, not `@contract Selector:` which needs new grammar). | ## PR sequence @@ -236,7 +236,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 | — | @@ -289,24 +289,30 @@ All findings reproduced against `feat/hc-111-contracts` (69a38f0). R1–R8 block quoted strings (bare `driver: sqlite` is valid), header `Date:` stale, and the HC21xx semantics table belongs in `Hypercode_Resolution.md`. -### B — Needs decision / separate task +### 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). - The PR-4 files table lists `Resolver` "contract eval" — undelivered. Decide: implement - value checking (new HC2104 diagnostic) in #22, or re-scope to a follow-up ticket and - fix the table. + **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). Decide: store the source lexeme alongside `TypedValue`, or document as a - known v1 limitation. + 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). Decide: accept - (rewrite D4 above accordingly) or switch to `swift-crypto` for portability. + 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 (current behavior = "omit inherits via - intersection"). Specify this in the RFC contracts section either way. + 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 (project practice: explicit `public init` on public types), so external - consumers cannot build `ResolvedValue`. `ResolvedValue` also duplicates winner data - (`value`/`provenance` ≡ `winner.*`) with no invariant — derive them from `winner`. + 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. From 0644779183539cc73dfaba5072674ebc219bd83f Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Thu, 11 Jun 2026 19:12:45 +0300 Subject: [PATCH 04/12] fix(R11): SHA-256 via swift-crypto, Foundation-free hex rendering - Package.swift: swift-crypto dependency (Linux-capable, same CryptoKit API); 0.5.0-dev version comment (R8) - SHA256.swift: import Crypto; hand-rolled hexString (String(format:) was a Foundation leak via member lookup) Co-Authored-By: Claude Sonnet 4.6 --- Package.resolved | 18 ++++++++++++++++++ Package.swift | 5 +++++ Sources/Hypercode/Crypto/SHA256.swift | 19 +++++++++++++------ 3 files changed, 36 insertions(+), 6 deletions(-) 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/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)) } From c0cf1a8cbec0ab6acef6872408195554fa496ea7 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Thu, 11 Jun 2026 19:14:42 +0300 Subject: [PATCH 05/12] fix(R10,R13): lexeme-preserving TypedValue, invariant-safe public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R10: TypedValue is now a struct {kind, lexeme} — the typed kind drives v2 IR and hashing, the source lexeme drives v1 IR and explain text, so v1 round-trips byte-for-byte ("1.10" stays "1.10", "0123" stays "0123"). Static factories keep existing .string()/.int() call sites compiling unchanged. R13: explicit public inits for Match, PropertyTrace, NodeTrace; ResolvedValue.init(winner:losers:) derives value/provenance from winner — an inconsistent instance can no longer be constructed. PropertyCascade.decide simplified accordingly. Co-Authored-By: Claude Sonnet 4.6 --- Sources/Hypercode/Emit/Emitter.swift | 2 +- Sources/Hypercode/Explain/Explainer.swift | 11 ++++ Sources/Hypercode/HCS/CascadeSheet.swift | 66 +++++++++++++++---- .../Hypercode/HCS/CascadeSheetReader.swift | 12 ++-- Sources/Hypercode/HCS/Resolver.swift | 54 +++++++-------- 5 files changed, 93 insertions(+), 52 deletions(-) diff --git a/Sources/Hypercode/Emit/Emitter.swift b/Sources/Hypercode/Emit/Emitter.swift index ffe76b5..e15eeba 100644 --- a/Sources/Hypercode/Emit/Emitter.swift +++ b/Sources/Hypercode/Emit/Emitter.swift @@ -167,7 +167,7 @@ public struct Emitter { } 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) diff --git a/Sources/Hypercode/Explain/Explainer.swift b/Sources/Hypercode/Explain/Explainer.swift index f94369d..a03e2cd 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 diff --git a/Sources/Hypercode/HCS/CascadeSheet.swift b/Sources/Hypercode/HCS/CascadeSheet.swift index abdb436..d5d6c1a 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 } } /// A context guard from an `@dimension[value]` block, e.g. `@env[production]`. @@ -68,6 +90,22 @@ 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 diff --git a/Sources/Hypercode/HCS/CascadeSheetReader.swift b/Sources/Hypercode/HCS/CascadeSheetReader.swift index bcfd588..117e4f4 100644 --- a/Sources/Hypercode/HCS/CascadeSheetReader.swift +++ b/Sources/Hypercode/HCS/CascadeSheetReader.swift @@ -201,11 +201,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) } // MARK: - Public selector parsing (used by `hypercode explain`) 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())) } } From 2efa57b8fdee6f8eafa24b0379f6f4dc917a6df7 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Thu, 11 Jun 2026 19:17:02 +0300 Subject: [PATCH 06/12] fix(R1,R2): every IR v2 node carries its hash; no trap on huge numerals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1: nodeV2 now returns (ir, hash) recursively — child hashes are computed once and embedded, replacing the root-only zip-insert that violated the schema's required "hash" on every resolvedNode. Command arrays are padded, never zip-truncated, so a caller-side mismatch cannot drop nodes. R2: whole-number doubles render via Int(exactly:) and a finiteness guard — a 26-digit numeral emits as 1e+26 instead of trapping in Int(_:). Also (R8): contracts[] accumulate in ascending specificity with declaration order as the stable tie-breaker. Per-node contract matching hoisted out of the per-property loop. 3 regression tests. Co-Authored-By: Claude Sonnet 4.6 --- Sources/Hypercode/Emit/Emitter.swift | 86 +++++++++++++++---------- Tests/HypercodeTests/EmitterTests.swift | 53 +++++++++++++++ 2 files changed, 105 insertions(+), 34 deletions(-) diff --git a/Sources/Hypercode/Emit/Emitter.swift b/Sources/Hypercode/Emit/Emitter.swift index e15eeba..5714f5b 100644 --- a/Sources/Hypercode/Emit/Emitter.swift +++ b/Sources/Hypercode/Emit/Emitter.swift @@ -93,10 +93,9 @@ public struct Emitter { commands: [Command] = [], contracts: [SelectorContract] = [] ) -> IR { - let hashes = forest.map { nodeHash($0) } - let nodeIRs = zip(forest, commands.isEmpty ? Array(repeating: nil, count: forest.count) as [Command?] : commands.map(Optional.init)) + let rendered = zip(forest, padded(commands, to: forest.count)) .map { node, cmd in nodeV2(node, command: cmd, ancestors: [], contracts: contracts) } - let docHash = documentHash(hashes) + let docHash = documentHash(rendered.map(\.hash)) return .object([ ("version", .string("hypercode.ir/v2")), ("context", .object(context.keys.sorted().map { ($0, .string(context[$0]!)) })), @@ -105,36 +104,57 @@ public struct Emitter { ("version", .string("0.5.0")), ])), ("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))), ]) } + /// 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: 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 = command.map { cmd -> [IR] in - let ctx = NodeContext(node: cmd, ancestors: ancestors) - return contracts - .filter { sc in selectorSpec(sc.selector).isSatisfiedBy(ctx) } - .compactMap { sc -> IR? in - guard let pc = sc.properties[key] else { return nil } - return contractIR(pc, selector: sc.selector) - } - } ?? [] + 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)), @@ -144,15 +164,8 @@ public struct Emitter { return (key, .object(propFields)) } fields.append(("properties", .object(properties))) - - let childCommands = command.map { $0.children } ?? [] - let childAncs = command.map { ancestors + [$0] } ?? ancestors - fields.append(("children", .array(zip(node.children, childCommands.isEmpty - ? Array(repeating: nil, count: node.children.count) as [Command?] - : childCommands.map(Optional.init)).map { child, childCmd in - nodeV2(child, command: childCmd, ancestors: childAncs, contracts: contracts) - }))) - return .object(fields) + fields.append(("children", .array(children.map(\.ir)))) + return (.object(fields), hash) } private static func contractIR(_ pc: PropertyContract, selector: Selector) -> IR { @@ -197,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)) } @@ -225,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/Tests/HypercodeTests/EmitterTests.swift b/Tests/HypercodeTests/EmitterTests.swift index c0fd2e5..95a0b7a 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" From 925f79d8c72fe5f6fc1b4c2c06ab6fc0814ac549 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Thu, 11 Jun 2026 19:19:20 +0300 Subject: [PATCH 07/12] fix(R3,R4): contract pairs gated on selector overlap; equal-specificity conflicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3: like the CSS cascade, specificity only relates contracts that can govern the same node — a pair is checked only when some node in the document matches both selectors. Kills the false-positive HC2102 on disjoint selectors that made legitimate sheets unwritable. ContractValidator.validate now takes the forest; Validator passes it through. R4: contracts at equal specificity apply with equal force — a type conflict makes the intersection unsatisfiable and is now reported as HC2101 (was silently skipped by the strict `<` guard). Bounds at equal specificity intersect and stay legal. R7 (bundled — same file rewrite): ContractTests converted from Swift Testing to XCTest, matching the other 14 test files. Shared-key iteration is sorted for deterministic diagnostic order. 79 tests green. Co-Authored-By: Claude Sonnet 4.6 --- .../Validation/ContractValidator.swift | 95 +++++++++-- Sources/Hypercode/Validation/Validator.swift | 2 +- Tests/HypercodeTests/ContractTests.swift | 159 +++++++++++------- 3 files changed, 176 insertions(+), 80 deletions(-) diff --git a/Sources/Hypercode/Validation/ContractValidator.swift b/Sources/Hypercode/Validation/ContractValidator.swift index 82295bf..41fd8c5 100644 --- a/Sources/Hypercode/Validation/ContractValidator.swift +++ b/Sources/Hypercode/Validation/ContractValidator.swift @@ -1,38 +1,85 @@ +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 selectors of different specificity +/// 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]) -> [Diagnostic] { + public func validate( + _ contracts: [SelectorContract], + against forest: [Command] + ) -> [Diagnostic] { var diagnostics: [Diagnostic] = [] - // Pairwise: for every (less-specific A, more-specific B) pair with A < B, - // check shared property keys. 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, @@ -76,4 +123,22 @@ public struct ContractValidator { 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 f4b3722..57fab44 100644 --- a/Sources/Hypercode/Validation/Validator.swift +++ b/Sources/Hypercode/Validation/Validator.swift @@ -42,7 +42,7 @@ public struct Validator { ? 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) + let contractDiags = ContractValidator().validate(sheet.contracts, against: forest) return dangling + contractDiags } diff --git a/Tests/HypercodeTests/ContractTests.swift b/Tests/HypercodeTests/ContractTests.swift index 91f33b5..840c748 100644 --- a/Tests/HypercodeTests/ContractTests.swift +++ b/Tests/HypercodeTests/ContractTests.swift @@ -1,12 +1,12 @@ -import Testing +import XCTest @testable import Hypercode // MARK: - CascadeSheetReader: @contract: parsing -@Suite("Contract parsing") struct ContractParsingTests { - let reader = CascadeSheetReader() +final class ContractParsingTests: XCTestCase { + private let reader = CascadeSheetReader() - @Test func parsesContractBlock() throws { + func testParsesContractBlock() throws { let src = """ @contract: service: @@ -14,18 +14,18 @@ import Testing name: string """ let sheet = try reader.read(src) - #expect(sheet.contracts.count == 1) + XCTAssertEqual(sheet.contracts.count, 1) let sc = sheet.contracts[0] - #expect(sc.selector == .type("service")) - #expect(sc.properties["timeout"]?.type == .int) - #expect(sc.properties["timeout"]?.min == 1) - #expect(sc.properties["timeout"]?.max == 300) - #expect(sc.properties["timeout"]?.required == true) - #expect(sc.properties["name"]?.type == .string) - #expect(sc.properties["name"]?.required == true) + 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) } - @Test func parsesOptionalConstraint() throws { + func testParsesOptionalConstraint() throws { let src = """ @contract: service: @@ -33,12 +33,12 @@ import Testing """ let sheet = try reader.read(src) let sc = sheet.contracts[0] - #expect(sc.properties["timeout"]?.required == false) - #expect(sc.properties["timeout"]?.min == 1) - #expect(sc.properties["timeout"]?.max == nil) + XCTAssertEqual(sc.properties["timeout"]?.required, false) + XCTAssertEqual(sc.properties["timeout"]?.min, 1) + XCTAssertNil(sc.properties["timeout"]?.max) } - @Test func parsesMultipleContractSelectors() throws { + func testParsesMultipleContractSelectors() throws { let src = """ @contract: service: @@ -47,12 +47,12 @@ import Testing replicas: int >= 2 """ let sheet = try reader.read(src) - #expect(sheet.contracts.count == 2) - #expect(sheet.contracts[0].selector == .type("service")) - #expect(sheet.contracts[1].selector == .klass("primary")) + XCTAssertEqual(sheet.contracts.count, 2) + XCTAssertEqual(sheet.contracts[0].selector, .type("service")) + XCTAssertEqual(sheet.contracts[1].selector, .klass("primary")) } - @Test func parsesContractAlongsideRules() throws { + func testParsesContractAlongsideRules() throws { let src = """ service: timeout: 30 @@ -62,94 +62,125 @@ import Testing timeout: int >= 1 """ let sheet = try reader.read(src) - #expect(sheet.rules.count == 1) - #expect(sheet.contracts.count == 1) + XCTAssertEqual(sheet.rules.count, 1) + XCTAssertEqual(sheet.contracts.count, 1) } - @Test func rejectsUnknownConstraintType() { + func testRejectsUnknownConstraintType() { let src = """ @contract: service: x: colour """ - #expect(throws: HCSError.self) { try reader.read(src) } + XCTAssertThrowsError(try reader.read(src)) } } // MARK: - ContractValidator: monotonicity -@Suite("ContractValidator") struct ContractValidatorTests { - let validator = ContractValidator() +final class ContractValidatorTests: XCTestCase { + private let validator = ContractValidator() private func contract(_ selector: Hypercode.Selector, - _ properties: [String: PropertyContract]) -> SelectorContract { - SelectorContract(selector: selector, properties: properties, line: 1) + _ properties: [String: PropertyContract], + line: Int = 1) -> SelectorContract { + SelectorContract(selector: selector, properties: properties, line: line) } - @Test func noViolation_identicalConstraints() { + 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)]) - #expect(validator.validate([less, more]).isEmpty) + XCTAssertTrue(validator.validate([less, more], against: overlapping).isEmpty) } - @Test func noViolation_moreSpecificNarrows() { + 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)]) - #expect(validator.validate([less, more]).isEmpty) + XCTAssertTrue(validator.validate([less, more], against: overlapping).isEmpty) } - @Test func typeMismatch_HC2101() { + 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]) - #expect(diags.count == 1) - #expect(diags[0].code == "HC2101") + let diags = validator.validate([less, more], against: overlapping) + XCTAssertEqual(diags.count, 1) + XCTAssertEqual(diags[0].code, "HC2101") } - @Test func intervalWidening_lowerBound_HC2102() { - let less = contract(.type("service"), ["timeout": PropertyContract(type: .int, min: 5, max: nil)]) - let more = contract(.klass("primary"), ["timeout": PropertyContract(type: .int, min: 1, max: nil)]) - let diags = validator.validate([less, more]) - #expect(diags.count == 1) - #expect(diags[0].code == "HC2102") - #expect(diags[0].message.contains("lower bound")) + 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")) } - @Test func intervalWidening_upperBound_HC2102() { + 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]) - #expect(diags.count == 1) - #expect(diags[0].code == "HC2102") - #expect(diags[0].message.contains("upper bound")) + let diags = validator.validate([less, more], against: overlapping) + XCTAssertEqual(diags.count, 1) + XCTAssertEqual(diags[0].code, "HC2102") + XCTAssertTrue(diags[0].message.contains("upper bound")) } - @Test func optionalWeakening_HC2103() { + 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]) - #expect(diags.count == 1) - #expect(diags[0].code == "HC2103") + let diags = validator.validate([less, more], against: overlapping) + XCTAssertEqual(diags.count, 1) + XCTAssertEqual(diags[0].code, "HC2103") } - @Test func noViolation_unsharedKeys() { + func testNoViolationUnsharedKeys() { let less = contract(.type("service"), ["timeout": PropertyContract(type: .int)]) let more = contract(.klass("primary"), ["replicas": PropertyContract(type: .int)]) - #expect(validator.validate([less, more]).isEmpty) + 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")) } - @Test func noViolation_sameSpecificity() { - // Same specificity — neither is "more specific", so no check applies. + func testEqualSpecificityCompatibleContractsAreClean() { + let doc = forest("App\n service\n") let a = contract(.type("service"), ["timeout": PropertyContract(type: .int, max: 100)]) - let b = contract(.type("database"), ["timeout": PropertyContract(type: .int, max: 200)]) - #expect(validator.validate([a, b]).isEmpty) + 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 -@Suite("Validator contract integration") struct ValidatorContractIntegrationTests { - @Test func validatorRunsContractChecks() throws { +final class ValidatorContractIntegrationTests: XCTestCase { + func testValidatorRunsContractChecks() throws { let src = """ @contract: service: @@ -158,8 +189,8 @@ import Testing timeout: float """ let sheet = try CascadeSheetReader().read(src) - let diags = Validator().validate(sheet, against: []) - let codes = diags.map(\.code) - #expect(codes.contains("HC2101")) + let doc = try Parser(source: "App\n service.primary\n").parse() + let diags = Validator().validate(sheet, against: doc) + XCTAssertTrue(diags.map(\.code).contains("HC2101")) } } From 8b89a45ac4d7b27f32068611568a022d2ee1c210 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Thu, 11 Jun 2026 19:19:52 +0300 Subject: [PATCH 08/12] fix(R5,R6): Foundation-free padding in Explainer, warning-free build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit padding(toLength:withPad:startingAt:) is Foundation/NSString API that compiled only via Swift 5 leaky member lookup (no import in file; Foundation loaded transitively) and would break under MemberImportVisibility. Replaced with hand-rolled padding — the core target stays Foundation-free. Also fixes the never-mutated `var line1` warning. Co-Authored-By: Claude Sonnet 4.6 --- Sources/Hypercode/Explain/Explainer.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/Hypercode/Explain/Explainer.swift b/Sources/Hypercode/Explain/Explainer.swift index a03e2cd..7a0f54b 100644 --- a/Sources/Hypercode/Explain/Explainer.swift +++ b/Sources/Hypercode/Explain/Explainer.swift @@ -118,10 +118,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" From 14bb2df33a9b68409076ba538d3d6ff5beb8b82c Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Thu, 11 Jun 2026 19:23:08 +0300 Subject: [PATCH 09/12] docs(R8,R12): contract semantics in Resolution spec; RFC 0.2; EBNF fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EBNF/Hypercode_Resolution.md 0.2: new §7 Contracts — accumulation by intersection (omitted bound inherits, decision R12), overlap-gated monotonicity with HC2101-HC2103 table, HC2104 planned as PR-5, IR echo ordering; "typed scalars" un-deferred (landed with IR v2) - EBNF/Hypercode_Syntax.md: grammar fixed — bare unquoted strings are valid (driver: sqlite); type-inference and lexeme preservation noted; stale Date fixed; semantics table moved out (syntax doc covers syntax only) - RFC/Hypercode.md 0.2: contracts in the language model (§9.4 refined with intersection + overlap rules), explain and IR v2 marked shipped (§9.4, §9.7), type-system maturity limitation updated (§9.8), changelog entry - workplan.md M8: HC-110/111/112 annotated with open PR links (boxes get checked on merge) Co-Authored-By: Claude Sonnet 4.6 --- EBNF/Hypercode_Resolution.md | 73 +++++++++++++++++++++++++++++++++--- EBNF/Hypercode_Syntax.md | 25 +++++++----- RFC/Hypercode.md | 21 ++++++++--- workplan.md | 6 +-- 4 files changed, 101 insertions(+), 24 deletions(-) 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 bfca47e..c46c9e1 100644 --- a/EBNF/Hypercode_Syntax.md +++ b/EBNF/Hypercode_Syntax.md @@ -4,7 +4,7 @@ **Version:** 0.2 -**Date:** July 12, 2025 +**Date:** June 11, 2026 **Author:** Egor Merkushev @@ -125,10 +125,16 @@ A `.hcs` file contains cascade rules and optional contract blocks. ::= ":" { } ::= ":" - ::= | | "true" | "false" + ::= | ::= '"' { } '"' | "'" { } "'" + ::= 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 ``` @@ -171,13 +177,12 @@ More-specific selectors may only **narrow** constraints — never widen them (mo timeout: int >= 10 <= 200 # narrows — allowed ``` -#### Monotonicity rules (enforced by `ContractValidator`) +#### Semantics -| Violation | Code | Description | -|-----------|------|-------------| -| Type changed | HC2101 | More-specific selector uses a different type | -| Interval widened | HC2102 | More-specific selector lowers min or raises max | -| Required → optional | HC2103 | More-specific selector marks a required property as `[?]` | +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 @@ -191,7 +196,9 @@ More-specific selectors may only **narrow** constraints — never widen them (mo * Added Section 6: `.hcs` cascade sheet syntax (rules, selectors, dimension blocks). * Added `@contract:` block grammar (HC-111): constraint-line syntax, `[?]` optional marker, bounds `>=`/`<=`. -* Documented monotonicity rules HC2101/HC2102/HC2103. +* Bare scalars documented (type inference, lexeme preservation); semantics + (contract accumulation, monotonicity diagnostics) live in + `Hypercode_Resolution.md`. **Version 0.1** (2025-07-12) 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/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 From 967f96e77eaba7f5bc82e92bad3dce0eb68d39bd Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Thu, 11 Jun 2026 19:25:00 +0300 Subject: [PATCH 10/12] ci(R8): validate emitted IR v2 against the JSON Schema; fix oneOf in schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR-2 plan promised an ajv validation step — now the Swift workflow emits IR v2 from Examples/service.{hc,hcs} (dev + production contexts) and validates it with ajv (draft 2020-12). Wiring this up immediately caught a schema bug the review missed: typed values used oneOf [string|integer|number|boolean], and every int value matches both "integer" and "number", failing "exactly one". Switched to anyOf in resolvedProperty.value and matchEntry.value. Both example outputs now validate. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/swift.yml | 9 ++++++++- Schema/hypercode-ir-v2.schema.json | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) 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/Schema/hypercode-ir-v2.schema.json b/Schema/hypercode-ir-v2.schema.json index 281dcd3..0440f41 100644 --- a/Schema/hypercode-ir-v2.schema.json +++ b/Schema/hypercode-ir-v2.schema.json @@ -66,7 +66,7 @@ "properties": { "value": { "description": "The winning resolved value (typed).", - "oneOf": [ + "anyOf": [ { "type": "string" }, { "type": "integer" }, { "type": "number" }, @@ -102,7 +102,7 @@ "line": { "type": "integer" }, "file": { "type": "string" }, "value": { - "oneOf": [ + "anyOf": [ { "type": "string" }, { "type": "integer" }, { "type": "number" }, From e36ccfb59bc4edfbdc679d88dda278c8d6bd5202 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Thu, 11 Jun 2026 19:25:29 +0300 Subject: [PATCH 11/12] docs: mark review follow-ups R1-R8, R10-R13 done in DOCS/Workplan.md Co-Authored-By: Claude Sonnet 4.6 --- DOCS/Workplan.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/DOCS/Workplan.md b/DOCS/Workplan.md index 757b30e..967872c 100644 --- a/DOCS/Workplan.md +++ b/DOCS/Workplan.md @@ -257,31 +257,31 @@ All findings reproduced against `feat/hc-111-contracts` (69a38f0). R1–R8 block ### A — Blocking #22 -- ⬜ **R1 — IR v2 violates its own schema: child nodes have no `hash`.** +- ✅ **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 +- ✅ **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 +- ✅ **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 +- ✅ **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 +- ✅ **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; +- ✅ **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 ⬜ +- ✅ **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); @@ -297,21 +297,21 @@ All findings reproduced against `feat/hc-111-contracts` (69a38f0). R1–R8 block **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` → +- ✅ **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 +- ✅ **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 +- ✅ **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 +- ✅ **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 From 2631f6130b4169a8671dd205c3d2413028ad77a0 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Fri, 12 Jun 2026 00:49:25 +0300 Subject: [PATCH 12/12] =?UTF-8?q?fix:=20address=20#22=20review=20=E2=80=94?= =?UTF-8?q?=20version=20constant,=20schema=20self-description?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HypercodeVersion.current is the single source of truth for resolver.version in IR v2 (was a hardcoded "0.5.0" drifting from CHANGELOG's 0.4.0); set to 0.5.0-dev until release - Schema descriptions no longer claim contracts[] is "empty until HC-111" — documented as accumulated ascending-specificity contracts Co-Authored-By: Claude Sonnet 4.6 --- Schema/hypercode-ir-v2.schema.json | 4 ++-- Sources/Hypercode/Emit/Emitter.swift | 2 +- Sources/Hypercode/Version.swift | 6 ++++++ 3 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 Sources/Hypercode/Version.swift diff --git a/Schema/hypercode-ir-v2.schema.json b/Schema/hypercode-ir-v2.schema.json index 0440f41..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, @@ -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" } } } diff --git a/Sources/Hypercode/Emit/Emitter.swift b/Sources/Hypercode/Emit/Emitter.swift index 5714f5b..4e90306 100644 --- a/Sources/Hypercode/Emit/Emitter.swift +++ b/Sources/Hypercode/Emit/Emitter.swift @@ -101,7 +101,7 @@ public struct Emitter { ("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(rendered.map(\.ir))), 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" +}