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/DOCS/Usage.md b/DOCS/Usage.md index ed9d404..fe10a36 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,55 @@ $ 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. 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 | 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..ca5b5cf 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. @@ -236,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 @@ -335,6 +352,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..2f2c9b2 100644 --- a/Sources/Hypercode/HCS/CascadeSheetReader.swift +++ b/Sources/Hypercode/HCS/CascadeSheetReader.swift @@ -6,16 +6,73 @@ 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. **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 + ) 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 +88,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/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/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..955cdae 100644 --- a/Sources/HypercodeCLI/main.swift +++ b/Sources/HypercodeCLI/main.swift @@ -48,6 +48,35 @@ 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 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 { + 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)'") + } + return (canonicalSheetPath(url), text) + }) +} + func runParse(_ path: String) throws { let forest = try Parser(source: readSource(path)).parse() print(Command.tree(forest), terminator: "") @@ -82,7 +111,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 +155,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 +220,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 +268,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..e9a3308 --- /dev/null +++ b/Tests/HypercodeTests/ImportTests.swift @@ -0,0 +1,197 @@ +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 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", + 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)