@drbaher/draft-cli 0.3.2 → 0.4.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/CHANGELOG.md CHANGED
@@ -4,6 +4,50 @@ All notable changes to this project will be documented in this file. The
4
4
  format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
5
5
  and the project adheres to semantic versioning once it leaves 0.x.
6
6
 
7
+ ## 0.4.0 — 2026-05-16
8
+
9
+ ### Added
10
+
11
+ - **Computed placeholders.** Long-form schema entries can declare a
12
+ `computed` block referencing another key:
13
+ ```json
14
+ "term_end": {
15
+ "aliases": ["Term End"],
16
+ "type": "date",
17
+ "format": "MMMM d, yyyy",
18
+ "computed": { "from": "effective_date", "op": "+", "value": "2 years" }
19
+ }
20
+ ```
21
+ At substitution time, if no value was supplied via CLI / `--params`
22
+ / interactive / default, the computed entry's value is derived from
23
+ the `from` placeholder. Explicit CLI / `--params` values still win —
24
+ computed only fills the gap. Q2.1 locked: expression syntax lives
25
+ in the schema only, not in template text — keeps T1 detection
26
+ unchanged. Q2.2 locked: v0.4.0 supports date arithmetic only
27
+ (`+` / `-` with `<n> day|week|month|year[s]` durations). Money
28
+ math and string concat deferred to a future release.
29
+ - **Schema-time cycle detection.** `parseSchema` throws if any
30
+ `computed.from` chain revisits a key (e.g. `a → b → a`), or if
31
+ `computed.from` references a key that doesn't exist in the same
32
+ schema. Catches misconfiguration before substitution starts.
33
+ - **Orphan-check exemption.** Schema entries that are referenced only
34
+ as another entry's `computed.from` source (and never appear as
35
+ detected aliases in the template) are no longer reported as
36
+ orphans. They're "feeders" — declared so a computed entry can
37
+ reference them, even though the template doesn't show them.
38
+ - **New public API:** `parseDuration(raw)`, `addDuration(date, op, dur)`,
39
+ `computeValues(placeholders, resolved)`.
40
+
41
+ ### Schema-contract change
42
+
43
+ `PARAM_SCHEMA.md` §5 gains a "Computed placeholders" section. Long-
44
+ form entries can now include a `computed: { from, op, value }` block;
45
+ short form is unchanged. v0.4.0 schemas are forward-compatible with
46
+ v0.3.x readers (which will silently ignore the `computed` field as
47
+ unrecognized long-form metadata, treating the entry as a regular
48
+ placeholder — but then the user has to supply a value, since v0.3.x
49
+ won't compute one).
50
+
7
51
  ## 0.3.2 — 2026-05-16
8
52
 
9
53
  ### Fixed
package/PARAM_SCHEMA.md CHANGED
@@ -270,6 +270,67 @@ Programmatic API for drivers: `parseDateValue`, `formatDateValue`,
270
270
  `parseMoneyValue`, `formatMoneyValue`, `normalizeTypedValue`,
271
271
  `normalizeTypedValues`.
272
272
 
273
+ ### Computed placeholders (v0.4.0, opt-in)
274
+
275
+ Long-form entries can declare a `computed` block referencing another
276
+ key in the same schema. At substitution time, if no value was
277
+ supplied via CLI / `--params` / interactive / default, the computed
278
+ entry's value is derived from the `from` placeholder via simple
279
+ date arithmetic. Explicit user-supplied values still win — computed
280
+ only fills the gap.
281
+
282
+ ```json
283
+ {
284
+ "_meta": { "schema_version": 1 },
285
+ "effective_date": {
286
+ "aliases": ["Effective Date"],
287
+ "type": "date", "format": "MMMM d, yyyy"
288
+ },
289
+ "term_end": {
290
+ "aliases": ["Term End"],
291
+ "type": "date", "format": "MMMM d, yyyy",
292
+ "computed": { "from": "effective_date", "op": "+", "value": "2 years" }
293
+ }
294
+ }
295
+ ```
296
+
297
+ | Field | Type | Required | Notes |
298
+ | ------- | -------- | -------- | ----- |
299
+ | `from` | string | yes | Key of another entry in the same schema. Schema validation rejects unknown references. |
300
+ | `op` | `"+"`/`"-"` | yes | Add or subtract the duration from the `from` value. |
301
+ | `value` | string | yes | Duration in `<n> <unit>` form, where `<unit>` is `day`, `week`, `month`, or `year` (singular or plural; case-insensitive). |
302
+
303
+ **Q2.1 locked:** Expression syntax lives in the schema, **not** in
304
+ template text. T1 bracket detection treats `[Term End]` as an
305
+ ordinary placeholder; the schema-level `computed` block decides how
306
+ its value is derived.
307
+
308
+ **Q2.2 locked:** v0.4.0 supports **date arithmetic only**. Money
309
+ math (`+ 10%`) and string concat (`Party A + " Inc."`) are deferred
310
+ to a future release once the date-arithmetic design is proven against
311
+ real templates.
312
+
313
+ **Resolution order:** value resolution → typed-parameter normalization
314
+ (§ above) → computed-placeholder evaluation → substitution.
315
+
316
+ **Cycle and reference safety:** `parseSchema` walks every
317
+ `computed.from` chain and throws at load time if a chain revisits a
318
+ key (e.g. `a → b → a`) or references a non-existent key. Caught
319
+ before substitution.
320
+
321
+ **Orphan-check exemption:** an entry that's referenced only as
322
+ another entry's `computed.from` (never appears in the template) is
323
+ **not** an orphan. It's a feeder used solely for computation.
324
+
325
+ **Format inheritance:** the computed entry's `format` field is used
326
+ to render the result. If the computed entry doesn't declare `format`,
327
+ the default `MMMM d, yyyy` applies. The `from` entry's `format` is
328
+ used for parsing the source value (since by then it's normalized to
329
+ that format).
330
+
331
+ Programmatic API for drivers: `parseDuration`, `addDuration`,
332
+ `computeValues`.
333
+
273
334
  ### Orphan handling (Q4 locked)
274
335
 
275
336
  Schema declares a key whose alias list matches no detected phrase →
package/draft-cli.mjs CHANGED
@@ -70,7 +70,7 @@ import { fileURLToPath } from "node:url";
70
70
  */
71
71
 
72
72
  /** @type {string} */
73
- export const VERSION = "0.3.2";
73
+ export const VERSION = "0.4.0";
74
74
 
75
75
  // ─── EXIT CODES ─────────────────────────────────────────────────────────────
76
76
  /**
@@ -914,6 +914,32 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
914
914
  e.exitCode = EXIT.IO;
915
915
  throw e;
916
916
  }
917
+ // v2 #2: computed placeholders. Optional `computed` block on long-form
918
+ // entries; { from: <other-key>, op: "+"|"-", value: "<n> <unit>" }.
919
+ let computed = null;
920
+ if (v.computed !== undefined) {
921
+ if (!v.computed || typeof v.computed !== "object" || Array.isArray(v.computed)) {
922
+ const e = new Error(`${sourceLabel}: long-form entry '${k}' has invalid 'computed' (must be an object)`);
923
+ e.exitCode = EXIT.IO;
924
+ throw e;
925
+ }
926
+ if (typeof v.computed.from !== "string") {
927
+ const e = new Error(`${sourceLabel}: long-form entry '${k}' computed.from must be a string (key of another schema entry)`);
928
+ e.exitCode = EXIT.IO;
929
+ throw e;
930
+ }
931
+ if (v.computed.op !== "+" && v.computed.op !== "-") {
932
+ const e = new Error(`${sourceLabel}: long-form entry '${k}' computed.op must be "+" or "-"`);
933
+ e.exitCode = EXIT.IO;
934
+ throw e;
935
+ }
936
+ if (typeof v.computed.value !== "string") {
937
+ const e = new Error(`${sourceLabel}: long-form entry '${k}' computed.value must be a string (duration like "2 years")`);
938
+ e.exitCode = EXIT.IO;
939
+ throw e;
940
+ }
941
+ computed = { from: v.computed.from, op: v.computed.op, value: v.computed.value };
942
+ }
917
943
  entries[k] = {
918
944
  aliases: v.aliases.slice(),
919
945
  required: v.required !== false,
@@ -924,6 +950,7 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
924
950
  type: typeof v.type === "string" ? v.type : null,
925
951
  format: typeof v.format === "string" ? v.format : null,
926
952
  currency: typeof v.currency === "string" ? v.currency : null,
953
+ computed,
927
954
  };
928
955
  } else {
929
956
  if (!Array.isArray(v)) {
@@ -931,7 +958,30 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
931
958
  e.exitCode = EXIT.IO;
932
959
  throw e;
933
960
  }
934
- entries[k] = { aliases: v.slice(), required: true, default: null, type: null, format: null, currency: null };
961
+ entries[k] = { aliases: v.slice(), required: true, default: null, type: null, format: null, currency: null, computed: null };
962
+ }
963
+ }
964
+ // v2 #2: validate computed references (point to existing keys; no cycles).
965
+ for (const [key, entry] of Object.entries(entries)) {
966
+ if (!entry.computed) continue;
967
+ if (!entries[entry.computed.from]) {
968
+ const e = new Error(`${sourceLabel}: '${key}'.computed.from = "${entry.computed.from}" does not match any other key in this schema`);
969
+ e.exitCode = EXIT.IO;
970
+ throw e;
971
+ }
972
+ // Walk the computed.from chain from this key; bail if we revisit.
973
+ const visited = [key];
974
+ let cursor = entry.computed.from;
975
+ while (cursor) {
976
+ if (visited.includes(cursor)) {
977
+ const e = new Error(`${sourceLabel}: computed cycle detected: ${[...visited, cursor].join(" → ")}`);
978
+ e.exitCode = EXIT.IO;
979
+ throw e;
980
+ }
981
+ visited.push(cursor);
982
+ const next = entries[cursor];
983
+ if (!next || !next.computed) break;
984
+ cursor = next.computed.from;
935
985
  }
936
986
  }
937
987
  return { form: long ? "long" : "short", entries };
@@ -1075,6 +1125,7 @@ function assemble(tier, hits, schema, warnings, fromLlm = false) {
1075
1125
  type: resolved.type,
1076
1126
  format: resolved.format,
1077
1127
  currency: resolved.currency,
1128
+ computed: resolved.computed,
1078
1129
  hits: [],
1079
1130
  });
1080
1131
  }
@@ -1097,6 +1148,7 @@ function resolveKey(hit, schema, fromLlm) {
1097
1148
  type: entry.type || null,
1098
1149
  format: entry.format || null,
1099
1150
  currency: entry.currency || null,
1151
+ computed: entry.computed || null,
1100
1152
  };
1101
1153
  }
1102
1154
  }
@@ -1104,7 +1156,7 @@ function resolveKey(hit, schema, fromLlm) {
1104
1156
  }
1105
1157
  const key = fromLlm && hit.suggested_key ? hit.suggested_key : canonicalKey(hit.inner);
1106
1158
  if (!validKey(key)) return null;
1107
- return { key, required: true, default: null, aliases: [hit.inner], type: null, format: null, currency: null };
1159
+ return { key, required: true, default: null, aliases: [hit.inner], type: null, format: null, currency: null, computed: null };
1108
1160
  }
1109
1161
 
1110
1162
  // ─── VALUE RESOLUTION (CLI > JSON > prompt > default) ───────────────────────
@@ -1177,7 +1229,9 @@ export async function resolveValues(placeholders, opts, paramsObj, { prompter =
1177
1229
  sources[p.key] = "default";
1178
1230
  continue;
1179
1231
  }
1180
- if (p.required) missing.push(p);
1232
+ // v2 #2: computed placeholders auto-resolve later via `computeValues`.
1233
+ // Don't count them as missing here even though no source supplied a value.
1234
+ if (p.required && !p.computed) missing.push(p);
1181
1235
  }
1182
1236
  return { resolved, missing, sources };
1183
1237
  }
@@ -1255,6 +1309,46 @@ export function formatDateValue(date, format) {
1255
1309
  });
1256
1310
  }
1257
1311
 
1312
+ /**
1313
+ * Parse a duration string for computed placeholders (v2 #2).
1314
+ * Accepts `<n> <unit>` where unit is one of `day | week | month | year`
1315
+ * (singular or plural). Returns an object with the unit as plural key.
1316
+ * Returns `null` on parse failure.
1317
+ *
1318
+ * @param {string} raw
1319
+ * @returns {{ days?: number, weeks?: number, months?: number, years?: number } | null}
1320
+ */
1321
+ export function parseDuration(raw) {
1322
+ const m = /^(\d+)\s+(day|week|month|year)s?$/i.exec(String(raw).trim());
1323
+ if (!m) return null;
1324
+ const n = parseInt(m[1], 10);
1325
+ if (!isFinite(n) || n < 0) return null;
1326
+ const unit = m[2].toLowerCase();
1327
+ return { [`${unit}s`]: n };
1328
+ }
1329
+
1330
+ /**
1331
+ * Add or subtract a duration from a Date. Uses UTC field manipulation
1332
+ * via `setUTC*` methods, so day/month/year overflow follows JavaScript's
1333
+ * default behavior (e.g. Jan 31 + 1 month = Mar 3, not Feb 28). For
1334
+ * legal-doc use cases ("2 years from effective date") this is the
1335
+ * expected behavior; anniversary dates are unambiguous.
1336
+ *
1337
+ * @param {Date} date
1338
+ * @param {"+"|"-"} op
1339
+ * @param {{ days?: number, weeks?: number, months?: number, years?: number }} dur
1340
+ * @returns {Date}
1341
+ */
1342
+ export function addDuration(date, op, dur) {
1343
+ const sign = op === "-" ? -1 : 1;
1344
+ const d = new Date(date.getTime());
1345
+ if (dur.years) d.setUTCFullYear(d.getUTCFullYear() + sign * dur.years);
1346
+ if (dur.months) d.setUTCMonth(d.getUTCMonth() + sign * dur.months);
1347
+ if (dur.weeks) d.setUTCDate(d.getUTCDate() + sign * dur.weeks * 7);
1348
+ if (dur.days) d.setUTCDate(d.getUTCDate() + sign * dur.days);
1349
+ return d;
1350
+ }
1351
+
1258
1352
  /**
1259
1353
  * Parse a money input. Accepts `$5,000`, `5000.50`, `$5M`, `2.5K`, etc.
1260
1354
  * Handles `K`/`M`/`B` suffixes (case-insensitive). Returns the value in
@@ -1394,6 +1488,91 @@ export function normalizeTypedValues(placeholders, resolved) {
1394
1488
  return { ok: errors.length === 0, errors, normalized };
1395
1489
  }
1396
1490
 
1491
+ // ─── COMPUTED PLACEHOLDERS (v2 #2) ──────────────────────────────────────────
1492
+ // Schema entries can declare a `computed` block referencing another key in
1493
+ // the same schema:
1494
+ //
1495
+ // "term_end": { "aliases": ["Term End"], "type": "date",
1496
+ // "computed": { "from": "effective_date", "op": "+", "value": "2 years" } }
1497
+ //
1498
+ // At substitution time, if no value was supplied via CLI/--params/interactive/
1499
+ // default, the computed entry's value is derived from its `from` placeholder.
1500
+ // CLI/--params explicit values still win — computed only fills the gap.
1501
+ //
1502
+ // Cycles in `from` references are detected at parseSchema time. Missing-`from`
1503
+ // errors and bad-duration errors surface at compute time with a per-key
1504
+ // message; like typed-param errors, all errors are collected before returning
1505
+ // so the user sees every failure at once.
1506
+
1507
+ /**
1508
+ * Run computed-placeholder evaluation on already-resolved values. Mutates
1509
+ * `resolved` in place with computed values for any placeholder that has a
1510
+ * `computed` block and no existing value. Iterative — handles chains
1511
+ * (B from A, C from B) without an explicit topological sort.
1512
+ *
1513
+ * @param {Placeholder[]} placeholders
1514
+ * @param {Object<string,string>} resolved
1515
+ * @returns {{ ok: boolean, errors: Array<{ key: string, message: string }>, computed: Object<string,{from: string, op: string, value: string, to: string}> }}
1516
+ */
1517
+ export function computeValues(placeholders, resolved) {
1518
+ const errors = [];
1519
+ const computed = {};
1520
+ const pending = placeholders.filter(
1521
+ (p) => p.computed && resolved[p.key] === undefined
1522
+ );
1523
+ if (pending.length === 0) return { ok: true, errors: [], computed: {} };
1524
+
1525
+ let progress = true;
1526
+ while (progress && pending.length > 0) {
1527
+ progress = false;
1528
+ for (let i = pending.length - 1; i >= 0; i--) {
1529
+ const p = pending[i];
1530
+ const fromKey = p.computed.from;
1531
+ const fromValue = resolved[fromKey];
1532
+ if (fromValue === undefined) continue;
1533
+ try {
1534
+ const result = computeOneValue(p, fromValue);
1535
+ resolved[p.key] = result;
1536
+ computed[p.key] = { from: fromKey, op: p.computed.op, value: p.computed.value, to: result };
1537
+ pending.splice(i, 1);
1538
+ progress = true;
1539
+ } catch (e) {
1540
+ errors.push({ key: p.key, message: e.message });
1541
+ pending.splice(i, 1);
1542
+ }
1543
+ }
1544
+ }
1545
+ for (const p of pending) {
1546
+ errors.push({
1547
+ key: p.key,
1548
+ message: `cannot compute: depends on "${p.computed.from}" which is unresolved`,
1549
+ });
1550
+ }
1551
+ return { ok: errors.length === 0, errors, computed };
1552
+ }
1553
+
1554
+ function computeOneValue(p, fromValue) {
1555
+ // v2: dates only. Future expansions (money math, string concat) would
1556
+ // dispatch on placeholder type here.
1557
+ const date = parseDateValue(fromValue);
1558
+ if (!date) {
1559
+ throw new Error(
1560
+ `cannot parse "${fromValue}" as a date (from "${p.computed.from}"). ` +
1561
+ `Computed placeholders need a date-shaped source value.`
1562
+ );
1563
+ }
1564
+ const dur = parseDuration(p.computed.value);
1565
+ if (!dur) {
1566
+ throw new Error(
1567
+ `cannot parse duration "${p.computed.value}". Expected ` +
1568
+ `"<n> <unit>" where unit is day, week, month, or year (singular ` +
1569
+ `or plural).`
1570
+ );
1571
+ }
1572
+ const result = addDuration(date, p.computed.op, dur);
1573
+ return formatDateValue(result, p.format || "MMMM d, yyyy");
1574
+ }
1575
+
1397
1576
  async function nodePrompter(placeholder) {
1398
1577
  if (!process.stdin.isTTY) return null;
1399
1578
  const rl = createInterface({ input: process.stdin, output: process.stderr });
@@ -1416,9 +1595,18 @@ async function nodePrompter(placeholder) {
1416
1595
  export function findOrphans(schema, placeholders) {
1417
1596
  if (!schema) return [];
1418
1597
  const present = new Set(placeholders.map((p) => p.key));
1598
+ // v2 #2: an entry that another entry's `computed.from` points at is
1599
+ // legitimately not in the template — it's a "feeder" used only for
1600
+ // computation. Exempt those from the orphan check.
1601
+ const computedFromTargets = new Set();
1602
+ for (const entry of Object.values(schema.entries)) {
1603
+ if (entry.computed && entry.computed.from) computedFromTargets.add(entry.computed.from);
1604
+ }
1419
1605
  const orphans = [];
1420
1606
  for (const [key, entry] of Object.entries(schema.entries)) {
1421
- if (!present.has(key)) orphans.push({ key, aliases: entry.aliases.slice() });
1607
+ if (present.has(key)) continue;
1608
+ if (computedFromTargets.has(key)) continue;
1609
+ orphans.push({ key, aliases: entry.aliases.slice() });
1422
1610
  }
1423
1611
  return orphans;
1424
1612
  }
@@ -1647,6 +1835,20 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
1647
1835
  }
1648
1836
  return EXIT.VALIDATION;
1649
1837
  }
1838
+ // v2 #2: computed-placeholder validation (same gate as cmdDraft).
1839
+ const computeCheck = computeValues(result.placeholders, resolved);
1840
+ if (!computeCheck.ok) {
1841
+ for (const ce of computeCheck.errors) {
1842
+ err.write(paint(`error: computed value failed for "${ce.key}": ${ce.message}\n`, "red", err));
1843
+ }
1844
+ if (opts.json) {
1845
+ out.write(JSON.stringify({
1846
+ ok: false,
1847
+ computed_errors: computeCheck.errors.map(({ key, message }) => ({ key, message })),
1848
+ }, null, 2) + "\n");
1849
+ }
1850
+ return EXIT.VALIDATION;
1851
+ }
1650
1852
  if (opts.json) {
1651
1853
  out.write(JSON.stringify({ ok: true, resolved: Object.keys(resolved), sources }, null, 2) + "\n");
1652
1854
  } else {
@@ -1727,6 +1929,19 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
1727
1929
  return EXIT.VALIDATION;
1728
1930
  }
1729
1931
 
1932
+ // v2 #2: computed placeholders. Fill any computed entries whose value
1933
+ // wasn't already supplied via CLI / --params / --interactive / default.
1934
+ // Runs after typed normalization so the source values are in canonical
1935
+ // form (e.g. a "date" type is already in the format string before we
1936
+ // parse it back for arithmetic).
1937
+ const computeCheck = computeValues(result.placeholders, resolved);
1938
+ if (!computeCheck.ok) {
1939
+ for (const ce of computeCheck.errors) {
1940
+ err.write(paint(`error: computed value failed for "${ce.key}": ${ce.message}\n`, "red", err));
1941
+ }
1942
+ return EXIT.VALIDATION;
1943
+ }
1944
+
1730
1945
  // Diff mode: print a substitution table and exit without writing output.
1731
1946
  if (opts.diff) {
1732
1947
  if (opts.json) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drbaher/draft-cli",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Fill placeholders in a legal-document template with parameter values. Part of the contract-operations suite.",
5
5
  "type": "module",
6
6
  "bin": {