@drbaher/draft-cli 0.2.0 → 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,116 @@ 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
+
51
+ ## 0.3.2 — 2026-05-16
52
+
53
+ ### Fixed
54
+
55
+ - **Publish auth: restored `NODE_AUTH_TOKEN` env block.** v0.3.1's
56
+ hotfix bumped `publish.yml` Node from 20 to 22 on the hypothesis
57
+ that npm CLI 11.5.1+ would auto-detect OIDC and ignore the
58
+ setup-node placeholder. It didn't: Node 22's npm 11.x still sent
59
+ the literal `XXXXX-XXXXX-XXXXX-XXXXX` placeholder and got the
60
+ same 404 from npm. Root cause is *not* Node version; either
61
+ `setup-node@v6` always writes the placeholder env into the
62
+ publish step, or npm CLI prefers the `.npmrc` token over OIDC
63
+ even when both are available. Under investigation.
64
+ - **v0.3.1 tag exists on GitHub but did NOT publish to npm.** Skip
65
+ it. Registry latest is `0.2.0` until v0.3.2 ships.
66
+
67
+ Pragmatic call: v0.3.2 ships via the bootstrap `NPM_TOKEN` path.
68
+ Trusted Publisher stays configured on npm so the switch back to
69
+ pure OIDC is a one-line change in `publish.yml` once we understand
70
+ why npm CLI isn't using OIDC. `feedback_oidc_setup_node_v6_placeholder.md`
71
+ in memory tracks the symptom + workarounds tried.
72
+
73
+ ## 0.3.1 — 2026-05-16
74
+
75
+ ### Fixed
76
+
77
+ - **`publish.yml` now uses Node 22 instead of Node 20.** npm
78
+ Trusted Publishing requires npm CLI 11.5.1 or later, which ships
79
+ with Node 22.14+. Node 20 (npm 10.x) silently falls back to
80
+ `NODE_AUTH_TOKEN` when configured for a registry, and to
81
+ `setup-node@v6`'s placeholder value (`XXXXX-...`) when the env
82
+ isn't set — producing a 404 from npm masking the actual 401.
83
+ - **v0.3.0 tag exists on GitHub but did NOT publish to npm.** v0.3.0
84
+ was the first publish attempt without an `NPM_TOKEN` fallback
85
+ (PR #10 reverted the bootstrap); the npm-CLI-too-old issue
86
+ surfaced immediately. No package was uploaded. v0.3.1 is the
87
+ rebrand of v0.3.0's typed-parameter feature with the workflow
88
+ fix applied. Skip v0.3.0.
89
+
90
+ ## 0.3.0 — 2026-05-16
91
+
92
+ ### Added
93
+
94
+ - **Typed parameters (`type: date | money | party`).** Long-form
95
+ schema entries can declare a `type`, with optional `format` (date)
96
+ or `currency` (money). Inputs are validated and normalized between
97
+ value resolution and substitution; bad inputs hard-error with a
98
+ per-key message (exit 4). See `PARAM_SCHEMA.md` §5 for the accepted
99
+ shapes per type, the rejected ambiguous forms (Q3.1: US
100
+ `MM/DD/YYYY` and European `DD/MM/YYYY` are rejected as ambiguous),
101
+ and the v2 currency scope (Q3.2: USD only).
102
+ - **`--validate` now catches type errors** before draft runs. With
103
+ `--json`, errors are emitted as a `type_errors` array on the
104
+ result payload.
105
+ - **New public API:** `parseDateValue(raw)`, `formatDateValue(date, fmt)`,
106
+ `parseMoneyValue(raw)` → minor units, `formatMoneyValue(minor, currency)`,
107
+ `normalizeTypedValue(raw, placeholder)`, `normalizeTypedValues(placeholders, resolved)`.
108
+
109
+ ### Schema-contract change
110
+
111
+ `PARAM_SCHEMA.md` §5 gains a "Typed parameters" section. Long-form
112
+ entries can now include `type`, `format`, and `currency` fields; short
113
+ form is unchanged. v0.3.0 schemas are forward-compatible with v0.2.x
114
+ readers (which will silently ignore the new fields, since they're
115
+ opt-in metadata on the long-form entry).
116
+
7
117
  ## 0.2.0 — 2026-05-16
8
118
 
9
119
  ### Added
package/PARAM_SCHEMA.md CHANGED
@@ -237,6 +237,100 @@ its own alias list (Q3 locked) — list it explicitly if needed.
237
237
  Parser selects long form iff a top-level `_meta` key is present. Short
238
238
  and long are not mixable within one file.
239
239
 
240
+ ### Typed parameters (v0.3.0, opt-in)
241
+
242
+ Long-form entries can declare `type`, with optional `format` (`date`)
243
+ or `currency` (`money`). Inputs are validated and normalized between
244
+ value resolution and substitution. Bad input → exit 4
245
+ (`EXIT.VALIDATION`) with a per-key error message; all type errors are
246
+ collected before exit so the user sees every issue at once.
247
+
248
+ ```json
249
+ {
250
+ "_meta": { "schema_version": 1 },
251
+ "effective_date": { "aliases": ["Effective Date"], "type": "date", "format": "MMMM d, yyyy" },
252
+ "purchase_amount": { "aliases": ["Purchase Amount"], "type": "money", "currency": "USD" },
253
+ "party_a": { "aliases": ["Party A"], "type": "party" }
254
+ }
255
+ ```
256
+
257
+ | `type` | Accepts | Normalizes to | Notes |
258
+ | -------- | -------------------------------------------------------------------- | -------------------------------------- | ----- |
259
+ | `date` | ISO (`2027-01-15`) or spelled (`January 15, 2027`, `Jan 15 2027`) | `format` field (default `MMMM d, yyyy`) | Q3.1 locked: US (`MM/DD/YYYY`) and European (`DD/MM/YYYY`) numeric forms are **rejected** as ambiguous. |
260
+ | `money` | `$5,000`, `5000.50`, `$5M`, `2.5K`, `1B`; rejects `$$5`, `5,00`, etc. | `currency`-formatted (e.g. `$5,000,000.00`) | Q3.2 locked: v0.3.0 supports `currency: "USD"` only. |
261
+ | `party` | Non-empty after trim; no markdown links `[text](url)`; no trailing punctuation `.,;:!?` | Trimmed string | Q3.3 locked: hard error on bad input — typed params are opt-in. |
262
+
263
+ `format` for `date` supports `yyyy`, `MMMM`, `MM`, `d` tokens (matched
264
+ in a single pass — `MM` doesn't accidentally consume `MMMM`, `d`
265
+ doesn't leak into month names). Other literal characters pass through
266
+ unchanged. Other tokens (e.g. `HH:mm`, `dd`, `EEEE`) are deferred to
267
+ future versions.
268
+
269
+ Programmatic API for drivers: `parseDateValue`, `formatDateValue`,
270
+ `parseMoneyValue`, `formatMoneyValue`, `normalizeTypedValue`,
271
+ `normalizeTypedValues`.
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
+
240
334
  ### Orphan handling (Q4 locked)
241
335
 
242
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.2.0";
73
+ export const VERSION = "0.4.0";
74
74
 
75
75
  // ─── EXIT CODES ─────────────────────────────────────────────────────────────
76
76
  /**
@@ -914,10 +914,43 @@ 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,
920
946
  default: Object.prototype.hasOwnProperty.call(v, "default") ? v.default : null,
947
+ // v2 #3: typed parameters. `type` is one of `date|money|party` (or
948
+ // absent → no validation/normalization). `format` (date) and
949
+ // `currency` (money) are optional.
950
+ type: typeof v.type === "string" ? v.type : null,
951
+ format: typeof v.format === "string" ? v.format : null,
952
+ currency: typeof v.currency === "string" ? v.currency : null,
953
+ computed,
921
954
  };
922
955
  } else {
923
956
  if (!Array.isArray(v)) {
@@ -925,7 +958,30 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
925
958
  e.exitCode = EXIT.IO;
926
959
  throw e;
927
960
  }
928
- entries[k] = { aliases: v.slice(), required: true, default: 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;
929
985
  }
930
986
  }
931
987
  return { form: long ? "long" : "short", entries };
@@ -1066,6 +1122,10 @@ function assemble(tier, hits, schema, warnings, fromLlm = false) {
1066
1122
  required: resolved.required,
1067
1123
  default: resolved.default,
1068
1124
  aliases: resolved.aliases,
1125
+ type: resolved.type,
1126
+ format: resolved.format,
1127
+ currency: resolved.currency,
1128
+ computed: resolved.computed,
1069
1129
  hits: [],
1070
1130
  });
1071
1131
  }
@@ -1080,14 +1140,23 @@ function resolveKey(hit, schema, fromLlm) {
1080
1140
  if (schema) {
1081
1141
  for (const [key, entry] of Object.entries(schema.entries)) {
1082
1142
  if (entry.aliases.includes(hit.inner)) {
1083
- return { key, required: entry.required, default: entry.default, aliases: entry.aliases };
1143
+ return {
1144
+ key,
1145
+ required: entry.required,
1146
+ default: entry.default,
1147
+ aliases: entry.aliases,
1148
+ type: entry.type || null,
1149
+ format: entry.format || null,
1150
+ currency: entry.currency || null,
1151
+ computed: entry.computed || null,
1152
+ };
1084
1153
  }
1085
1154
  }
1086
1155
  return null;
1087
1156
  }
1088
1157
  const key = fromLlm && hit.suggested_key ? hit.suggested_key : canonicalKey(hit.inner);
1089
1158
  if (!validKey(key)) return null;
1090
- return { key, required: true, default: null, aliases: [hit.inner] };
1159
+ return { key, required: true, default: null, aliases: [hit.inner], type: null, format: null, currency: null, computed: null };
1091
1160
  }
1092
1161
 
1093
1162
  // ─── VALUE RESOLUTION (CLI > JSON > prompt > default) ───────────────────────
@@ -1160,11 +1229,350 @@ export async function resolveValues(placeholders, opts, paramsObj, { prompter =
1160
1229
  sources[p.key] = "default";
1161
1230
  continue;
1162
1231
  }
1163
- 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);
1164
1235
  }
1165
1236
  return { resolved, missing, sources };
1166
1237
  }
1167
1238
 
1239
+ // ─── TYPED-PARAMETER NORMALIZATION (v2 #3) ──────────────────────────────────
1240
+ // Schema entries can declare `type: date | money | party` with optional
1241
+ // `format` (date) or `currency` (money). Inputs are validated and normalized
1242
+ // after value resolution and before substitution. Hard error (exit 4) on
1243
+ // invalid input — typed params are opt-in; the user asked for validation.
1244
+
1245
+ const MONTH_NAMES = ["January", "February", "March", "April", "May", "June",
1246
+ "July", "August", "September", "October", "November", "December"];
1247
+ const MONTH_INDEX = (() => {
1248
+ const m = {};
1249
+ MONTH_NAMES.forEach((name, i) => {
1250
+ m[name.toLowerCase()] = i;
1251
+ m[name.slice(0, 3).toLowerCase()] = i;
1252
+ });
1253
+ // "Sept" is a common 4-letter abbrev.
1254
+ m.sept = 8;
1255
+ return m;
1256
+ })();
1257
+
1258
+ /**
1259
+ * Parse a date input. Accepts ISO `YYYY-MM-DD` or spelled
1260
+ * `Month D, YYYY` / `Mon D YYYY`. Returns a UTC `Date` on success or `null`
1261
+ * on failure. Q3.1: US (`MM/DD/YYYY`) and European (`DD/MM/YYYY`) numeric
1262
+ * formats are NOT accepted — they're ambiguous and footgun-y. Use ISO for
1263
+ * machine input, spelled for human input.
1264
+ *
1265
+ * @param {string} raw
1266
+ * @returns {Date | null}
1267
+ */
1268
+ export function parseDateValue(raw) {
1269
+ const s = String(raw).trim();
1270
+ const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
1271
+ if (iso) {
1272
+ const [, y, m, d] = iso;
1273
+ const date = new Date(Date.UTC(+y, +m - 1, +d));
1274
+ // Reject impossible dates (e.g. 2026-02-31 round-trips to 2026-03-03).
1275
+ if (date.getUTCFullYear() !== +y || date.getUTCMonth() !== +m - 1 || date.getUTCDate() !== +d) return null;
1276
+ return date;
1277
+ }
1278
+ const spelled = /^([A-Za-z]+)\s+(\d{1,2}),?\s+(\d{4})$/.exec(s);
1279
+ if (spelled) {
1280
+ const month = MONTH_INDEX[spelled[1].toLowerCase()];
1281
+ if (month === undefined) return null;
1282
+ const date = new Date(Date.UTC(+spelled[3], month, +spelled[2]));
1283
+ if (date.getUTCMonth() !== month || date.getUTCDate() !== +spelled[2]) return null;
1284
+ return date;
1285
+ }
1286
+ return null;
1287
+ }
1288
+
1289
+ /**
1290
+ * Format a `Date` per a simple format string. Supported tokens:
1291
+ * `yyyy` (year), `MMMM` (full month name), `MM` (2-digit month), `d` (day).
1292
+ * Order doesn't matter; tokens are matched in a single pass so MMMM doesn't
1293
+ * accidentally consume MM, and `d` doesn't leak into month names.
1294
+ *
1295
+ * @param {Date} date
1296
+ * @param {string} format
1297
+ * @returns {string}
1298
+ */
1299
+ export function formatDateValue(date, format) {
1300
+ const y = date.getUTCFullYear();
1301
+ const m = date.getUTCMonth();
1302
+ const d = date.getUTCDate();
1303
+ return format.replace(/yyyy|MMMM|MM|d/g, (token) => {
1304
+ if (token === "yyyy") return String(y);
1305
+ if (token === "MMMM") return MONTH_NAMES[m];
1306
+ if (token === "MM") return String(m + 1).padStart(2, "0");
1307
+ if (token === "d") return String(d);
1308
+ return token;
1309
+ });
1310
+ }
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
+
1352
+ /**
1353
+ * Parse a money input. Accepts `$5,000`, `5000.50`, `$5M`, `2.5K`, etc.
1354
+ * Handles `K`/`M`/`B` suffixes (case-insensitive). Returns the value in
1355
+ * minor units (cents for USD) as an integer, or `null` on failure.
1356
+ *
1357
+ * @param {string} raw
1358
+ * @returns {number | null}
1359
+ */
1360
+ export function parseMoneyValue(raw) {
1361
+ const s = String(raw).trim();
1362
+ if (!s) return null;
1363
+ // Strict shape: optional minus, optional single $, digits (with optional
1364
+ // thousand-comma groups), optional decimal, optional K/M/B. Rejects
1365
+ // doubled `$`, ad-hoc comma placement, multiple decimals, words.
1366
+ if (!/^-?\$?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?[KMB]?$/i.test(s)) return null;
1367
+ let core = s.replace(/[$,\s]/g, "");
1368
+ let mult = 1;
1369
+ if (/[KMB]$/i.test(core)) {
1370
+ mult = { K: 1e3, M: 1e6, B: 1e9 }[core.slice(-1).toUpperCase()];
1371
+ core = core.slice(0, -1);
1372
+ }
1373
+ const n = parseFloat(core);
1374
+ if (!isFinite(n)) return null;
1375
+ return Math.round(n * mult * 100);
1376
+ }
1377
+
1378
+ /**
1379
+ * Format a money value (in minor units, e.g. cents for USD) per a currency.
1380
+ * Q3.2: v2 supports USD only. Adds thousand separators and always renders
1381
+ * two decimal places.
1382
+ *
1383
+ * @param {number} minor — value in minor units (cents).
1384
+ * @param {string} currency — currency code (only "USD" supported in v2).
1385
+ * @returns {string}
1386
+ * @throws {Error} on unsupported currency.
1387
+ */
1388
+ export function formatMoneyValue(minor, currency) {
1389
+ if (currency !== "USD") {
1390
+ throw new Error(`only USD is supported in v0.3.0; got currency="${currency}"`);
1391
+ }
1392
+ const sign = minor < 0 ? "-" : "";
1393
+ const abs = Math.abs(minor);
1394
+ const dollars = Math.floor(abs / 100);
1395
+ const cents = abs % 100;
1396
+ const intPart = String(dollars).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
1397
+ return `${sign}$${intPart}.${String(cents).padStart(2, "0")}`;
1398
+ }
1399
+
1400
+ /**
1401
+ * Normalize a raw value per a placeholder's schema-declared type. Returns
1402
+ * the normalized string. Throws on invalid input (Q3.3 → hard error).
1403
+ * If no `type` is declared on the placeholder, returns the raw value
1404
+ * unchanged.
1405
+ *
1406
+ * @param {string} rawValue
1407
+ * @param {{ type?: string|null, format?: string|null, currency?: string|null, key?: string }} placeholder
1408
+ * @returns {string}
1409
+ * @throws {Error} with `.exitCode = EXIT.VALIDATION` on bad input.
1410
+ */
1411
+ export function normalizeTypedValue(rawValue, placeholder) {
1412
+ const type = placeholder && placeholder.type;
1413
+ if (!type) return rawValue;
1414
+ if (type === "date") {
1415
+ const date = parseDateValue(rawValue);
1416
+ if (!date) {
1417
+ const e = new Error(
1418
+ `could not parse "${rawValue}" as a date. expected ISO ` +
1419
+ `(2027-01-15) or spelled ("January 15, 2027"). ` +
1420
+ `US ("01/15/2027") and European ("15/01/2027") forms are not ` +
1421
+ `accepted — they're ambiguous.`
1422
+ );
1423
+ e.exitCode = EXIT.VALIDATION;
1424
+ throw e;
1425
+ }
1426
+ return formatDateValue(date, placeholder.format || "MMMM d, yyyy");
1427
+ }
1428
+ if (type === "money") {
1429
+ const minor = parseMoneyValue(rawValue);
1430
+ if (minor === null) {
1431
+ const e = new Error(
1432
+ `could not parse "${rawValue}" as money. expected like ` +
1433
+ `"$5,000", "5000.50", "$5M", "2.5K".`
1434
+ );
1435
+ e.exitCode = EXIT.VALIDATION;
1436
+ throw e;
1437
+ }
1438
+ return formatMoneyValue(minor, placeholder.currency || "USD");
1439
+ }
1440
+ if (type === "party") {
1441
+ const s = String(rawValue).trim();
1442
+ if (!s) {
1443
+ const e = new Error(`party value must be non-empty`);
1444
+ e.exitCode = EXIT.VALIDATION;
1445
+ throw e;
1446
+ }
1447
+ if (/\]\(/.test(s)) {
1448
+ const e = new Error(`party value "${rawValue}" contains a markdown link; pass the bare party name instead.`);
1449
+ e.exitCode = EXIT.VALIDATION;
1450
+ throw e;
1451
+ }
1452
+ if (/[.!?,;:]$/.test(s)) {
1453
+ const e = new Error(`party value "${rawValue}" has trailing punctuation; remove it before passing.`);
1454
+ e.exitCode = EXIT.VALIDATION;
1455
+ throw e;
1456
+ }
1457
+ return s;
1458
+ }
1459
+ const e = new Error(`unknown type "${type}" on placeholder${placeholder.key ? ` "${placeholder.key}"` : ""}. expected one of: date, money, party.`);
1460
+ e.exitCode = EXIT.IO;
1461
+ throw e;
1462
+ }
1463
+
1464
+ /**
1465
+ * Run {@link normalizeTypedValue} across every resolved placeholder value.
1466
+ * Mutates `resolved` in place with normalized strings. Collects all errors
1467
+ * before returning so the user sees every type failure at once.
1468
+ *
1469
+ * @param {Placeholder[]} placeholders
1470
+ * @param {Object<string,string>} resolved
1471
+ * @returns {{ ok: boolean, errors: Array<{ key: string, message: string }>, normalized: Object<string,{from: string, to: string, type: string}> }}
1472
+ */
1473
+ export function normalizeTypedValues(placeholders, resolved) {
1474
+ const errors = [];
1475
+ const normalized = {};
1476
+ for (const p of placeholders) {
1477
+ if (!p.type) continue;
1478
+ if (resolved[p.key] === undefined) continue;
1479
+ const raw = resolved[p.key];
1480
+ try {
1481
+ const norm = normalizeTypedValue(raw, p);
1482
+ if (norm !== raw) normalized[p.key] = { from: raw, to: norm, type: p.type };
1483
+ resolved[p.key] = norm;
1484
+ } catch (e) {
1485
+ errors.push({ key: p.key, message: e.message });
1486
+ }
1487
+ }
1488
+ return { ok: errors.length === 0, errors, normalized };
1489
+ }
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
+
1168
1576
  async function nodePrompter(placeholder) {
1169
1577
  if (!process.stdin.isTTY) return null;
1170
1578
  const rl = createInterface({ input: process.stdin, output: process.stderr });
@@ -1187,9 +1595,18 @@ async function nodePrompter(placeholder) {
1187
1595
  export function findOrphans(schema, placeholders) {
1188
1596
  if (!schema) return [];
1189
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
+ }
1190
1605
  const orphans = [];
1191
1606
  for (const [key, entry] of Object.entries(schema.entries)) {
1192
- 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() });
1193
1610
  }
1194
1611
  return orphans;
1195
1612
  }
@@ -1403,6 +1820,35 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
1403
1820
  }
1404
1821
  return EXIT.VALIDATION;
1405
1822
  }
1823
+ // v2 #3: typed-parameter validation. Mirror what cmdDraft does so
1824
+ // `--validate` catches type errors before the user runs draft.
1825
+ const typeCheck = normalizeTypedValues(result.placeholders, resolved);
1826
+ if (!typeCheck.ok) {
1827
+ for (const te of typeCheck.errors) {
1828
+ err.write(paint(`error: type validation failed for "${te.key}": ${te.message}\n`, "red", err));
1829
+ }
1830
+ if (opts.json) {
1831
+ out.write(JSON.stringify({
1832
+ ok: false,
1833
+ type_errors: typeCheck.errors.map(({ key, message }) => ({ key, message })),
1834
+ }, null, 2) + "\n");
1835
+ }
1836
+ return EXIT.VALIDATION;
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
+ }
1406
1852
  if (opts.json) {
1407
1853
  out.write(JSON.stringify({ ok: true, resolved: Object.keys(resolved), sources }, null, 2) + "\n");
1408
1854
  } else {
@@ -1472,6 +1918,30 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
1472
1918
  return EXIT.VALIDATION;
1473
1919
  }
1474
1920
 
1921
+ // v2 #3: typed-parameter normalization. Schema entries can declare
1922
+ // `type: date | money | party`. Inputs are validated and normalized
1923
+ // before substitution. Hard error on bad input (Q3.3 decision).
1924
+ const typeCheck = normalizeTypedValues(result.placeholders, resolved);
1925
+ if (!typeCheck.ok) {
1926
+ for (const te of typeCheck.errors) {
1927
+ err.write(paint(`error: type validation failed for "${te.key}": ${te.message}\n`, "red", err));
1928
+ }
1929
+ return EXIT.VALIDATION;
1930
+ }
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
+
1475
1945
  // Diff mode: print a substitution table and exit without writing output.
1476
1946
  if (opts.diff) {
1477
1947
  if (opts.json) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drbaher/draft-cli",
3
- "version": "0.2.0",
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": {