tasknotes-spec 0.2.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.
Files changed (53) hide show
  1. package/00-overview.md +172 -0
  2. package/01-terminology.md +156 -0
  3. package/02-model-and-mapping.md +288 -0
  4. package/03-temporal-semantics.md +290 -0
  5. package/04-recurrence.md +398 -0
  6. package/05-operations.md +968 -0
  7. package/06-validation.md +267 -0
  8. package/07-conformance.md +292 -0
  9. package/08-compatibility-and-migrations.md +188 -0
  10. package/09-configuration.md +837 -0
  11. package/10-dependencies-and-reminders.md +266 -0
  12. package/11-links.md +373 -0
  13. package/CHANGELOG.md +25 -0
  14. package/README.md +80 -0
  15. package/conformance/README.md +31 -0
  16. package/conformance/adapters/mdbase-tasknotes.adapter.mjs +141 -0
  17. package/conformance/adapters/tasknotes-core/conformance.ts +2498 -0
  18. package/conformance/adapters/tasknotes-core/create-compat.ts +1 -0
  19. package/conformance/adapters/tasknotes-core/date.ts +12 -0
  20. package/conformance/adapters/tasknotes-core/field-mapping.ts +14 -0
  21. package/conformance/adapters/tasknotes-core/recurrence.ts +10 -0
  22. package/conformance/adapters/tasknotes-date-bridge.ts +20 -0
  23. package/conformance/adapters/tasknotes-runtime-bridge.ts +1107 -0
  24. package/conformance/adapters/tasknotes-runtime-obsidian-shim.ts +84 -0
  25. package/conformance/adapters/tasknotes-templating-bridge.ts +13 -0
  26. package/conformance/adapters/tasknotes.adapter.mjs +485 -0
  27. package/conformance/docs/ADAPTER_CONTRACT.md +245 -0
  28. package/conformance/docs/FIXTURE_FORMAT.md +247 -0
  29. package/conformance/docs/RUNNER_GUIDE.md +393 -0
  30. package/conformance/fixtures/config-schema.json +634 -0
  31. package/conformance/fixtures/config.json +18984 -0
  32. package/conformance/fixtures/conformance.json +444 -0
  33. package/conformance/fixtures/create-compat.json +18639 -0
  34. package/conformance/fixtures/date.json +25612 -0
  35. package/conformance/fixtures/dependencies.json +8647 -0
  36. package/conformance/fixtures/field-mapping.json +5406 -0
  37. package/conformance/fixtures/links.json +1127 -0
  38. package/conformance/fixtures/migrations.json +668 -0
  39. package/conformance/fixtures/operations.json +2761 -0
  40. package/conformance/fixtures/recurrence.json +22958 -0
  41. package/conformance/fixtures/reminders.json +13333 -0
  42. package/conformance/fixtures/templating.json +497 -0
  43. package/conformance/fixtures/validation.json +5308 -0
  44. package/conformance/lib/adapter-loader.mjs +23 -0
  45. package/conformance/lib/load-fixtures.mjs +43 -0
  46. package/conformance/lib/matchers.mjs +200 -0
  47. package/conformance/manifest.json +232 -0
  48. package/conformance/scripts/generate-fixtures.mjs +5239 -0
  49. package/conformance/scripts/package-fixtures.mjs +101 -0
  50. package/conformance/tests/coverage.test.mjs +213 -0
  51. package/conformance/tests/runner.test.mjs +102 -0
  52. package/conformance/tests/tasknotes-runtime-routing.test.mjs +64 -0
  53. package/package.json +49 -0
@@ -0,0 +1,393 @@
1
+ # Runner Guide
2
+
3
+ This guide explains how to implement a conformance runner for the tasknotes-spec suite in any programming language. After reading this document you should be able to write a runner in Python, Rust, Go, or any other language that can parse JSON and call your implementation's API.
4
+
5
+ ---
6
+
7
+ ## Overview
8
+
9
+ A conformance run has three phases:
10
+
11
+ 1. **Load** — read the fixture JSON files and parse each fixture object
12
+ 2. **Execute** — for each fixture, call your implementation with `(operation, input)` and collect the envelope response
13
+ 3. **Assert** — apply the fixture's assertion kind to the envelope; report pass, fail, or skip
14
+
15
+ ```
16
+ for each fixture in all_fixtures:
17
+ if fixture.requires any capability not in claimed_capabilities:
18
+ report SKIP
19
+ continue
20
+ envelope = implementation.execute(fixture.operation, fixture.input)
21
+ result = apply_assertion(fixture, envelope)
22
+ report PASS or FAIL
23
+ ```
24
+
25
+ ### Envelope shape
26
+
27
+ Every call to `execute` must return an envelope:
28
+
29
+ ```json
30
+ { "ok": true, "result": { ... } } // success
31
+ { "ok": false, "error": "message", "error_details": { ... } } // failure (`error_details` optional)
32
+ ```
33
+
34
+ Your implementation must never throw or panic for invalid inputs — all errors go in the envelope.
35
+
36
+ ---
37
+
38
+ ## Step 1: Load fixtures
39
+
40
+ The distributable tarball contains:
41
+
42
+ ```
43
+ fixtures/date.json
44
+ fixtures/field-mapping.json
45
+ fixtures/recurrence.json
46
+ fixtures/create-compat.json
47
+ fixtures/conformance.json
48
+ fixtures/config.json
49
+ fixtures/config-schema.json
50
+ fixtures/validation.json
51
+ fixtures/operations.json
52
+ fixtures/templating.json
53
+ fixtures/migrations.json
54
+ fixtures/dependencies.json
55
+ fixtures/reminders.json
56
+ fixtures/links.json
57
+ manifest.json
58
+ ```
59
+
60
+ `manifest.json` lists all fixture files and their case counts — use it to discover which JSON files to load:
61
+
62
+ ```json
63
+ {
64
+ "files": [
65
+ { "file": "date.json", "cases": "<number>" },
66
+ ...
67
+ ],
68
+ "totalCases": "<number>"
69
+ }
70
+ ```
71
+
72
+ Counts above are illustrative. Always use the current `manifest.json` values.
73
+
74
+ Each fixture file is a JSON array. Each element is a fixture object (see `FIXTURE_FORMAT.md` for full field reference).
75
+
76
+ **Pseudocode**:
77
+ ```
78
+ manifest = parse_json(read_file("manifest.json"))
79
+ fixtures = []
80
+ for each entry in manifest.files:
81
+ fixtures += parse_json(read_file("fixtures/" + entry.file))
82
+ ```
83
+
84
+ ---
85
+
86
+ ## Step 2: Obtain profile and capability claims
87
+
88
+ Before running fixtures, call the `meta.claim` operation with an empty input to get your implementation's metadata:
89
+
90
+ ```
91
+ envelope = implementation.execute("meta.claim", {})
92
+ claimed = envelope.result
93
+ # claimed.implementation : string
94
+ # claimed.version : string
95
+ # claimed.spec_version : string
96
+ # claimed.validation_modes : string[] (must include "strict")
97
+ # claimed.profiles : string[]
98
+ # claimed.capabilities : string[]
99
+ ```
100
+
101
+ This result drives profile/capability-based skipping in Step 3.
102
+
103
+ ---
104
+
105
+ ## Step 3: Filtering by profile and capability
106
+
107
+ Each fixture has:
108
+
109
+ - `profile` (required): conformance profile for the case
110
+ - optional `requires`: capability token list
111
+
112
+ A fixture must be **skipped** if:
113
+
114
+ 1. its profile is not satisfied by claimed profiles (with cumulative profile expansion), or
115
+ 2. any required capability is missing.
116
+
117
+ Profile expansion rules:
118
+
119
+ - `recurrence` implies `core-lite`
120
+ - `extended` implies `recurrence` and `core-lite`
121
+ - `templating` is non-cumulative (it implies only itself)
122
+ - `materialized-occurrences` is non-cumulative and must be explicitly claimed alongside `recurrence`
123
+
124
+ **Pseudocode**:
125
+ ```
126
+ function expand_profiles(claimed_profiles):
127
+ expanded = set(claimed_profiles)
128
+ if "extended" in expanded:
129
+ expanded.add("recurrence")
130
+ expanded.add("core-lite")
131
+ if "recurrence" in expanded:
132
+ expanded.add("core-lite")
133
+ return expanded
134
+
135
+ function should_skip(fixture, claimed_profiles, claimed_capabilities):
136
+ effective_profiles = expand_profiles(claimed_profiles)
137
+ if fixture.profile not in effective_profiles:
138
+ return true
139
+ if fixture.requires is absent or empty:
140
+ return false
141
+ for each cap in fixture.requires:
142
+ if cap not in claimed_capabilities:
143
+ return true
144
+ return false
145
+ ```
146
+
147
+ Skipped fixtures count toward the skip total but not toward pass or fail.
148
+
149
+ ---
150
+
151
+ ## Step 4: Execute the operation
152
+
153
+ Call your implementation's `execute` function:
154
+
155
+ ```
156
+ envelope = implementation.execute(fixture.operation, fixture.input)
157
+ ```
158
+
159
+ The envelope must be a plain object with at minimum an `ok` field (boolean).
160
+
161
+ ---
162
+
163
+ ## Step 5: Apply assertions
164
+
165
+ Apply the assertion kind named in `fixture.assertion`. The context object (used by `$ref`) is `{ input: fixture.input }`.
166
+
167
+ ### 5.1 `envelope_equals`
168
+
169
+ Deep-match the entire envelope against `fixture.expect` using the matcher directives:
170
+
171
+ ```
172
+ deep_match(envelope, fixture.expect, context={ input: fixture.input })
173
+ ```
174
+
175
+ Pass if no mismatch is found; fail otherwise.
176
+
177
+ ### 5.2 `envelope_error`
178
+
179
+ ```
180
+ if envelope.ok != false:
181
+ fail("Expected ok=false, got ok=true")
182
+ if fixture.expect.error is present:
183
+ deep_match(envelope.error, fixture.expect.error, context)
184
+ ```
185
+
186
+ ### 5.3 `recurrence_complete_invariants`
187
+
188
+ ```
189
+ assert envelope.ok == true
190
+ result = envelope.result
191
+ completionDate = fixture.input.completionDate
192
+
193
+ assert is_array(result.completeInstances)
194
+ assert is_array(result.skippedInstances)
195
+ assert result.completeInstances contains completionDate
196
+ assert result.skippedInstances does NOT contain completionDate
197
+ assert result.updatedRecurrence matches /FREQ=/
198
+ assert result.updatedRecurrence matches /DTSTART:/
199
+
200
+ if fixture.input.recurrenceAnchor == "completion":
201
+ dtstartDay = completionDate.replace("-", "") # e.g. "20260301"
202
+ assert result.updatedRecurrence matches /DTSTART:{dtstartDay}(?:;|$)/
203
+
204
+ if fixture.input.recurrenceAnchor == "scheduled" and fixture.input.scheduled is string:
205
+ dtstartDay = fixture.input.scheduled[0:10].replace("-", "")
206
+ assert result.updatedRecurrence matches /DTSTART:{dtstartDay}(?:;|$)/
207
+
208
+ if result.nextScheduled is present:
209
+ assert result.nextScheduled matches /^\d{4}-\d{2}-\d{2}/
210
+ assert result.nextScheduled[0:10] >= completionDate # lexicographic
211
+
212
+ if result.nextScheduled and result.nextDue and fixture.input.scheduled and fixture.input.due are all strings:
213
+ originalOffset = date_diff_days(fixture.input.due[0:10], fixture.input.scheduled[0:10])
214
+ actualOffset = date_diff_days(result.nextDue[0:10], result.nextScheduled[0:10])
215
+ assert actualOffset == originalOffset
216
+ ```
217
+
218
+ `date_diff_days(a, b)` = number of whole days from b to a (parse as UTC midnight, divide ms by 86400000, round).
219
+
220
+ ### 5.4 `recurrence_recalculate_invariants`
221
+
222
+ ```
223
+ assert envelope.ok == true
224
+ result = envelope.result
225
+ referenceDate = fixture.input.referenceDate
226
+
227
+ assert result.updatedRecurrence matches /FREQ=/
228
+
229
+ if fixture.input.recurrenceAnchor == "scheduled":
230
+ assert result.updatedRecurrence matches /DTSTART:/
231
+
232
+ if result.nextScheduled is present:
233
+ nextDay = result.nextScheduled[0:10]
234
+ assert nextDay >= referenceDate # lexicographic
235
+
236
+ complete = fixture.input.completeInstances ?? []
237
+ skipped = fixture.input.skippedInstances ?? []
238
+ if fixture.input.recurrenceAnchor != "completion":
239
+ assert complete does NOT contain nextDay
240
+ assert skipped does NOT contain nextDay
241
+
242
+ if result.nextScheduled and result.nextDue and fixture.input.scheduled and fixture.input.due are all strings:
243
+ originalOffset = date_diff_days(fixture.input.due[0:10], fixture.input.scheduled[0:10])
244
+ actualOffset = date_diff_days(result.nextDue[0:10], result.nextScheduled[0:10])
245
+ assert actualOffset == originalOffset
246
+ ```
247
+
248
+ ### 5.5 `create_compat_invariants`
249
+
250
+ ```
251
+ deep_match(envelope, fixture.expect, context) # same as envelope_equals
252
+
253
+ if envelope.ok == true and envelope.result.path is present:
254
+ assert envelope.result.path ends with ".md"
255
+ assert envelope.result.path does NOT contain "{"
256
+ assert envelope.result.path does NOT contain "}"
257
+ ```
258
+
259
+ ---
260
+
261
+ ## Step 6: Implementing the matcher directives
262
+
263
+ `deep_match(actual, expected, context)` is a recursive function. The `context` object is `{ input: fixture.input }` and is passed unchanged through all recursive calls.
264
+
265
+ ```
266
+ function deep_match(actual, expected, context):
267
+ if expected is a plain object (not array, not null):
268
+
269
+ if expected has key "$regex":
270
+ assert actual is a string
271
+ assert actual matches regex(expected["$regex"])
272
+ return
273
+
274
+ if expected has key "$oneOf":
275
+ for each option in expected["$oneOf"]:
276
+ try:
277
+ deep_match(actual, option, context)
278
+ return # matched
279
+ fail("no option matched")
280
+
281
+ if expected has key "$contains":
282
+ subset = expected["$contains"]
283
+ if actual is an array:
284
+ assert subset is an array
285
+ for each expectedItem in subset:
286
+ assert any element of actual deep_matches expectedItem
287
+ return
288
+ else:
289
+ assert actual is a plain object
290
+ assert subset is a plain object
291
+ for each (key, value) in subset:
292
+ deep_match(actual[key], value, context)
293
+ return
294
+
295
+ if expected has key "$ref":
296
+ resolved = resolve_ref(expected["$ref"], context)
297
+ deep_match(actual, resolved, context)
298
+ return
299
+
300
+ # plain object comparison — expected is a subset
301
+ assert actual is a plain object
302
+ for each (key, value) in expected:
303
+ deep_match(actual[key], value, context)
304
+ return
305
+
306
+ if expected is an array:
307
+ assert actual is an array
308
+ assert len(actual) == len(expected)
309
+ for i in 0..len(expected):
310
+ deep_match(actual[i], expected[i], context)
311
+ return
312
+
313
+ # scalar: exact equality
314
+ assert actual == expected
315
+ ```
316
+
317
+ ### `resolve_ref(ref, context)`
318
+
319
+ ```
320
+ function resolve_ref(ref, context):
321
+ if ref is not a string or does not start with "input.":
322
+ return ref # not a ref, use as-is
323
+ path = ref["input.".length:].split(".")
324
+ current = context.input
325
+ for each segment in path:
326
+ if current is null or not an object:
327
+ return undefined
328
+ current = current[segment]
329
+ return current
330
+ ```
331
+
332
+ ### Notes on "plain object"
333
+
334
+ A plain object is:
335
+ - Not null
336
+ - Not an array
337
+ - Has object/dict/map type (not a primitive)
338
+
339
+ In dynamic languages this is usually just `typeof x === "object" && !Array.isArray(x) && x !== null`.
340
+
341
+ ### Scalar equality
342
+
343
+ For the scalar case (`assert actual == expected`), use strict/deep equality: `null != false`, `0 != ""`, etc. Do not coerce types.
344
+
345
+ ---
346
+
347
+ ## Step 7: Reporting
348
+
349
+ Output results in **TAP (Test Anything Protocol)** format for maximum interoperability with CI systems and aggregators.
350
+
351
+ ```
352
+ TAP version 14
353
+ 1..{total_cases}
354
+ ok 1 - date.parse_utc.valid.0001 date.parse_utc
355
+ not ok 2 - date.parse_local.invalid.0001 date.parse_local
356
+ ---
357
+ message: Expected ok=false, got ok=true
358
+ ...
359
+ ok 3 - # SKIP recurrence.complete.anchor.0001 (missing capability: dependencies)
360
+ ```
361
+
362
+ At the end, print a summary line:
363
+
364
+ ```
365
+ # pass: 4850 fail: 10 skip: 39
366
+ ```
367
+
368
+ Exit with code `0` if all non-skipped cases pass; exit with a non-zero code if any case fails.
369
+
370
+ ---
371
+
372
+ ## Reference implementation
373
+
374
+ The reference JS implementation is included in the distributable:
375
+
376
+ | File | Purpose |
377
+ |------|---------|
378
+ | `lib/matchers.mjs` | `applyAssertion(caseDef, envelope)` and `deepMatch` — canonical matcher logic |
379
+ | `tests/runner.test.mjs` | Full runner: loads fixtures, calls adapter, applies assertions, reports via node:test |
380
+
381
+ If this guide and the reference implementation disagree, the reference implementation is authoritative. Please file an issue.
382
+
383
+ ---
384
+
385
+ ## Quick-start checklist
386
+
387
+ - [ ] Parse `manifest.json` → load all fixture files
388
+ - [ ] Call `meta.claim` → store `claimed.capabilities`
389
+ - [ ] For each fixture: skip if missing capabilities
390
+ - [ ] Call `execute(fixture.operation, fixture.input)` → get envelope
391
+ - [ ] Dispatch on `fixture.assertion` → apply assertion
392
+ - [ ] Implement `deep_match` with `$regex`, `$contains`, `$oneOf`, `$ref` directives
393
+ - [ ] Output TAP; exit non-zero on any failure