@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 +110 -0
- package/PARAM_SCHEMA.md +94 -0
- package/draft-cli.mjs +476 -6
- package/package.json +1 -1
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.
|
|
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 {
|
|
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
|
-
|
|
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 (
|
|
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) {
|