@pathmx/completion 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts ADDED
@@ -0,0 +1,251 @@
1
+ import {
2
+ defineAction,
3
+ type AppContext,
4
+ type PathMXDataSchema,
5
+ type Plugin,
6
+ type Source,
7
+ } from "@pathmx/core"
8
+ import type { CompletionOutcome } from "./model.ts"
9
+ import { COMPLETION_KEY } from "./model.ts"
10
+ import { createCompletionState } from "./state.ts"
11
+ import {
12
+ CompletionPropSchema,
13
+ completionTargets,
14
+ inspectCompletionTargets,
15
+ } from "./targets.ts"
16
+ import {
17
+ completionDeclarations,
18
+ inspectConfiguredCompletionDeclarations,
19
+ type CompletionContributor,
20
+ type CompletionEvidence,
21
+ } from "./contributors.ts"
22
+ import { completionReader } from "./evidence.ts"
23
+
24
+ export type {
25
+ CompletionFact,
26
+ CompletionOutcome,
27
+ CompletionReader,
28
+ CompletionRef,
29
+ CompletionStatus,
30
+ CompletionSummary,
31
+ } from "./model.ts"
32
+ export { completionTargets, type CompletionTarget } from "./targets.ts"
33
+ export type {
34
+ CompletionContributor,
35
+ CompletionEvidence,
36
+ } from "./contributors.ts"
37
+
38
+ type CompletionChange = CompletionOutcome | "pending"
39
+ type CompletionInput = Readonly<{
40
+ source: string
41
+ key: string
42
+ outcome: CompletionChange
43
+ }>
44
+ type CompletionOptions = Readonly<{
45
+ contributors?: readonly CompletionContributor[]
46
+ evidence?: readonly CompletionEvidence[]
47
+ }>
48
+
49
+ function issue(message: string, path?: string) {
50
+ return {
51
+ issues: [{ message, ...(path ? { path: [path] } : {}) }],
52
+ }
53
+ }
54
+
55
+ const CompletionInputJson = Object.freeze({
56
+ type: "object",
57
+ additionalProperties: false,
58
+ required: ["source", "key", "outcome"],
59
+ properties: {
60
+ source: { type: "string", pattern: "^/", maxLength: 2_048 },
61
+ key: { type: "string", pattern: COMPLETION_KEY.source, maxLength: 256 },
62
+ outcome: { enum: ["complete", "skipped", "pending"] },
63
+ },
64
+ })
65
+
66
+ const CompletionInput: PathMXDataSchema<unknown, CompletionInput> = {
67
+ "~standard": {
68
+ version: 1,
69
+ vendor: "pathmx",
70
+ validate(value) {
71
+ if (value == null || typeof value !== "object" || Array.isArray(value)) {
72
+ return issue("Expected an object")
73
+ }
74
+ const input = value as Record<string, unknown>
75
+ const source = input.source
76
+ if (
77
+ typeof source !== "string" ||
78
+ !source.startsWith("/") ||
79
+ source.length > 2_048
80
+ ) {
81
+ return issue("Expected a canonical Source id", "source")
82
+ }
83
+ const key = input.key
84
+ if (
85
+ typeof key !== "string" ||
86
+ !COMPLETION_KEY.test(key) ||
87
+ key.length > 256
88
+ ) {
89
+ return issue("Expected a provider-namespaced completion key", "key")
90
+ }
91
+ const outcome = input.outcome
92
+ if (
93
+ outcome !== "complete" &&
94
+ outcome !== "skipped" &&
95
+ outcome !== "pending"
96
+ ) {
97
+ return issue("Expected complete, skipped, or pending", "outcome")
98
+ }
99
+ return { value: { source, key, outcome } }
100
+ },
101
+ jsonSchema: {
102
+ input: () => CompletionInputJson,
103
+ output: () => CompletionInputJson,
104
+ },
105
+ },
106
+ }
107
+
108
+ function completionPlugin(
109
+ state: ReturnType<typeof createCompletionState>,
110
+ contributors: readonly CompletionContributor[],
111
+ evidence: readonly CompletionEvidence[],
112
+ ): Plugin {
113
+ const setCompletion = defineAction({
114
+ title: "Update completion",
115
+ description: "Set or clear one Actor-owned completion outcome.",
116
+ input: CompletionInput,
117
+ affordance: ({ target }) =>
118
+ completionDeclarations(target, contributors, evidence).length
119
+ ? { boundInput: { source: target.id } }
120
+ : false,
121
+ authorize: ({ target }) =>
122
+ target != null || "Completion requires a target Source.",
123
+ async execute(input, { actor, target, tx, yaml }) {
124
+ if (!target || input.source !== target.id) {
125
+ throw new Error("Completion Source does not match the target Source.")
126
+ }
127
+ const declaration = completionDeclarations(
128
+ target,
129
+ contributors,
130
+ evidence,
131
+ ).find(({ key }) => key === input.key)
132
+ if (!declaration) {
133
+ throw new Error(
134
+ `Completion target ${input.key} is not declared on ${target.id}.`,
135
+ )
136
+ }
137
+ if (input.outcome === "skipped" && !declaration.optional) {
138
+ throw new Error(
139
+ `Completion target ${input.key} is not optional and cannot be skipped.`,
140
+ )
141
+ }
142
+ return state.write(
143
+ tx,
144
+ actor,
145
+ { source: input.source, key: input.key },
146
+ input.outcome === "pending" ? undefined : input.outcome,
147
+ yaml,
148
+ )
149
+ },
150
+ })
151
+
152
+ return {
153
+ id: "completion",
154
+ name: "CompletionPlugin",
155
+ schema: {
156
+ props: { completion: CompletionPropSchema },
157
+ actions: { set: setCompletion },
158
+ },
159
+ lint(report, app) {
160
+ lintCompletion(report, app, state.records(), contributors, evidence)
161
+ },
162
+ index(source, app) {
163
+ state.index(source, app)
164
+ },
165
+ unindex(sourcePath) {
166
+ state.unindex(sourcePath)
167
+ },
168
+ }
169
+ }
170
+
171
+ function lintCompletion(
172
+ report: (source: Source, message: string) => void,
173
+ app: AppContext,
174
+ states: ReturnType<ReturnType<typeof createCompletionState>["records"]>,
175
+ contributors: readonly CompletionContributor[],
176
+ evidence: readonly CompletionEvidence[],
177
+ ) {
178
+ for (const source of app.sources.all()) {
179
+ const content = inspectCompletionTargets(source)
180
+ const issues = [
181
+ ...content.issues,
182
+ ...inspectConfiguredCompletionDeclarations(
183
+ source,
184
+ contributors,
185
+ evidence,
186
+ content.targets,
187
+ ).issues,
188
+ ]
189
+ for (const message of issues) {
190
+ report(source, message)
191
+ }
192
+ }
193
+ for (const state of states) {
194
+ const origin = app.sources.get(state.originId)
195
+ if (!origin) {
196
+ report(
197
+ state.source,
198
+ `Completion State ${state.source.id} targets missing Source ${state.originId}.`,
199
+ )
200
+ continue
201
+ }
202
+ const configured = inspectConfiguredCompletionDeclarations(
203
+ origin,
204
+ contributors,
205
+ evidence,
206
+ )
207
+ const targets = new Map(
208
+ configured.manual.map((target) => [target.key, target]),
209
+ )
210
+ const derived = new Set(configured.derived.map(({ key }) => key))
211
+ for (const [key, fact] of Object.entries(state.items)) {
212
+ const target = targets.get(key)
213
+ if (derived.has(key)) {
214
+ report(
215
+ state.source,
216
+ `Completion fact ${key} duplicates provider-owned evidence on ${origin.id}.`,
217
+ )
218
+ } else if (!target) {
219
+ report(
220
+ state.source,
221
+ `Completion fact ${key} does not resolve on ${origin.id}.`,
222
+ )
223
+ } else if (fact.outcome === "skipped" && !target.optional) {
224
+ report(
225
+ state.source,
226
+ `Completion fact ${key} is skipped but its target is not optional.`,
227
+ )
228
+ }
229
+ }
230
+ }
231
+ }
232
+
233
+ /** A standalone Completion plugin instance for ordinary preset composition. */
234
+ export function CompletionPlugin(options: CompletionOptions = {}): Plugin {
235
+ return completionPlugin(
236
+ createCompletionState(),
237
+ Object.freeze([...(options.contributors ?? [])]),
238
+ Object.freeze([...(options.evidence ?? [])]),
239
+ )
240
+ }
241
+
242
+ /** Compose Completion with its immutable reader for domain and UI plugins. */
243
+ export function createCompletionPlugin(options: CompletionOptions = {}) {
244
+ const state = createCompletionState()
245
+ const contributors = Object.freeze([...(options.contributors ?? [])])
246
+ const evidence = Object.freeze([...(options.evidence ?? [])])
247
+ return Object.freeze({
248
+ reader: completionReader(state.reader, evidence),
249
+ plugin: () => completionPlugin(state, contributors, evidence),
250
+ })
251
+ }
package/model.ts ADDED
@@ -0,0 +1,50 @@
1
+ export type CompletionRef = Readonly<{
2
+ source: string
3
+ key: string
4
+ }>
5
+
6
+ export type CompletionOutcome = "complete" | "skipped"
7
+ export type CompletionFact = Readonly<{
8
+ outcome: CompletionOutcome
9
+ occurredAt: string
10
+ }>
11
+ export type CompletionStatus = "not-started" | "in-progress" | "complete"
12
+
13
+ export type CompletionSummary = Readonly<{
14
+ status: CompletionStatus
15
+ complete: number
16
+ skipped: number
17
+ total: number
18
+ }>
19
+
20
+ export type CompletionReader = Readonly<{
21
+ read(actorId: string, ref: CompletionRef): CompletionFact | undefined
22
+ summary(actorId: string, refs: readonly CompletionRef[]): CompletionSummary
23
+ }>
24
+
25
+ export function completionSummary<T>(
26
+ refs: readonly T[],
27
+ read: (ref: T) => CompletionFact | undefined,
28
+ ): CompletionSummary {
29
+ let complete = 0
30
+ let skipped = 0
31
+ for (const ref of refs) {
32
+ const value = read(ref)?.outcome
33
+ if (value === "complete") complete++
34
+ if (value === "skipped") skipped++
35
+ }
36
+ return Object.freeze({
37
+ status:
38
+ refs.length > 0 && complete + skipped === refs.length
39
+ ? "complete"
40
+ : complete + skipped > 0
41
+ ? "in-progress"
42
+ : "not-started",
43
+ complete,
44
+ skipped,
45
+ total: refs.length,
46
+ })
47
+ }
48
+
49
+ /** Provider namespace plus one stable provider-local leaf id. */
50
+ export const COMPLETION_KEY = /^[a-z][a-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@pathmx/completion",
3
+ "description": "Actor-relative completion tracking for PathMX.",
4
+ "version": "0.5.0",
5
+ "license": "SEE LICENSE IN LICENSE.md",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/pathmx/pathmx-beta.git",
9
+ "directory": "pathmx/completion"
10
+ },
11
+ "homepage": "https://github.com/pathmx/pathmx-beta#readme",
12
+ "bugs": "https://github.com/pathmx/pathmx-beta/issues",
13
+ "publishConfig": {
14
+ "access": "public",
15
+ "registry": "https://registry.npmjs.org/",
16
+ "tag": "beta"
17
+ },
18
+ "type": "module",
19
+ "exports": {
20
+ ".": "./index.ts",
21
+ "./react": "./react.tsx"
22
+ },
23
+ "engines": {
24
+ "bun": ">=1.4.0"
25
+ },
26
+ "peerDependencies": {
27
+ "@pathmx/react": "*",
28
+ "@pathmx/core": "^0.5.0",
29
+ "react": ">=19.0.0"
30
+ },
31
+ "peerDependenciesMeta": {
32
+ "@pathmx/react": {
33
+ "optional": true
34
+ },
35
+ "react": {
36
+ "optional": true
37
+ }
38
+ }
39
+ }
package/react.tsx ADDED
@@ -0,0 +1,116 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ type ReactNode,
9
+ } from "react"
10
+ import { useAction } from "@pathmx/react"
11
+ import {
12
+ completionSummary,
13
+ type CompletionFact,
14
+ type CompletionOutcome,
15
+ type CompletionSummary,
16
+ } from "./model.ts"
17
+
18
+ export type CompletionSnapshot = Readonly<
19
+ Record<string, CompletionFact | undefined>
20
+ >
21
+
22
+ type CompletionReactValue = Readonly<{
23
+ facts: CompletionSnapshot
24
+ setFact(key: string, fact: CompletionFact | undefined): void
25
+ }>
26
+
27
+ const CompletionContext = createContext<CompletionReactValue | undefined>(
28
+ undefined,
29
+ )
30
+
31
+ export function CompletionProvider(
32
+ props: Readonly<{
33
+ initial?: CompletionSnapshot
34
+ children: ReactNode
35
+ }>,
36
+ ) {
37
+ const [facts, setFacts] = useState<CompletionSnapshot>(props.initial ?? {})
38
+ useEffect(() => setFacts(props.initial ?? {}), [props.initial])
39
+ const setFact = useCallback(
40
+ (key: string, fact: CompletionFact | undefined) => {
41
+ setFacts((current) => Object.freeze({ ...current, [key]: fact }))
42
+ },
43
+ [],
44
+ )
45
+ const value = useMemo(() => ({ facts, setFact }), [facts, setFact])
46
+ return (
47
+ <CompletionContext.Provider value={value}>
48
+ {props.children}
49
+ </CompletionContext.Provider>
50
+ )
51
+ }
52
+
53
+ function useCompletionContext() {
54
+ const value = useContext(CompletionContext)
55
+ if (!value) {
56
+ throw new Error("Completion hooks must be used inside CompletionProvider.")
57
+ }
58
+ return value
59
+ }
60
+
61
+ function actionFact(value: unknown, outcome: CompletionOutcome) {
62
+ if (value == null || typeof value !== "object" || Array.isArray(value)) {
63
+ throw new Error("Completion Action returned an invalid fact.")
64
+ }
65
+ const fact = value as Record<string, unknown>
66
+ if (
67
+ fact.outcome !== outcome ||
68
+ typeof fact.occurredAt !== "string" ||
69
+ !Number.isFinite(Date.parse(fact.occurredAt))
70
+ ) {
71
+ throw new Error("Completion Action returned an invalid fact.")
72
+ }
73
+ return Object.freeze({ outcome, occurredAt: fact.occurredAt })
74
+ }
75
+
76
+ /** Read one current-Source Completion fact from a server-projected snapshot. */
77
+ export function useCompletion(key: string) {
78
+ return useCompletionContext().facts[key]
79
+ }
80
+
81
+ /** Summarize current-Source keys without introducing a second status model. */
82
+ export function useCompletionSummary(
83
+ keys: readonly string[],
84
+ ): CompletionSummary {
85
+ const { facts } = useCompletionContext()
86
+ return useMemo(
87
+ () => completionSummary(keys, (key) => facts[key]),
88
+ [facts, keys],
89
+ )
90
+ }
91
+
92
+ /** Invoke the canonical manual Completion Action and update local app state. */
93
+ export function useCompletionAction(key: string) {
94
+ const { facts, setFact } = useCompletionContext()
95
+ const { action, invoke } = useAction("completion:set")
96
+ const [pending, setPending] = useState(false)
97
+ const set = useCallback(
98
+ async (outcome: CompletionOutcome | "pending") => {
99
+ setPending(true)
100
+ try {
101
+ const response = await invoke({ key, outcome })
102
+ const result = (await response.json()) as { output?: unknown }
103
+ setFact(
104
+ key,
105
+ outcome === "pending"
106
+ ? undefined
107
+ : actionFact(result.output, outcome),
108
+ )
109
+ } finally {
110
+ setPending(false)
111
+ }
112
+ },
113
+ [invoke, key, setFact],
114
+ )
115
+ return Object.freeze({ fact: facts[key], action, pending, set })
116
+ }
package/state.ts ADDED
@@ -0,0 +1,220 @@
1
+ import {
2
+ formatFrontmatter,
3
+ type AppContext,
4
+ type PathMXEnvironment,
5
+ type Source,
6
+ type SourceTransaction,
7
+ type UserActor,
8
+ } from "@pathmx/core"
9
+ import {
10
+ COMPLETION_KEY,
11
+ completionSummary,
12
+ type CompletionFact,
13
+ type CompletionOutcome,
14
+ type CompletionReader,
15
+ type CompletionRef,
16
+ } from "./model.ts"
17
+
18
+ type CompletionStateRecord = Readonly<{
19
+ actorId: string
20
+ originId: string
21
+ source: Source
22
+ items: Readonly<Record<string, CompletionFact>>
23
+ }>
24
+
25
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
26
+ return value != null && typeof value === "object" && !Array.isArray(value)
27
+ ? (value as Record<string, unknown>)
28
+ : undefined
29
+ }
30
+
31
+ function occurredAt(value: unknown) {
32
+ if (value instanceof Date && Number.isFinite(value.getTime())) {
33
+ return value.toISOString()
34
+ }
35
+ if (typeof value !== "string" || !value.trim()) return
36
+ const time = Date.parse(value)
37
+ return Number.isFinite(time) ? new Date(time).toISOString() : undefined
38
+ }
39
+
40
+ function invalidState(app: AppContext, source: Source, message: string): never {
41
+ return app.diagnostics.invalid(
42
+ source,
43
+ `Invalid Completion state in ${source.path}: ${message}`,
44
+ )
45
+ }
46
+
47
+ function stateItems(source: Source, app: AppContext) {
48
+ const rawItems = source.data.items
49
+ if (rawItems == null) return Object.freeze({})
50
+ if (typeof rawItems !== "object" || Array.isArray(rawItems)) {
51
+ invalidState(app, source, "items must be a map.")
52
+ }
53
+ const items: Array<[string, CompletionFact]> = []
54
+ for (const [key, value] of Object.entries(rawItems)) {
55
+ if (!COMPLETION_KEY.test(key) || key.length > 256) {
56
+ invalidState(app, source, `invalid item key ${key}.`)
57
+ }
58
+ const data = asRecord(value)
59
+ if (!data || (data.outcome !== "complete" && data.outcome !== "skipped")) {
60
+ invalidState(app, source, `${key}.outcome must be complete or skipped.`)
61
+ }
62
+ const timestamp = occurredAt(data.occurred_at)
63
+ if (!timestamp) {
64
+ invalidState(
65
+ app,
66
+ source,
67
+ `${key}.occurred_at must be an ISO 8601 timestamp.`,
68
+ )
69
+ }
70
+ const extra = Object.keys(data).find(
71
+ (name) => name !== "outcome" && name !== "occurred_at",
72
+ )
73
+ if (extra) {
74
+ invalidState(app, source, `${key}.${extra} is not supported.`)
75
+ }
76
+ items.push([
77
+ key,
78
+ Object.freeze({
79
+ outcome: data.outcome,
80
+ occurredAt: timestamp,
81
+ }),
82
+ ])
83
+ }
84
+ return Object.freeze(Object.fromEntries(items))
85
+ }
86
+
87
+ function stateRecord(
88
+ source: Source,
89
+ actorId: string | undefined,
90
+ app: AppContext,
91
+ ): CompletionStateRecord | undefined {
92
+ if (source.type !== "state" || source.data.plugin !== "completion") return
93
+ if (!actorId) {
94
+ invalidState(app, source, "state must be stored in an Actor Home.")
95
+ }
96
+ const originId =
97
+ typeof source.data.for === "string" ? source.data.for.trim() : ""
98
+ if (!originId.startsWith("/") || originId.length > 2_048) {
99
+ invalidState(app, source, "frontmatter for must be a canonical Source id.")
100
+ }
101
+ return Object.freeze({
102
+ actorId,
103
+ originId,
104
+ source,
105
+ items: stateItems(source, app),
106
+ })
107
+ }
108
+
109
+ function stateContent(
110
+ originId: string,
111
+ items: Readonly<Record<string, CompletionFact>>,
112
+ previous: CompletionStateRecord | undefined,
113
+ yaml: PathMXEnvironment["yaml"],
114
+ ) {
115
+ return `${formatFrontmatter(
116
+ {
117
+ ...previous?.source.data,
118
+ type: "state",
119
+ plugin: "completion",
120
+ for: originId,
121
+ items: Object.fromEntries(
122
+ Object.entries(items).map(([key, fact]) => [
123
+ key,
124
+ { outcome: fact.outcome, occurred_at: fact.occurredAt },
125
+ ]),
126
+ ),
127
+ },
128
+ yaml,
129
+ )}\n`
130
+ }
131
+
132
+ function stateId(actor: UserActor, originId: string) {
133
+ return `${actor.home}/completion${originId}.state`
134
+ }
135
+
136
+ export function createCompletionState() {
137
+ const bySourcePath = new Map<string, CompletionStateRecord>()
138
+ const byActorOrigin = new Map<string, CompletionStateRecord>()
139
+ const key = (actorId: string, originId: string) => `${actorId}\0${originId}`
140
+ const stateFor = (actorId: string, originId: string) =>
141
+ byActorOrigin.get(key(actorId, originId))
142
+
143
+ const reader: CompletionReader = Object.freeze({
144
+ read(actorId, ref) {
145
+ return stateFor(actorId, ref.source)?.items[ref.key]
146
+ },
147
+ summary(actorId, refs) {
148
+ return completionSummary(
149
+ refs,
150
+ (ref) => stateFor(actorId, ref.source)?.items[ref.key],
151
+ )
152
+ },
153
+ })
154
+
155
+ return Object.freeze({
156
+ reader,
157
+ records() {
158
+ return Object.freeze([...bySourcePath.values()])
159
+ },
160
+ index(source: Source, app: AppContext) {
161
+ const completion = stateRecord(
162
+ source,
163
+ app.actors.homeActorId(source.id),
164
+ app,
165
+ )
166
+ if (!completion) return
167
+ const recordKey = key(completion.actorId, completion.originId)
168
+ const existing = byActorOrigin.get(recordKey)
169
+ if (existing && existing.source.path !== source.path) {
170
+ invalidState(
171
+ app,
172
+ source,
173
+ `duplicate State for ${completion.actorId} and ${completion.originId}: ${existing.source.path} and ${source.path}.`,
174
+ )
175
+ }
176
+ bySourcePath.set(source.path, completion)
177
+ byActorOrigin.set(recordKey, completion)
178
+ },
179
+ unindex(sourcePath: string) {
180
+ const completion = bySourcePath.get(sourcePath)
181
+ if (!completion) return
182
+ bySourcePath.delete(sourcePath)
183
+ byActorOrigin.delete(key(completion.actorId, completion.originId))
184
+ },
185
+ async write(
186
+ tx: SourceTransaction,
187
+ actor: UserActor,
188
+ ref: CompletionRef,
189
+ outcome: CompletionOutcome | undefined,
190
+ yaml: PathMXEnvironment["yaml"],
191
+ ) {
192
+ const current = stateFor(actor.id, ref.source)
193
+ if (!current && outcome == null) return
194
+ const items = new Map(Object.entries(current?.items ?? {}))
195
+ let fact: CompletionFact | undefined
196
+ if (outcome) {
197
+ const previous = items.get(ref.key)
198
+ if (previous?.outcome === outcome) return previous
199
+ fact = Object.freeze({
200
+ outcome,
201
+ occurredAt: new Date().toISOString(),
202
+ })
203
+ items.set(ref.key, fact)
204
+ } else items.delete(ref.key)
205
+ if (!items.size) {
206
+ if (current) await tx.delete(current.source.id)
207
+ return
208
+ }
209
+ const content = stateContent(
210
+ ref.source,
211
+ Object.fromEntries(items),
212
+ current,
213
+ yaml,
214
+ )
215
+ if (current) await tx.update(current.source.id, content)
216
+ else await tx.create(stateId(actor, ref.source), content)
217
+ return fact
218
+ },
219
+ })
220
+ }