@drbaher/draft-cli 0.3.2 → 0.5.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 +117 -18
- package/PARAM_SCHEMA.md +118 -0
- package/draft-cli.mjs +352 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,102 @@ 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.5.0 — 2026-05-16
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Positional addressing** for same-text placeholders with different
|
|
12
|
+
semantic roles. Long-form schema entries can declare a `positions`
|
|
13
|
+
array; each position gets its own canonical key (via `role`), so the
|
|
14
|
+
CLI uses standard `--<role>` flags. Validated against the YC SAFE
|
|
15
|
+
`$[_____________] × 2` case (valuation cap vs. purchase amount).
|
|
16
|
+
```json
|
|
17
|
+
"blank": {
|
|
18
|
+
"aliases": ["_____________"],
|
|
19
|
+
"type": "money", "currency": "USD",
|
|
20
|
+
"positions": [
|
|
21
|
+
{ "role": "valuation_cap" },
|
|
22
|
+
{ "role": "purchase_amount" }
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
```sh
|
|
27
|
+
draft safe.docx \
|
|
28
|
+
--valuation-cap 5000000 \
|
|
29
|
+
--purchase-amount 100000
|
|
30
|
+
```
|
|
31
|
+
Decisions locked (V2_BRIEFS_REMAINING Q1.1–Q1.3):
|
|
32
|
+
- **Q1.1 Index base**: schema positions are 0-indexed internally;
|
|
33
|
+
the CLI uses role names, not numeric indices.
|
|
34
|
+
- **Q1.2 Length mismatch**: schema declares N positions but
|
|
35
|
+
detection finds M ≠ N occurrences → hard error (exit 4).
|
|
36
|
+
- **Q1.3 Bare-key CLI**: a `--<role>` flag targets its specific
|
|
37
|
+
position; values still flow through `--params` JSON or
|
|
38
|
+
`--interactive` normally.
|
|
39
|
+
|
|
40
|
+
### Constraints
|
|
41
|
+
|
|
42
|
+
- Positional addressing only works at tier T1 (bracket) and T2
|
|
43
|
+
(mustache) — those tiers carry per-hit byte indices needed for
|
|
44
|
+
position-specific substitution. T3 (docx-highlight), T4 (heuristic),
|
|
45
|
+
T5 (LLM) raise a positional error if a positional schema entry's
|
|
46
|
+
aliases are matched by them. `.docx` templates with `[X]` brackets
|
|
47
|
+
that fire T1 still work; `.docx` templates that rely on T3 highlights
|
|
48
|
+
for the same alias do not.
|
|
49
|
+
|
|
50
|
+
### Schema-contract change
|
|
51
|
+
|
|
52
|
+
`PARAM_SCHEMA.md` §5 gains a "Positional addressing" subsection. Long-
|
|
53
|
+
form entries can now include a `positions` array; short form is
|
|
54
|
+
unchanged. Forward-compatible with v0.4.x readers — they'll ignore the
|
|
55
|
+
unknown field and treat the entry as a regular non-positional
|
|
56
|
+
placeholder (which means the first detected occurrence wins for
|
|
57
|
+
substitution, and ambiguity is unresolved).
|
|
58
|
+
|
|
59
|
+
## 0.4.0 — 2026-05-16
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
|
|
63
|
+
- **Computed placeholders.** Long-form schema entries can declare a
|
|
64
|
+
`computed` block referencing another key:
|
|
65
|
+
```json
|
|
66
|
+
"term_end": {
|
|
67
|
+
"aliases": ["Term End"],
|
|
68
|
+
"type": "date",
|
|
69
|
+
"format": "MMMM d, yyyy",
|
|
70
|
+
"computed": { "from": "effective_date", "op": "+", "value": "2 years" }
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
At substitution time, if no value was supplied via CLI / `--params`
|
|
74
|
+
/ interactive / default, the computed entry's value is derived from
|
|
75
|
+
the `from` placeholder. Explicit CLI / `--params` values still win —
|
|
76
|
+
computed only fills the gap. Q2.1 locked: expression syntax lives
|
|
77
|
+
in the schema only, not in template text — keeps T1 detection
|
|
78
|
+
unchanged. Q2.2 locked: v0.4.0 supports date arithmetic only
|
|
79
|
+
(`+` / `-` with `<n> day|week|month|year[s]` durations). Money
|
|
80
|
+
math and string concat deferred to a future release.
|
|
81
|
+
- **Schema-time cycle detection.** `parseSchema` throws if any
|
|
82
|
+
`computed.from` chain revisits a key (e.g. `a → b → a`), or if
|
|
83
|
+
`computed.from` references a key that doesn't exist in the same
|
|
84
|
+
schema. Catches misconfiguration before substitution starts.
|
|
85
|
+
- **Orphan-check exemption.** Schema entries that are referenced only
|
|
86
|
+
as another entry's `computed.from` source (and never appear as
|
|
87
|
+
detected aliases in the template) are no longer reported as
|
|
88
|
+
orphans. They're "feeders" — declared so a computed entry can
|
|
89
|
+
reference them, even though the template doesn't show them.
|
|
90
|
+
- **New public API:** `parseDuration(raw)`, `addDuration(date, op, dur)`,
|
|
91
|
+
`computeValues(placeholders, resolved)`.
|
|
92
|
+
|
|
93
|
+
### Schema-contract change
|
|
94
|
+
|
|
95
|
+
`PARAM_SCHEMA.md` §5 gains a "Computed placeholders" section. Long-
|
|
96
|
+
form entries can now include a `computed: { from, op, value }` block;
|
|
97
|
+
short form is unchanged. v0.4.0 schemas are forward-compatible with
|
|
98
|
+
v0.3.x readers (which will silently ignore the `computed` field as
|
|
99
|
+
unrecognized long-form metadata, treating the entry as a regular
|
|
100
|
+
placeholder — but then the user has to supply a value, since v0.3.x
|
|
101
|
+
won't compute one).
|
|
102
|
+
|
|
7
103
|
## 0.3.2 — 2026-05-16
|
|
8
104
|
|
|
9
105
|
### Fixed
|
|
@@ -219,22 +315,25 @@ suite ([cli.drbaher.com](https://cli.drbaher.com)).
|
|
|
219
315
|
that contains at least one letter. False positives are filtered with
|
|
220
316
|
the schema file; false negatives in this domain are higher-cost.
|
|
221
317
|
|
|
222
|
-
## Deferred (
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
318
|
+
## Deferred (post-v0.4.0 candidates)
|
|
319
|
+
|
|
320
|
+
Three of the original seven v1 "Deferred" entries shipped in v0.2.0,
|
|
321
|
+
v0.3.2, and v0.4.0 (see entries above). The four remaining items are
|
|
322
|
+
the next chunk of design work, with briefs in `V2_BRIEFS_REMAINING.md`:
|
|
323
|
+
|
|
324
|
+
- **Positional addressing.** Disambiguate same-text placeholders by
|
|
325
|
+
index in the schema. The validated case: YC SAFE has
|
|
326
|
+
`$[_____________]` twice — once for the valuation cap, once for
|
|
327
|
+
the purchase amount. Smallest of the four (~150 LOC).
|
|
328
|
+
- **Cross-template `parties.json` registry.** Declare parties once
|
|
329
|
+
with `ref:parties.<key>.<field>` references from schemas. Eliminates
|
|
330
|
+
duplicating party metadata across every template (~250 LOC).
|
|
331
|
+
- **Multi-document bundles.** Resolve placeholders once and emit
|
|
332
|
+
multiple documents in one call (MSA + Order Form + DPA with shared
|
|
333
|
+
parameter values) (~250 LOC).
|
|
334
|
+
- **LLM inference from a deal description.** `--from-deal <path>`
|
|
335
|
+
reads free-form deal text and asks the T5 LLM provider to fill the
|
|
336
|
+
schema's parameters. Inverse of the existing T5 detection (~250 LOC).
|
|
239
337
|
- **`.docx` highlight detection beyond yellow/green/cyan/magenta.** v1
|
|
240
|
-
ignores other colors (black/white/none) by design.
|
|
338
|
+
ignores other colors (black/white/none) by design. Backlog, not in
|
|
339
|
+
V2_BRIEFS_REMAINING (low priority).
|
package/PARAM_SCHEMA.md
CHANGED
|
@@ -270,6 +270,124 @@ 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
|
+
|
|
334
|
+
### Positional addressing (v0.5.0, opt-in)
|
|
335
|
+
|
|
336
|
+
Some templates have the same placeholder text appearing multiple times
|
|
337
|
+
with *different* semantic roles. The validated YC SAFE case:
|
|
338
|
+
`$[_____________]` appears twice — once as valuation cap, once as
|
|
339
|
+
purchase amount. Long-form entries can declare a `positions` array
|
|
340
|
+
that splits each occurrence into its own canonical-keyed placeholder.
|
|
341
|
+
|
|
342
|
+
```json
|
|
343
|
+
{
|
|
344
|
+
"_meta": { "schema_version": 1 },
|
|
345
|
+
"blank": {
|
|
346
|
+
"aliases": ["_____________"],
|
|
347
|
+
"type": "money", "currency": "USD",
|
|
348
|
+
"positions": [
|
|
349
|
+
{ "role": "valuation_cap" },
|
|
350
|
+
{ "role": "purchase_amount" }
|
|
351
|
+
]
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
CLI uses standard `--<role>` flags (no special `@N` grammar):
|
|
357
|
+
|
|
358
|
+
```sh
|
|
359
|
+
draft safe.docx --valuation-cap 5000000 --purchase-amount 100000
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
**Q1.1 locked:** index base is 0 internally; the CLI uses role names,
|
|
363
|
+
not numeric indices.
|
|
364
|
+
|
|
365
|
+
**Q1.2 locked:** count mismatch (schema declares N positions but
|
|
366
|
+
detection finds M ≠ N occurrences of the alias) is a **hard error**
|
|
367
|
+
(exit 4). The schema and the template are out of sync; silently filling
|
|
368
|
+
or trimming hides the bug.
|
|
369
|
+
|
|
370
|
+
**Q1.3 locked:** there is no bare-key CLI variant (`--<parent-key>
|
|
371
|
+
VALUE` with no role). The CLI uses role-named flags. Bare `--<role>`
|
|
372
|
+
targets that role's position; values can also come from `--params`
|
|
373
|
+
JSON keyed by role, or `--interactive`.
|
|
374
|
+
|
|
375
|
+
**Tier constraint:** positional addressing only works at T1 (bracket)
|
|
376
|
+
or T2 (mustache). T3/T4/T5 detection paths don't carry per-hit byte
|
|
377
|
+
indices needed for position-specific substitution; if a positional
|
|
378
|
+
schema entry's aliases are matched by those tiers, the command exits 4
|
|
379
|
+
with a clear error.
|
|
380
|
+
|
|
381
|
+
**Validation:** at schema parse time, positions must be a non-empty
|
|
382
|
+
array of `{role: string}` objects; roles must be valid snake_case keys
|
|
383
|
+
and unique within the entry.
|
|
384
|
+
|
|
385
|
+
Programmatic API: positions flow through detection and resolution as
|
|
386
|
+
normal `Placeholder` objects with `position_parent` (parent schema key)
|
|
387
|
+
and `position_index` (0-based) fields. `substitute` switches to
|
|
388
|
+
byte-index substitution for these, which `substituteDocxXml` does not
|
|
389
|
+
currently support.
|
|
390
|
+
|
|
273
391
|
### Orphan handling (Q4 locked)
|
|
274
392
|
|
|
275
393
|
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.5.0";
|
|
74
74
|
|
|
75
75
|
// ─── EXIT CODES ─────────────────────────────────────────────────────────────
|
|
76
76
|
/**
|
|
@@ -914,6 +914,66 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
|
|
|
914
914
|
e.exitCode = EXIT.IO;
|
|
915
915
|
throw e;
|
|
916
916
|
}
|
|
917
|
+
// v2 #7: positional addressing. Optional `positions` array; each
|
|
918
|
+
// element declares a role (its own canonical key) for the Nth detected
|
|
919
|
+
// occurrence of this entry's aliases. Roles must be valid keys and
|
|
920
|
+
// unique within the entry.
|
|
921
|
+
let positions = null;
|
|
922
|
+
if (v.positions !== undefined) {
|
|
923
|
+
if (!Array.isArray(v.positions) || v.positions.length === 0) {
|
|
924
|
+
const e = new Error(`${sourceLabel}: long-form entry '${k}' positions must be a non-empty array`);
|
|
925
|
+
e.exitCode = EXIT.IO;
|
|
926
|
+
throw e;
|
|
927
|
+
}
|
|
928
|
+
const roleSet = new Set();
|
|
929
|
+
positions = [];
|
|
930
|
+
for (let pi = 0; pi < v.positions.length; pi++) {
|
|
931
|
+
const pos = v.positions[pi];
|
|
932
|
+
if (!pos || typeof pos !== "object" || Array.isArray(pos)) {
|
|
933
|
+
const e = new Error(`${sourceLabel}: '${k}'.positions[${pi}] must be an object with a 'role' string`);
|
|
934
|
+
e.exitCode = EXIT.IO;
|
|
935
|
+
throw e;
|
|
936
|
+
}
|
|
937
|
+
if (typeof pos.role !== "string" || !validKey(pos.role)) {
|
|
938
|
+
const e = new Error(`${sourceLabel}: '${k}'.positions[${pi}].role must be a valid snake_case key`);
|
|
939
|
+
e.exitCode = EXIT.IO;
|
|
940
|
+
throw e;
|
|
941
|
+
}
|
|
942
|
+
if (roleSet.has(pos.role)) {
|
|
943
|
+
const e = new Error(`${sourceLabel}: '${k}'.positions has duplicate role '${pos.role}'`);
|
|
944
|
+
e.exitCode = EXIT.IO;
|
|
945
|
+
throw e;
|
|
946
|
+
}
|
|
947
|
+
roleSet.add(pos.role);
|
|
948
|
+
positions.push({ role: pos.role });
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
// v2 #2: computed placeholders. Optional `computed` block on long-form
|
|
952
|
+
// entries; { from: <other-key>, op: "+"|"-", value: "<n> <unit>" }.
|
|
953
|
+
let computed = null;
|
|
954
|
+
if (v.computed !== undefined) {
|
|
955
|
+
if (!v.computed || typeof v.computed !== "object" || Array.isArray(v.computed)) {
|
|
956
|
+
const e = new Error(`${sourceLabel}: long-form entry '${k}' has invalid 'computed' (must be an object)`);
|
|
957
|
+
e.exitCode = EXIT.IO;
|
|
958
|
+
throw e;
|
|
959
|
+
}
|
|
960
|
+
if (typeof v.computed.from !== "string") {
|
|
961
|
+
const e = new Error(`${sourceLabel}: long-form entry '${k}' computed.from must be a string (key of another schema entry)`);
|
|
962
|
+
e.exitCode = EXIT.IO;
|
|
963
|
+
throw e;
|
|
964
|
+
}
|
|
965
|
+
if (v.computed.op !== "+" && v.computed.op !== "-") {
|
|
966
|
+
const e = new Error(`${sourceLabel}: long-form entry '${k}' computed.op must be "+" or "-"`);
|
|
967
|
+
e.exitCode = EXIT.IO;
|
|
968
|
+
throw e;
|
|
969
|
+
}
|
|
970
|
+
if (typeof v.computed.value !== "string") {
|
|
971
|
+
const e = new Error(`${sourceLabel}: long-form entry '${k}' computed.value must be a string (duration like "2 years")`);
|
|
972
|
+
e.exitCode = EXIT.IO;
|
|
973
|
+
throw e;
|
|
974
|
+
}
|
|
975
|
+
computed = { from: v.computed.from, op: v.computed.op, value: v.computed.value };
|
|
976
|
+
}
|
|
917
977
|
entries[k] = {
|
|
918
978
|
aliases: v.aliases.slice(),
|
|
919
979
|
required: v.required !== false,
|
|
@@ -924,6 +984,8 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
|
|
|
924
984
|
type: typeof v.type === "string" ? v.type : null,
|
|
925
985
|
format: typeof v.format === "string" ? v.format : null,
|
|
926
986
|
currency: typeof v.currency === "string" ? v.currency : null,
|
|
987
|
+
computed,
|
|
988
|
+
positions,
|
|
927
989
|
};
|
|
928
990
|
} else {
|
|
929
991
|
if (!Array.isArray(v)) {
|
|
@@ -931,7 +993,30 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
|
|
|
931
993
|
e.exitCode = EXIT.IO;
|
|
932
994
|
throw e;
|
|
933
995
|
}
|
|
934
|
-
entries[k] = { aliases: v.slice(), required: true, default: null, type: null, format: null, currency: null };
|
|
996
|
+
entries[k] = { aliases: v.slice(), required: true, default: null, type: null, format: null, currency: null, computed: null, positions: null };
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
// v2 #2: validate computed references (point to existing keys; no cycles).
|
|
1000
|
+
for (const [key, entry] of Object.entries(entries)) {
|
|
1001
|
+
if (!entry.computed) continue;
|
|
1002
|
+
if (!entries[entry.computed.from]) {
|
|
1003
|
+
const e = new Error(`${sourceLabel}: '${key}'.computed.from = "${entry.computed.from}" does not match any other key in this schema`);
|
|
1004
|
+
e.exitCode = EXIT.IO;
|
|
1005
|
+
throw e;
|
|
1006
|
+
}
|
|
1007
|
+
// Walk the computed.from chain from this key; bail if we revisit.
|
|
1008
|
+
const visited = [key];
|
|
1009
|
+
let cursor = entry.computed.from;
|
|
1010
|
+
while (cursor) {
|
|
1011
|
+
if (visited.includes(cursor)) {
|
|
1012
|
+
const e = new Error(`${sourceLabel}: computed cycle detected: ${[...visited, cursor].join(" → ")}`);
|
|
1013
|
+
e.exitCode = EXIT.IO;
|
|
1014
|
+
throw e;
|
|
1015
|
+
}
|
|
1016
|
+
visited.push(cursor);
|
|
1017
|
+
const next = entries[cursor];
|
|
1018
|
+
if (!next || !next.computed) break;
|
|
1019
|
+
cursor = next.computed.from;
|
|
935
1020
|
}
|
|
936
1021
|
}
|
|
937
1022
|
return { form: long ? "long" : "short", entries };
|
|
@@ -1075,6 +1160,8 @@ function assemble(tier, hits, schema, warnings, fromLlm = false) {
|
|
|
1075
1160
|
type: resolved.type,
|
|
1076
1161
|
format: resolved.format,
|
|
1077
1162
|
currency: resolved.currency,
|
|
1163
|
+
computed: resolved.computed,
|
|
1164
|
+
positions: resolved.positions,
|
|
1078
1165
|
hits: [],
|
|
1079
1166
|
});
|
|
1080
1167
|
}
|
|
@@ -1082,7 +1169,52 @@ function assemble(tier, hits, schema, warnings, fromLlm = false) {
|
|
|
1082
1169
|
entry.occurrences += 1;
|
|
1083
1170
|
entry.hits.push(h);
|
|
1084
1171
|
}
|
|
1085
|
-
|
|
1172
|
+
// v2 #7: expand positional entries. Each detected occurrence becomes a
|
|
1173
|
+
// separate role-keyed placeholder. Count mismatch → positional_errors;
|
|
1174
|
+
// tier T3/T4/T5 (no per-hit index) → positional_errors (not supported).
|
|
1175
|
+
const placeholders = [];
|
|
1176
|
+
const positional_errors = [];
|
|
1177
|
+
const detected_schema_keys = [...byKey.keys()];
|
|
1178
|
+
for (const p of byKey.values()) {
|
|
1179
|
+
if (!p.positions) {
|
|
1180
|
+
placeholders.push(p);
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
if (tier !== "bracket" && tier !== "mustache") {
|
|
1184
|
+
positional_errors.push({
|
|
1185
|
+
key: p.key,
|
|
1186
|
+
reason: `tier '${tier}' does not carry per-hit index info; positional addressing requires T1 (bracket) or T2 (mustache) detection`,
|
|
1187
|
+
});
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1190
|
+
if (p.hits.length !== p.positions.length) {
|
|
1191
|
+
positional_errors.push({
|
|
1192
|
+
key: p.key,
|
|
1193
|
+
reason: `schema declares ${p.positions.length} position(s) but detected ${p.hits.length} occurrence(s) of "${p.aliases[0] || p.key}"`,
|
|
1194
|
+
});
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
for (let i = 0; i < p.positions.length; i++) {
|
|
1198
|
+
placeholders.push({
|
|
1199
|
+
key: p.positions[i].role,
|
|
1200
|
+
first_seen_as: p.hits[i].inner,
|
|
1201
|
+
occurrences: 1,
|
|
1202
|
+
tier,
|
|
1203
|
+
required: true,
|
|
1204
|
+
default: null,
|
|
1205
|
+
aliases: p.aliases.slice(),
|
|
1206
|
+
type: p.type,
|
|
1207
|
+
format: p.format,
|
|
1208
|
+
currency: p.currency,
|
|
1209
|
+
computed: null,
|
|
1210
|
+
positions: null, // expanded; no further re-expansion
|
|
1211
|
+
hits: [p.hits[i]],
|
|
1212
|
+
position_parent: p.key,
|
|
1213
|
+
position_index: i,
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
return { tier, placeholders, warnings, unmapped, positional_errors, detected_schema_keys };
|
|
1086
1218
|
}
|
|
1087
1219
|
|
|
1088
1220
|
function resolveKey(hit, schema, fromLlm) {
|
|
@@ -1097,6 +1229,8 @@ function resolveKey(hit, schema, fromLlm) {
|
|
|
1097
1229
|
type: entry.type || null,
|
|
1098
1230
|
format: entry.format || null,
|
|
1099
1231
|
currency: entry.currency || null,
|
|
1232
|
+
computed: entry.computed || null,
|
|
1233
|
+
positions: entry.positions || null,
|
|
1100
1234
|
};
|
|
1101
1235
|
}
|
|
1102
1236
|
}
|
|
@@ -1104,7 +1238,7 @@ function resolveKey(hit, schema, fromLlm) {
|
|
|
1104
1238
|
}
|
|
1105
1239
|
const key = fromLlm && hit.suggested_key ? hit.suggested_key : canonicalKey(hit.inner);
|
|
1106
1240
|
if (!validKey(key)) return null;
|
|
1107
|
-
return { key, required: true, default: null, aliases: [hit.inner], type: null, format: null, currency: null };
|
|
1241
|
+
return { key, required: true, default: null, aliases: [hit.inner], type: null, format: null, currency: null, computed: null, positions: null };
|
|
1108
1242
|
}
|
|
1109
1243
|
|
|
1110
1244
|
// ─── VALUE RESOLUTION (CLI > JSON > prompt > default) ───────────────────────
|
|
@@ -1177,7 +1311,9 @@ export async function resolveValues(placeholders, opts, paramsObj, { prompter =
|
|
|
1177
1311
|
sources[p.key] = "default";
|
|
1178
1312
|
continue;
|
|
1179
1313
|
}
|
|
1180
|
-
|
|
1314
|
+
// v2 #2: computed placeholders auto-resolve later via `computeValues`.
|
|
1315
|
+
// Don't count them as missing here even though no source supplied a value.
|
|
1316
|
+
if (p.required && !p.computed) missing.push(p);
|
|
1181
1317
|
}
|
|
1182
1318
|
return { resolved, missing, sources };
|
|
1183
1319
|
}
|
|
@@ -1255,6 +1391,46 @@ export function formatDateValue(date, format) {
|
|
|
1255
1391
|
});
|
|
1256
1392
|
}
|
|
1257
1393
|
|
|
1394
|
+
/**
|
|
1395
|
+
* Parse a duration string for computed placeholders (v2 #2).
|
|
1396
|
+
* Accepts `<n> <unit>` where unit is one of `day | week | month | year`
|
|
1397
|
+
* (singular or plural). Returns an object with the unit as plural key.
|
|
1398
|
+
* Returns `null` on parse failure.
|
|
1399
|
+
*
|
|
1400
|
+
* @param {string} raw
|
|
1401
|
+
* @returns {{ days?: number, weeks?: number, months?: number, years?: number } | null}
|
|
1402
|
+
*/
|
|
1403
|
+
export function parseDuration(raw) {
|
|
1404
|
+
const m = /^(\d+)\s+(day|week|month|year)s?$/i.exec(String(raw).trim());
|
|
1405
|
+
if (!m) return null;
|
|
1406
|
+
const n = parseInt(m[1], 10);
|
|
1407
|
+
if (!isFinite(n) || n < 0) return null;
|
|
1408
|
+
const unit = m[2].toLowerCase();
|
|
1409
|
+
return { [`${unit}s`]: n };
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Add or subtract a duration from a Date. Uses UTC field manipulation
|
|
1414
|
+
* via `setUTC*` methods, so day/month/year overflow follows JavaScript's
|
|
1415
|
+
* default behavior (e.g. Jan 31 + 1 month = Mar 3, not Feb 28). For
|
|
1416
|
+
* legal-doc use cases ("2 years from effective date") this is the
|
|
1417
|
+
* expected behavior; anniversary dates are unambiguous.
|
|
1418
|
+
*
|
|
1419
|
+
* @param {Date} date
|
|
1420
|
+
* @param {"+"|"-"} op
|
|
1421
|
+
* @param {{ days?: number, weeks?: number, months?: number, years?: number }} dur
|
|
1422
|
+
* @returns {Date}
|
|
1423
|
+
*/
|
|
1424
|
+
export function addDuration(date, op, dur) {
|
|
1425
|
+
const sign = op === "-" ? -1 : 1;
|
|
1426
|
+
const d = new Date(date.getTime());
|
|
1427
|
+
if (dur.years) d.setUTCFullYear(d.getUTCFullYear() + sign * dur.years);
|
|
1428
|
+
if (dur.months) d.setUTCMonth(d.getUTCMonth() + sign * dur.months);
|
|
1429
|
+
if (dur.weeks) d.setUTCDate(d.getUTCDate() + sign * dur.weeks * 7);
|
|
1430
|
+
if (dur.days) d.setUTCDate(d.getUTCDate() + sign * dur.days);
|
|
1431
|
+
return d;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1258
1434
|
/**
|
|
1259
1435
|
* Parse a money input. Accepts `$5,000`, `5000.50`, `$5M`, `2.5K`, etc.
|
|
1260
1436
|
* Handles `K`/`M`/`B` suffixes (case-insensitive). Returns the value in
|
|
@@ -1394,6 +1570,91 @@ export function normalizeTypedValues(placeholders, resolved) {
|
|
|
1394
1570
|
return { ok: errors.length === 0, errors, normalized };
|
|
1395
1571
|
}
|
|
1396
1572
|
|
|
1573
|
+
// ─── COMPUTED PLACEHOLDERS (v2 #2) ──────────────────────────────────────────
|
|
1574
|
+
// Schema entries can declare a `computed` block referencing another key in
|
|
1575
|
+
// the same schema:
|
|
1576
|
+
//
|
|
1577
|
+
// "term_end": { "aliases": ["Term End"], "type": "date",
|
|
1578
|
+
// "computed": { "from": "effective_date", "op": "+", "value": "2 years" } }
|
|
1579
|
+
//
|
|
1580
|
+
// At substitution time, if no value was supplied via CLI/--params/interactive/
|
|
1581
|
+
// default, the computed entry's value is derived from its `from` placeholder.
|
|
1582
|
+
// CLI/--params explicit values still win — computed only fills the gap.
|
|
1583
|
+
//
|
|
1584
|
+
// Cycles in `from` references are detected at parseSchema time. Missing-`from`
|
|
1585
|
+
// errors and bad-duration errors surface at compute time with a per-key
|
|
1586
|
+
// message; like typed-param errors, all errors are collected before returning
|
|
1587
|
+
// so the user sees every failure at once.
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* Run computed-placeholder evaluation on already-resolved values. Mutates
|
|
1591
|
+
* `resolved` in place with computed values for any placeholder that has a
|
|
1592
|
+
* `computed` block and no existing value. Iterative — handles chains
|
|
1593
|
+
* (B from A, C from B) without an explicit topological sort.
|
|
1594
|
+
*
|
|
1595
|
+
* @param {Placeholder[]} placeholders
|
|
1596
|
+
* @param {Object<string,string>} resolved
|
|
1597
|
+
* @returns {{ ok: boolean, errors: Array<{ key: string, message: string }>, computed: Object<string,{from: string, op: string, value: string, to: string}> }}
|
|
1598
|
+
*/
|
|
1599
|
+
export function computeValues(placeholders, resolved) {
|
|
1600
|
+
const errors = [];
|
|
1601
|
+
const computed = {};
|
|
1602
|
+
const pending = placeholders.filter(
|
|
1603
|
+
(p) => p.computed && resolved[p.key] === undefined
|
|
1604
|
+
);
|
|
1605
|
+
if (pending.length === 0) return { ok: true, errors: [], computed: {} };
|
|
1606
|
+
|
|
1607
|
+
let progress = true;
|
|
1608
|
+
while (progress && pending.length > 0) {
|
|
1609
|
+
progress = false;
|
|
1610
|
+
for (let i = pending.length - 1; i >= 0; i--) {
|
|
1611
|
+
const p = pending[i];
|
|
1612
|
+
const fromKey = p.computed.from;
|
|
1613
|
+
const fromValue = resolved[fromKey];
|
|
1614
|
+
if (fromValue === undefined) continue;
|
|
1615
|
+
try {
|
|
1616
|
+
const result = computeOneValue(p, fromValue);
|
|
1617
|
+
resolved[p.key] = result;
|
|
1618
|
+
computed[p.key] = { from: fromKey, op: p.computed.op, value: p.computed.value, to: result };
|
|
1619
|
+
pending.splice(i, 1);
|
|
1620
|
+
progress = true;
|
|
1621
|
+
} catch (e) {
|
|
1622
|
+
errors.push({ key: p.key, message: e.message });
|
|
1623
|
+
pending.splice(i, 1);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
for (const p of pending) {
|
|
1628
|
+
errors.push({
|
|
1629
|
+
key: p.key,
|
|
1630
|
+
message: `cannot compute: depends on "${p.computed.from}" which is unresolved`,
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
return { ok: errors.length === 0, errors, computed };
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
function computeOneValue(p, fromValue) {
|
|
1637
|
+
// v2: dates only. Future expansions (money math, string concat) would
|
|
1638
|
+
// dispatch on placeholder type here.
|
|
1639
|
+
const date = parseDateValue(fromValue);
|
|
1640
|
+
if (!date) {
|
|
1641
|
+
throw new Error(
|
|
1642
|
+
`cannot parse "${fromValue}" as a date (from "${p.computed.from}"). ` +
|
|
1643
|
+
`Computed placeholders need a date-shaped source value.`
|
|
1644
|
+
);
|
|
1645
|
+
}
|
|
1646
|
+
const dur = parseDuration(p.computed.value);
|
|
1647
|
+
if (!dur) {
|
|
1648
|
+
throw new Error(
|
|
1649
|
+
`cannot parse duration "${p.computed.value}". Expected ` +
|
|
1650
|
+
`"<n> <unit>" where unit is day, week, month, or year (singular ` +
|
|
1651
|
+
`or plural).`
|
|
1652
|
+
);
|
|
1653
|
+
}
|
|
1654
|
+
const result = addDuration(date, p.computed.op, dur);
|
|
1655
|
+
return formatDateValue(result, p.format || "MMMM d, yyyy");
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1397
1658
|
async function nodePrompter(placeholder) {
|
|
1398
1659
|
if (!process.stdin.isTTY) return null;
|
|
1399
1660
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
@@ -1413,12 +1674,30 @@ async function nodePrompter(placeholder) {
|
|
|
1413
1674
|
* @param {Placeholder[]} placeholders
|
|
1414
1675
|
* @returns {Array<{key: string, aliases: string[]}>}
|
|
1415
1676
|
*/
|
|
1416
|
-
export function findOrphans(schema, placeholders) {
|
|
1677
|
+
export function findOrphans(schema, placeholders, detectedSchemaKeys = null) {
|
|
1417
1678
|
if (!schema) return [];
|
|
1418
|
-
|
|
1679
|
+
// v2 #7: for positional entries we check `detectedSchemaKeys` (the
|
|
1680
|
+
// pre-expansion key set) since the placeholders list shows role keys,
|
|
1681
|
+
// not the parent positional key. When detectedSchemaKeys is not given
|
|
1682
|
+
// (older callers / no schema-expansion path), fall back to the
|
|
1683
|
+
// placeholders list — same behavior as before v0.5.0.
|
|
1684
|
+
const presentForPositional = detectedSchemaKeys
|
|
1685
|
+
? new Set(detectedSchemaKeys)
|
|
1686
|
+
: new Set(placeholders.map((p) => p.key));
|
|
1687
|
+
const presentForRegular = new Set(placeholders.map((p) => p.key));
|
|
1688
|
+
// v2 #2: an entry that another entry's `computed.from` points at is
|
|
1689
|
+
// legitimately not in the template — it's a "feeder" used only for
|
|
1690
|
+
// computation. Exempt those from the orphan check.
|
|
1691
|
+
const computedFromTargets = new Set();
|
|
1692
|
+
for (const entry of Object.values(schema.entries)) {
|
|
1693
|
+
if (entry.computed && entry.computed.from) computedFromTargets.add(entry.computed.from);
|
|
1694
|
+
}
|
|
1419
1695
|
const orphans = [];
|
|
1420
1696
|
for (const [key, entry] of Object.entries(schema.entries)) {
|
|
1421
|
-
if (
|
|
1697
|
+
if (computedFromTargets.has(key)) continue;
|
|
1698
|
+
const present = entry.positions ? presentForPositional : presentForRegular;
|
|
1699
|
+
if (present.has(key)) continue;
|
|
1700
|
+
orphans.push({ key, aliases: entry.aliases.slice() });
|
|
1422
1701
|
}
|
|
1423
1702
|
return orphans;
|
|
1424
1703
|
}
|
|
@@ -1437,8 +1716,30 @@ export function findOrphans(schema, placeholders) {
|
|
|
1437
1716
|
* @returns {string} the substituted body.
|
|
1438
1717
|
*/
|
|
1439
1718
|
export function substitute(body, placeholders, values, tier) {
|
|
1719
|
+
// v2 #7: positional placeholders (`position_index !== undefined`) substitute
|
|
1720
|
+
// at a specific byte index, not by global replace. Collect them first,
|
|
1721
|
+
// apply in reverse-index order so earlier hits' indices stay stable. Then
|
|
1722
|
+
// the remaining (non-positional) placeholders use the original
|
|
1723
|
+
// replaceAll/regex logic, which is safe because positional hits all share
|
|
1724
|
+
// the same alias text — and after the index-based substitution, only the
|
|
1725
|
+
// exact bytes at each position have been replaced.
|
|
1440
1726
|
let out = body;
|
|
1727
|
+
const positionalSubs = [];
|
|
1441
1728
|
for (const p of placeholders) {
|
|
1729
|
+
if (p.position_index === undefined) continue;
|
|
1730
|
+
const v = values[p.key];
|
|
1731
|
+
if (v === undefined) continue;
|
|
1732
|
+
for (const h of p.hits) {
|
|
1733
|
+
if (typeof h.index !== "number") continue;
|
|
1734
|
+
positionalSubs.push({ index: h.index, length: h.match.length, value: v });
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
positionalSubs.sort((a, b) => b.index - a.index);
|
|
1738
|
+
for (const s of positionalSubs) {
|
|
1739
|
+
out = out.slice(0, s.index) + s.value + out.slice(s.index + s.length);
|
|
1740
|
+
}
|
|
1741
|
+
for (const p of placeholders) {
|
|
1742
|
+
if (p.position_index !== undefined) continue; // already handled above
|
|
1442
1743
|
const v = values[p.key];
|
|
1443
1744
|
if (v === undefined) continue;
|
|
1444
1745
|
for (const h of p.hits) {
|
|
@@ -1617,7 +1918,14 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
|
|
|
1617
1918
|
err.write(paint("error: no placeholders detected by any tier\n", "red", err));
|
|
1618
1919
|
return EXIT.VALIDATION;
|
|
1619
1920
|
}
|
|
1620
|
-
|
|
1921
|
+
// v2 #7: positional addressing errors (count mismatch, unsupported tier).
|
|
1922
|
+
if (result.positional_errors && result.positional_errors.length > 0) {
|
|
1923
|
+
for (const pe of result.positional_errors) {
|
|
1924
|
+
err.write(paint(`error: positional placeholder "${pe.key}": ${pe.reason}\n`, "red", err));
|
|
1925
|
+
}
|
|
1926
|
+
return EXIT.VALIDATION;
|
|
1927
|
+
}
|
|
1928
|
+
const orphans = findOrphans(schema, result.placeholders, result.detected_schema_keys);
|
|
1621
1929
|
if (orphans.length > 0) {
|
|
1622
1930
|
for (const o of orphans) {
|
|
1623
1931
|
err.write(paint(`error: schema declares "${o.key}" with aliases [${o.aliases.map(a => `"${a}"`).join(",")}], but no matching phrase was detected by tier '${result.tier}'.\n`, "red", err));
|
|
@@ -1647,6 +1955,20 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
|
|
|
1647
1955
|
}
|
|
1648
1956
|
return EXIT.VALIDATION;
|
|
1649
1957
|
}
|
|
1958
|
+
// v2 #2: computed-placeholder validation (same gate as cmdDraft).
|
|
1959
|
+
const computeCheck = computeValues(result.placeholders, resolved);
|
|
1960
|
+
if (!computeCheck.ok) {
|
|
1961
|
+
for (const ce of computeCheck.errors) {
|
|
1962
|
+
err.write(paint(`error: computed value failed for "${ce.key}": ${ce.message}\n`, "red", err));
|
|
1963
|
+
}
|
|
1964
|
+
if (opts.json) {
|
|
1965
|
+
out.write(JSON.stringify({
|
|
1966
|
+
ok: false,
|
|
1967
|
+
computed_errors: computeCheck.errors.map(({ key, message }) => ({ key, message })),
|
|
1968
|
+
}, null, 2) + "\n");
|
|
1969
|
+
}
|
|
1970
|
+
return EXIT.VALIDATION;
|
|
1971
|
+
}
|
|
1650
1972
|
if (opts.json) {
|
|
1651
1973
|
out.write(JSON.stringify({ ok: true, resolved: Object.keys(resolved), sources }, null, 2) + "\n");
|
|
1652
1974
|
} else {
|
|
@@ -1689,8 +2011,15 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
|
|
|
1689
2011
|
}
|
|
1690
2012
|
}
|
|
1691
2013
|
|
|
2014
|
+
// v2 #7: positional addressing errors (count mismatch, unsupported tier).
|
|
2015
|
+
if (result.positional_errors && result.positional_errors.length > 0) {
|
|
2016
|
+
for (const pe of result.positional_errors) {
|
|
2017
|
+
err.write(paint(`error: positional placeholder "${pe.key}": ${pe.reason}\n`, "red", err));
|
|
2018
|
+
}
|
|
2019
|
+
return EXIT.VALIDATION;
|
|
2020
|
+
}
|
|
1692
2021
|
// Orphan check.
|
|
1693
|
-
const orphans = findOrphans(schema, result.placeholders);
|
|
2022
|
+
const orphans = findOrphans(schema, result.placeholders, result.detected_schema_keys);
|
|
1694
2023
|
if (orphans.length > 0) {
|
|
1695
2024
|
for (const o of orphans) {
|
|
1696
2025
|
err.write(paint(`error: schema declares "${o.key}" with aliases [${o.aliases.map(a => `"${a}"`).join(",")}], but no matching phrase was detected by tier '${result.tier}'.\n`, "red", err));
|
|
@@ -1727,6 +2056,19 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
|
|
|
1727
2056
|
return EXIT.VALIDATION;
|
|
1728
2057
|
}
|
|
1729
2058
|
|
|
2059
|
+
// v2 #2: computed placeholders. Fill any computed entries whose value
|
|
2060
|
+
// wasn't already supplied via CLI / --params / --interactive / default.
|
|
2061
|
+
// Runs after typed normalization so the source values are in canonical
|
|
2062
|
+
// form (e.g. a "date" type is already in the format string before we
|
|
2063
|
+
// parse it back for arithmetic).
|
|
2064
|
+
const computeCheck = computeValues(result.placeholders, resolved);
|
|
2065
|
+
if (!computeCheck.ok) {
|
|
2066
|
+
for (const ce of computeCheck.errors) {
|
|
2067
|
+
err.write(paint(`error: computed value failed for "${ce.key}": ${ce.message}\n`, "red", err));
|
|
2068
|
+
}
|
|
2069
|
+
return EXIT.VALIDATION;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
1730
2072
|
// Diff mode: print a substitution table and exit without writing output.
|
|
1731
2073
|
if (opts.diff) {
|
|
1732
2074
|
if (opts.json) {
|