diff --git a/DOCS/Workplan.md b/DOCS/Workplan.md index 4901122..944155c 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 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 @@ -122,13 +122,14 @@ New `Schema/hypercode-ir-v2.schema.json`: `hypercode emit ... [--ir-version 1|2]` — default v2. v1 emitter kept intact for `--ir-version 1`. -### Hashing (D4) +### Hashing (D4, superseded — see decisions table) -Vendor `Sources/Hypercode/Crypto/SHA256.swift` (~100 lines, FIPS 180-4). -Test vectors from NIST FIPS 180-4 in `Tests/SHA256Tests.swift`. +`Sources/Hypercode/Crypto/SHA256.swift` is a thin wrapper over the platform +SHA-256 (CryptoKit in this PR; switched to `swift-crypto` later in the chain +per R11). Test vectors from NIST FIPS 180-4 in `Tests/SHA256Tests.swift`. Node hash input: deterministic JSON of `{type, class?, id?, properties: {key: value}, childHashes: []}`. -Document hash: SHA-256 of newline-joined sorted node hashes (BFS order). +Document hash: SHA-256 of newline-joined root-node hashes in document order. ### CI diff --git a/Schema/hypercode-ir-v2.schema.json b/Schema/hypercode-ir-v2.schema.json new file mode 100644 index 0000000..281dcd3 --- /dev/null +++ b/Schema/hypercode-ir-v2.schema.json @@ -0,0 +1,127 @@ +{ + "$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.", + "type": "object", + "required": ["version", "context", "resolver", "documentHash", "nodes"], + "additionalProperties": false, + "properties": { + "version": { + "type": "string", + "const": "hypercode.ir/v2" + }, + "context": { + "type": "object", + "description": "Echo of the --ctx flags supplied at resolution time.", + "additionalProperties": { "type": "string" } + }, + "resolver": { + "type": "object", + "required": ["name", "version"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "version": { "type": "string" } + } + }, + "documentHash": { + "type": "string", + "description": "SHA-256 of newline-joined root-node hashes (document-level fingerprint).", + "pattern": "^[0-9a-f]{64}$" + }, + "nodes": { + "type": "array", + "items": { "$ref": "#/$defs/resolvedNode" } + } + }, + "$defs": { + "resolvedNode": { + "type": "object", + "required": ["type", "hash", "properties", "children"], + "additionalProperties": false, + "properties": { + "type": { "type": "string" }, + "class": { "type": "string" }, + "id": { "type": "string" }, + "hash": { + "type": "string", + "description": "SHA-256 of the node's stable content (type, class?, id?, resolved values, child hashes).", + "pattern": "^[0-9a-f]{64}$" + }, + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/resolvedProperty" } + }, + "children": { + "type": "array", + "items": { "$ref": "#/$defs/resolvedNode" } + } + } + }, + "resolvedProperty": { + "type": "object", + "required": ["value", "winner", "losers", "contracts"], + "additionalProperties": false, + "properties": { + "value": { + "description": "The winning resolved value (typed).", + "oneOf": [ + { "type": "string" }, + { "type": "integer" }, + { "type": "number" }, + { "type": "boolean" } + ] + }, + "winner": { "$ref": "#/$defs/matchEntry" }, + "losers": { + "type": "array", + "items": { "$ref": "#/$defs/matchEntry" } + }, + "contracts": { + "type": "array", + "description": "Accumulated selector contracts (HC-111). Empty until contracts are added to .hcs files.", + "items": { "$ref": "#/$defs/contractEntry" } + } + } + }, + "matchEntry": { + "type": "object", + "required": ["selector", "specificity", "order", "line", "value"], + "additionalProperties": false, + "properties": { + "selector": { "type": "string" }, + "specificity": { + "type": "array", + "description": "[ids, classes, types]", + "items": { "type": "integer" }, + "minItems": 3, + "maxItems": 3 + }, + "order": { "type": "integer" }, + "line": { "type": "integer" }, + "file": { "type": "string" }, + "value": { + "oneOf": [ + { "type": "string" }, + { "type": "integer" }, + { "type": "number" }, + { "type": "boolean" } + ] + } + } + }, + "contractEntry": { + "type": "object", + "required": ["selector", "type", "required"], + "additionalProperties": false, + "properties": { + "selector": { "type": "string" }, + "type": { "type": "string", "enum": ["string", "int", "float", "bool"] }, + "required": { "type": "boolean" }, + "min": { "type": "number" }, + "max": { "type": "number" } + } + } + } +} diff --git a/Sources/Hypercode/Crypto/SHA256.swift b/Sources/Hypercode/Crypto/SHA256.swift new file mode 100644 index 0000000..ffa43a4 --- /dev/null +++ b/Sources/Hypercode/Crypto/SHA256.swift @@ -0,0 +1,24 @@ +import CryptoKit + +// 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). + +struct SHA256Digest { + let bytes: [UInt8] + + var hexString: String { + bytes.map { String(format: "%02x", $0) }.joined() + } +} + +enum SHA256 { + static func hash(_ input: [UInt8]) -> SHA256Digest { + let digest = CryptoKit.SHA256.hash(data: input) + return SHA256Digest(bytes: Array(digest)) + } + + static func hash(utf8 string: String) -> SHA256Digest { + hash(Array(string.utf8)) + } +} diff --git a/Sources/Hypercode/Emit/Emitter.swift b/Sources/Hypercode/Emit/Emitter.swift index 0fa634b..5c5d6b2 100644 --- a/Sources/Hypercode/Emit/Emitter.swift +++ b/Sources/Hypercode/Emit/Emitter.swift @@ -4,37 +4,66 @@ public enum EmitFormat: String, Sendable { case yaml } -/// Serializes a resolved graph into a canonical, schema-agnostic IR -/// (`hypercode.ir/v1`). This output is a generated artifact, not a source — -/// `.hc` + `.hcs` remain the source of truth. +/// IR schema version to emit. +public enum EmitVersion: String, Sendable { + case v1 = "1" + case v2 = "2" +} + +/// Serializes a resolved graph into a canonical, schema-agnostic IR. +/// v1 = `hypercode.ir/v1` (string values, selector + line provenance). +/// v2 = `hypercode.ir/v2` (typed values, winner/losers, per-node SHA-256 hashes, context echo). +/// This output is a generated artifact, not a source — `.hc` + `.hcs` remain the source of truth. public struct Emitter { public init() {} - public func emit(_ forest: [ResolvedNode], as format: EmitFormat) -> String { - let root = Emitter.intermediate(forest) + public func emit( + _ forest: [ResolvedNode], + version: EmitVersion = .v2, + context: ResolutionContext = [:], + as format: EmitFormat + ) -> String { + let root: IR + switch version { + case .v1: root = Emitter.intermediateV1(forest) + case .v2: root = Emitter.intermediateV2(forest, context: context) + } switch format { case .json: return Emitter.json(root, indent: 0) + "\n" case .yaml: return Emitter.yaml(root) } } + /// Legacy overload — emits v1 (preserved for existing callers). + public func emit(_ forest: [ResolvedNode], as format: EmitFormat) -> String { + emit(forest, version: .v1, as: format) + } + // MARK: - Canonical intermediate representation indirect enum IR { case string(String) case int(Int) + case double(Double) + case bool(Bool) + case null case array([IR]) case object([(String, IR)]) // ordered keys } - static func intermediate(_ forest: [ResolvedNode]) -> IR { + // MARK: v1 + + static func intermediateV1(_ forest: [ResolvedNode]) -> IR { .object([ ("version", .string("hypercode.ir/v1")), - ("nodes", .array(forest.map(node))), + ("nodes", .array(forest.map(nodeV1))), ]) } - private static func node(_ node: ResolvedNode) -> IR { + // Legacy entry point used by EmitterTests and the legacy emit() overload. + static func intermediate(_ forest: [ResolvedNode]) -> IR { intermediateV1(forest) } + + private static func nodeV1(_ node: ResolvedNode) -> 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))) } @@ -48,10 +77,101 @@ public struct Emitter { ])) } fields.append(("properties", .object(properties))) - fields.append(("children", .array(node.children.map(self.node)))) + fields.append(("children", .array(node.children.map(self.nodeV1)))) + return .object(fields) + } + + // MARK: v2 + + static func intermediateV2(_ forest: [ResolvedNode], context: ResolutionContext) -> IR { + let hashes = forest.map { nodeHash($0) } + let nodeIRs = forest.map { nodeV2($0) } + let docHash = documentHash(hashes) + return .object([ + ("version", .string("hypercode.ir/v2")), + ("context", .object(context.keys.sorted().map { ($0, .string(context[$0]!)) })), + ("resolver", .object([ + ("name", .string("hypercode-swift")), + ("version", .string("0.5.0")), + ])), + ("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) + })), + ]) + } + + private static func nodeV2(_ node: ResolvedNode) -> 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 propFields: [(String, IR)] = [ + ("value", typedValueIR(rv.value)), + ("winner", matchIR(rv.winner)), + ("losers", .array(rv.losers.map(matchIR))), + ("contracts", .array([])), + ] + return (key, .object(propFields)) + } + fields.append(("properties", .object(properties))) + fields.append(("children", .array(node.children.map(self.nodeV2)))) return .object(fields) } + private static func typedValueIR(_ v: TypedValue) -> IR { + switch v { + case .string(let s): return .string(s) + case .int(let i): return .int(i) + case .double(let d): return .double(d) + case .bool(let b): return .bool(b) + } + } + + private static func matchIR(_ m: Match) -> IR { + var pairs: [(String, IR)] = [ + ("selector", .string(m.selector.description)), + ("specificity", .array([ + .int(m.specificity.ids), + .int(m.specificity.classes), + .int(m.specificity.types), + ])), + ("order", .int(m.order)), + ("line", .int(m.line)), + ] + if let f = m.file { pairs.append(("file", .string(f))) } + pairs.append(("value", typedValueIR(m.value))) + return .object(pairs) + } + + // MARK: Hashing + + /// Computes a SHA-256 hash over the stable resolved content of a node: + /// 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) } + let stableProps = node.properties.keys.sorted().map { key -> (String, IR) in + (key, typedValueIR(node.properties[key]!.value)) + } + var fields: [(String, IR)] = [("type", .string(node.type))] + if let c = node.className { fields.append(("class", .string(c))) } + if let i = node.id { fields.append(("id", .string(i))) } + fields.append(("properties", .object(stableProps))) + fields.append(("children", .array(childHashes.map { .string($0) }))) + return SHA256.hash(utf8: json(.object(fields), indent: 0)).hexString + } + + private static func documentHash(_ nodeHashes: [String]) -> String { + SHA256.hash(utf8: nodeHashes.joined(separator: "\n")).hexString + } + // MARK: - JSON static func json(_ value: IR, indent: Int) -> String { @@ -62,6 +182,15 @@ public struct Emitter { return "\"\(escape(string))\"" 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) + case let .bool(flag): + return flag ? "true" : "false" + case .null: + return "null" case let .array(items): if items.isEmpty { return "[]" } let body = items.map { inner + json($0, indent: indent + 1) }.joined(separator: ",\n") @@ -69,7 +198,7 @@ public struct Emitter { case let .object(pairs): if pairs.isEmpty { return "{}" } let body = pairs - .map { inner + "\"\($0.0)\": " + json($0.1, indent: indent + 1) } + .map { inner + "\"\(escape($0.0))\": " + json($0.1, indent: indent + 1) } .joined(separator: ",\n") return "{\n\(body)\n\(pad)}" } @@ -106,7 +235,7 @@ public struct Emitter { private static func yamlPair(_ key: String, _ value: IR, indent: Int) -> String { let pad = String(repeating: " ", count: indent) switch value { - case .string, .int: + case .string, .int, .double, .bool, .null: return "\(pad)\(key): \(yamlScalar(value))\n" case let .array(items): if items.isEmpty { return "\(pad)\(key): []\n" } @@ -132,6 +261,9 @@ public struct Emitter { private static func yamlScalar(_ value: IR) -> String { switch value { case let .int(number): return String(number) + case let .double(d): return String(d) + case let .bool(b): return b ? "true" : "false" + case .null: return "null" case let .string(string): return needsQuoting(string) ? "\"\(escape(string))\"" : string default: return "" } diff --git a/Sources/HypercodeCLI/main.swift b/Sources/HypercodeCLI/main.swift index c9ee529..7f49ddf 100644 --- a/Sources/HypercodeCLI/main.swift +++ b/Sources/HypercodeCLI/main.swift @@ -6,7 +6,7 @@ usage: hypercode parse hypercode validate [--hcs ] hypercode resolve --hcs [--ctx key=value]... - hypercode emit [--hcs ] [--ctx key=value]... [--format json|yaml] + hypercode emit [--hcs ] [--ctx key=value]... [--format json|yaml] [--ir-version 1|2] hypercode lsp # language server (LSP over stdio) global: [--diagnostics text|json] @@ -31,6 +31,21 @@ func readSource(_ path: String) -> String { return text } +/// Parses a `--ctx key=value` argument. The key must be a Hypercode +/// identifier — the same lexical class as `@dimension` names — so a typo +/// fails loudly here instead of silently matching nothing (and so arbitrary +/// text can never reach the emitted IR as an object key). +func parseContextAssignment(_ pair: String) -> (key: String, value: String) { + guard let equals = pair.firstIndex(of: "=") else { + fail("error: --ctx expects key=value, got '\(pair)'") + } + let key = String(pair[..` flag out of the argument list. diff --git a/Tests/HypercodeTests/EmitterTests.swift b/Tests/HypercodeTests/EmitterTests.swift index f688294..6d7c660 100644 --- a/Tests/HypercodeTests/EmitterTests.swift +++ b/Tests/HypercodeTests/EmitterTests.swift @@ -8,6 +8,8 @@ final class EmitterTests: XCTestCase { return Resolver(sheet: sheet).resolve(forest) } + // MARK: v1 (legacy) + func testJSONEmit() throws { let json = Emitter().emit(try sample(), as: .json) XCTAssertTrue(json.contains("\"version\": \"hypercode.ir/v1\"")) @@ -27,4 +29,70 @@ final class EmitterTests: XCTestCase { XCTAssertTrue(yaml.contains("value: Go")) XCTAssertTrue(yaml.contains("from: Button")) } + + // MARK: v2 + + func testV2JSONStructure() throws { + let forest = try Parser(source: "App\n Button.primary#ok\n").parse() + let sheet = try CascadeSheetReader().read( + "Button:\n count: 3\n active: true\n label: \"Go\"\n" + ) + let resolved = Resolver(sheet: sheet).resolve(forest) + let json = Emitter().emit(resolved, version: .v2, context: ["env": "test"], as: .json) + + XCTAssertTrue(json.contains("\"version\": \"hypercode.ir/v2\"")) + XCTAssertTrue(json.contains("\"env\": \"test\"")) + XCTAssertTrue(json.contains("\"name\": \"hypercode-swift\"")) + XCTAssertTrue(json.contains("\"documentHash\"")) + XCTAssertTrue(json.contains("\"hash\"")) + + // Typed values: int, bool, string + XCTAssertTrue(json.contains("\"value\": 3")) + XCTAssertTrue(json.contains("\"value\": true")) + XCTAssertTrue(json.contains("\"value\": \"Go\"")) + + // winner/losers/contracts present + XCTAssertTrue(json.contains("\"winner\"")) + XCTAssertTrue(json.contains("\"losers\"")) + XCTAssertTrue(json.contains("\"contracts\"")) + + // documentHash is a 64-char hex string + let hashRange = json.range(of: #""documentHash": "[0-9a-f]{64}""#, + options: .regularExpression) + XCTAssertNotNil(hashRange) + } + + func testV2HashChangesWhenValueChanges() throws { + let hc = "App\n" + let hcsA = "App:\n x: 1\n" + let hcsB = "App:\n x: 2\n" + + func emit(_ hcs: String) throws -> String { + let forest = try Parser(source: hc).parse() + let sheet = try CascadeSheetReader().read(hcs) + let resolved = Resolver(sheet: sheet).resolve(forest) + return Emitter().emit(resolved, version: .v2, as: .json) + } + + let jsonA = try emit(hcsA) + let jsonB = try emit(hcsB) + XCTAssertNotEqual(jsonA, jsonB, "different resolved values must produce different hashes") + + // Same content → identical hashes (determinism) + let jsonA2 = try emit(hcsA) + XCTAssertEqual(jsonA, jsonA2, "repeated emit of same input must produce identical output") + } + + func testV2JSONEscapesObjectKeys() throws { + // The CLI gates --ctx keys to identifiers, but a library consumer can + // pass any string — keys must be escaped exactly like string values. + let forest = try Parser(source: "App\n").parse() + let resolved = Resolver(sheet: CascadeSheet(rules: [])).resolve(forest) + let json = Emitter().emit( + resolved, version: .v2, context: ["bad\"key\\name": "x\"y"], as: .json + ) + XCTAssertTrue(json.contains(#""bad\"key\\name": "x\"y""#), + "context key and value must be JSON-escaped:\n\(json)") + XCTAssertFalse(json.contains("bad\"key"), "raw quote must not survive in a key") + } } diff --git a/Tests/HypercodeTests/SHA256Tests.swift b/Tests/HypercodeTests/SHA256Tests.swift new file mode 100644 index 0000000..1b9f3ac --- /dev/null +++ b/Tests/HypercodeTests/SHA256Tests.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import Hypercode + +// NIST FIPS 180-4 example vectors. +final class SHA256Tests: XCTestCase { + func testEmptyString() { + // SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + XCTAssertEqual( + SHA256.hash(utf8: "").hexString, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ) + } + + func testABC() { + // SHA-256("abc") — verified with shasum -a 256 and OpenSSL + XCTAssertEqual( + SHA256.hash(utf8: "abc").hexString, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ) + } + + func testLongMessage() { + // SHA-256("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq") + // = 248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1 + XCTAssertEqual( + SHA256.hash(utf8: "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq").hexString, + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1" + ) + } +}