diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 22ec4a3..f2a1113 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -24,5 +24,12 @@ jobs: - name: Validate IR v2 against schema run: npm exec --yes --package=ajv-cli@5 -- ajv validate --spec=draft2020 -s Schema/hypercode-ir-v2.schema.json -d /tmp/ir-dev.json -d /tmp/ir-prod.json + - name: Diff IR and validate against the diff schema + run: | + sed 's/port: 8080/port: 9090/' Examples/service.hcs > /tmp/edited.hcs + .build/debug/hypercode emit Examples/service.hc --hcs /tmp/edited.hcs --ctx env=production --format json > /tmp/ir-edited.json + .build/debug/hypercode diff /tmp/ir-prod.json /tmp/ir-edited.json --format json > /tmp/diff.json || test $? -eq 1 + npm exec --yes --package=ajv-cli@5 -- ajv validate --spec=draft2020 -s Schema/hypercode-diff-v1.schema.json -d /tmp/diff.json + - name: Codegen demo — generated artifacts fresh & contract-conformant run: python3 Examples/codegen-demo/check.py \ No newline at end of file diff --git a/DOCS/Usage.md b/DOCS/Usage.md index a0676e8..86de2e0 100644 --- a/DOCS/Usage.md +++ b/DOCS/Usage.md @@ -6,8 +6,8 @@ and [`Examples/service.hcs`](../Examples/service.hcs). ``` .hc + .hcs + --ctx ──▶ resolve ──▶ validate (contracts) ──▶ emit (IR v2) ──▶ your generator - │ - explain (why is this value X?) + │ │ + explain (why is this value X?) diff (what changed?) ``` ## The running example @@ -200,8 +200,48 @@ specification, code is regenerated output. This loop is runnable today: [`Examples/codegen-demo/`](../Examples/codegen-demo/) generates a module per node, verifies freshness by node hash and contract -conformance of the generated values in CI. `hypercode diff` (HC-113 in the -[work plan](../workplan.md)) will productize the hash comparison. +conformance of the generated values in CI. `hypercode diff` (§6) is the +productized form of that hash comparison. + +## 6. Semantic diff: `hypercode diff` + +The invalidation signal as a first-class command (HC-113). Emit two IRs, +diff them — the output is the affected-node set with reasons: + +```console +$ hypercode emit app.hc --hcs app.hcs --ctx env=production > old.ir.json +$ sed 's/port: 8080/port: 9090/' app.hcs > edited.hcs +$ hypercode emit app.hc --hcs edited.hcs --ctx env=production > new.ir.json +$ hypercode diff old.ir.json new.ir.json +~ Service > APIServer > Listen + ~ port: 8080 → 9090 + was: APIServer > Listen @ app.hcs:26 + now: APIServer > Listen @ edited.hcs:26 + +1 affected node(s) +$ echo $? +1 +``` + +- Hash-driven: unchanged subtrees are skipped wholesale, so cost is + proportional to the change, not the tree. A provenance-only change (a + different rule winning the same value) is invisible — by design. +- **Resolved content only.** Document metadata (`context`, `resolver`) does + not participate: two IRs that resolve to the same graph under different + contexts are *identical* (exit `0`) — same graph means nothing to + regenerate, whichever context produced it. When the contexts differ, the + text output says so in a leading `note:` line. +- Nodes are matched by selector identity (`type[.class][#id]`); duplicate + siblings pair by content hash first, so a duplicate that merely moved is a + reorder, not two modifications. Added, removed and reordered nodes are + reported as such. +- `--format json` emits `hypercode.diff/v1` + ([schema](../Schema/hypercode-diff-v1.schema.json), ajv-validated in CI) — + the machine-readable feed for incremental regeneration (feed it to your + generator instead of re-running everything). +- Exit code is `diff`-like: `0` identical, `1` documents differ, `2` trouble + (unreadable or non-v2 input) — usable as a CI gate + ("spec changed → require regeneration"). ## Scalar typing cheat-sheet diff --git a/Examples/codegen-demo/README.md b/Examples/codegen-demo/README.md index 5ada907..938219b 100644 --- a/Examples/codegen-demo/README.md +++ b/Examples/codegen-demo/README.md @@ -98,6 +98,10 @@ regenerating api_server.py from node /Service/APIServer … subtree of each stale node (the source of truth) plus the module conventions, then re-runs the checks. No stale module — no LLM call. +The same comparison is available as a compiler command: `hypercode diff +old.ir.json new.ir.json` reports the affected nodes with the old and new +winning rules (see [DOCS/Usage.md §6](../../DOCS/Usage.md)). + ## 5. It actually runs ```console diff --git a/OVERVIEW.md b/OVERVIEW.md index e624a84..28ca70a 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -1,145 +1,141 @@ +# Hypercode Conceptual Overview -## Hypercode Conceptual Overview +## 1. Purpose -### 1. Purpose +> **Hypercode is a formal, provenance-preserving, context-resolved +> specification layer between human-reviewed architectural intent and +> deterministic or AI-assisted code generation.** -Hypercode is an **executable architecture language**. +The shortest mental model: **CSS for application structure**. A small, +stable, addressable topology (`.hc`) plus selector-based cascade sheets +(`.hcs`) resolve — under an explicit context — into a typed, hashed, +provenance-carrying graph that downstream tooling consumes. -It is designed to describe **how a system is structured at the level of architecture**, and how concrete behavior emerges from that structure and its contextual specialization: +Hypercode does not replace general-purpose languages. It is the canonical +place where the system's *structure and its contextual policies* are defined, +reviewed, and versioned — so that code, configuration, and validation can be +derived from one deterministic source. -- which elements (agents, services, commands, tasks) exist, -- how they are related and composed, -- which stages and alternative paths are structurally defined, -- how this structure is specialized across environments, tenants, and feature sets. +## 2. Two Complementary Artifacts: `.hc` and `.hcs` -Its goal is **not** to replace general-purpose languages, but to become the **canonical place where the system's architectural structure is defined**, so that behavior emerges from the combination of that structure and contextual rules. +### 2.1. Hypercode file (`.hc`) — the structural skeleton ---- +A purely declarative, indentation-based declaration of *what exists*: -### 2. Two Complementary Artifacts: `.hc` and `.hcs` +```hypercode +Service + Logger.console + Database#main-db + Connect + APIServer + Listen +``` -Hypercode consists of two tightly related artifacts. - -#### 2.1. Hypercode file (`.hc`) – architectural skeleton - -A `.hc` file is a **purely declarative structural declaration** of the system: - -- Declares **structural elements** of the system (nodes, agents, commands, pipelines, states). -- Declares **structural topology and element relationships**: - - which elements exist and their hierarchical containment, - - which stages form a sequence, - - which named alternatives or fallback slots are declared, - - what structural connections exist between elements. - -In the ideal model, **host components are shaped by the Hypercode architecture**, not the other way around: implementations are designed as projections of Hypercode elements and roles, following an "architecture-first" style inspired by ideas like Elegant Objects and EOlang. - -This is already **meaningful structure** – it defines the **architectural shape** of the system, not algorithmic behavior. - -#### 2.2. Hypercode Cascade Sheet (`.hcs`) – contextual specialization & policies - -A `.hcs` file describes how the declared structure behaves under different contexts by applying **cascade- and context-aware rules** to elements from `.hc`: - -- Contains **selector-based rules** that apply to elements declared in `.hc`. -- Modulates **how the architecture behaves in specific contexts**: - - enables or disables certain elements or paths, - - switches implementations or routes, - - adjusts limits, timeouts, retries, strategies, - - applies security, logging, or resource policies. -- Uses **cascade semantics**: more specific rules override general ones in a deterministic way. -- Uses **context-aware directives** (`@env`, `@profile`, `@feature`, etc.) to express how architecture changes across environments and scenarios. - -Together, `.hc` and `.hcs` describe an **executable architectural program**: -`.hc` defines *what exists and how it is arranged*, `.hcs` defines *how that arrangement behaves in each context*. - ---- - -### 3. Division of Responsibilities - -Hypercode deliberately **separates levels of concern**. - -#### Architectural topology & orchestration structure – in Hypercode - -Hypercode expresses: - -- which components participate in a scenario, -- in which sequence they are declared, -- which alternative paths are structurally defined, -- which architectural roles and connections exist between components, -- which policies and strategies are associated with particular structural elements (via `.hcs`). - -#### Algorithmic & low-level behavior – in host languages - -Host languages and runtimes remain responsible for: - -- how a video is encoded or transcoded, -- how a database query is built and optimized, -- how a request is validated against a schema, -- how cryptographic primitives and low-level protocols are implemented. - -Hypercode is the **source of truth for architectural structure and orchestration structure**. -Host languages are the source of truth for **how each atomic step works internally**. - -Behavior emerges from **interpreting the declared structure (.hc) under the contextual and cascading rules (.hcs)**. - ---- - -### 4. Execution Model - -Hypercode is not "just configuration". - -There is a **Hypercode runtime** (or several runtimes in different ecosystems) that: - -- loads `.hc` and `.hcs`, -- builds an **architectural execution graph** from the declared structure, -- applies cascade and context resolution to that graph, -- interprets the declared structure and drives execution according to the resolved cascade rules. - -In addition to interpreted/executed-at-runtime scenarios, **Hypercode code can also be transformed ahead of time into host-language code**: - -- a compiler or generator can translate `.hc + .hcs` into Swift / Java / TypeScript / other code that embeds the architectural structure and its contextual behavior, -- the resulting code is then compiled into a regular program or library, -- Hypercode in this mode becomes a **primary architectural source**, from which host code is derived. - -Important nuances: - -- Hypercode **does not directly construct objects** or manage memory; it **tells the runtime or generated host code**: - - which roles and elements are declared, - - how they are structurally connected, - - which context rules from `.hcs` govern their activation. - -- The runtime or generated code maps Hypercode elements to **concrete objects, services, processes, or containers** in a given platform: - - Swift objects, Java services, microservices, actors, etc. - -So, **Hypercode is executed through a runtime or via generated code**, but the **program being executed at the architectural level** is the combination of `.hc` (structure) and `.hcs` (contextual cascade). - ---- - -### 5. Cascade and Context as First-Class Semantics - -A key differentiator of Hypercode is that **cascade and context are built into the language model**, not bolted on via tooling conventions. - -- **Selectors** (by type, ID, tags, hierarchy, roles, etc.) express *where* a rule applies in the structural graph. -- **Cascade** expresses *how multiple rules combine*, with deterministic resolution (specificity, precedence, ordering). -- **Context directives** (`@env`, `@profile`, `@tenant`, `@feature`, etc.) express *when* a rule applies. - -This allows: - -- expressing **multi-environment, multi-tenant, feature-flagged architecture** declaratively, -- moving scattered `if (env == "prod")` and feature-flag checks from host code into a **central, inspectable architectural program**, -- reasoning about behavior changes across contexts at the level of a single structural and rule-based model. - ---- - -### 6. Relationship to Other Tools and Languages - -Hypercode is intentionally **orthogonal** to many existing tools: - -- It is **not just a DI container**: DI focuses on object graphs and injection; Hypercode focuses on **architectural structure and the policies that govern how that structure is used**. -- It is **not a plain config format**: configs store parameters; Hypercode defines the **architectural topology** of the system and how it behaves under different contexts. -- It is **not just another DSL**: - - It is a **system-level DSL** for executable architecture, - - **language-agnostic** by design, meant to sit above general-purpose languages, - - with **cascade and context** as first-class constructs. - -Hypercode is the place where the system's architectural structure is defined, reviewed, and versioned. -The system's behavior emerges when this declared structure (.hc) is combined with the contextual and cascading rules (.hcs) and interpreted by a runtime or compiled into host code. +Nodes carry a **type**, an optional **class** (`.console`) and an optional +**id** (`#main-db`) — the anchors every other layer addresses. The `.hc` is +deliberately a *skeleton*, simpler than YAML: structure and intent only, no +values. ([Formal grammar](EBNF/Hypercode_Syntax.md).) + +### 2.2. Cascade sheet (`.hcs`) — values, contexts, contracts + +Selector-based rules attach values to the structure, context blocks +specialize them, and contract blocks declare invariants: + +```hcs +APIServer > Listen: + port: 5000 + +@env[production]: + APIServer > Listen: + port: 8080 + +@contract: + APIServer > Listen: + port: int >= 1 <= 65535 +``` + +- **Selectors** (`type`, `.class`, `'#id'`, `parent > child`) express *where* + a rule applies; CSS-style **specificity** plus source order decides *which* + rule wins. +- **`@dimension[value]` blocks** (e.g. `@env[production]`, `@client[acme]`) + express *when* rules apply; the context is supplied at resolution time + (`--ctx env=production`). +- **`@contract:` blocks** attach property schemas (type, bounds, required) + to selectors. + +## 3. The Safety Lock: Asymmetric Cascade + +The rule that makes overriding defensible (normative, [RFC §9.4](RFC/Hypercode.md)): + +> Values cascade. Contracts accumulate and narrow. +> A more specific selector MAY override a value. +> A more specific selector MUST NOT weaken an inherited contract. + +Hypercode cascades *behavior*, never *safety*. A production override that +violates a bound is a build error (`HC2104`), not an incident. This combines +CUE-like monotonic safety with CSS-like contextual selection. + +## 4. The Resolved Graph Is the Contract + +Resolution is **deterministic and happens at build/generation time**: +`.hc + .hcs + context → resolved graph → hypercode.ir/v2` +([schema](Schema/hypercode-ir-v2.schema.json)). The IR carries, per property: + +- the **typed value** (`8080`, not `"8080"`), +- full **provenance** — the winning rule *and* every losing rule, each with + selector, file, line, specificity and source order, +- the **contracts** governing it, + +and, per node, a **stable content hash** (Merkle over the subtree) — the +invalidation signal for incremental regeneration. + +Consumers depend on the IR, never the reverse. Target-specific output +(code, Kubernetes manifests, ontology packages, …) is consumer-owned, +downstream of the resolved graph ([backends](DOCS/Backends.md)). + +## 5. The Toolchain + +| Command | Question it answers | +|---|---| +| `hypercode resolve` | what does the structure look like in this context? | +| `hypercode validate --ctx …` | does the cascade respect every contract here? | +| `hypercode explain [prop]` | *why* is this value what it is? (winner + losers) | +| `hypercode emit` | the IR v2 for downstream consumers | +| `hypercode diff old.ir new.ir` | which nodes changed, and which rule did it? | +| `hypercode lsp` | live diagnostics in the editor | + +Every command with real outputs: [usage guide](DOCS/Usage.md). + +## 6. Where Behavior Comes From + +Algorithmic behavior stays in host languages. Hypercode's role is the layer +above: an LLM or a deterministic generator consumes the resolved IR and +produces code **per node**; node hashes scope regeneration to what actually +changed; the same contracts validate the generated artifacts; provenance lets +a validator state *which rule* demanded a behavior. + +The unit of human review shifts from generated code to the **specification +diff** — humans approve a small, formally resolved change; machines expand it +into code and validate the expansion (*review compression*). This loop is +runnable today: [`Examples/codegen-demo/`](Examples/codegen-demo/). + +Binding time is explicit: context resolves at build/generation time. Runtime +feature flags (OpenFeature, LaunchDarkly) are a different, composable layer; +an embedded runtime resolver is an optional mode, currently out of scope +([RFC §9.8](RFC/Hypercode.md)). + +## 7. What Hypercode Is Not + +- **Not a typed configuration language** (CUE, Dhall, Nickel) — their subject + is configuration *data*; Hypercode's subject is an addressable *topology* + plus rules over it. +- **Not model-driven architecture** — `.hc` is deliberately incomplete: a + skeleton plus context policies, not a complete model. +- **Not a Markdown SDD format** (Spec Kit, Kiro, AGENTS.md) — Hypercode sits + *underneath* such documents as the part that resolves deterministically and + diffs semantically. +- **Not a DI container, an interface contract, or a feature-flag system** — + it provides stable anchors those layers can target. + +Full positioning, prior-art map and phrasing discipline: +[DOCS/Positioning.md](DOCS/Positioning.md) · [RFC §9](RFC/Hypercode.md). diff --git a/README.md b/README.md index 00bb979..d1c4b0c 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ hypercode validate [--hcs ] [--ctx key=value]... # incl. c 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 diff [--format text|json] # affected nodes, exit 1 on change hypercode lsp # LSP over stdio ``` @@ -77,7 +78,7 @@ hypercode resolve Examples/service.hc --hcs Examples/service.hcs --ctx env=produ - [RFC — the paradigm](RFC/Hypercode.md) - Formal specs: [`.hc` syntax (BNF)](EBNF/Hypercode_Syntax.md) · [resolution semantics](EBNF/Hypercode_Resolution.md) - Architecture: [overview](DOCS/Architecture.md) · [backends & adapters](DOCS/Backends.md) · [core vs dialects](DOCS/Dialects.md) · [positioning](DOCS/Positioning.md) -- Resolved-graph IR schemas — the cross-implementation contract: [v2](Schema/hypercode-ir-v2.schema.json) · [v1 (legacy)](Schema/hypercode-ir-v1.schema.json) +- Resolved-graph IR schemas — the cross-implementation contract: [IR v2](Schema/hypercode-ir-v2.schema.json) · [diff v1](Schema/hypercode-diff-v1.schema.json) · [IR v1 (legacy)](Schema/hypercode-ir-v1.schema.json) - [Lean 4 cascade oracle](SPEC/lean/) — machine-checked agreement with the resolver - [Work plan](workplan.md) · [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) diff --git a/RFC/Hypercode.md b/RFC/Hypercode.md index 7b720f5..a11f347 100644 --- a/RFC/Hypercode.md +++ b/RFC/Hypercode.md @@ -297,7 +297,7 @@ In layered configuration systems the recurring operational question is "where di Spec-driven development is converging on the view that the specification is the durable artifact and code is increasingly a regenerated output. Today that movement runs almost entirely on natural-language Markdown, which neither resolves deterministically nor diffs semantically. Hypercode's intended role is the formal substrate underneath it, with three consequences: * **Confined nondeterminism.** The specification side resolves deterministically (machine-checked, §9.4); nondeterminism is confined to the generation step, where it can be validated against the resolved graph. -* **A fixed generated/durable boundary.** Classic MDA demanded complete models; an LLM generator tolerates incompleteness, so `.hc` can stay a skeleton. The node boundary fixes the division of labor: orchestration and wiring are derived from the resolved graph (mechanically where possible), while durable leaf implementations live behind generated interfaces and are never overwritten. Node-level hashes over the resolved IR (`hypercode.ir/v2`, [schema](../Schema/hypercode-ir-v2.schema.json)) provide the invalidation signal for incremental regeneration: the hash covers the stable resolved content — type, class, id, resolved values, child hashes — so a provenance-only change (a different rule winning with the same value) does not invalidate. +* **A fixed generated/durable boundary.** Classic MDA demanded complete models; an LLM generator tolerates incompleteness, so `.hc` can stay a skeleton. The node boundary fixes the division of labor: orchestration and wiring are derived from the resolved graph (mechanically where possible), while durable leaf implementations live behind generated interfaces and are never overwritten. Node-level hashes over the resolved IR (`hypercode.ir/v2`, [schema](../Schema/hypercode-ir-v2.schema.json)) provide the invalidation signal for incremental regeneration: the hash covers the stable resolved content — type, class, id, resolved values, child hashes — so a provenance-only change (a different rule winning with the same value) does not invalidate. `hypercode diff` computes the affected-node set between two resolved documents, with reasons (old/new value and winning rule), emitting `hypercode.diff/v1` ([schema](../Schema/hypercode-diff-v1.schema.json)) as the machine-readable feed. * **Review compression.** The unit of human review shifts from generated code to the specification diff: humans approve a small, formally resolved change; machines expand it into code and validate the expansion against the same graph. ### 9.8. Acknowledged Limits @@ -340,6 +340,7 @@ Prior art surveyed in §9: * Contracts are now part of the language model (HC-111): `@contract:` block syntax in `.hcs`, accumulation by intersection (an omitted bound inherits), specificity relating only contracts that can govern the same node, monotonicity diagnostics HC2101–HC2103 (§9.4); value-level enforcement (HC2104) declared in progress. * `hypercode explain` (HC-110) shipped: full cascade trace with winner and losing rules (§9.4). * `hypercode.ir/v2` (HC-112) shipped: typed values with lexeme preservation, winner/losers per property, contract echo, per-node and per-document SHA-256 hashes over stable resolved content (§9.7, §9.8). +* `hypercode diff` (HC-113) shipped: hash-driven affected-node set between two resolved documents, with old/new winning rules; `hypercode.diff/v1` output format (§9.7). * Normative contract semantics delegated to the resolution specification (`EBNF/Hypercode_Resolution.md` §7). **Version 0.1.1** (2026-06-10): diff --git a/Schema/hypercode-diff-v1.schema.json b/Schema/hypercode-diff-v1.schema.json new file mode 100644 index 0000000..9ff2322 --- /dev/null +++ b/Schema/hypercode-diff-v1.schema.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://0al-spec.github.io/Hypercode/schema/hypercode-diff-v1.schema.json", + "title": "Hypercode diff v1", + "description": "Output of `hypercode diff --format json`: the affected-node set between two hypercode.ir/v2 documents, with reasons (old/new value and winning rule). The machine-readable invalidation feed for incremental regeneration (HC-113). The comparison covers resolved content only — document metadata (`context`, `resolver`) and provenance do not participate, so two IRs that resolve to the same graph under different contexts diff as identical (an empty `changes` array).", + "type": "object", + "required": ["version", "changes"], + "additionalProperties": false, + "properties": { + "version": { + "type": "string", + "const": "hypercode.diff/v1" + }, + "changes": { + "type": "array", + "items": { "$ref": "#/$defs/change" } + } + }, + "$defs": { + "change": { + "type": "object", + "required": ["kind", "node"], + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "enum": ["added", "removed", "modified", "reordered"] + }, + "node": { + "type": "string", + "description": "Selector-identity path from the root, e.g. 'Service > APIServer > Listen'." + }, + "properties": { + "type": "array", + "description": "Present for kind=modified: the property-level differences.", + "items": { "$ref": "#/$defs/propertyChange" } + } + } + }, + "propertyChange": { + "type": "object", + "required": ["key", "kind"], + "additionalProperties": false, + "properties": { + "key": { "type": "string" }, + "kind": { "type": "string", "enum": ["added", "removed", "changed"] }, + "old": { "type": "string", "description": "Scalar text of the old value." }, + "new": { "type": "string", "description": "Scalar text of the new value." }, + "winner": { + "type": "string", + "description": "For kind=added: the rule that introduced the value ('selector @ file:line')." + }, + "oldWinner": { "type": "string" }, + "newWinner": { "type": "string" } + } + } + } +} diff --git a/Sources/Hypercode/Diff/IRDiffer.swift b/Sources/Hypercode/Diff/IRDiffer.swift new file mode 100644 index 0000000..bc16301 --- /dev/null +++ b/Sources/Hypercode/Diff/IRDiffer.swift @@ -0,0 +1,236 @@ +/// One property-level difference inside a modified node. +public struct PropertyDiff: Equatable, Sendable { + public enum Kind: Equatable, Sendable { + case added(new: String, winner: String) + case removed(old: String) + case changed(old: String, new: String, oldWinner: String, newWinner: String) + } + + public let key: String + public let kind: Kind + + public init(key: String, kind: Kind) { + self.key = key + self.kind = kind + } +} + +/// One node-level change between two IR v2 documents. +public enum IRChange: Equatable, Sendable { + case nodeAdded(path: String) + case nodeRemoved(path: String) + case nodeModified(path: String, properties: [PropertyDiff]) + /// Matched children changed relative order under this path. Reordering + /// changes parent hashes (they are Merkle over children) without any + /// value changing. + case childrenReordered(path: String) +} + +/// Computes the affected-node set between two `hypercode.ir/v2` documents — +/// the invalidation signal for incremental regeneration (HC-113). +/// +/// Node hashes drive the traversal: a subtree whose hash is unchanged is +/// skipped entirely, so the cost is proportional to what changed, not to the +/// size of the tree. Nodes are matched across versions by their selector +/// identity (`type[.class][#id]`); duplicate siblings pair by content hash +/// first, then by source order — so a duplicate that merely moved is a +/// reorder, not two modifications. Provenance-only changes (a different rule +/// winning the same value) do not alter hashes and therefore do not appear +/// in the diff. +public struct IRDiffer { + public init() {} + + public func diff(old: IRDocument, new: IRDocument) -> [IRChange] { + guard old.documentHash != new.documentHash else { return [] } + var changes: [IRChange] = [] + diffForest(old.nodes, new.nodes, parentPath: "", into: &changes) + return changes + } + + // MARK: - Tree walk + + private func diffForest( + _ old: [IRNode], _ new: [IRNode], + parentPath: String, + into changes: inout [IRChange] + ) { + let pairedOldIndex = pairChildren(old, new) + + var matchedOldOrder: [Int] = [] + for (newIndex, node) in new.enumerated() { + let path = join(parentPath, node.label) + if let oldIndex = pairedOldIndex[newIndex] { + matchedOldOrder.append(oldIndex) + diffNode(old[oldIndex], node, path: path, into: &changes) + } else { + changes.append(.nodeAdded(path: path)) + } + } + let matched = Set(pairedOldIndex.compactMap { $0 }) + for oldIndex in old.indices where !matched.contains(oldIndex) { + changes.append(.nodeRemoved(path: join(parentPath, old[oldIndex].label))) + } + if matchedOldOrder != matchedOldOrder.sorted() { + changes.append(.childrenReordered(path: parentPath.isEmpty ? "(root)" : parentPath)) + } + } + + /// For each new child, the index of the old child it pairs with (nil = + /// added). Pairing is per selector-identity label; inside a label group + /// equal hashes pair first, so a duplicate sibling that only moved keeps + /// its identity instead of stealing another occurrence's positional slot. + /// Leftovers pair in source order. + private func pairChildren(_ old: [IRNode], _ new: [IRNode]) -> [Int?] { + var oldByLabel: [String: [Int]] = [:] + for (index, node) in old.enumerated() { + oldByLabel[node.label, default: []].append(index) + } + var newByLabel: [String: [Int]] = [:] + for (index, node) in new.enumerated() { + newByLabel[node.label, default: []].append(index) + } + + var result = [Int?](repeating: nil, count: new.count) + for (label, newIndices) in newByLabel { + var available = oldByLabel[label] ?? [] + for newIndex in newIndices { + if let slot = available.firstIndex(where: { old[$0].hash == new[newIndex].hash }) { + result[newIndex] = available.remove(at: slot) + } + } + for newIndex in newIndices where result[newIndex] == nil { + if available.isEmpty { break } + result[newIndex] = available.removeFirst() + } + } + return result + } + + private func diffNode( + _ old: IRNode, _ new: IRNode, + path: String, + into changes: inout [IRChange] + ) { + // The hash covers the whole subtree's stable content — equal means + // nothing below this point changed. + guard old.hash != new.hash else { return } + + let propertyDiffs = diffProperties(old.properties, new.properties) + if !propertyDiffs.isEmpty { + changes.append(.nodeModified(path: path, properties: propertyDiffs)) + } + diffForest(old.children, new.children, parentPath: path, into: &changes) + } + + private func diffProperties( + _ old: [String: IRProperty], _ new: [String: IRProperty] + ) -> [PropertyDiff] { + var diffs: [PropertyDiff] = [] + for key in Set(old.keys).union(new.keys).sorted() { + switch (old[key], new[key]) { + case let (nil, newProp?): + diffs.append(PropertyDiff( + key: key, + kind: .added(new: newProp.value.scalarText, winner: newProp.winner))) + case let (oldProp?, nil): + diffs.append(PropertyDiff( + key: key, kind: .removed(old: oldProp.value.scalarText))) + case let (oldProp?, newProp?) where oldProp.value != newProp.value: + diffs.append(PropertyDiff(key: key, kind: .changed( + old: oldProp.value.scalarText, new: newProp.value.scalarText, + oldWinner: oldProp.winner, newWinner: newProp.winner))) + default: + break // unchanged + } + } + return diffs + } + + private func join(_ parent: String, _ label: String) -> String { + parent.isEmpty ? label : "\(parent) > \(label)" + } +} + +// MARK: - Rendering + +extension IRDiffer { + /// Human-readable diff, `git diff`-flavored. + public static func renderText(_ changes: [IRChange]) -> String { + guard !changes.isEmpty else { return "documents identical\n" } + var out = "" + var affected = 0 + for change in changes { + affected += 1 + switch change { + case .nodeAdded(let path): + out += "+ \(path) (added)\n" + case .nodeRemoved(let path): + out += "- \(path) (removed)\n" + case .childrenReordered(let path): + out += "± \(path) (children reordered)\n" + case let .nodeModified(path, properties): + out += "~ \(path)\n" + for diff in properties { + switch diff.kind { + case let .added(new, winner): + out += " + \(diff.key): \(new)\n" + out += " from: \(winner)\n" + case let .removed(old): + out += " - \(diff.key): \(old)\n" + case let .changed(old, new, oldWinner, newWinner): + out += " ~ \(diff.key): \(old) → \(new)\n" + out += " was: \(oldWinner)\n" + out += " now: \(newWinner)\n" + } + } + } + } + out += "\n\(affected) affected node(s)\n" + return out + } + + /// Machine-readable diff — the invalidation feed for incremental + /// regeneration (`hypercode.diff/v1`). + public static func renderJSON(_ changes: [IRChange]) -> String { + let items: [Emitter.IR] = changes.map { change in + switch change { + case .nodeAdded(let path): + return .object([("kind", .string("added")), ("node", .string(path))]) + case .nodeRemoved(let path): + return .object([("kind", .string("removed")), ("node", .string(path))]) + case .childrenReordered(let path): + return .object([("kind", .string("reordered")), ("node", .string(path))]) + case let .nodeModified(path, properties): + let props: [Emitter.IR] = properties.map { diff in + switch diff.kind { + case let .added(new, winner): + return .object([ + ("key", .string(diff.key)), ("kind", .string("added")), + ("new", .string(new)), ("winner", .string(winner)), + ]) + case let .removed(old): + return .object([ + ("key", .string(diff.key)), ("kind", .string("removed")), + ("old", .string(old)), + ]) + case let .changed(old, new, oldWinner, newWinner): + return .object([ + ("key", .string(diff.key)), ("kind", .string("changed")), + ("old", .string(old)), ("new", .string(new)), + ("oldWinner", .string(oldWinner)), ("newWinner", .string(newWinner)), + ]) + } + } + return .object([ + ("kind", .string("modified")), ("node", .string(path)), + ("properties", .array(props)), + ]) + } + } + let root = Emitter.IR.object([ + ("version", .string("hypercode.diff/v1")), + ("changes", .array(items)), + ]) + return Emitter.json(root, indent: 0) + "\n" + } +} diff --git a/Sources/Hypercode/Diff/IRDocument.swift b/Sources/Hypercode/Diff/IRDocument.swift new file mode 100644 index 0000000..2957775 --- /dev/null +++ b/Sources/Hypercode/Diff/IRDocument.swift @@ -0,0 +1,141 @@ +/// An error raised while interpreting a parsed JSON document as IR v2. +public struct IRError: Error, Equatable, CustomStringConvertible, Sendable { + public let message: String + + public var description: String { "ir error: \(message)" } +} + +/// One resolved property as read back from an IR v2 document: the typed value +/// and a human-readable description of the rule that won it. +public struct IRProperty: Equatable, Sendable { + public let value: JSONValue + /// "selector @ file:line" — where the winning value was written. + public let winner: String + + public init(value: JSONValue, winner: String) { + self.value = value + self.winner = winner + } +} + +/// One node of an IR v2 document. +public struct IRNode: Equatable, Sendable { + public let type: String + public let className: String? + public let id: String? + public let hash: String + public let properties: [String: IRProperty] + public let children: [IRNode] + + public init( + type: String, className: String? = nil, id: String? = nil, + hash: String, properties: [String: IRProperty] = [:], + children: [IRNode] = [] + ) { + self.type = type + self.className = className + self.id = id + self.hash = hash + self.properties = properties + self.children = children + } + + /// Selector-style label: `type[.class][#id]` — the node's identity for + /// matching across document versions. + public var label: String { + var text = type + if let c = className { text += ".\(c)" } + if let i = id { text += "#\(i)" } + return text + } +} + +/// A `hypercode.ir/v2` document read back from JSON, carrying just what the +/// differ needs: hashes, typed values, winner provenance, and the tree shape. +public struct IRDocument: Equatable, Sendable { + public let documentHash: String + public let context: [String: String] + public let nodes: [IRNode] + + public init(documentHash: String, context: [String: String] = [:], nodes: [IRNode]) { + self.documentHash = documentHash + self.context = context + self.nodes = nodes + } + + /// Interprets parsed JSON as an IR v2 document. Rejects other versions — + /// v1 has no hashes, so it cannot be diffed structurally. + public init(json: JSONValue) throws { + guard case let .object(root) = json else { + throw IRError(message: "expected a JSON object at the top level") + } + guard case let .string(version)? = root["version"] else { + throw IRError(message: "missing 'version'") + } + guard version == "hypercode.ir/v2" else { + throw IRError(message: "diff requires hypercode.ir/v2 (got '\(version)'); re-emit with --ir-version 2") + } + guard case let .string(documentHash)? = root["documentHash"] else { + throw IRError(message: "missing 'documentHash'") + } + var context: [String: String] = [:] + if case let .object(ctx)? = root["context"] { + for (key, value) in ctx { + if case let .string(s) = value { context[key] = s } + } + } + guard case let .array(nodes)? = root["nodes"] else { + throw IRError(message: "missing 'nodes'") + } + self.documentHash = documentHash + self.context = context + self.nodes = try nodes.map { try IRDocument.node($0) } + } + + private static func node(_ json: JSONValue) throws -> IRNode { + guard case let .object(fields) = json else { + throw IRError(message: "node is not an object") + } + guard case let .string(type)? = fields["type"] else { + throw IRError(message: "node missing 'type'") + } + guard case let .string(hash)? = fields["hash"] else { + throw IRError(message: "node '\(type)' missing 'hash'") + } + var className: String? + if case let .string(c)? = fields["class"] { className = c } + var id: String? + if case let .string(i)? = fields["id"] { id = i } + + var properties: [String: IRProperty] = [:] + if case let .object(props)? = fields["properties"] { + for (key, entry) in props { + guard case let .object(prop) = entry, let value = prop["value"] else { + throw IRError(message: "property '\(key)' missing 'value'") + } + properties[key] = IRProperty(value: value, winner: winner(prop["winner"])) + } + } + + var children: [IRNode] = [] + if case let .array(kids)? = fields["children"] { + children = try kids.map { try node($0) } + } + return IRNode( + type: type, className: className, id: id, + hash: hash, properties: properties, children: children + ) + } + + private static func winner(_ json: JSONValue?) -> String { + guard case let .object(w)? = json, case let .string(selector)? = w["selector"] else { + return "?" + } + var line = "?" + if case let .number(n)? = w["line"] { line = n } + if case let .string(file)? = w["file"] { + return "\(selector) @ \(file):\(line)" + } + return "\(selector) @ line \(line)" + } +} diff --git a/Sources/Hypercode/Diff/JSONValue.swift b/Sources/Hypercode/Diff/JSONValue.swift new file mode 100644 index 0000000..2815768 --- /dev/null +++ b/Sources/Hypercode/Diff/JSONValue.swift @@ -0,0 +1,233 @@ +/// A parsed JSON value. Numbers keep their source lexeme so comparisons are +/// exact — `9007199254740993` never collapses into `9007199254740992` the way +/// it would through `Double`. +public enum JSONValue: Equatable, Sendable { + case string(String) + case number(String) // raw lexeme + case bool(Bool) + case null + case array([JSONValue]) + case object([String: JSONValue]) + + /// Scalar display text for diff output (objects/arrays render as markers). + public var scalarText: String { + switch self { + case .string(let s): return s + case .number(let n): return n + case .bool(let b): return b ? "true" : "false" + case .null: return "null" + case .array: return "[…]" + case .object: return "{…}" + } + } +} + +/// An error raised while parsing JSON text. +public struct JSONError: Error, Equatable, CustomStringConvertible, Sendable { + public let message: String + + public var description: String { "json error: \(message)" } +} + +/// A minimal, hand-rolled JSON parser — Foundation-free, like the rest of the +/// core. Primary input is our own emitter's canonical output, but it accepts +/// any valid JSON document. +public enum JSONParser { + public static func parse(_ text: String) throws -> JSONValue { + var scanner = Scanner(Array(text)) + let value = try scanner.parseValue() + scanner.skipWhitespace() + guard scanner.isAtEnd else { + throw JSONError(message: "trailing characters after JSON value") + } + return value + } + + private struct Scanner { + let chars: [Character] + var index = 0 + + init(_ chars: [Character]) { self.chars = chars } + + var isAtEnd: Bool { index >= chars.count } + + mutating func skipWhitespace() { + while index < chars.count, " \t\n\r".contains(chars[index]) { index += 1 } + } + + mutating func parseValue() throws -> JSONValue { + skipWhitespace() + guard index < chars.count else { throw JSONError(message: "unexpected end of input") } + switch chars[index] { + case "{": return try parseObject() + case "[": return try parseArray() + case "\"": return .string(try parseString()) + case "t": try expect("true"); return .bool(true) + case "f": try expect("false"); return .bool(false) + case "n": try expect("null"); return .null + default: return .number(try parseNumberLexeme()) + } + } + + mutating func parseObject() throws -> JSONValue { + index += 1 // { + var pairs: [String: JSONValue] = [:] + skipWhitespace() + if index < chars.count, chars[index] == "}" { index += 1; return .object(pairs) } + while true { + skipWhitespace() + guard index < chars.count, chars[index] == "\"" else { + throw JSONError(message: "expected object key") + } + let key = try parseString() + skipWhitespace() + guard index < chars.count, chars[index] == ":" else { + throw JSONError(message: "expected ':' after object key '\(key)'") + } + index += 1 + pairs[key] = try parseValue() + skipWhitespace() + guard index < chars.count else { throw JSONError(message: "unterminated object") } + if chars[index] == "," { index += 1; continue } + if chars[index] == "}" { index += 1; return .object(pairs) } + throw JSONError(message: "expected ',' or '}' in object") + } + } + + mutating func parseArray() throws -> JSONValue { + index += 1 // [ + var items: [JSONValue] = [] + skipWhitespace() + if index < chars.count, chars[index] == "]" { index += 1; return .array(items) } + while true { + items.append(try parseValue()) + skipWhitespace() + guard index < chars.count else { throw JSONError(message: "unterminated array") } + if chars[index] == "," { index += 1; continue } + if chars[index] == "]" { index += 1; return .array(items) } + throw JSONError(message: "expected ',' or ']' in array") + } + } + + mutating func parseString() throws -> String { + index += 1 // opening quote + var out = "" + while index < chars.count { + let c = chars[index] + if c == "\"" { index += 1; return out } + if c == "\\" { + index += 1 + guard index < chars.count else { break } + switch chars[index] { + case "\"": out.append("\"") + case "\\": out.append("\\") + case "/": out.append("/") + case "b": out.append("\u{08}") + case "f": out.append("\u{0C}") + case "n": out.append("\n") + case "r": out.append("\r") + case "t": out.append("\t") + case "u": out.append(try parseUnicodeEscape()) + default: throw JSONError(message: "invalid escape '\\\(chars[index])'") + } + index += 1 + } else { + // RFC 8259 §7: U+0000…U+001F must be escaped — a raw + // newline inside a string is malformed JSON, not data. + // (\r\n is a single Character, hence the scalar scan.) + if c.unicodeScalars.contains(where: { $0.value < 0x20 }) { + throw JSONError(message: "unescaped control character in string") + } + out.append(c) + index += 1 + } + } + throw JSONError(message: "unterminated string") + } + + /// Parses the 4 hex digits after `\u`, combining surrogate pairs. + mutating func parseUnicodeEscape() throws -> Character { + func hex4() throws -> UInt32 { + guard index + 4 < chars.count else { + throw JSONError(message: "truncated \\u escape") + } + var value: UInt32 = 0 + for offset in 1...4 { + guard let digit = chars[index + offset].hexDigitValue else { + throw JSONError(message: "invalid \\u escape") + } + value = value << 4 | UInt32(digit) + } + index += 4 + return value + } + let unit = try hex4() + if (0xD800...0xDBFF).contains(unit) { + guard index + 2 < chars.count, + chars[index + 1] == "\\", chars[index + 2] == "u" else { + throw JSONError(message: "unpaired surrogate in \\u escape") + } + index += 2 + let low = try hex4() + guard (0xDC00...0xDFFF).contains(low) else { + throw JSONError(message: "invalid low surrogate in \\u escape") + } + let scalar = 0x10000 + ((unit - 0xD800) << 10) + (low - 0xDC00) + return Character(Unicode.Scalar(scalar)!) + } + guard let scalar = Unicode.Scalar(unit) else { + throw JSONError(message: "invalid \\u escape") + } + return Character(scalar) + } + + /// RFC 8259 grammar: `-? (0 | [1-9][0-9]*) (\.[0-9]+)? ([eE][+-]?[0-9]+)?`. + /// Strict on purpose: `diff` reads arbitrary files from disk, and a + /// lexeme that round-trips here must stay valid JSON when re-emitted. + mutating func parseNumberLexeme() throws -> String { + let start = index + if index < chars.count, chars[index] == "-" { index += 1 } + + guard index < chars.count, isDigit(chars[index]) else { + throw JSONError(message: "invalid number") + } + if chars[index] == "0" { index += 1 } else { skipDigits() } + if index < chars.count, isDigit(chars[index]) { + throw JSONError(message: "invalid number: leading zero") + } + + if index < chars.count, chars[index] == "." { + index += 1 + guard index < chars.count, isDigit(chars[index]) else { + throw JSONError(message: "invalid number: fraction needs digits") + } + skipDigits() + } + if index < chars.count, chars[index] == "e" || chars[index] == "E" { + index += 1 + if index < chars.count, chars[index] == "+" || chars[index] == "-" { index += 1 } + guard index < chars.count, isDigit(chars[index]) else { + throw JSONError(message: "invalid number: exponent needs digits") + } + skipDigits() + } + return String(chars[start.. Bool { c >= "0" && c <= "9" } + + private mutating func skipDigits() { + while index < chars.count, isDigit(chars[index]) { index += 1 } + } + + mutating func expect(_ literal: String) throws { + for expected in literal { + guard index < chars.count, chars[index] == expected else { + throw JSONError(message: "invalid literal (expected '\(literal)')") + } + index += 1 + } + } + } +} diff --git a/Sources/Hypercode/Documentation.docc/Hypercode.md b/Sources/Hypercode/Documentation.docc/Hypercode.md index c56791f..92769ff 100644 --- a/Sources/Hypercode/Documentation.docc/Hypercode.md +++ b/Sources/Hypercode/Documentation.docc/Hypercode.md @@ -10,13 +10,17 @@ Hypercode separates a program's **structure** (`.hc`) from its **context** to different outputs by swapping the context, without ever touching the `.hc`. ``` -.hc + .hcs ──[resolve]──▶ resolved graph ──[emit]──▶ canonical IR (hypercode.ir/v1) +.hc + .hcs ──[resolve]──▶ resolved graph ──[validate contracts]──▶ canonical IR (hypercode.ir/v2) + │ + explain (cascade trace) · diff (affected nodes) ``` The library provides the lexer and parser, the grammar expressed as SpecificationCore specifications, the cascade resolver (selectors, specificity, -context), plus validation and emit. The `hypercode` CLI exposes `parse`, -`validate`, `resolve`, and `emit`. +context), contract validation (monotonicity and value-level checks), the +cascade trace (``Explainer``), the typed/hashed IR v2 emitter, and the +semantic IR diff (``IRDiffer``). The `hypercode` CLI exposes `parse`, +`validate`, `resolve`, `emit`, `explain`, `diff`, and `lsp`. See also: the [resolution semantics](https://github.com/0al-spec/Hypercode/blob/main/EBNF/Hypercode_Resolution.md) and the [architecture overview](https://github.com/0al-spec/Hypercode/blob/main/DOCS/Architecture.md). @@ -60,12 +64,21 @@ $ hypercode resolve app.hc --hcs app.hcs --ctx env=production # …level is now "info [Logger]" ``` -Or emit the canonical IR (`hypercode.ir/v1`) for a downstream consumer: +Or emit the canonical IR (`hypercode.ir/v2` — typed values, per-node hashes, +cascade trace, contracts) for a downstream consumer: ```bash hypercode emit app.hc --hcs app.hcs --ctx env=production --format json ``` +Ask the cascade *why* a value won, or diff two resolved documents to get the +affected-node set for incremental regeneration: + +```bash +hypercode explain app.hc --hcs app.hcs --ctx env=production Logger level +hypercode diff old.ir.json new.ir.json +``` + ## Topics ### Parsing @@ -92,14 +105,41 @@ hypercode emit app.hc --hcs app.hcs --ctx env=production --format json - ``Provenance`` - ``NodeContext`` +### Contracts + +- ``ContractType`` +- ``PropertyContract`` +- ``SelectorContract`` +- ``ContractValidator`` +- ``ContractValueValidator`` + +### Explain + +- ``Explainer`` +- ``NodeTrace`` +- ``PropertyTrace`` +- ``Match`` + ### Emit & validation - ``Emitter`` - ``EmitFormat`` +- ``EmitVersion`` - ``Validator`` - ``Diagnostic`` - ``Severity`` +### Semantic diff + +- ``IRDiffer`` +- ``IRChange`` +- ``PropertyDiff`` +- ``IRDocument`` +- ``IRNode`` +- ``IRProperty`` +- ``JSONParser`` +- ``JSONValue`` + ### Grammar specifications - ``IdentifierSpec`` diff --git a/Sources/HypercodeCLI/main.swift b/Sources/HypercodeCLI/main.swift index fcac06d..789954e 100644 --- a/Sources/HypercodeCLI/main.swift +++ b/Sources/HypercodeCLI/main.swift @@ -8,6 +8,7 @@ usage: 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 diff [--format text|json] # exit 1 when documents differ hypercode lsp # language server (LSP over stdio) global: [--diagnostics text|json] @@ -263,6 +264,59 @@ func runExplain(_ args: [String]) throws { } } +func runDiff(_ args: [String]) throws { + var paths: [String] = [] + var format = "text" + + var index = 0 + while index < args.count { + switch args[index] { + case "--format": + index += 1 + guard index < args.count, ["text", "json"].contains(args[index]) else { + fail("error: --format expects text|json") + } + format = args[index] + default: + paths.append(args[index]) + } + index += 1 + } + guard paths.count == 2 else { + fail("error: diff needs exactly two IR files: \n\n\(usage)", code: 64) + } + + // GNU diff convention: 0 identical, 1 differ, 2 trouble — an unreadable + // input must not be mistaken for "documents differ" by a CI gate. + func document(_ path: String) -> IRDocument { + do { + return try IRDocument(json: JSONParser.parse(readSource(path))) + } catch { + fail("error: \(path): \(error)", code: 2) + } + } + let old = document(paths[0]) + let new = document(paths[1]) + + let changes = IRDiffer().diff(old: old, new: new) + switch format { + case "json": print(IRDiffer.renderJSON(changes), terminator: "") + default: + // diff compares resolved content only — the same graph produced under + // different contexts is "identical". Say so instead of leaving it implicit. + if old.context != new.context { + func show(_ ctx: [String: String]) -> String { + ctx.isEmpty + ? "(none)" + : ctx.keys.sorted().map { "\($0)=\(ctx[$0]!)" }.joined(separator: " ") + } + print("note: contexts differ (old: \(show(old.context)); new: \(show(new.context))) — diff compares resolved content only") + } + print(IRDiffer.renderText(changes), terminator: "") + } + if !changes.isEmpty { exit(1) } +} + // Pull the global `--diagnostics ` flag out of the argument list. let arguments: [String] = { let raw = Array(CommandLine.arguments.dropFirst()) @@ -299,6 +353,8 @@ do { try runEmit(Array(arguments.dropFirst())) case "explain": try runExplain(Array(arguments.dropFirst())) + case "diff": + try runDiff(Array(arguments.dropFirst())) case "lsp": LSPServer().run() case "parse": diff --git a/Tests/HypercodeTests/IRDiffTests.swift b/Tests/HypercodeTests/IRDiffTests.swift new file mode 100644 index 0000000..3cacefa --- /dev/null +++ b/Tests/HypercodeTests/IRDiffTests.swift @@ -0,0 +1,223 @@ +import XCTest +@testable import Hypercode + +/// HC-113 — `hypercode diff` over IR v2 documents. +/// End-to-end: real sheets are resolved, emitted, parsed back and diffed. +final class IRDiffTests: XCTestCase { + private func document( + hc: String, hcs: String, context: ResolutionContext = [:] + ) throws -> IRDocument { + let commands = try Parser(source: hc).parse() + let sheet = try CascadeSheetReader().read(hcs) + let resolved = Resolver(sheet: sheet, context: context).resolve(commands) + let json = Emitter().emit( + resolved, version: .v2, context: context, + commands: commands, contracts: sheet.contracts, as: .json + ) + return try IRDocument(json: JSONParser.parse(json)) + } + + private let hc = "App\n Service\n Listen\n Cache\n" + private let hcs = "Service:\n timeout: 30\nListen:\n port: 5000\nCache:\n size: 100\n" + + // MARK: - Diff semantics + + func testSelfDiffIsEmpty() throws { + let doc = try document(hc: hc, hcs: hcs) + XCTAssertEqual(IRDiffer().diff(old: doc, new: doc), []) + } + + func testValueChangeReportedWithBothWinners() throws { + let old = try document(hc: hc, hcs: hcs) + let new = try document(hc: hc, hcs: hcs.replacingOccurrences(of: "port: 5000", with: "port: 9090")) + let changes = IRDiffer().diff(old: old, new: new) + + XCTAssertEqual(changes.count, 1, "only the Listen node changed: \(changes)") + guard case let .nodeModified(path, properties) = changes[0] else { + return XCTFail("expected nodeModified, got \(changes[0])") + } + XCTAssertEqual(path, "App > Service > Listen") + XCTAssertEqual(properties.count, 1) + XCTAssertEqual(properties[0].key, "port") + guard case let .changed(oldValue, newValue, oldWinner, newWinner) = properties[0].kind else { + return XCTFail("expected changed kind") + } + XCTAssertEqual(oldValue, "5000") + XCTAssertEqual(newValue, "9090") + XCTAssertTrue(oldWinner.contains("Listen")) + XCTAssertTrue(newWinner.contains("Listen")) + } + + func testUnchangedSiblingSubtreeNotReported() throws { + // Cache is untouched by the edit — hash short-circuit must skip it. + let old = try document(hc: hc, hcs: hcs) + let new = try document(hc: hc, hcs: hcs.replacingOccurrences(of: "timeout: 30", with: "timeout: 60")) + let changes = IRDiffer().diff(old: old, new: new) + XCTAssertFalse(changes.contains { change in + if case let .nodeModified(path, _) = change { return path.contains("Cache") } + if case let .nodeAdded(path) = change { return path.contains("Cache") } + if case let .nodeRemoved(path) = change { return path.contains("Cache") } + return false + }) + } + + func testNodeAddedAndRemoved() throws { + let old = try document(hc: "App\n Service\n LegacyQueue\n", hcs: "Service:\n x: 1\n") + let new = try document(hc: "App\n Service\n Cache\n", hcs: "Service:\n x: 1\n") + let changes = IRDiffer().diff(old: old, new: new) + XCTAssertTrue(changes.contains(.nodeAdded(path: "App > Cache"))) + XCTAssertTrue(changes.contains(.nodeRemoved(path: "App > LegacyQueue"))) + } + + func testPropertyAddedAndRemoved() throws { + let old = try document(hc: "App\n Service\n", hcs: "Service:\n timeout: 30\n legacy: x\n") + let new = try document(hc: "App\n Service\n", hcs: "Service:\n timeout: 30\n retries: 3\n") + let changes = IRDiffer().diff(old: old, new: new) + guard case let .nodeModified(_, properties)? = changes.first, changes.count == 1 else { + return XCTFail("expected a single nodeModified, got \(changes)") + } + XCTAssertEqual(properties.map(\.key), ["legacy", "retries"]) + guard case .removed = properties[0].kind, case .added = properties[1].kind else { + return XCTFail("expected removed + added, got \(properties)") + } + } + + func testProvenanceOnlyChangeIsInvisible() throws { + // A later duplicate rule wins with the same value — provenance changes, + // the stable content does not, so hashes (and the diff) are unchanged. + let old = try document(hc: "App\n Service\n", hcs: "Service:\n timeout: 30\n") + let new = try document(hc: "App\n Service\n", hcs: "Service:\n timeout: 30\nService:\n timeout: 30\n") + XCTAssertEqual(old.documentHash, new.documentHash) + XCTAssertEqual(IRDiffer().diff(old: old, new: new), []) + } + + func testReorderedChildrenReported() throws { + let old = try document(hc: "App\n Service\n Cache\n", hcs: "Service:\n x: 1\n") + let new = try document(hc: "App\n Cache\n Service\n", hcs: "Service:\n x: 1\n") + let changes = IRDiffer().diff(old: old, new: new) + XCTAssertEqual(changes, [.childrenReordered(path: "App")]) + } + + func testDuplicateSiblingSwapIsReorderOnly() throws { + // Two same-label siblings with different content swap positions — + // identity follows the content hash, not the source position, so this + // is a reorder, not two spurious modifications. + let sheets = "TaskA:\n x: 1\nTaskB:\n x: 2\n" + let old = try document(hc: "App\n Worker\n TaskA\n Worker\n TaskB\n", hcs: sheets) + let new = try document(hc: "App\n Worker\n TaskB\n Worker\n TaskA\n", hcs: sheets) + XCTAssertEqual(IRDiffer().diff(old: old, new: new), + [.childrenReordered(path: "App")]) + } + + func testDuplicateSiblingModifiedInPlace() throws { + // No swap: the changed duplicate pairs positionally and reports its + // own modification; the untouched one stays silent. + let old = try document(hc: "App\n Worker\n TaskA\n Worker\n TaskB\n", + hcs: "TaskA:\n x: 1\nTaskB:\n x: 2\n") + let new = try document(hc: "App\n Worker\n TaskA\n Worker\n TaskB\n", + hcs: "TaskA:\n x: 1\nTaskB:\n x: 3\n") + let changes = IRDiffer().diff(old: old, new: new) + XCTAssertEqual(changes.count, 1, "exactly one modification: \(changes)") + guard case let .nodeModified(path, properties)? = changes.first else { + return XCTFail("expected nodeModified, got \(changes)") + } + XCTAssertEqual(path, "App > Worker > TaskB") + XCTAssertEqual(properties.map(\.key), ["x"]) + } + + func testNodeIdentityByClassAndId() throws { + // Same type, different id → not the same node. + let old = try document(hc: "App\n Database#primary\n", hcs: "") + let new = try document(hc: "App\n Database#replica\n", hcs: "") + let changes = IRDiffer().diff(old: old, new: new) + XCTAssertTrue(changes.contains(.nodeAdded(path: "App > Database#replica"))) + XCTAssertTrue(changes.contains(.nodeRemoved(path: "App > Database#primary"))) + } + + func testRejectsV1Documents() throws { + let commands = try Parser(source: "App\n").parse() + let resolved = Resolver(sheet: CascadeSheet(rules: [])).resolve(commands) + let v1 = Emitter().emit(resolved, as: .json) + XCTAssertThrowsError(try IRDocument(json: JSONParser.parse(v1))) { error in + XCTAssertTrue("\(error)".contains("hypercode.ir/v2")) + } + } + + // MARK: - JSON parser + + func testParserRoundTripsEmitterOutput() throws { + let doc = try document(hc: hc, hcs: hcs, context: ["env": "test"]) + XCTAssertEqual(doc.context, ["env": "test"]) + XCTAssertEqual(doc.nodes.count, 1) + XCTAssertEqual(doc.nodes[0].children.map(\.type), ["Service", "Cache"]) + } + + func testParserKeepsNumberLexemes() throws { + guard case let .object(fields) = try JSONParser.parse( + #"{"big": 9007199254740993, "neg": -1.5e3}"#) else { + return XCTFail("expected object") + } + XCTAssertEqual(fields["big"], .number("9007199254740993")) + XCTAssertEqual(fields["neg"], .number("-1.5e3")) + } + + func testParserHandlesEscapesAndNesting() throws { + let parsed = try JSONParser.parse( + #"{"s": "a\"b\\c\nd A 😀", "a": [1, true, null, {"k": []}]}"#) + guard case let .object(fields) = parsed else { return XCTFail() } + XCTAssertEqual(fields["s"], .string("a\"b\\c\nd A 😀")) + guard case let .array(items)? = fields["a"] else { return XCTFail() } + XCTAssertEqual(items[0], .number("1")) + XCTAssertEqual(items[1], .bool(true)) + XCTAssertEqual(items[2], .null) + } + + func testParserRejectsMalformedInput() { + for bad in ["{", "[1,", "\"unterminated", "{\"k\" 1}", "12 34", ""] { + XCTAssertThrowsError(try JSONParser.parse(bad), "should reject: \(bad)") + } + } + + func testParserEnforcesRFC8259Numbers() { + // diff reads arbitrary files from disk — the number grammar is strict. + for bad in ["1.", "1e", ".5", "-.5", "01", "-01", "1.2e+", "-", "+1", "0x1"] { + XCTAssertThrowsError(try JSONParser.parse(bad), "should reject: \(bad)") + } + for good in ["0", "-0", "0.5", "-0.5", "10", "1e10", "1E+10", "-1.5e-3"] { + XCTAssertEqual(try? JSONParser.parse(good), .number(good), "should accept: \(good)") + } + } + + func testParserRejectsUnescapedControlCharacters() { + // RFC 8259 §7: raw U+0000…U+001F inside a string is malformed. + for bad in ["\"a\nb\"", "\"a\tb\"", "\"a\u{01}b\"", "\"a\r\nb\""] { + XCTAssertThrowsError(try JSONParser.parse(bad), "should reject: \(bad.debugDescription)") + } + // The escaped spellings are the valid way to carry the same data. + XCTAssertEqual(try? JSONParser.parse(#""a\nb\tc""#), .string("a\nb\tc")) + } + + // MARK: - Rendering + + func testTextRendering() throws { + let old = try document(hc: hc, hcs: hcs) + let new = try document(hc: hc, hcs: hcs.replacingOccurrences(of: "port: 5000", with: "port: 9090")) + let text = IRDiffer.renderText(IRDiffer().diff(old: old, new: new)) + XCTAssertTrue(text.contains("~ App > Service > Listen")) + XCTAssertTrue(text.contains("port: 5000 → 9090")) + XCTAssertTrue(text.contains("1 affected node(s)")) + XCTAssertEqual(IRDiffer.renderText([]), "documents identical\n") + } + + func testJSONRenderingIsValidAndVersioned() throws { + let old = try document(hc: hc, hcs: hcs) + let new = try document(hc: hc, hcs: hcs.replacingOccurrences(of: "port: 5000", with: "port: 9090")) + let json = IRDiffer.renderJSON(IRDiffer().diff(old: old, new: new)) + guard case let .object(fields) = try JSONParser.parse(json) else { + return XCTFail("diff JSON must parse") + } + XCTAssertEqual(fields["version"], .string("hypercode.diff/v1")) + guard case let .array(changes)? = fields["changes"] else { return XCTFail() } + XCTAssertEqual(changes.count, 1) + } +} diff --git a/workplan.md b/workplan.md index c7d2078..3ff7f9f 100644 --- a/workplan.md +++ b/workplan.md @@ -96,7 +96,7 @@ P0 — what makes the novelty claim defensible: - ⬜ HC-112 `hypercode.ir/v2` (breaking) — typed values (v1 is strings-only), `file` alongside `line`, specificity + source order, losing rules, contract results, per-node and per-document hashes, context echo, resolver name/version — `Schema/`; in review: [#19](https://github.com/0al-spec/Hypercode/pull/19) + [#20](https://github.com/0al-spec/Hypercode/pull/20) P1 — built on v2: -- ⬜ HC-113 `hypercode diff ` — affected nodes/properties with reasons (which rule changed), node-hash based; the invalidation signal for incremental (re)generation +- [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)