From a9402b07081fda681a6f7d4a505f8df3d7133bb9 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Thu, 11 Jun 2026 00:57:39 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20HC-110=20hypercode=20explain=20?= =?UTF-8?q?=E2=80=94=20full=20cascade=20trace=20with=20winner=20and=20lose?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `hypercode explain --hcs [--ctx ...] [property]` prints the winning rule, losing rules with specificity/order/file/line; exits 1 on no match (message to stderr) - `Explainer` struct in `Sources/Hypercode/Explain/` walks Command + ResolvedNode trees in parallel; uses existing `selectorSpec()` for matching and `losers: [Match]` from PR-1 for the trace - `CascadeSheetReader.parseSelector(fromString:)` exposes selector parsing for CLI use - 6 tests covering: dev/production property trace, child selector, no-match, all-properties, text rendering Co-Authored-By: Claude Haiku 4.5 --- Sources/Hypercode/Explain/Explainer.swift | 119 +++++++++++++++++ .../Hypercode/HCS/CascadeSheetReader.swift | 7 + Sources/HypercodeCLI/main.swift | 70 ++++++++++ Tests/HypercodeTests/ExplainTests.swift | 126 ++++++++++++++++++ 4 files changed, 322 insertions(+) create mode 100644 Sources/Hypercode/Explain/Explainer.swift create mode 100644 Tests/HypercodeTests/ExplainTests.swift diff --git a/Sources/Hypercode/Explain/Explainer.swift b/Sources/Hypercode/Explain/Explainer.swift new file mode 100644 index 0000000..f94369d --- /dev/null +++ b/Sources/Hypercode/Explain/Explainer.swift @@ -0,0 +1,119 @@ +/// The cascade trace for one resolved property on one matched node. +public struct PropertyTrace: Sendable { + public let key: String + public let winner: Match + public let losers: [Match] +} + +/// All cascade traces for one node matched by the explain query. +public struct NodeTrace: Sendable { + /// Human-readable selector path from the root, e.g. "Service > Database". + public let nodePath: String + /// One trace per property (filtered to the queried property if provided). + public let properties: [PropertyTrace] +} + +/// Walks a resolved tree to produce cascade traces for every node that matches +/// a given selector. Used by `hypercode explain`. +public struct Explainer { + private let commands: [Command] + private let resolved: [ResolvedNode] + + public init(commands: [Command], resolved: [ResolvedNode]) { + self.commands = commands + self.resolved = resolved + } + + /// Returns cascade traces for every node matching `selector`. + /// - Parameters: + /// - selector: The selector to match against the tree. + /// - property: If non-nil, only include this property key in each trace. + public func explain(selector: Selector, property: String?) -> [NodeTrace] { + var results: [NodeTrace] = [] + walk( + commands: commands, resolved: resolved, + ancestors: [], pathComponents: [], + selector: selector, property: property, + into: &results + ) + return results + } + + // MARK: - Private tree walk + + private func walk( + commands: [Command], + resolved: [ResolvedNode], + ancestors: [Command], + pathComponents: [String], + selector: Selector, + property: String?, + into results: inout [NodeTrace] + ) { + for (cmd, node) in zip(commands, resolved) { + let context = NodeContext(node: cmd, ancestors: ancestors) + let nodeLabel = nodeLabel(cmd) + let path = (pathComponents + [nodeLabel]).joined(separator: " > ") + + if selectorSpec(selector).isSatisfiedBy(context) { + let traces = propertyTraces(node: node, filter: property) + results.append(NodeTrace(nodePath: path, properties: traces)) + } + + walk( + commands: cmd.children, resolved: node.children, + ancestors: ancestors + [cmd], pathComponents: pathComponents + [nodeLabel], + selector: selector, property: property, + into: &results + ) + } + } + + private func propertyTraces(node: ResolvedNode, filter: String?) -> [PropertyTrace] { + let keys = node.properties.keys.sorted() + return keys.compactMap { key in + guard filter == nil || key == filter else { return nil } + let rv = node.properties[key]! + return PropertyTrace(key: key, winner: rv.winner, losers: rv.losers) + } + } + + private func nodeLabel(_ cmd: Command) -> String { + var label = cmd.type + if let c = cmd.className { label += ".\(c)" } + if let i = cmd.id { label += "#\(i)" } + return label + } +} + +// MARK: - Text rendering + +extension NodeTrace { + public func renderText() -> String { + var out = "" + for trace in properties { + out += " \(trace.key)\n" + out += renderMatch(trace.winner, role: "WINNER") + if !trace.losers.isEmpty { + out += " \(String(repeating: "โ”€", count: 20))\n" + for loser in trace.losers { + out += renderMatch(loser, role: "losing") + } + } + } + return out + } + + 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) + " " + 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" + var line2 = cont + if let f = m.file { line2 += "file: \(f) " } + line2 += "line: \(m.line) specificity: \(spec) order: \(m.order)\n" + return line1 + line2 + } +} diff --git a/Sources/Hypercode/HCS/CascadeSheetReader.swift b/Sources/Hypercode/HCS/CascadeSheetReader.swift index d2c393e..0232370 100644 --- a/Sources/Hypercode/HCS/CascadeSheetReader.swift +++ b/Sources/Hypercode/HCS/CascadeSheetReader.swift @@ -125,6 +125,13 @@ public struct CascadeSheetReader { return .string(s) } + // MARK: - Public selector parsing (used by `hypercode explain`) + + /// Parse a CSS-like selector string from user input (e.g. `service > database`). + public func parseSelector(fromString text: String) throws -> Selector { + try parseSelector(text, line: 1) + } + // MARK: - Selectors & guards private func parseGuard(_ text: String, line: Int) throws -> ContextGuard { diff --git a/Sources/HypercodeCLI/main.swift b/Sources/HypercodeCLI/main.swift index ea92156..bd43b3e 100644 --- a/Sources/HypercodeCLI/main.swift +++ b/Sources/HypercodeCLI/main.swift @@ -7,6 +7,7 @@ usage: hypercode validate [--hcs ] hypercode resolve --hcs [--ctx key=value]... hypercode emit [--hcs ] [--ctx key=value]... [--format json|yaml] [--ir-version 1|2] + hypercode explain --hcs [--ctx key=value]... [property] hypercode lsp # language server (LSP over stdio) global: [--diagnostics text|json] @@ -170,6 +171,73 @@ func runEmit(_ args: [String]) throws { print(Emitter().emit(resolved, version: irVersion, context: context, as: format), terminator: "") } +func runExplain(_ args: [String]) throws { + var hcPath: String? + var hcsPath: String? + var context: ResolutionContext = [:] + var positional: [String] = [] + + var index = 0 + while index < args.count { + switch args[index] { + case "--hcs": + index += 1 + guard index < args.count else { fail("error: --hcs needs a path") } + hcsPath = args[index] + case "--ctx": + index += 1 + guard index < args.count else { fail("error: --ctx needs key=value") } + let pair = args[index] + guard let equals = pair.firstIndex(of: "=") else { + fail("error: --ctx expects key=value, got '\(pair)'") + } + context[String(pair[..\n\n\(usage)", code: 64) } + guard !positional.isEmpty else { fail("error: explain needs a \n\n\(usage)", code: 64) } + + let selectorText = positional[0] + let propertyFilter: String? = positional.count >= 2 ? positional[1] : nil + + let selectorParsed: Hypercode.Selector + do { + selectorParsed = try CascadeSheetReader().parseSelector(fromString: selectorText) + } catch { + fail("error: invalid selector '\(selectorText)': \(error)") + } + + let commands = try Parser(source: readSource(hcPath)).parse() + let sheet = try CascadeSheetReader().read(readSource(hcsPath), file: hcsPath) + let resolved = Resolver(sheet: sheet, context: context).resolve(commands) + let traces = Explainer(commands: commands, resolved: resolved).explain( + selector: selectorParsed, property: propertyFilter + ) + + if traces.isEmpty { + let msg = "no nodes matched selector '\(selectorText)'" + FileHandle.standardError.write(Data((msg + "\n").utf8)) + exit(1) + } + + let countStr = traces.count == 1 ? "1 node" : "\(traces.count) nodes" + print("Matched \(countStr) for selector '\(selectorText)'\n") + for trace in traces { + print("Node: \(trace.nodePath)") + if trace.properties.isEmpty { + print(" (no resolved properties)") + } else { + print(trace.renderText(), terminator: "") + } + print("") + } +} + // Pull the global `--diagnostics ` flag out of the argument list. let arguments: [String] = { let raw = Array(CommandLine.arguments.dropFirst()) @@ -204,6 +272,8 @@ do { try runValidate(Array(arguments.dropFirst())) case "emit": try runEmit(Array(arguments.dropFirst())) + case "explain": + try runExplain(Array(arguments.dropFirst())) case "lsp": LSPServer().run() case "parse": diff --git a/Tests/HypercodeTests/ExplainTests.swift b/Tests/HypercodeTests/ExplainTests.swift new file mode 100644 index 0000000..d88d58a --- /dev/null +++ b/Tests/HypercodeTests/ExplainTests.swift @@ -0,0 +1,126 @@ +import XCTest +@testable import Hypercode + +/// Verifies the `Explainer` cascade trace using the RFC ยง5 service example. +final class ExplainTests: XCTestCase { + private let hc = """ + Service + Logger.console + Database#main-db + Connect + APIServer + Listen + """ + + private let hcs = """ + Logger: + level: "debug" + .console: + format: "text" + Database: + driver: "sqlite" + file: "dev.sqlite3" + APIServer > Listen: + host: "127.0.0.1" + port: 5000 + @env[production]: + Logger: + level: "info" + .console: + format: "json" + '#main-db': + driver: "postgres" + pool_size: 50 + APIServer > Listen: + host: "0.0.0.0" + port: 8080 + """ + + private func explainer(env: String? = nil) throws -> Explainer { + let commands = try Parser(source: hc).parse() + let sheet = try CascadeSheetReader().read(hcs) + let context: ResolutionContext = env.map { ["env": $0] } ?? [:] + let resolved = Resolver(sheet: sheet, context: context).resolve(commands) + return Explainer(commands: commands, resolved: resolved) + } + + func testSinglePropertyExplainDev() throws { + let ex = try explainer() + let traces = ex.explain( + selector: try CascadeSheetReader().parseSelector(fromString: "Database"), + property: "driver" + ) + XCTAssertEqual(traces.count, 1) + let trace = traces[0] + XCTAssertEqual(trace.nodePath, "Service > Database#main-db") + XCTAssertEqual(trace.properties.count, 1) + let prop = trace.properties[0] + XCTAssertEqual(prop.key, "driver") + XCTAssertEqual(prop.winner.value, .string("sqlite")) + XCTAssertEqual(prop.winner.selector, .type("Database")) + XCTAssertTrue(prop.losers.isEmpty) + } + + func testSinglePropertyExplainProduction() throws { + let ex = try explainer(env: "production") + let traces = ex.explain( + selector: try CascadeSheetReader().parseSelector(fromString: "Database"), + property: "driver" + ) + XCTAssertEqual(traces.count, 1) + let prop = traces[0].properties[0] + // #main-db beats Database by specificity + XCTAssertEqual(prop.winner.value, .string("postgres")) + XCTAssertEqual(prop.winner.selector, .id("main-db")) + // The losing Database rule should be retained + XCTAssertEqual(prop.losers.count, 1) + XCTAssertEqual(prop.losers[0].value, .string("sqlite")) + XCTAssertEqual(prop.losers[0].selector, .type("Database")) + } + + func testChildSelectorExplain() throws { + let ex = try explainer(env: "production") + let traces = ex.explain( + selector: try CascadeSheetReader().parseSelector(fromString: "APIServer > Listen"), + property: "port" + ) + XCTAssertEqual(traces.count, 1) + let prop = traces[0].properties[0] + XCTAssertEqual(prop.winner.value, .int(8080)) + } + + func testNoMatchReturnsEmpty() throws { + let ex = try explainer() + let traces = ex.explain( + selector: try CascadeSheetReader().parseSelector(fromString: "NonExistent"), + property: nil + ) + XCTAssertTrue(traces.isEmpty) + } + + func testAllPropertiesWhenFilterNil() throws { + let ex = try explainer() + let traces = ex.explain( + selector: try CascadeSheetReader().parseSelector(fromString: "Database"), + property: nil + ) + XCTAssertEqual(traces.count, 1) + // Database has driver and file in dev context + let keys = Set(traces[0].properties.map(\.key)) + XCTAssertEqual(keys, ["driver", "file"]) + } + + func testTextRenderingContainsExpectedLines() throws { + let ex = try explainer(env: "production") + let traces = ex.explain( + selector: try CascadeSheetReader().parseSelector(fromString: "Database"), + property: "driver" + ) + let text = traces[0].renderText() + XCTAssertTrue(text.contains("driver")) + XCTAssertTrue(text.contains("WINNER")) + XCTAssertTrue(text.contains("postgres")) + XCTAssertTrue(text.contains("losing")) + XCTAssertTrue(text.contains("sqlite")) + } +} From 7bed77c11366be4a9a174b04ef4ccb9dc1e6585c Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Fri, 12 Jun 2026 00:47:51 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20#21=20review=20=E2=80=94?= =?UTF-8?q?=20fail-fast=20tree=20walk,=20strict=20explain=20arguments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Explainer.walk asserts command/resolved arrays stay parallel instead of silently truncating via zip if a caller ever breaks the resolver invariant - runExplain rejects extra positional arguments (was silently ignoring them, hiding user mistakes), consistent with other subcommands Co-Authored-By: Claude Sonnet 4.6 --- Sources/Hypercode/Explain/Explainer.swift | 4 ++++ Sources/HypercodeCLI/main.swift | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/Hypercode/Explain/Explainer.swift b/Sources/Hypercode/Explain/Explainer.swift index f94369d..49dd254 100644 --- a/Sources/Hypercode/Explain/Explainer.swift +++ b/Sources/Hypercode/Explain/Explainer.swift @@ -50,6 +50,10 @@ public struct Explainer { property: String?, into results: inout [NodeTrace] ) { + // The resolver preserves tree shape, so the two arrays are parallel by + // construction โ€” fail fast in debug builds if a caller breaks that. + assert(commands.count == resolved.count, + "command/resolved tree shape mismatch (\(commands.count) vs \(resolved.count))") for (cmd, node) in zip(commands, resolved) { let context = NodeContext(node: cmd, ancestors: ancestors) let nodeLabel = nodeLabel(cmd) diff --git a/Sources/HypercodeCLI/main.swift b/Sources/HypercodeCLI/main.swift index bd43b3e..3d80c78 100644 --- a/Sources/HypercodeCLI/main.swift +++ b/Sources/HypercodeCLI/main.swift @@ -201,9 +201,12 @@ func runExplain(_ args: [String]) throws { guard let hcPath else { fail("error: explain needs a .hc file\n\n\(usage)", code: 64) } guard let hcsPath else { fail("error: explain needs --hcs \n\n\(usage)", code: 64) } guard !positional.isEmpty else { fail("error: explain needs a \n\n\(usage)", code: 64) } + guard positional.count <= 2 else { + fail("error: unexpected argument '\(positional[2])' โ€” explain takes [property]\n\n\(usage)", code: 64) + } let selectorText = positional[0] - let propertyFilter: String? = positional.count >= 2 ? positional[1] : nil + let propertyFilter: String? = positional.count == 2 ? positional[1] : nil let selectorParsed: Hypercode.Selector do {