Skip to content

feat(parser): . as a subject, ../field as an alias of ${field}, function forms on . (#14) - #24

Open
PrjShrestha wants to merge 3 commits into
masterfrom
9a_parser_dot_subject
Open

PrjShrestha wants to merge 3 commits into
masterfrom
9a_parser_dot_subject

Conversation

@PrjShrestha

Copy link
Copy Markdown
Collaborator

Closes #14 (T9a). Parent epic #9. Plan: docs/plans/9_complex_logic_calculation_relevant_constraint/README.md.

Stacked on #23 (9b): both touch parseSinglePart / ruleToString, so this branch is cut from the 9b tip. The first two commits are #23; the diff collapses to the last commit once #23 merges.

What

Shared-only, additive. A closed operand grammar (shared/src/xlsform/operand.ts) and four new rule kinds on top of it:

Kind Opens
expr-comparison . >= 0, .<=100, string-length(.) <= 100, int(format-date(today(),'%Y')) + 57 >= int(.), . > max(coalesce(${a}, 0), …), . <= today() - 30, . >= date('…'), difference-in-months(., today()) > 3, decimal-date-time(.) …
predicate regex(., '…'), selected(., 'none'), contains(…), each with not(…)
not-group not(selected(., 'none') and count-selected(.) > 1)
always-true true, true(), 1 (text carried, never rewritten)

../field on the existing comparison / selected / answered / truthy kinds is a refSpelling: 'relative' flag, re-emitted exactly as written; a single segment only, so ../inputs/contact/x keeps its own kind and other multi-segment paths stay raw.

No normalising. Each new rule carries the clause verbatim as source; the serializer re-emits it while it still parses to the same rule (same pattern as refSource on contact-summary rules). A consumer that edits a rule gets canonical spacing for that rule only. The reducer attaches source to any hydrated clause whose canonical emission would differ, so selected(../lmp_approx, 'approx_weeks') opens in the inline strip and saves back unchanged.

Self-check on all-raw chains. a and b (two spaces; six distinct real FCHV / LMP constraints) was rejoined with one space. Such a chain is now one raw rule, byte-identical. This is the pre-existing drift noted in #23.

Three consumers that switch exhaustively on Rule['kind'] show the new kinds as the text the author wrote (modal row, decisions / calc prose). No other UI; the . kinds get presets in 9e and clauses in 9f.

Measured on the seven analysis configs

Fully structured Before
constraint 682 / 777 (51 are true placeholders; 631 / 726 real rules) 3 / 777
relevant 1839 / 3050 1674 / 3050
choice_filter 2 / 92 fully, 8 partial 2 / 92
drift (serializeAnyParsed(parseRelevantGrouped(x)) === x) 0 / 3919 cells 37

../ vs ${} share across the three columns: 410 relative references vs 4433 ${} (8.5%); 329 of 3919 cells carry a ../ reference, 9 mix both; none of them is ../inputs/…. Recorded in the plan README.

What still stays raw (23 distinct constraints): a newline before and (eight rules), double spaces (six), curly quotes around none (one), and and/or mixed inside not(…) (three). All byte-identical.

Tests

  • operand.test.ts: tree shape, canonical spacing, what is outside the grammar.
  • relevantParser.selfSubject.roundtrip.test.ts: every round-trip calls the serializer and starts from a non-canonical fixture (.<=100, (.)>=1 and (.)<=7, .>= 36.5 and .<= 40, regex(.,'…')). The ../field hostile fixtures are live for both halves (byte identity and opens-as-clause), since they pass. An "additive" guard asserts every pre-T9a fixture still yields its old kind and that ${a}='x' stays raw.
  • Reducer: ../ rules hydrate as clauses and re-emit their spelling; a new clause beside them is canonical; . rules are structured in the parser but fall to the strip's raw text (9e owns them).
  • One expectation updated: today() - ${field} > 5*42 is no longer raw but an operand comparison, still not a date_offset, still byte-identical.
  • Playwright parser-dot-subject.spec.ts: seeds . constraints and ../ relevants through the API, opens the form, asserts the modal shows a . constraint as rows with no raw warning and the strip opens a ../ relevant as a clause, saves, and asserts every seeded cell byte-identical on disk.

Validate

Check Result
pnpm --filter @cht-ui/shared build && test 853 tests, 816 pass, 0 fail, 37 todo
pnpm typecheck clean; no consumer of parseRelevant changed shape
pnpm lint fails on master already (164 problems in untouched files); every file this PR touches lints clean
node scripts/smoke-parser.mjs …/pregnancy.xlsx (geriatric-workflow, lumbini) Round-trip stable: YES
node scripts/corpus-sweep.mjs output byte-identical to master
Playwright (3 specs across #23 and this PR) 3 passed

🤖 Generated with Claude Code

PrjShrestha and others added 3 commits October 1, 2026 17:19
…lected" reopen as clauses (#15)

Two defects met at ruleToClause in conditionReducer.ts:

(a) clauseToRule emitted `${f}` and `not(${f})` as kind 'raw', so on
    reopen the parser handed back a raw rule, hydrateColumn fell to
    rawFallback and every control was disabled. They now have a real
    parser kind, `truthy` { field, negated }, that serializes to exactly
    the two spellings the builder has always written. Spacing-divergent
    forms (`${ f }`, `not( ${f} )`) stay raw via the self-check.

(b) `${f} != ''` / `${f} = ''` parsed cleanly as kind 'answered' and were
    dropped at the same spot. They now map to the ref / not clause and
    carry the author's spelling as Clause.source, which clauseToRule
    re-emits while it still describes the clause. An unedited reopen
    writes the same bytes; `${f} != ''` is never rewritten to `${f}`.

Pinned separately: a bare `${f}` fixture exercises only the truthy path,
`${f} != ''` only the answered path. All four reducer tests and the
three positive parser round-trip tests fail on bff69cb and pass here.

Three UI consumers that switch exhaustively on Rule['kind'] gained a
branch for the new kind (modal row, decisions prose, calc prose).

Verified: shared tests 792 pass / 0 fail; typecheck clean; corpus sweep
output byte-identical to master; a cell-level serialize(parse(x)) check
over 4034 relevant/constraint/choice_filter cells in seven configs shows
the same 37 pre-existing drifts as master and none new; 21 relevant cells
now open as clauses that were raw before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…uilder (#15)

Two Playwright specs on a throwaway copy of the mini-config fixture:

- write `${lmp_date}` and `not(${danger_signs})` through the strip, save,
  assert the bytes via the API, reload, and reopen both rows as clauses
  (no "hand-written" status, undo-last-clause present, insert enabled);
- the fixture's existing `${lmp_date} != ''` relevant opens as a clause,
  re-inserts with zero edits as the same bytes, and survives a save.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… function forms on `.` (#14)

Real validation rules are written against the answer itself and real
relevants use relative paths; the shared parser only knew `${field}`
subjects, so 774 of the 777 constraints in seven real configs opened as
plain text. This slice is shared-only and additive.

New in shared/src/xlsform/operand.ts: a closed operand grammar (`.`,
`${f}` / `../f` with the spelling kept, literals, whitelisted ODK + CHT
calls, `+ - * div mod`, parens). On top of it, four additive rule kinds:

- expr-comparison  `. >= 0`, `string-length(.) <= 100`,
                   `int(format-date(today(),'%Y')) + 57 >= int(.)`,
                   `. > max(coalesce(${a}, 0), ...)`, `. <= today() - 30`
- predicate        `regex(., '...')`, `selected(., 'none')`, `not(...)` of those
- not-group        `not(selected(., 'none') and count-selected(.) > 1)`
- always-true      `true`, `true()`, `1` (text carried, never rewritten)

Each carries the clause verbatim as `source`; the serializer re-emits it
while it still parses to the same rule, so `.<=100` and `. <= 100` both
open AND save back byte-identical, and only a rule the author changed
gets canonical spacing. `../field` on the existing comparison / selected
/ answered / truthy kinds is a `refSpelling: 'relative'` flag, re-emitted
exactly as written in either direction. The reducer attaches `source`
to any hydrated clause whose canonical emission would differ, so a
`../field` rule opens in the inline strip and saves back unchanged.

Also: the self-check now runs on all-raw chains too. Splitting on the
combinator rejoined `a and  b` with one space (six distinct real FCHV /
LMP constraints); such a chain is now one raw rule, byte-identical.

Consumers that switch exhaustively on Rule['kind'] show the new kinds
as the text the author wrote (modal row, decisions / calc prose); a
change in the modal turns the rule into a raw fragment, as before.

Measured on the seven analysis configs (777 constraint cells):
  constraint  682 / 777 open fully structured (3 before; 51 are placeholders)
  relevant   1839 / 3050
  drift        0 / 3919 cells (serialize(parse(x)) === x on every cell)
  `../` share: 410 relative refs vs 4433 ${} refs; 329 cells use `../`

Tests: operand grammar; serializer-exercising round trips from
non-canonical fixtures for every form in the ticket; `../` hostile
fixtures live for both halves (byte identity and opens-as-clause); the
all-raw self-check; an "additive" guard that every pre-T9a fixture still
yields its old kind; Playwright: seeded `.` / `../` cells survive
open-and-save byte-identical, the modal opens a `.` constraint as rows,
a `../` relevant opens in the strip.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

T9a: Parser reads real rules: . as subject, ../field alias, function forms

1 participant