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.
- package/00-overview.md +172 -0
- package/01-terminology.md +156 -0
- package/02-model-and-mapping.md +288 -0
- package/03-temporal-semantics.md +290 -0
- package/04-recurrence.md +398 -0
- package/05-operations.md +968 -0
- package/06-validation.md +267 -0
- package/07-conformance.md +292 -0
- package/08-compatibility-and-migrations.md +188 -0
- package/09-configuration.md +837 -0
- package/10-dependencies-and-reminders.md +266 -0
- package/11-links.md +373 -0
- package/CHANGELOG.md +25 -0
- package/README.md +80 -0
- package/conformance/README.md +31 -0
- package/conformance/adapters/mdbase-tasknotes.adapter.mjs +141 -0
- package/conformance/adapters/tasknotes-core/conformance.ts +2498 -0
- package/conformance/adapters/tasknotes-core/create-compat.ts +1 -0
- package/conformance/adapters/tasknotes-core/date.ts +12 -0
- package/conformance/adapters/tasknotes-core/field-mapping.ts +14 -0
- package/conformance/adapters/tasknotes-core/recurrence.ts +10 -0
- package/conformance/adapters/tasknotes-date-bridge.ts +20 -0
- package/conformance/adapters/tasknotes-runtime-bridge.ts +1107 -0
- package/conformance/adapters/tasknotes-runtime-obsidian-shim.ts +84 -0
- package/conformance/adapters/tasknotes-templating-bridge.ts +13 -0
- package/conformance/adapters/tasknotes.adapter.mjs +485 -0
- package/conformance/docs/ADAPTER_CONTRACT.md +245 -0
- package/conformance/docs/FIXTURE_FORMAT.md +247 -0
- package/conformance/docs/RUNNER_GUIDE.md +393 -0
- package/conformance/fixtures/config-schema.json +634 -0
- package/conformance/fixtures/config.json +18984 -0
- package/conformance/fixtures/conformance.json +444 -0
- package/conformance/fixtures/create-compat.json +18639 -0
- package/conformance/fixtures/date.json +25612 -0
- package/conformance/fixtures/dependencies.json +8647 -0
- package/conformance/fixtures/field-mapping.json +5406 -0
- package/conformance/fixtures/links.json +1127 -0
- package/conformance/fixtures/migrations.json +668 -0
- package/conformance/fixtures/operations.json +2761 -0
- package/conformance/fixtures/recurrence.json +22958 -0
- package/conformance/fixtures/reminders.json +13333 -0
- package/conformance/fixtures/templating.json +497 -0
- package/conformance/fixtures/validation.json +5308 -0
- package/conformance/lib/adapter-loader.mjs +23 -0
- package/conformance/lib/load-fixtures.mjs +43 -0
- package/conformance/lib/matchers.mjs +200 -0
- package/conformance/manifest.json +232 -0
- package/conformance/scripts/generate-fixtures.mjs +5239 -0
- package/conformance/scripts/package-fixtures.mjs +101 -0
- package/conformance/tests/coverage.test.mjs +213 -0
- package/conformance/tests/runner.test.mjs +102 -0
- package/conformance/tests/tasknotes-runtime-routing.test.mjs +64 -0
- 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
|