Skip to content

Commit 73ab269

Browse files
authored
feat(core): add stable semantic node ids to inspectScreen (#50)
* feat(core): add stable semantic node ids to inspectScreen * feat(core): harden semantic ID slugging with punctuation removal, empty-slug fallback, deterministic duplicates
1 parent 98ed436 commit 73ab269

11 files changed

Lines changed: 326 additions & 12 deletions

File tree

.changeset/silent-goats-dance.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@intent-framework/core": patch
3+
---
4+
5+
Add stable semantic node IDs to `inspectScreen` output so diagnostics and graph inspection can reference deterministic semantic nodes.

docs/MVP-Checkpoint.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ It now has:
3434
- Resources can be runtime-scoped and reloaded.
3535
- Router can inject typed navigation and route context.
3636
- Tests can assert semantic behavior.
37-
- `inspectScreen()` can surface graph diagnostics.
37+
- `inspectScreen()` can surface graph diagnostics with deterministic semantic node IDs.
3838
- A small reviewer-friendly demo can be run locally.
3939
- CI can enforce clean-dist validation.
4040

docs/Specification.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,41 @@ type IntentGraph = {
841841
842842
The graph is what enables semantic reactivity, generated runtime behavior, target-specific materialization, DevTools, testing, documentation, privacy redaction, accessibility warnings, OpenAPI generation, and AI inspection.
843843
844+
### Graph Node Identifiers
845+
846+
Every semantic node in `inspectScreen()` output carries a stable, deterministic `semanticId`:
847+
848+
```ts
849+
type InspectedNode = {
850+
id: string // internal format: "ask_email", "act_log_in"
851+
semanticId: string // stable format: "ask:email", "action:log-in"
852+
// ...
853+
}
854+
```
855+
856+
Semantic IDs are:
857+
858+
- deterministic for the same screen definition
859+
- unique within a single inspected screen
860+
- human-readable
861+
- stable across repeated `inspectScreen()` calls
862+
- stable even if another screen is created before this screen
863+
- independent of runtime instances and resource connection state
864+
865+
Example IDs by node type:
866+
867+
```
868+
screen:invite-memberscreen
869+
ask:emailask
870+
ask:email-2duplicate ask with suffixed ID
871+
action:send-inviteact
872+
resource:teamresource
873+
surface:mainsurface
874+
flow:primaryflow
875+
```
876+
877+
Diagnostics reference nodes through both `nodeId` (internal format) and `semanticNodeId` (stable format). These IDs are intended for inspection, diagnostics, tests, and future DevTools. They may change when the screen structure, labels, or names change.
878+
844879
## Reactivity Model
845880
846881
Intent UI should not use component rerendering as its main reactive unit.

packages/core/src/act.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { signal, createCondition, type Signal, isCondition, type Condition } from "./signal.js"
2-
import { registerActNode } from "./registry.js"
2+
import { registerActNode, getActs, nextSuffix } from "./registry.js"
33

44
export type NavigationService = (name: string, params?: Record<string, string>) => void
55

@@ -154,7 +154,11 @@ export class ActBuilder<TServices extends object = DefaultScreenServices> {
154154
private node: ActNode<TServices>
155155

156156
constructor(label: string) {
157-
const id = `act_${label.toLowerCase().replace(/\s+/g, "_")}`
157+
const baseId = `act_${label.toLowerCase().replace(/\s+/g, "_")}`
158+
const existing = getActs()
159+
const id = existing.has(baseId)
160+
? nextSuffix(baseId, (id) => existing.has(id))
161+
: baseId
158162
this.node = createActNode<TServices>(id, label, [], null, undefined, false)
159163
registerActNode(this.node)
160164
}

packages/core/src/ask.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { signal, type Signal, type Condition } from "./signal.js"
2-
import { registerAskNode } from "./registry.js"
2+
import { registerAskNode, getAsks, nextSuffix } from "./registry.js"
33

44
export type AskKind = "text" | "contact" | "secret" | "choice"
55

@@ -94,7 +94,11 @@ export class AskBuilder<T> {
9494
private node: AskNode<T>
9595

9696
constructor(label: string, stateRef: { value: T; onChange?: (fn: (value: T) => void) => () => void }) {
97-
const id = `ask_${label.toLowerCase().replace(/\s+/g, "_")}`
97+
const baseId = `ask_${label.toLowerCase().replace(/\s+/g, "_")}`
98+
const existing = getAsks()
99+
const id = existing.has(baseId)
100+
? nextSuffix(baseId, (id) => existing.has(id))
101+
: baseId
98102
const onChange = stateRef.onChange
99103
const subscribeToState = onChange
100104
? (fn: () => void) => onChange((_v: T) => fn())

packages/core/src/core.test.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1605,6 +1605,186 @@ describe("resource invalidation", () => {
16051605
})
16061606
})
16071607

1608+
describe("stable semantic node IDs", () => {
1609+
it("inspectScreen includes semanticId for all node types", () => {
1610+
const screenDef = screen("TestScreen", $ => {
1611+
const email = $.state.text("email")
1612+
const emailAsk = $.ask("Email", email)
1613+
const login = $.act("Log in")
1614+
$.resource("team", {
1615+
load: async () => "data",
1616+
autoLoad: false,
1617+
})
1618+
$.flow("login").startsWith(emailAsk).then(login)
1619+
$.surface("main").contains(emailAsk, login)
1620+
})
1621+
1622+
const inspected = inspectScreen(screenDef)
1623+
expect(inspected.semanticId).toBe("screen:test-screen")
1624+
expect(inspected.asks[0]?.semanticId).toBe("ask:email")
1625+
expect(inspected.acts[0]?.semanticId).toBe("action:log-in")
1626+
expect(inspected.flows[0]?.semanticId).toBe("flow:login")
1627+
expect(inspected.surfaces[0]?.semanticId).toBe("surface:main")
1628+
})
1629+
1630+
it("calling inspectScreen twice on the same screen returns the same IDs", () => {
1631+
const screenDef = screen("LoginScreen", $ => {
1632+
const email = $.state.text("email")
1633+
const emailAsk = $.ask("Email", email)
1634+
const login = $.act("Log in")
1635+
$.surface("main").contains(emailAsk, login)
1636+
})
1637+
1638+
const first = inspectScreen(screenDef)
1639+
const second = inspectScreen(screenDef)
1640+
1641+
expect(first.semanticId).toBe(second.semanticId)
1642+
expect(first.asks[0]?.semanticId).toBe(second.asks[0]?.semanticId)
1643+
expect(first.acts[0]?.semanticId).toBe(second.acts[0]?.semanticId)
1644+
})
1645+
1646+
it("creating an unrelated screen before the target screen does not change semantic IDs", () => {
1647+
screen("UnrelatedScreen", $ => {
1648+
$.ask("Name", $.state.text("name"))
1649+
$.act("Save")
1650+
$.surface("main").contains()
1651+
})
1652+
1653+
const screenDef = screen("LoginScreen", $ => {
1654+
const email = $.state.text("email")
1655+
const emailAsk = $.ask("Email", email)
1656+
const login = $.act("Log in")
1657+
$.surface("main").contains(emailAsk, login)
1658+
})
1659+
1660+
const inspected = inspectScreen(screenDef)
1661+
expect(inspected.semanticId).toBe("screen:login-screen")
1662+
expect(inspected.asks[0]?.semanticId).toBe("ask:email")
1663+
expect(inspected.acts[0]?.semanticId).toBe("action:log-in")
1664+
})
1665+
1666+
it("duplicate labels get deterministic suffixed semantic IDs", () => {
1667+
const screenDef = screen("Duplicates", $ => {
1668+
const name1 = $.state.text("name1")
1669+
const name2 = $.state.text("name2")
1670+
const ask1 = $.ask("Email", name1)
1671+
const ask2 = $.ask("Email", name2)
1672+
const act1 = $.act("Save")
1673+
const act2 = $.act("Save")
1674+
$.surface("main").contains(ask1, ask2, act1, act2)
1675+
})
1676+
1677+
const inspected = inspectScreen(screenDef)
1678+
expect(inspected.asks).toHaveLength(2)
1679+
expect(inspected.asks[0]?.semanticId).toBe("ask:email")
1680+
expect(inspected.asks[1]?.semanticId).toBe("ask:email-2")
1681+
expect(inspected.acts).toHaveLength(2)
1682+
expect(inspected.acts[0]?.semanticId).toBe("action:save")
1683+
expect(inspected.acts[1]?.semanticId).toBe("action:save-2")
1684+
})
1685+
1686+
it("diagnostics include semanticNodeId alongside nodeId", () => {
1687+
const screenDef = screen("DiagSemanticId", $ => {
1688+
const pwd = $.state.text("password")
1689+
const pwdAsk = $.ask("Password", pwd).asSecret()
1690+
$.surface("main").contains(pwdAsk)
1691+
})
1692+
1693+
const inspected = inspectScreen(screenDef)
1694+
const secretDiag = inspected.diagnostics.find(d => d.code === "secret-ask-not-private")
1695+
expect(secretDiag).toBeDefined()
1696+
expect(secretDiag?.nodeId).toBe("ask_password")
1697+
expect(secretDiag?.semanticNodeId).toBe("ask:password")
1698+
})
1699+
1700+
it("resource semanticId is stable and independent of runtime state", async () => {
1701+
const screenDef = screen("ResourceStable", $ => {
1702+
$.resource("team", {
1703+
load: async () => "data",
1704+
autoLoad: false,
1705+
})
1706+
})
1707+
1708+
const runtime = createScreenRuntime(screenDef)
1709+
await runtime.start()
1710+
const inspected = inspectScreen(screenDef, runtime.resources)
1711+
expect(inspected.resources[0]?.semanticId).toBe("resource:team")
1712+
expect(inspected.resources[0]?.id).toBe("resource_team")
1713+
})
1714+
1715+
it("punctuation is normalized in semantic IDs", () => {
1716+
const screenDef = screen("PunctuationTest", $ => {
1717+
const email = $.state.text("email")
1718+
const emailAsk = $.ask("Email!", email)
1719+
const login = $.act("Save!")
1720+
$.surface("main").contains(emailAsk, login)
1721+
})
1722+
1723+
const inspected = inspectScreen(screenDef)
1724+
expect(inspected.asks[0]?.semanticId).toBe("ask:email")
1725+
expect(inspected.acts[0]?.semanticId).toBe("action:save")
1726+
})
1727+
1728+
it("duplicate normalized labels suffix deterministically", () => {
1729+
const screenDef = screen("NormalizedDupes", $ => {
1730+
const s1 = $.state.text("s1")
1731+
const s2 = $.state.text("s2")
1732+
const ask1 = $.ask("Email", s1)
1733+
const ask2 = $.ask("Email!", s2)
1734+
const act1 = $.act("Save")
1735+
const act2 = $.act("Save!")
1736+
const act3 = $.act("Save!!")
1737+
$.surface("main").contains(ask1, ask2, act1, act2, act3)
1738+
})
1739+
1740+
const inspected = inspectScreen(screenDef)
1741+
expect(inspected.asks).toHaveLength(2)
1742+
expect(inspected.asks[0]?.semanticId).toBe("ask:email")
1743+
expect(inspected.asks[1]?.semanticId).toBe("ask:email-2")
1744+
expect(inspected.acts).toHaveLength(3)
1745+
expect(inspected.acts[0]?.semanticId).toBe("action:save")
1746+
expect(inspected.acts[1]?.semanticId).toBe("action:save-2")
1747+
expect(inspected.acts[2]?.semanticId).toBe("action:save-3")
1748+
})
1749+
1750+
it("symbol-only labels use kind-local order fallback", () => {
1751+
const screenDef = screen("SymbolFallback", $ => {
1752+
const s1 = $.state.text("s1")
1753+
const s2 = $.state.text("s2")
1754+
const act1 = $.act("!!!")
1755+
const act2 = $.act("???")
1756+
const ask1 = $.ask("***", s1)
1757+
const ask2 = $.ask("@@@", s2)
1758+
$.surface("main").contains(act1, act2, ask1, ask2)
1759+
})
1760+
1761+
const inspected = inspectScreen(screenDef)
1762+
expect(inspected.acts).toHaveLength(2)
1763+
expect(inspected.acts[0]?.semanticId).toBe("action:1")
1764+
expect(inspected.acts[1]?.semanticId).toBe("action:2")
1765+
expect(inspected.asks).toHaveLength(2)
1766+
expect(inspected.asks[0]?.semanticId).toBe("ask:1")
1767+
expect(inspected.asks[1]?.semanticId).toBe("ask:2")
1768+
})
1769+
1770+
it("diagnostics semanticNodeId uses new slugging logic", () => {
1771+
const screenDef = screen("DiagSlugged", $ => {
1772+
const pwd = $.state.text("password")
1773+
const pwdAsk = $.ask("Password!", pwd).asSecret()
1774+
const login = $.act("Log in!")
1775+
.primary()
1776+
.when(true)
1777+
$.surface("main").contains(pwdAsk, login)
1778+
})
1779+
1780+
const inspected = inspectScreen(screenDef)
1781+
const secretDiag = inspected.diagnostics.find(d => d.code === "secret-ask-not-private")
1782+
expect(secretDiag).toBeDefined()
1783+
expect(secretDiag?.nodeId).toBe("ask_password!")
1784+
expect(secretDiag?.semanticNodeId).toBe("ask:password")
1785+
})
1786+
})
1787+
16081788
// Type-only test helpers — these are verified during pnpm typecheck
16091789
type _TypeTestAppServices = {
16101790
analytics: { track(event: "login_clicked"): void }

packages/core/src/flow.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { registerFlowNode } from "./registry.js"
1+
import { registerFlowNode, getFlows, nextSuffix } from "./registry.js"
22
import type { AnyAskNode } from "./ask.js"
33
import type { ActNode } from "./act.js"
44
import { AskBuilder } from "./ask.js"
@@ -16,7 +16,11 @@ export class FlowBuilder {
1616
private node: FlowNode
1717

1818
constructor(name: string) {
19-
const id = `flow_${name}`
19+
const baseId = `flow_${name}`
20+
const existing = getFlows()
21+
const id = existing.has(baseId)
22+
? nextSuffix(baseId, (id) => existing.has(id))
23+
: baseId
2024
this.node = {
2125
id,
2226
name,

0 commit comments

Comments
 (0)