From 677d4b07598c103c64901a14393544324857088a Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Fri, 12 Jun 2026 18:51:10 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20HC-116=20=E2=80=94=20@import=20for?= =?UTF-8?q?=20.hcs=20cascade=20sheets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sheet modularity: a sheet may begin with @import "path.hcs" directives. - Expansion is depth-first at the directive position, so the importing sheet's own rules come later in source order and win specificity ties — the importer overrides what it imports (CSS discipline: imports must precede rules, context blocks and contracts). - Each sheet expands at most once per resolution (diamonds dedupe); a cyclic import is an error naming the chain. - Provenance keeps the file each rule was defined in, so explain and the IR point at the real source across files; contracts compose by the same expansion and still only narrow (an imported baseline's @contract: gates every value the importer sets). - API: ImportHandling (.unsupported default / .syntaxOnly for LSP single-buffer diagnostics / .loader for expansion); the CLI loader resolves targets relative to the importing sheet and reports cwd-relative provenance paths. - Docs: EBNF syntax 0.3 (), resolution semantics 0.3 (§5.1 sheet composition, normative), RFC 0.3 (§4.2.4), Usage §7 with real outputs, tenant example Examples/imports/acme.hcs. - Version: 0.6.0-dev (new dev cycle). 14 new tests (126 total). Co-Authored-By: Claude Fable 5 --- DOCS/Usage.md | 48 ++++- EBNF/Hypercode_Resolution.md | 24 ++- EBNF/Hypercode_Syntax.md | 27 ++- Examples/imports/acme.hcs | 11 ++ Package.swift | 2 +- RFC/Hypercode.md | 24 ++- .../Diagnostics/DocumentDiagnostics.swift | 4 +- .../Hypercode/Documentation.docc/Hypercode.md | 1 + .../Hypercode/HCS/CascadeSheetReader.swift | 135 ++++++++++++- Sources/Hypercode/Version.swift | 2 +- Sources/HypercodeCLI/main.swift | 30 ++- Tests/HypercodeTests/ImportTests.swift | 183 ++++++++++++++++++ workplan.md | 2 +- 13 files changed, 467 insertions(+), 26 deletions(-) create mode 100644 Examples/imports/acme.hcs create mode 100644 Tests/HypercodeTests/ImportTests.swift diff --git a/DOCS/Usage.md b/DOCS/Usage.md index ed9d404..2020249 100644 --- a/DOCS/Usage.md +++ b/DOCS/Usage.md @@ -144,7 +144,7 @@ $ hypercode emit Examples/service.hc --hcs Examples/service.hcs --ctx env=produc { "version": "hypercode.ir/v2", "context": { "env": "production" }, // echo of --ctx - "resolver": { "name": "hypercode-swift", "version": "0.5.0" }, + "resolver": { "name": "hypercode-swift", "version": "0.6.0-dev" }, "documentHash": "3be098179523f21c…", "nodes": [ /* … each node: */ { @@ -243,6 +243,52 @@ $ echo $? (unreadable or non-v2 input) — usable as a CI gate ("spec changed → require regeneration"). +## 7. Sheet modularity: `@import` + +Real configurations don't live in one file (HC-116). A tenant sheet starts +from the product baseline and overrides only what differs +([`Examples/imports/acme.hcs`](../Examples/imports/acme.hcs)): + +```hcs +@import "../service.hcs" + +'#main-db': + pool_size: 80 + +APIServer > Listen: + port: 8443 +``` + +Imports expand at the directive position, so the importer's rules come later +in source order and win specificity ties. `explain` shows the cross-file +cascade — every rule keeps the file it was defined in: + +```console +$ hypercode explain Examples/service.hc --hcs Examples/imports/acme.hcs \ + --ctx env=production "APIServer > Listen" port +Node: Service > APIServer > Listen + port + WINNER APIServer > Listen { value: 8443 } + file: Examples/imports/acme.hcs line: 10 specificity: (0,0,2) order: 9 + ──────────────────── + losing APIServer > Listen { value: 8080 } + file: Examples/service.hcs line: 26 specificity: (0,0,2) order: 7 + losing APIServer > Listen { value: 5000 } + file: Examples/service.hcs line: 11 specificity: (0,0,2) order: 3 +``` + +The baseline's `@contract:` block still gates the tenant — contracts compose +across imports and only narrow: + +```console +$ hypercode validate Examples/service.hc --hcs bad-tenant.hcs --ctx env=production +bad-tenant.hcs:3:1: error[HC2104]: contract violation for 'port': 99999 exceeds upper bound 65535.0 from contract 'APIServer > Listen' +``` + +Rules of the road: imports go first; paths resolve relative to the importing +sheet; a sheet expands once per resolution (diamonds are fine); a cycle is an +error naming the chain. + ## Scalar typing cheat-sheet | Written in `.hcs` | Resolved as | diff --git a/EBNF/Hypercode_Resolution.md b/EBNF/Hypercode_Resolution.md index 377be1e..1bc1433 100644 --- a/EBNF/Hypercode_Resolution.md +++ b/EBNF/Hypercode_Resolution.md @@ -2,9 +2,9 @@ **Status:** Draft -**Version:** 0.2 +**Version:** 0.3 -**Date:** June 11, 2026 +**Date:** June 12, 2026 **Author:** Egor Merkushev @@ -91,6 +91,26 @@ Because `order` is unique per rule, `max` is unambiguous: higher specificity wins, and equal specificity is broken by later source order. (`PropertyCascade` is the `DecisionSpec` that performs this choice.) +### 5.1 Sheet composition (`@import`, HC-116) + +The rule sequence the cascade operates on may be composed from several sheets: + +```text +rules(sheet) = expand(imports(sheet)) ++ ownRules(sheet) +``` + +- An imported sheet expands **depth-first at the position of its directive**; + `order` is assigned over the fully expanded sequence. Imports must precede + all rules, so the importing sheet's own rules always come later in source + order and win specificity ties — the importer overrides what it imports. +- Each sheet expands **at most once** per resolution (a diamond keeps the + first occurrence); a cyclic import is an error. +- Contracts compose by the same expansion and then accumulate as in §7 — + a contract declared in an imported sheet still gates values set by the + importer. +- `provenance(rule)` keeps the file the rule was **defined** in, not the + entry sheet. + ## 6. Resolution ```text diff --git a/EBNF/Hypercode_Syntax.md b/EBNF/Hypercode_Syntax.md index c46c9e1..5717452 100644 --- a/EBNF/Hypercode_Syntax.md +++ b/EBNF/Hypercode_Syntax.md @@ -2,9 +2,9 @@ **Status:** Draft -**Version:** 0.2 +**Version:** 0.3 -**Date:** June 11, 2026 +**Date:** June 12, 2026 **Author:** Egor Merkushev @@ -110,12 +110,14 @@ Root ## 6. `.hcs` Cascade Sheet Syntax -A `.hcs` file contains cascade rules and optional contract blocks. +A `.hcs` file contains optional import directives, cascade rules and optional +contract blocks. ### 6.1 Cascade Rule ``` - ::= { } + ::= { } { } + ::= "@import" ::= | | ::= "@" "[" "]" ":" @@ -135,6 +137,17 @@ 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. +#### `@import` (HC-116) + +`@import "path.hcs"` includes another sheet. All imports must precede rules, +context blocks and contracts (CSS discipline). An imported sheet expands +depth-first **at the position of the directive**, so the importing sheet's +own rules come later in source order and win specificity ties — the importer +overrides what it imports. Each sheet is expanded at most once per resolution +(diamond imports are deduplicated); a cyclic import is an error. Every rule +keeps the file it was defined in for provenance. Paths resolve relative to +the importing sheet's location. + ### 6.2 Selectors ``` @@ -192,6 +205,12 @@ only. ## 8. Change Log +**Version 0.3** (2026-06-12) + +* Added `@import` directive grammar (HC-116): `` precedes all + blocks; expansion order, import-once and cycle semantics summarized here, + defined normatively in `Hypercode_Resolution.md` §5.1. + **Version 0.2** (2026-06-11) * Added Section 6: `.hcs` cascade sheet syntax (rules, selectors, dimension blocks). diff --git a/Examples/imports/acme.hcs b/Examples/imports/acme.hcs new file mode 100644 index 0000000..e908753 --- /dev/null +++ b/Examples/imports/acme.hcs @@ -0,0 +1,11 @@ +# Tenant sheet (HC-116): start from the product baseline, override only what +# differs for this tenant. Imported rules come first in source order, so the +# overrides below win specificity ties; the baseline's @contract: block still +# gates every value set here. +@import "../service.hcs" + +'#main-db': + pool_size: 80 + +APIServer > Listen: + port: 8443 diff --git a/Package.swift b/Package.swift index 1caea6d..056d392 100644 --- a/Package.swift +++ b/Package.swift @@ -1,7 +1,7 @@ // swift-tools-version: 5.9 import PackageDescription -// Package version: 0.5.0 (echoed by the IR v2 emitter as resolver.version) +// Package version: 0.6.0-dev (echoed by the IR v2 emitter as resolver.version) let package = Package( name: "Hypercode", platforms: [ diff --git a/RFC/Hypercode.md b/RFC/Hypercode.md index a11f347..4b195aa 100644 --- a/RFC/Hypercode.md +++ b/RFC/Hypercode.md @@ -2,7 +2,7 @@ **Status:** Draft -**Version:** 0.2 +**Version:** 0.3 **Date:** June 11, 2026 @@ -152,6 +152,21 @@ The HCS resolution process follows a strict order of precedence, analogous to CS When multiple rules match a single command, their properties are merged. Properties from higher-specificity rules override those from lower-specificity rules. +#### 4.2.4. Sheet Composition (`@import`) + +Real configurations do not live in one file. A sheet MAY begin with one or +more `@import "path.hcs"` directives; all imports MUST precede rules, context +blocks and contracts. An imported sheet expands depth-first at the position of +its directive, so the importing sheet's own rules come later in source order +and win specificity ties — the importer overrides what it imports, which gives +the "user override file" origin from §4.2.3 concrete semantics without an +`!important` mechanism. Each sheet expands at most once per resolution +(diamonds are deduplicated); a cyclic import is an error. Contracts compose by +the same expansion and still only narrow: a contract declared in an imported +baseline gates every value the importer sets. Provenance keeps the file each +rule was defined in, so `explain` and the IR point at the real source across +files. (Normative semantics: `EBNF/Hypercode_Resolution.md` §5.1.) + ## 5. Example: A Simple Web Service This example demonstrates how a single Hypercode file can be configured for both development and production environments using an HCS file. @@ -335,6 +350,13 @@ Prior art surveyed in §9: ## 12. Change Log +**Version 0.3** (2026-06-12): + +* Sheet composition via `@import` (HC-116, §4.2.4): depth-first expansion at + the directive position, importer wins specificity ties, import-once, + cycle detection, cross-file provenance. Normative semantics in + `EBNF/Hypercode_Resolution.md` §5.1. + **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. diff --git a/Sources/Hypercode/Diagnostics/DocumentDiagnostics.swift b/Sources/Hypercode/Diagnostics/DocumentDiagnostics.swift index 421c8ee..137bddd 100644 --- a/Sources/Hypercode/Diagnostics/DocumentDiagnostics.swift +++ b/Sources/Hypercode/Diagnostics/DocumentDiagnostics.swift @@ -15,7 +15,9 @@ public func diagnostics(for kind: DocumentKind, text: String, file: String? = ni do { switch kind { case .cascadeSheet: - _ = try CascadeSheetReader().read(text) + // A lone text buffer can't load other files — @import directives + // are syntax-checked here; expansion happens in the CLI/library. + _ = try CascadeSheetReader().read(text, imports: .syntaxOnly) return [] case .hypercode: let forest = try Parser(source: text).parse() diff --git a/Sources/Hypercode/Documentation.docc/Hypercode.md b/Sources/Hypercode/Documentation.docc/Hypercode.md index 92769ff..4c17b49 100644 --- a/Sources/Hypercode/Documentation.docc/Hypercode.md +++ b/Sources/Hypercode/Documentation.docc/Hypercode.md @@ -98,6 +98,7 @@ hypercode diff old.ir.json new.ir.json - ``Rule`` - ``ContextGuard`` - ``CascadeSheetReader`` +- ``ImportHandling`` - ``Resolver`` - ``ResolutionContext`` - ``ResolvedNode`` diff --git a/Sources/Hypercode/HCS/CascadeSheetReader.swift b/Sources/Hypercode/HCS/CascadeSheetReader.swift index a2e084c..85cbd2b 100644 --- a/Sources/Hypercode/HCS/CascadeSheetReader.swift +++ b/Sources/Hypercode/HCS/CascadeSheetReader.swift @@ -6,16 +6,66 @@ public struct HCSError: Error, Equatable, CustomStringConvertible, Sendable { public var description: String { "hcs error at line \(line): \(message)" } } +/// How `@import` directives are handled while reading a `.hcs` sheet (HC-116). +public enum ImportHandling { + /// `@import` is an error — the default for single-sheet contexts that + /// have no way to load other files. + case unsupported + /// Directives are validated syntactically but not expanded — live + /// diagnostics over a lone text buffer (LSP). + case syntaxOnly + /// Expand imports. The loader resolves a target *as written* (plus the + /// importing file, when known) to a canonical file identity and its + /// source text; the identity is what cycle detection and import-once + /// dedupe compare, so it must be stable for the same physical sheet. + case loader((_ target: String, _ importingFile: String?) throws -> (file: String, source: String)) +} + /// A minimal, hand-rolled reader for the `.hcs` subset we use today: selector -/// headers, `@dimension[value]` context blocks, and `key: value` properties, -/// nested by indentation. No third-party YAML dependency — typed scalars and -/// full YAML come later, only if we ever consume real YAML input. +/// headers, `@dimension[value]` context blocks, `@contract:` blocks, +/// `@import "path"` directives, and `key: value` properties, nested by +/// indentation. No third-party YAML dependency — typed scalars and full YAML +/// come later, only if we ever consume real YAML input. public struct CascadeSheetReader { public init() {} /// Parse a `.hcs` source string into a `CascadeSheet`. - /// - Parameter file: Optional path of the source file, stored in each `Rule` for provenance. - public func read(_ source: String, file: String? = nil) throws -> CascadeSheet { + /// + /// Imports expand depth-first at the position of the directive, so the + /// importing sheet's own rules come later in source order and win + /// specificity ties — the importer overrides what it imports. Each sheet + /// is loaded at most once per `read` (diamonds are fine); a cyclic import + /// is an error. Rules keep the file they were defined in for provenance. + /// + /// - Parameters: + /// - file: Optional path of the source file, stored in each `Rule` for provenance. + /// - imports: How `@import` directives are handled (default: error). + public func read( + _ source: String, file: String? = nil, imports: ImportHandling = .unsupported + ) throws -> CascadeSheet { + var state = ReadState() + if let file { + state.loaded.insert(file) + state.stack.append(file) + } + try readSheet(source, file: file, imports: imports, state: &state) + return CascadeSheet(rules: state.rules, contracts: state.contracts) + } + + /// Accumulator threaded through import expansion: one global rule order + /// (later = wins ties), the set of files already expanded (import-once), + /// and the active import chain (cycle detection). + private struct ReadState { + var rules: [Rule] = [] + var contracts: [SelectorContract] = [] + var order = 0 + var loaded: Set = [] + var stack: [String] = [] + } + + private func readSheet( + _ source: String, file: String?, imports: ImportHandling, state: inout ReadState + ) throws { let lines: [RawLine] = source .split(separator: "\n", omittingEmptySubsequences: false) .enumerated() @@ -31,13 +81,78 @@ public struct CascadeSheetReader { var index = 0 let outline = buildOutline(lines, &index, parentIndent: -1) - var rules: [Rule] = [] - var contracts: [SelectorContract] = [] - var order = 0 + var importsAllowed = true for node in outline { - try interpretTopLevel(node, file: file, into: &rules, contracts: &contracts, order: &order) + if isImportDirective(node.line.trimmed) { + try handleImport(node, file: file, imports: imports, + allowed: importsAllowed, state: &state) + } else { + importsAllowed = false + try interpretTopLevel(node, file: file, into: &state.rules, + contracts: &state.contracts, order: &state.order) + } + } + } + + // MARK: - Imports (HC-116) + + /// `@import` only with a word boundary — `@important[x]:` stays a guard. + private func isImportDirective(_ trimmed: Substring) -> Bool { + trimmed == "@import" || trimmed.hasPrefix("@import ") || trimmed.hasPrefix("@import\t") + } + + private func handleImport( + _ node: Outline, file: String?, imports: ImportHandling, + allowed: Bool, state: inout ReadState + ) throws { + let line = node.line.number + guard node.children.isEmpty else { + throw HCSError(message: "unexpected nested block under @import", line: line) + } + // CSS discipline: imports first. This is what makes "imported rules + // come earlier in source order" hold by construction. + guard allowed else { + throw HCSError(message: "@import must precede rules, context blocks and contracts", line: line) + } + let rest = trim(node.line.trimmed.dropFirst("@import".count)) + guard rest.count >= 2, let first = rest.first, let last = rest.last, + (first == "\"" && last == "\"") || (first == "'" && last == "'") else { + throw HCSError(message: "expected @import \"path.hcs\"", line: line) + } + let target = String(rest.dropFirst().dropLast()) + guard !target.isEmpty else { + throw HCSError(message: "empty @import path", line: line) + } + + switch imports { + case .unsupported: + throw HCSError(message: "@import is not supported here (no import loader)", line: line) + case .syntaxOnly: + return + case .loader(let load): + let loadedFile: String + let loadedSource: String + do { + (loadedFile, loadedSource) = try load(target, file) + } catch { + throw HCSError(message: "cannot load @import \"\(target)\": \(error)", line: line) + } + if state.stack.contains(loadedFile) { + let chain = (state.stack + [loadedFile]).joined(separator: " -> ") + throw HCSError(message: "import cycle: \(chain)", line: line) + } + if state.loaded.contains(loadedFile) { return } // diamond: import once + state.loaded.insert(loadedFile) + state.stack.append(loadedFile) + defer { state.stack.removeLast() } + do { + try readSheet(loadedSource, file: loadedFile, imports: imports, state: &state) + } catch let error as HCSError { + // Point at the @import line, but keep the imported file's + // own location in the message so the chain stays traceable. + throw HCSError(message: "\(loadedFile):\(error.line): \(error.message)", line: line) + } } - return CascadeSheet(rules: rules, contracts: contracts) } // MARK: - Outline (group lines into a tree by indentation) diff --git a/Sources/Hypercode/Version.swift b/Sources/Hypercode/Version.swift index 40a87ae..b753c69 100644 --- a/Sources/Hypercode/Version.swift +++ b/Sources/Hypercode/Version.swift @@ -2,5 +2,5 @@ /// (`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" + public static let current = "0.6.0-dev" } diff --git a/Sources/HypercodeCLI/main.swift b/Sources/HypercodeCLI/main.swift index 789954e..cccd2f7 100644 --- a/Sources/HypercodeCLI/main.swift +++ b/Sources/HypercodeCLI/main.swift @@ -48,6 +48,28 @@ func parseContextAssignment(_ pair: String) -> (key: String, value: String) { return (key, String(pair[pair.index(after: equals)...])) } +/// Read a `.hcs` sheet with `@import` expansion: targets resolve relative to +/// the importing sheet's directory; the standardized absolute path is the +/// identity used for cycle detection and import-once dedupe. +struct SheetLoadError: Error, CustomStringConvertible { let description: String } + +func readSheet(_ path: String) throws -> CascadeSheet { + try CascadeSheetReader().read(readSource(path), file: path, imports: .loader { target, importingFile in + let baseDir = URL(fileURLWithPath: importingFile ?? path).deletingLastPathComponent() + let url = URL(fileURLWithPath: target, relativeTo: baseDir).standardizedFileURL + guard let data = try? Data(contentsOf: url), + let text = String(data: data, encoding: .utf8) else { + throw SheetLoadError(description: "cannot read '\(url.path)'") + } + // Keep provenance readable: paths under the working directory are + // reported relative to it (the canonical form stays consistent, so + // cycle detection and dedupe are unaffected). + let cwd = FileManager.default.currentDirectoryPath + "/" + let identity = url.path.hasPrefix(cwd) ? String(url.path.dropFirst(cwd.count)) : url.path + return (identity, text) + }) +} + func runParse(_ path: String) throws { let forest = try Parser(source: readSource(path)).parse() print(Command.tree(forest), terminator: "") @@ -82,7 +104,7 @@ func runResolve(_ args: [String]) throws { guard let hcsPath else { fail("error: resolve needs --hcs \n\n\(usage)", code: 64) } let forest = try Parser(source: readSource(hcPath)).parse() - let sheet = try CascadeSheetReader().read(readSource(hcsPath), file: hcsPath) + let sheet = try readSheet(hcsPath) let resolved = Resolver(sheet: sheet, context: context).resolve(forest) print(ResolvedNode.tree(resolved), terminator: "") } @@ -126,7 +148,7 @@ func runValidate(_ args: [String]) throws { // .hc diagnostics point at the .hc file; .hcs diagnostics at the .hcs file. var located = tagged(validator.validate(forest), file: hcPath) if let hcsPath { - let sheet = try CascadeSheetReader().read(readSource(hcsPath), file: hcsPath) + let sheet = try readSheet(hcsPath) located += tagged(validator.validate(sheet, against: forest), file: hcsPath) // Value-level contract checks run on the resolved graph (HC2104) — // resolution is context-dependent, hence validate accepts --ctx. @@ -191,7 +213,7 @@ func runEmit(_ args: [String]) throws { guard let hcPath else { fail("error: emit needs a .hc file\n\n\(usage)", code: 64) } 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 sheet = try hcsPath.map(readSheet) ?? CascadeSheet(rules: []) 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: "") @@ -239,7 +261,7 @@ func runExplain(_ args: [String]) throws { } let commands = try Parser(source: readSource(hcPath)).parse() - let sheet = try CascadeSheetReader().read(readSource(hcsPath), file: hcsPath) + let sheet = try readSheet(hcsPath) let resolved = Resolver(sheet: sheet, context: context).resolve(commands) let traces = Explainer(commands: commands, resolved: resolved).explain( selector: selectorParsed, property: propertyFilter diff --git a/Tests/HypercodeTests/ImportTests.swift b/Tests/HypercodeTests/ImportTests.swift new file mode 100644 index 0000000..d1b4543 --- /dev/null +++ b/Tests/HypercodeTests/ImportTests.swift @@ -0,0 +1,183 @@ +import XCTest +@testable import Hypercode + +/// HC-116 — `@import` for `.hcs` cascade sheets. +final class ImportTests: XCTestCase { + /// Dictionary-backed loader: targets are looked up verbatim, so the + /// target string *is* the canonical identity. + private func loader(_ sheets: [String: String]) -> ImportHandling { + .loader { target, _ in + guard let source = sheets[target] else { + throw HCSError(message: "no such sheet '\(target)'", line: 1) + } + return (target, source) + } + } + + private func resolve( + hc: String, entry: String, file: String = "entry.hcs", + sheets: [String: String], context: ResolutionContext = [:] + ) throws -> [ResolvedNode] { + let commands = try Parser(source: hc).parse() + let sheet = try CascadeSheetReader().read(entry, file: file, imports: loader(sheets)) + return Resolver(sheet: sheet, context: context).resolve(commands) + } + + // MARK: - Cascade semantics + + func testImporterOverridesImportedOnEqualSpecificity() throws { + // Imports expand at the directive position, so the importer's own + // rules come later in source order and win specificity ties. + let resolved = try resolve( + hc: "Service\n", + entry: "@import \"base.hcs\"\n\nService:\n port: 9090\n", + sheets: ["base.hcs": "Service:\n port: 5000\n timeout: 30\n"] + ) + XCTAssertEqual(resolved[0].properties["port"]?.value.rawString, "9090") + XCTAssertEqual(resolved[0].properties["timeout"]?.value.rawString, "30", + "non-overridden imported values must survive") + } + + func testProvenanceKeepsDefiningFile() throws { + let resolved = try resolve( + hc: "Service\n", + entry: "@import \"base.hcs\"\n\nService:\n port: 9090\n", + sheets: ["base.hcs": "Service:\n port: 5000\n timeout: 30\n"] + ) + XCTAssertEqual(resolved[0].properties["timeout"]?.winner.file, "base.hcs") + XCTAssertEqual(resolved[0].properties["port"]?.winner.file, "entry.hcs") + } + + func testNestedImportsExpandDepthFirst() throws { + let resolved = try resolve( + hc: "Service\n", + entry: "@import \"mid.hcs\"\nService:\n a: entry\n", + sheets: [ + "mid.hcs": "@import \"deep.hcs\"\nService:\n a: mid\n b: mid\n", + "deep.hcs": "Service:\n a: deep\n b: deep\n c: deep\n", + ] + ) + // deep < mid < entry in source order; later wins equal specificity. + XCTAssertEqual(resolved[0].properties["a"]?.value.rawString, "entry") + XCTAssertEqual(resolved[0].properties["b"]?.value.rawString, "mid") + XCTAssertEqual(resolved[0].properties["c"]?.value.rawString, "deep") + } + + func testDiamondImportsLoadOnce() throws { + let sheet = try CascadeSheetReader().read( + "@import \"left.hcs\"\n@import \"right.hcs\"\n", + file: "entry.hcs", + imports: loader([ + "left.hcs": "@import \"shared.hcs\"\nService:\n l: 1\n", + "right.hcs": "@import \"shared.hcs\"\nService:\n r: 1\n", + "shared.hcs": "Service:\n s: 1\n", + ]) + ) + let sharedRules = sheet.rules.filter { $0.file == "shared.hcs" } + XCTAssertEqual(sharedRules.count, 1, "diamond import must expand once") + XCTAssertEqual(sheet.rules.count, 3) + } + + func testContractsAccumulateAcrossImports() throws { + // A contract declared in the imported baseline still gates values + // set by the importer (HC2104). + let hc = "Service\n" + let entry = "@import \"base.hcs\"\nService:\n port: 99999\n" + let sheets = ["base.hcs": "@contract:\n Service:\n port: int >= 1 <= 65535\n"] + let commands = try Parser(source: hc).parse() + let sheet = try CascadeSheetReader().read(entry, file: "entry.hcs", imports: loader(sheets)) + let resolved = Resolver(sheet: sheet).resolve(commands) + let diags = ContractValueValidator().validate( + resolved: resolved, commands: commands, contracts: sheet.contracts + ) + XCTAssertEqual(diags.count, 1) + XCTAssertEqual(diags.first?.code, "HC2104") + XCTAssertTrue(diags.first?.message.contains("port") ?? false) + } + + // MARK: - Errors + + func testDirectCycleFails() { + XCTAssertThrowsError(try CascadeSheetReader().read( + "@import \"a.hcs\"\n", file: "a.hcs", + imports: loader(["a.hcs": "@import \"a.hcs\"\n"]) + )) { error in + XCTAssertTrue("\(error)".contains("import cycle"), "\(error)") + } + } + + func testIndirectCycleFails() { + XCTAssertThrowsError(try CascadeSheetReader().read( + "@import \"b.hcs\"\n", file: "a.hcs", + imports: loader([ + "a.hcs": "@import \"b.hcs\"\n", + "b.hcs": "@import \"c.hcs\"\n", + "c.hcs": "@import \"a.hcs\"\n", + ]) + )) { error in + XCTAssertTrue("\(error)".contains("import cycle"), "\(error)") + } + } + + func testImportAfterRuleFails() { + XCTAssertThrowsError(try CascadeSheetReader().read( + "Service:\n a: 1\n@import \"base.hcs\"\n", + imports: loader(["base.hcs": ""]) + )) { error in + XCTAssertTrue("\(error)".contains("must precede"), "\(error)") + } + } + + func testImportWithoutLoaderFails() { + XCTAssertThrowsError(try CascadeSheetReader().read("@import \"base.hcs\"\n")) { error in + XCTAssertTrue("\(error)".contains("no import loader"), "\(error)") + } + } + + func testSyntaxOnlyToleratesImports() throws { + let sheet = try CascadeSheetReader().read( + "@import \"base.hcs\"\nService:\n a: 1\n", imports: .syntaxOnly + ) + XCTAssertEqual(sheet.rules.count, 1, "own rules parse; imports are skipped") + } + + func testUnreadableImportFails() { + XCTAssertThrowsError(try CascadeSheetReader().read( + "@import \"missing.hcs\"\n", imports: loader([:]) + )) { error in + XCTAssertTrue("\(error)".contains("missing.hcs"), "\(error)") + } + } + + func testMalformedDirectives() { + for bad in [ + "@import base.hcs\n", // unquoted + "@import \"\"\n", // empty path + "@import\n", // no target + "@import \"a.hcs\"\n x: 1\n", // nested block + ] { + XCTAssertThrowsError( + try CascadeSheetReader().read(bad, imports: loader(["a.hcs": ""])), + "should reject: \(bad.debugDescription)" + ) + } + } + + func testImportPrefixedGuardIsNotAnImport() throws { + // `@important[x]:` is a context guard, not a typo'd directive. + let sheet = try CascadeSheetReader().read( + "@important[on]:\n Service:\n a: 1\n" + ) + XCTAssertEqual(sheet.rules.count, 1) + XCTAssertEqual(sheet.rules[0].condition?.dimension, "important") + } + + func testErrorInImportedSheetNamesTheFile() { + XCTAssertThrowsError(try CascadeSheetReader().read( + "@import \"broken.hcs\"\n", file: "entry.hcs", + imports: loader(["broken.hcs": "Service\n a: 1\n"]) // missing ':' + )) { error in + XCTAssertTrue("\(error)".contains("broken.hcs"), "\(error)") + } + } +} diff --git a/workplan.md b/workplan.md index fab3ce3..e1ba86b 100644 --- a/workplan.md +++ b/workplan.md @@ -99,7 +99,7 @@ P1 — built on v2: - [x] HC-113 `hypercode diff ` — affected nodes/properties with reasons (old/new winner rule), node-hash short-circuit (unchanged subtrees skipped), selector-identity node matching, added/removed/reordered detection, `--format json` = `hypercode.diff/v1` feed, exit 1 on change — `Sources/Hypercode/Diff/` (Foundation-free JSON parser with exact number lexemes) - ⬜ HC-114 Runtime resolver boundary — document default build/generation-time mode vs. optional embedded runtime resolver (per-request context: caching, latency, provenance); decide library API or explicit out-of-scope — RFC §9.8 - 🅿️ HC-115 OpenFeature bridge for the runtime mode *(only if HC-114 decides "in scope")* -- ⬜ HC-116 `@import` for `.hcs` — sheet modularity; real configurations don't live in one file (resolution order, cycle detection, provenance keeps the importing file) +- [x] HC-116 `@import` for `.hcs` — sheet modularity: depth-first expansion at the directive position (importer wins ties), import-once for diamonds, cycle detection, cross-file provenance; contracts compose across imports — normative semantics `EBNF/Hypercode_Resolution.md` §5.1 ## M9 — Validation & adoption (DOCS/Positioning.md) From 4c8ae89b027791d3a8623ca0a7c2e663176e29b7 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Fri, 12 Jun 2026 21:20:27 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20#27=20review=20=E2=80=94?= =?UTF-8?q?=20cross-file=20HC3002,=20canonical=20entry=20identity,=20Unrel?= =?UTF-8?q?eased=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HC3002 dangling-selector warnings carry the rule's defining file, so a dead selector in an imported baseline points at the baseline, not at the entry sheet that imported it. - The entry sheet path goes through the same canonicalization as every import target (standardize + cwd-relativize), so an absolute entry path and a relative import of the same file can no longer get two identities — which previously missed a cycle and expanded the entry sheet twice. - CHANGELOG gains the Unreleased — 0.6.0-dev section the Version.swift comment promises (the EBNF specs keep their own in-file change logs, already updated to 0.3). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 ++++++ Sources/Hypercode/Validation/Validator.swift | 8 +++++- Sources/HypercodeCLI/main.swift | 27 ++++++++++++-------- Tests/HypercodeTests/ImportTests.swift | 14 ++++++++++ 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf3e32e..3e98240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/), and the project aims to follow [Semantic Versioning](https://semver.org/). +## [Unreleased] — 0.6.0-dev + +### Added +- `@import "path.hcs"` for cascade sheets (HC-116): depth-first expansion at + the directive position (the importer wins specificity ties), import-once + for diamonds, cycle detection, cross-file provenance; contracts compose + across imports. `ImportHandling` API (`.unsupported`/`.syntaxOnly`/`.loader`). + ## [0.5.0] — 2026-06-12 Spec-layer hardening: the resolver becomes a verifiable pipeline — typed IR diff --git a/Sources/Hypercode/Validation/Validator.swift b/Sources/Hypercode/Validation/Validator.swift index 57fab44..81f8043 100644 --- a/Sources/Hypercode/Validation/Validator.swift +++ b/Sources/Hypercode/Validation/Validator.swift @@ -40,7 +40,13 @@ public struct Validator { 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))) + // The rule's own file matters: with @import the sheet is + // composed from several files, and a dead selector must point + // at the sheet that defines it, not at the entry sheet. + : Diagnostic(severity: .warning, code: "HC3002", + message: "selector '\(rule.selector)' matches no node", + file: rule.file, + range: SourceRange(SourcePosition(line: rule.line, column: 1))) } let contractDiags = ContractValidator().validate(sheet.contracts, against: forest) return dangling + contractDiags diff --git a/Sources/HypercodeCLI/main.swift b/Sources/HypercodeCLI/main.swift index cccd2f7..955cdae 100644 --- a/Sources/HypercodeCLI/main.swift +++ b/Sources/HypercodeCLI/main.swift @@ -49,24 +49,31 @@ func parseContextAssignment(_ pair: String) -> (key: String, value: String) { } /// Read a `.hcs` sheet with `@import` expansion: targets resolve relative to -/// the importing sheet's directory; the standardized absolute path is the -/// identity used for cycle detection and import-once dedupe. +/// the importing sheet's directory; the canonical path is the identity used +/// for cycle detection and import-once dedupe. struct SheetLoadError: Error, CustomStringConvertible { let description: String } +/// One canonical spelling per physical sheet — standardized, and relative to +/// the working directory when underneath it (readable provenance). The entry +/// sheet goes through the same normalization as every import target, so an +/// absolute entry path and a relative import of the same file can never get +/// two identities (which would break cycle detection and dedupe). +func canonicalSheetPath(_ url: URL) -> String { + let standardized = url.standardizedFileURL.path + let cwd = FileManager.default.currentDirectoryPath + "/" + return standardized.hasPrefix(cwd) ? String(standardized.dropFirst(cwd.count)) : standardized +} + func readSheet(_ path: String) throws -> CascadeSheet { - try CascadeSheetReader().read(readSource(path), file: path, imports: .loader { target, importingFile in - let baseDir = URL(fileURLWithPath: importingFile ?? path).deletingLastPathComponent() + let entry = canonicalSheetPath(URL(fileURLWithPath: path)) + return try CascadeSheetReader().read(readSource(path), file: entry, imports: .loader { target, importingFile in + let baseDir = URL(fileURLWithPath: importingFile ?? entry).deletingLastPathComponent() let url = URL(fileURLWithPath: target, relativeTo: baseDir).standardizedFileURL guard let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) else { throw SheetLoadError(description: "cannot read '\(url.path)'") } - // Keep provenance readable: paths under the working directory are - // reported relative to it (the canonical form stays consistent, so - // cycle detection and dedupe are unaffected). - let cwd = FileManager.default.currentDirectoryPath + "/" - let identity = url.path.hasPrefix(cwd) ? String(url.path.dropFirst(cwd.count)) : url.path - return (identity, text) + return (canonicalSheetPath(url), text) }) } diff --git a/Tests/HypercodeTests/ImportTests.swift b/Tests/HypercodeTests/ImportTests.swift index d1b4543..e9a3308 100644 --- a/Tests/HypercodeTests/ImportTests.swift +++ b/Tests/HypercodeTests/ImportTests.swift @@ -172,6 +172,20 @@ final class ImportTests: XCTestCase { XCTAssertEqual(sheet.rules[0].condition?.dimension, "important") } + func testDanglingSelectorDiagnosticNamesTheDefiningFile() throws { + // HC3002 for a dead selector in an imported baseline must point at + // the baseline, not at the entry sheet that imported it. + let commands = try Parser(source: "Service\n").parse() + let sheet = try CascadeSheetReader().read( + "@import \"base.hcs\"\nService:\n a: 1\n", file: "entry.hcs", + imports: loader(["base.hcs": "LegacyQueue:\n x: 1\n"]) + ) + let diags = Validator().validate(sheet, against: commands) + XCTAssertEqual(diags.count, 1) + XCTAssertEqual(diags.first?.code, "HC3002") + XCTAssertEqual(diags.first?.file, "base.hcs") + } + func testErrorInImportedSheetNamesTheFile() { XCTAssertThrowsError(try CascadeSheetReader().read( "@import \"broken.hcs\"\n", file: "entry.hcs", From 8a18f8fc4689385f16f46f235a641ebb9e84f224 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 13 Jun 2026 00:31:40 +0300 Subject: [PATCH 3/3] docs: @import trust model and the canonical-identity contract of read(file:) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Residual-risk follow-up on #27: - read(file:imports:) documents that the entry file must use the same canonical identity scheme the loader returns — identity strings are compared verbatim by cycle detection and dedupe; - RFC §8: the import loader is the policy boundary — the CLI loader reads any reachable path (fine for trusted local config), embedding contexts resolving untrusted sheets must supply a policy-bound loader; Usage §7 carries the same line. Co-Authored-By: Claude Fable 5 --- DOCS/Usage.md | 5 ++++- RFC/Hypercode.md | 2 ++ Sources/Hypercode/HCS/CascadeSheetReader.swift | 9 ++++++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/DOCS/Usage.md b/DOCS/Usage.md index 2020249..fe10a36 100644 --- a/DOCS/Usage.md +++ b/DOCS/Usage.md @@ -287,7 +287,10 @@ bad-tenant.hcs:3:1: error[HC2104]: contract violation for 'port': 99999 exceeds Rules of the road: imports go first; paths resolve relative to the importing sheet; a sheet expands once per resolution (diamonds are fine); a cycle is an -error naming the chain. +error naming the chain. Trust model: the CLI loader reads any path the +process can read (absolute and `../` included) — fine for local trusted +config; embedders resolving untrusted sheets supply a policy-bound loader +(RFC §8). ## Scalar typing cheat-sheet diff --git a/RFC/Hypercode.md b/RFC/Hypercode.md index 4b195aa..ca5b5cf 100644 --- a/RFC/Hypercode.md +++ b/RFC/Hypercode.md @@ -251,6 +251,8 @@ Hypercode and HCS are declarative and do not define runtime execution isolation The specification assumes that the resolution and execution engine is trusted. No mechanisms are currently defined for verifying integrity of `.hcs` rules or controlling their provenance. Future versions may include digital signing or validation capabilities. +`@import` (§4.2.4) widens the read surface: resolving a sheet now reads every transitively imported file. **The import loader is the policy boundary.** The reference CLI loader resolves any path the process can read — including absolute and `../` targets — which is appropriate for trusted local configuration. Embedding contexts that resolve untrusted sheets (agents, services) MUST supply a policy-bound loader (root-confined paths, allow-lists) instead; the `ImportHandling.loader` API exists precisely so that policy lives with the embedder. + ## 9. Novelty and Prior Art ### 9.1. The Claim diff --git a/Sources/Hypercode/HCS/CascadeSheetReader.swift b/Sources/Hypercode/HCS/CascadeSheetReader.swift index 85cbd2b..2f2c9b2 100644 --- a/Sources/Hypercode/HCS/CascadeSheetReader.swift +++ b/Sources/Hypercode/HCS/CascadeSheetReader.swift @@ -38,7 +38,14 @@ public struct CascadeSheetReader { /// is an error. Rules keep the file they were defined in for provenance. /// /// - Parameters: - /// - file: Optional path of the source file, stored in each `Rule` for provenance. + /// - file: Optional path of the source file, stored in each `Rule` for + /// provenance. **Must use the same canonical identity scheme the + /// loader returns** — cycle detection and import-once dedupe compare + /// these strings verbatim, so an absolute entry path combined with a + /// loader that returns relative identities (or vice versa) lets the + /// same physical sheet appear under two identities. The CLI + /// normalizes both sides with one helper; library callers must do + /// the same. /// - imports: How `@import` directives are handled (default: error). public func read( _ source: String, file: String? = nil, imports: ImportHandling = .unsupported