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,23 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { resolve } from "node:path";
3
+
4
+ export async function loadAdapter() {
5
+ const adapterPath = process.env.TASKNOTES_ADAPTER;
6
+ if (!adapterPath) {
7
+ throw new Error("TASKNOTES_ADAPTER is required (path to adapter module)");
8
+ }
9
+
10
+ const absPath = resolve(adapterPath);
11
+ const mod = await import(pathToFileURL(absPath).href);
12
+ const metadata = mod.metadata;
13
+ const execute = mod.execute;
14
+
15
+ if (!metadata || typeof metadata !== "object") {
16
+ throw new Error(`Adapter at ${absPath} must export 'metadata' object`);
17
+ }
18
+ if (typeof execute !== "function") {
19
+ throw new Error(`Adapter at ${absPath} must export 'execute(operation, input)' function`);
20
+ }
21
+
22
+ return { metadata, execute, absPath };
23
+ }
@@ -0,0 +1,43 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export function loadFixtures(fixturesDir) {
5
+ const validProfiles = new Set([
6
+ "core-lite",
7
+ "recurrence",
8
+ "extended",
9
+ "templating",
10
+ "materialized-occurrences",
11
+ ]);
12
+ const files = readdirSync(fixturesDir)
13
+ .filter((name) => name.endsWith(".json"))
14
+ .sort();
15
+
16
+ const all = [];
17
+ for (const name of files) {
18
+ const fullPath = join(fixturesDir, name);
19
+ const parsed = JSON.parse(readFileSync(fullPath, "utf8"));
20
+ if (!Array.isArray(parsed)) {
21
+ throw new Error(`Fixture file must contain an array: ${fullPath}`);
22
+ }
23
+ for (const entry of parsed) {
24
+ all.push(entry);
25
+ }
26
+ }
27
+
28
+ const ids = new Set();
29
+ for (const fixture of all) {
30
+ if (!fixture.id || typeof fixture.id !== "string") {
31
+ throw new Error(`Fixture missing string id: ${JSON.stringify(fixture)}`);
32
+ }
33
+ if (ids.has(fixture.id)) {
34
+ throw new Error(`Duplicate fixture id: ${fixture.id}`);
35
+ }
36
+ if (!fixture.profile || typeof fixture.profile !== "string" || !validProfiles.has(fixture.profile)) {
37
+ throw new Error(`Fixture ${fixture.id} has invalid profile: ${JSON.stringify(fixture.profile)}`);
38
+ }
39
+ ids.add(fixture.id);
40
+ }
41
+
42
+ return all;
43
+ }
@@ -0,0 +1,200 @@
1
+ import assert from "node:assert/strict";
2
+
3
+ function isPlainObject(value) {
4
+ return value != null && typeof value === "object" && !Array.isArray(value);
5
+ }
6
+
7
+ function resolveInputRef(context, ref) {
8
+ if (typeof ref !== "string" || !ref.startsWith("input.")) {
9
+ return ref;
10
+ }
11
+ const path = ref.slice("input.".length).split(".").filter(Boolean);
12
+ let current = context.input;
13
+ for (const part of path) {
14
+ if (current == null || typeof current !== "object") return undefined;
15
+ current = current[part];
16
+ }
17
+ return current;
18
+ }
19
+
20
+ function deepMatch(actual, expected, context) {
21
+ if (isPlainObject(expected)) {
22
+ if (Object.prototype.hasOwnProperty.call(expected, "$regex")) {
23
+ assert.equal(typeof actual, "string", `Expected string for regex match, got ${typeof actual}`);
24
+ const pattern = new RegExp(String(expected.$regex));
25
+ assert.match(actual, pattern);
26
+ return;
27
+ }
28
+
29
+ if (Object.prototype.hasOwnProperty.call(expected, "$oneOf")) {
30
+ const options = expected.$oneOf;
31
+ assert.equal(Array.isArray(options), true, "$oneOf must be an array");
32
+ let matched = false;
33
+ for (const option of options) {
34
+ try {
35
+ deepMatch(actual, option, context);
36
+ matched = true;
37
+ break;
38
+ } catch {
39
+ // try next
40
+ }
41
+ }
42
+ assert.equal(matched, true, `Expected value to match oneOf options`);
43
+ return;
44
+ }
45
+
46
+ if (Object.prototype.hasOwnProperty.call(expected, "$contains")) {
47
+ const subset = expected.$contains;
48
+ if (Array.isArray(actual)) {
49
+ assert.equal(Array.isArray(subset), true, "$contains subset for arrays must be array");
50
+ for (const expectedItem of subset) {
51
+ const found = actual.some((item) => {
52
+ try {
53
+ deepMatch(item, expectedItem, context);
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ });
59
+ assert.equal(found, true, `Expected array to contain item ${JSON.stringify(expectedItem)}`);
60
+ }
61
+ return;
62
+ }
63
+
64
+ assert.equal(isPlainObject(actual), true, "$contains for objects requires object actual");
65
+ assert.equal(isPlainObject(subset), true, "$contains for objects requires object subset");
66
+ for (const [key, value] of Object.entries(subset)) {
67
+ deepMatch(actual[key], value, context);
68
+ }
69
+ return;
70
+ }
71
+
72
+ if (Object.prototype.hasOwnProperty.call(expected, "$ref")) {
73
+ const resolved = resolveInputRef(context, expected.$ref);
74
+ deepMatch(actual, resolved, context);
75
+ return;
76
+ }
77
+
78
+ assert.equal(isPlainObject(actual), true, `Expected object, got ${typeof actual}`);
79
+ for (const [key, value] of Object.entries(expected)) {
80
+ deepMatch(actual[key], value, context);
81
+ }
82
+ return;
83
+ }
84
+
85
+ if (Array.isArray(expected)) {
86
+ assert.equal(Array.isArray(actual), true, `Expected array, got ${typeof actual}`);
87
+ assert.equal(actual.length, expected.length, "Array length mismatch");
88
+ for (let i = 0; i < expected.length; i += 1) {
89
+ deepMatch(actual[i], expected[i], context);
90
+ }
91
+ return;
92
+ }
93
+
94
+ assert.deepEqual(actual, expected);
95
+ }
96
+
97
+ function assertDateOffset(scheduled, due, expectedOffsetDays) {
98
+ const start = Date.parse(`${scheduled}T00:00:00Z`);
99
+ const end = Date.parse(`${due}T00:00:00Z`);
100
+ const diff = Math.round((end - start) / (24 * 60 * 60 * 1000));
101
+ assert.equal(diff, expectedOffsetDays);
102
+ }
103
+
104
+ export function applyAssertion(caseDef, envelope) {
105
+ const context = { input: caseDef.input };
106
+
107
+ if (caseDef.assertion === "envelope_equals") {
108
+ deepMatch(envelope, caseDef.expect, context);
109
+ return;
110
+ }
111
+
112
+ if (caseDef.assertion === "envelope_error") {
113
+ assert.equal(envelope.ok, false, "Expected operation to fail");
114
+ if (caseDef.expect?.error) {
115
+ deepMatch(envelope.error, caseDef.expect.error, context);
116
+ }
117
+ return;
118
+ }
119
+
120
+ if (caseDef.assertion === "recurrence_complete_invariants") {
121
+ assert.equal(envelope.ok, true, envelope.error || "Expected success");
122
+ const result = envelope.result;
123
+ const completionDate = caseDef.input.completionDate;
124
+ assert.equal(Array.isArray(result.completeInstances), true);
125
+ assert.equal(Array.isArray(result.skippedInstances), true);
126
+ assert.equal(result.completeInstances.includes(completionDate), true);
127
+ assert.equal(result.skippedInstances.includes(completionDate), false);
128
+ assert.match(result.updatedRecurrence, /FREQ=/);
129
+ assert.match(result.updatedRecurrence, /DTSTART:/);
130
+
131
+ if (caseDef.input.recurrenceAnchor === "completion") {
132
+ const dtstartDay = completionDate.replace(/-/g, "");
133
+ assert.match(result.updatedRecurrence, new RegExp(`DTSTART:${dtstartDay}(?:;|$)`));
134
+ }
135
+
136
+ if (caseDef.input.recurrenceAnchor === "scheduled") {
137
+ const scheduled = caseDef.input.scheduled;
138
+ if (typeof scheduled === "string") {
139
+ const dtstartDay = scheduled.slice(0, 10).replace(/-/g, "");
140
+ assert.match(result.updatedRecurrence, new RegExp(`DTSTART:${dtstartDay}(?:;|$)`));
141
+ }
142
+ }
143
+
144
+ if (result.nextScheduled) {
145
+ assert.match(result.nextScheduled, /^\d{4}-\d{2}-\d{2}/);
146
+ assert.equal(result.nextScheduled.slice(0, 10) >= completionDate, true);
147
+ }
148
+
149
+ const scheduled = caseDef.input.scheduled;
150
+ const due = caseDef.input.due;
151
+ if (result.nextScheduled && result.nextDue && typeof scheduled === "string" && typeof due === "string") {
152
+ const originalOffset = Math.round((Date.parse(`${due.slice(0, 10)}T00:00:00Z`) - Date.parse(`${scheduled.slice(0, 10)}T00:00:00Z`)) / (24 * 60 * 60 * 1000));
153
+ assertDateOffset(result.nextScheduled.slice(0, 10), result.nextDue.slice(0, 10), originalOffset);
154
+ }
155
+ return;
156
+ }
157
+
158
+ if (caseDef.assertion === "recurrence_recalculate_invariants") {
159
+ assert.equal(envelope.ok, true, envelope.error || "Expected success");
160
+ const result = envelope.result;
161
+ const referenceDate = caseDef.input.referenceDate;
162
+
163
+ assert.match(result.updatedRecurrence, /FREQ=/);
164
+ if (caseDef.input.recurrenceAnchor === "scheduled") {
165
+ assert.match(result.updatedRecurrence, /DTSTART:/);
166
+ }
167
+
168
+ if (result.nextScheduled) {
169
+ const nextDay = result.nextScheduled.slice(0, 10);
170
+ assert.equal(nextDay >= referenceDate, true);
171
+
172
+ const complete = Array.isArray(caseDef.input.completeInstances) ? caseDef.input.completeInstances : [];
173
+ const skipped = Array.isArray(caseDef.input.skippedInstances) ? caseDef.input.skippedInstances : [];
174
+ if (caseDef.input.recurrenceAnchor !== "completion") {
175
+ assert.equal(complete.includes(nextDay), false);
176
+ }
177
+ assert.equal(skipped.includes(nextDay), false);
178
+ }
179
+
180
+ const scheduled = caseDef.input.scheduled;
181
+ const due = caseDef.input.due;
182
+ if (result.nextScheduled && result.nextDue && typeof scheduled === "string" && typeof due === "string") {
183
+ const originalOffset = Math.round((Date.parse(`${due.slice(0, 10)}T00:00:00Z`) - Date.parse(`${scheduled.slice(0, 10)}T00:00:00Z`)) / (24 * 60 * 60 * 1000));
184
+ assertDateOffset(result.nextScheduled.slice(0, 10), result.nextDue.slice(0, 10), originalOffset);
185
+ }
186
+ return;
187
+ }
188
+
189
+ if (caseDef.assertion === "create_compat_invariants") {
190
+ deepMatch(envelope, caseDef.expect, context);
191
+ if (envelope.ok && envelope.result?.path) {
192
+ assert.match(envelope.result.path, /\.md$/);
193
+ assert.equal(envelope.result.path.includes("{"), false);
194
+ assert.equal(envelope.result.path.includes("}"), false);
195
+ }
196
+ return;
197
+ }
198
+
199
+ throw new Error(`Unknown assertion kind: ${caseDef.assertion}`);
200
+ }
@@ -0,0 +1,232 @@
1
+ {
2
+ "generatedAt": "2026-05-31T10:35:30.270Z",
3
+ "totalCases": 4972,
4
+ "files": [
5
+ {
6
+ "file": "date.json",
7
+ "cases": 1601
8
+ },
9
+ {
10
+ "file": "field-mapping.json",
11
+ "cases": 131
12
+ },
13
+ {
14
+ "file": "recurrence.json",
15
+ "cases": 996
16
+ },
17
+ {
18
+ "file": "create-compat.json",
19
+ "cases": 322
20
+ },
21
+ {
22
+ "file": "conformance.json",
23
+ "cases": 20
24
+ },
25
+ {
26
+ "file": "config.json",
27
+ "cases": 682
28
+ },
29
+ {
30
+ "file": "config-schema.json",
31
+ "cases": 27
32
+ },
33
+ {
34
+ "file": "validation.json",
35
+ "cases": 60
36
+ },
37
+ {
38
+ "file": "operations.json",
39
+ "cases": 100
40
+ },
41
+ {
42
+ "file": "templating.json",
43
+ "cases": 17
44
+ },
45
+ {
46
+ "file": "migrations.json",
47
+ "cases": 23
48
+ },
49
+ {
50
+ "file": "dependencies.json",
51
+ "cases": 386
52
+ },
53
+ {
54
+ "file": "reminders.json",
55
+ "cases": 564
56
+ },
57
+ {
58
+ "file": "links.json",
59
+ "cases": 43
60
+ }
61
+ ],
62
+ "byProfile": {
63
+ "core-lite": 2874,
64
+ "recurrence": 1020,
65
+ "extended": 1059,
66
+ "templating": 18,
67
+ "materialized-occurrences": 1
68
+ },
69
+ "bySection": {
70
+ "§3": 1595,
71
+ "§3.6": 6,
72
+ "§2": 131,
73
+ "§4": 996,
74
+ "§5.3": 322,
75
+ "§7.10": 6,
76
+ "§7.11": 11,
77
+ "§7.3.5": 1,
78
+ "§7.3.3": 2,
79
+ "§7.3.4": 1,
80
+ "§9": 667,
81
+ "§9.2.4": 8,
82
+ "§11.7": 1,
83
+ "§9.20": 1,
84
+ "§9.7.1": 14,
85
+ "§9.2.3": 3,
86
+ "§9.19": 15,
87
+ "§6": 60,
88
+ "§5.2": 7,
89
+ "§5.21": 1,
90
+ "§5.2.1": 3,
91
+ "§5.4": 4,
92
+ "§5.5": 4,
93
+ "§5.6": 3,
94
+ "§5.7": 4,
95
+ "§5.8": 5,
96
+ "§5.9": 8,
97
+ "§4.11": 4,
98
+ "§5.10.1": 3,
99
+ "§5.10.2": 2,
100
+ "§5.10.3": 3,
101
+ "§5.11.1": 2,
102
+ "§5.11.2": 3,
103
+ "§5.11.3": 2,
104
+ "§5.12": 1,
105
+ "§5.13": 2,
106
+ "§5.14": 4,
107
+ "§5.4.4": 2,
108
+ "§5.15": 4,
109
+ "§5.16": 3,
110
+ "§5.17": 2,
111
+ "§5.18": 3,
112
+ "§5.19.1": 3,
113
+ "§5.19.2": 3,
114
+ "§5.19.3": 3,
115
+ "§5.19.4": 3,
116
+ "§5.19.5": 4,
117
+ "§5.19.6": 5,
118
+ "§5.3.5": 15,
119
+ "§9.14": 1,
120
+ "§8.2": 2,
121
+ "§8.3": 1,
122
+ "§8.4": 2,
123
+ "§8.5": 3,
124
+ "§8.6": 3,
125
+ "§8.7": 3,
126
+ "§8.8": 2,
127
+ "§8.9": 2,
128
+ "§8.13": 1,
129
+ "§8.10": 1,
130
+ "§8.11": 1,
131
+ "§8.12": 1,
132
+ "§8.14": 1,
133
+ "§10": 946,
134
+ "§10.2.6": 4,
135
+ "§11.3": 25,
136
+ "§11": 10,
137
+ "§11.5": 4,
138
+ "§11.6": 4
139
+ },
140
+ "byOperation": {
141
+ "date.parse_utc": 509,
142
+ "date.parse_local": 509,
143
+ "date.validate": 485,
144
+ "date.get_part": 16,
145
+ "date.has_time": 20,
146
+ "date.is_same": 20,
147
+ "date.is_before": 20,
148
+ "date.resolve_operation_target": 19,
149
+ "date.day_in_timezone": 6,
150
+ "field.default_mapping": 17,
151
+ "field.build_mapping": 48,
152
+ "field.is_completed_status": 9,
153
+ "field.default_completed_status": 3,
154
+ "field.normalize": 23,
155
+ "field.denormalize": 23,
156
+ "field.resolve_display_title": 8,
157
+ "recurrence.complete": 760,
158
+ "recurrence.recalculate": 240,
159
+ "create_compat.create": 322,
160
+ "meta.claim": 4,
161
+ "meta.has_capability": 11,
162
+ "meta.has_profile": 5,
163
+ "config.resolve_collection_path": 648,
164
+ "config.merge_top_level": 4,
165
+ "config.spec_version_effective": 6,
166
+ "config.map_tasknotes_plugin": 10,
167
+ "config.detect_task_file": 14,
168
+ "config.provider_behavior": 3,
169
+ "config.validate_schema": 24,
170
+ "validation.core_evaluate": 56,
171
+ "validation.time_entries": 4,
172
+ "op.mutate_with_validation": 4,
173
+ "op.atomic_write": 2,
174
+ "op.idempotency_check": 2,
175
+ "op.update_patch": 4,
176
+ "op.complete_nonrecurring": 4,
177
+ "op.uncomplete_nonrecurring": 3,
178
+ "recurrence.uncomplete_instance": 5,
179
+ "recurrence.skip_instance": 4,
180
+ "recurrence.unskip_instance": 4,
181
+ "recurrence.effective_state": 4,
182
+ "dependency.add": 3,
183
+ "dependency.remove": 2,
184
+ "dependency.replace": 3,
185
+ "reminder.add": 2,
186
+ "reminder.update": 3,
187
+ "reminder.remove": 2,
188
+ "archive.apply": 1,
189
+ "delete.remove": 2,
190
+ "rename.apply": 4,
191
+ "rename.title_storage_interaction": 2,
192
+ "batch.apply": 4,
193
+ "op.detect_conflict": 3,
194
+ "op.dry_run": 2,
195
+ "op.error_shape": 3,
196
+ "time.start": 3,
197
+ "time.stop": 3,
198
+ "time.replace_entries": 3,
199
+ "time.remove_entry": 3,
200
+ "time.auto_stop_on_complete": 4,
201
+ "time.report_totals": 5,
202
+ "templating.parse_sections": 2,
203
+ "templating.expand_variables": 5,
204
+ "templating.tokenize": 2,
205
+ "templating.merge_frontmatter": 2,
206
+ "templating.create_pipeline": 2,
207
+ "templating.handle_failure": 2,
208
+ "templating.config_defaults": 1,
209
+ "templating.profile_claim_requirements": 1,
210
+ "migration.compat_mode": 2,
211
+ "migration.plan": 1,
212
+ "migration.normalize_aliases": 2,
213
+ "migration.normalize_temporal": 3,
214
+ "migration.resolve_instance_overlap": 3,
215
+ "migration.normalize_dependencies": 3,
216
+ "migration.normalize_reminders": 2,
217
+ "migration.normalize_links": 2,
218
+ "migration.report_summary": 1,
219
+ "migration.divergence_register": 1,
220
+ "migration.deprecation_policy": 1,
221
+ "migration.safety_guards": 1,
222
+ "migration.compat_statement": 1,
223
+ "dependency.validate_entry": 378,
224
+ "dependency.validate_set": 4,
225
+ "dependency.missing_target_behavior": 4,
226
+ "reminder.validate_entry": 560,
227
+ "reminder.validate_set": 4,
228
+ "link.parse": 25,
229
+ "link.resolve": 14,
230
+ "link.update_references_on_rename": 4
231
+ }
232
+ }