Skip to content

Categorization review file, review flag, and file inventory [6/6] - #103

Open
terryaney wants to merge 14 commits into
davidfowl:mainfrom
terryaney:feature/categorization
Open

Categorization review file, review flag, and file inventory [6/6]#103
terryaney wants to merge 14 commits into
davidfowl:mainfrom
terryaney:feature/categorization

Conversation

@terryaney

@terryaney terryaney commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Title: Categorization review file, review flag, and file inventory [6/6]

[6/6] Based on feature/merchant-composite-keys (#102). Top of the stack.
Merging this PR into main also lands #98, #99, #100, #101, and #102 if not already merged.
This layer only: feature/merchant-composite-keys...feature/categorization

This PR adds a second, optional way to answer "what is this merchant?" — an editable review file that tally up generates alongside the HTML report — plus a review: rule flag and a data-file register for closing the loop on files you have already checked.

This works best with a AI skill. I've included a sample skill file at bottom of this description.

  • Prose (unchanged). Tell an agent "all VIOC is oil change." Still the right choice when a decision needs judgment.
  • File (new). Answer in an editor at your own pace, with autocomplete driven by your own rules to eliminate AI cycle/token burn.

Set generate_categorization_file: false to switch the file path off entirely.

What it looks like

Rule and Memo
tally-memo

Creating a new Category Tagging Rule

Note: I have rules in my merchant.rules file:

is_amazon = contains("Amazon.com*") or contains("AMAZON MKTPL*") or contains("AMAZON MKTPLACE PMTS")

[Amazon]
match: is_amazon and contains(field.tagging, "CATEGORY: Health / Supplements")
category: Health & Fitness
subcategory: Supplements
tally-newCategoryRule

Use Rule Suggestion
tally-useRule

Changes

Review fileconfig/categorization.yaml lists one row per uncategorized transaction. Fill in useRule, newRule, or edits. On re-run, matched rows drop out, unknown rows keep your answers. Answers reattach by a stable key (sha1 of source + date + amount + raw description), never by id. Malformed YAML hard-fails with line and column, leaves your file untouched, and the HTML report is still written.

Hints fileconfig/categorization.hints.yaml provides deterministic match suggestions (difflib against your rules; no AI, no network). Regenerated every run, safe to delete.

Schemacategorization-schema.json drives editor autocomplete and hover. useRule, edits.category and edits.tags all complete from your own rules. One thing to know before trying it: VS Code caches the schema, so a run that adds rules keeps completing from the previous list until Ctrl+Shift+P → "Developer: Reload Window". That is the extension (redhat-developer/vscode-yaml#309), not tally — the generated file's header and guide.html both say so.

review: true rule flag — marks a rule whose categorizations you want to eyeball. Matched transactions appear in a reviews: list in categorization.yaml with their current categorization. Setting reviewComplete on the data file in inventory.yaml closes them out.

Data inventoryconfig/inventory.yaml auto-registers every data file tally parses. Set reviewComplete: true to stop review rows appearing for that file. Tally only ever appends entries, never modifies or removes them.

Behavior changes on tally up

  • Writes three files and prints one status line when unknowns exist
  • Can now exit non-zero on malformed categorization.yaml or inventory.yaml, where it previously always exited 0
  • With generate_categorization_file: false, prints a STALE warning naming the file and when it was written, rather than leaving generated files on disk silently. It never deletes them — the file holds answers you may have typed, and a config toggle must not destroy data
  • reviewComplete is review-scoped only and never gates parsing or analysis. Every transaction always feeds report aggregates and tally discover; confirming a file only stops its review rows appearing

tally discover, the HTML report, JSON/CSV/markdown output, analysis, and rule matching are otherwise unchanged.

Added while addressing review feedback

  • Fewer transactions surface for review. review: was taken from every rule whose expression matched, including rules that lost the specificity contest and set nothing — so a broad catch-all flagged review: pulled in every transaction a more specific rule had already categorized, defeating the specific-beats-general pattern in guide.html. It now comes from the rules that actually applied: the category, merchant and subcategory winners, plus the rules that contributed tags. Tag-only review rules still surface.

  • Resolving the last row rewrites categorization.yaml empty instead of returning early and leaving the previous rows on disk, where an agent would read answers that had already been applied. The file is still never deleted. The empty list is written explicitly as unknowns: [], because a bare unknowns: parses as null and would be rejected on the next read.

  • An unrecognized field name is now an error, naming the field and suggesting the intended one. Only PRESERVED_FIELDS survive a regeneration, so a typo such as useRules: was not applied and was then silently dropped when the file was next written — the answer disappeared with no sign it had been there. This matches how inventory.yaml already treats unknown keys.

  • useRule labels can now carry a match expression. Two rules can agree on merchant, category, subcategory and tags while matching different things; their labels were identical, so useRule named a rule that could not be identified. Where labels collide, and only there, the expression is appended. Unique labels are untouched.

  • Transaction identity is 64 bits, not 32. At 32 bits a collision was around 1% likely by 9,000 distinct transactions, and a collision is indistinguishable from a genuine duplicate — the two rows were ordinalized, their keys shifted, and a stored answer could reattach to the wrong transaction.

    This changes every key, so an in-flight categorization.yaml will not reattach its answers. The old key is a strict prefix of the new one, so migrating an existing file is mechanical. No compatibility shim is included, since this branch has never shipped.

Added since the last review pass

  • edits.category now completes. It is written as an anyOf of an enum branch and an open string branch, which looks redundant but isn't: examples is the keyword that suggests values without restricting them, but the redhat YAML server does not treat it as a completion source, so Ctrl+Space had nothing to offer; a plain enum completes but reports a category you are inventing as invalid. A bare category: parses as null and the enum branch is the only one that accepts null, so the server narrows to it and completes from it, while the open branch keeps any other value valid. Suggestions are the Category / Subcategory paths plus the bare category, since Subcategory is its own column in merchants.rules and may be empty.
  • The generated file's header is a field-usage reminder, not a description of how an agent consumes the file. Editing hints (useRule/newRule/edits, what each one does to your data, the schema-cache reload) stay; the rest lives in guide.html.

Compatibility

  • review: in merchants.rules will not load on older tally builds (parser hard-fails on unknown properties — by design)
  • review defaults to false, so every existing merchants.rules behaves identically
  • tests/test_rule_snapshots.py passes unchanged

Testing

1067 tests pass across the stack. New test files: test_categorization.py, test_categorization_schema.py, test_categorization_review.py, test_inventory.py.

One pre-existing test in this PR was modified while addressing review feedback: test_review_rows_persist_across_runs_until_stamped asserted written is False after the last review row was stamped, which was the early-return behaviour described above. It now asserts the file is rewritten empty and can be read back.

Stack

# Branch PR Description Diff
1 feature/globbing-documentation #98 Glob pattern docs and CLI tests from main
2 feature/report-json-determinism #99 Deterministic report JSON from PR98 · from main
3 feature/ui-tweaks #100 Date filter, transaction details, per-txn tags from PR99 · from main
4 feature/charts-reimagined #101 Reimagined charts and KPIs from PR100 · from main
5 feature/merchant-composite-keys #102 Composite merchant keys from PR101 · from main
6 feature/categorization #103 Categorization review file from PR102 · from main

Independent: feature/ci-repair#97 — fixes fork PR builds.

Sample Skill File

Note: Still iterating over size and doing prompt testing.

---
name: 'tally-categorize'
description: 'Run tally, review the categorization.yaml file it generates for unknown transactions, apply answers, and loop until 0 unknowns remain.Run tally, review the categorization.yaml file it generates for unknown transactions, apply answers, and loop until 0 unknowns remain.'
---

You are categorizing unknown transactions and managing merchant rules. Tally generates the review
file; you apply it. Run the report, open the review file, interpret the user's natural-language
instructions, apply them to `merchants.rules` and the CSV tagging column, and loop until 0 unknowns
remain.

## Files and division of labor

Tally generates `categorization.yaml`, `categorization.hints.yaml`, and
`categorization-schema.json` as part of `tally up`, whenever `merchants_file` is set and unknowns
exist. You never build any of them — that used to be this skill's job and it isn't anymore. Tally
has no rules-file writer and never will: `merchants.rules` edits and CSV tagging-column edits are
entirely yours.

- **`categorization.yaml`** — the answer file, the one you and the user edit. Top-level `unknowns:`
  and `reviews:` lists.
- **`categorization.hints.yaml`** — read-only deterministic match data, regenerated every run and
  safe to delete. Correlate an entry to an answer row by `key` (stable across runs — prefer this)
  or `id`. If the file is missing, degrade gracefully: treat every row as having no `nearest` entry
  rather than erroring.
- Both open with a `state:` block (`generated`, `totalSources`, `totalUnknowns`, `totalReviews`).

**`id:` is a display label**, renumbered from 1 on every regeneration; `key:` is the stable
identity but not what users say out loud. "Process 7, 9, 13" always means the file **currently on
screen** — re-read before acting on any id-based instruction, even if you just wrote to the file.
`reviews:` continues the same id sequence as `unknowns:`, so a row number refers to exactly one row
across both lists.

## Workflow

1. Run `tally up`. If `settings.yaml` has `generate_categorization_file: false`, or tally prints a
   "generation is off" / STALE warning, `categorization.yaml` is stale — do not read or act on it.
   Fall back to the prose workflow instead: `tally discover`, reading `merchants.rules`, and
   conversation with the user.
2. Read tally's status line: the unknown/carried-forward/dropped breakdown, an "awaiting review"
   count when `reviews:` is non-empty, and a "still unknown after apply" count if a previous apply
   pass silently failed. 0 unknown **and** 0 awaiting review → skip to step 7. If unknowns are 0 but
   review rows remain, there is still work waiting.
3. Open `categorization.yaml` — tally prints its path, and the companions sit next to it. Check the
   `TERM_PROGRAM` environment variable (an environment check, not a harness check, so it behaves
   identically under Claude, Copilot, or Codex, terminal or VS Code extension): if it is `vscode`,
   open with `code -r <path>` to reuse the window; otherwise print the path and let the user open
   it. Never block on the file being open, and never assume it stayed open.


   **Editor note (first open only):** Mention that editing in VS Code with the
   [YAML extension by Red Hat](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml)
   is recommended for Ctrl+Space autocomplete on `useRule` values, but any editor with YAML
   language support will work.
4. Read the file once, then immediately run the **Policy** pass below — it applies to `unknowns:`
   rows only, never `reviews:` rows. Tell the user how many unknown rows are waiting, how many
   review rows await confirmation, how many you auto-applied, and how many you annotated. Do not
   dump rows into chat unless asked; if asked ("show me the next 15"), read the file and render the
   slice yourself. Tally will never gain a listing command for this, and `tally discover`'s table is
   a different, tally-owned output that this feature does not extend.
5. Wait for the user — typed answers in the file, a chat instruction, or both. See **Phrasebook**.
6. Re-read the YAML fresh, apply what was asked (see **Applying answers**), then go to step 1.
7. If `merchants.rules` was modified this session, regenerate the all-rules report via
   `/tally-rules`. Then open the HTML report in the browser for the user.

## Policy: auto-apply vs. annotate thresholds

**These numbers are yours to tune — edit them, not the prose around them.**

```
AUTO_APPLY_MIN_SCORE = 0.95   # nearest[0].score must be at least this
AUTO_APPLY_MAX_RULES = 1      # nearest[0].rules must equal this (unambiguous merchant)
```

For every unresolved `unknowns:` row, look at `nearest` in `categorization.hints.yaml` (matched by
`key`) and pick exactly one outcome:

1. **Auto-apply** — `nearest[0].score >= AUTO_APPLY_MIN_SCORE` AND `nearest[0].rules ==
   AUTO_APPLY_MAX_RULES`. That entry's `useRule` value is unambiguous (one merchant, one variant);
   apply it immediately, exactly as you would a user-filled `useRule`, without waiting to be asked.
2. **Annotate** — the row is not auto-appliable AND you can say something the user cannot already
   see. Write it into `aiNotes`. Apply the value test below before writing anything.
3. **Leave alone** — everything else, including every row where you have nothing to add. Don't
   touch `aiNotes`, don't apply anything.

**Never auto-apply on `score` alone.** `score: 1.0` with `rules: 25` (e.g. Amazon) means the
merchant name matched verbatim and tells you nothing about the category. Both terms, always.

### The value test for `aiNotes`

**A blank `aiNotes` is a good outcome. Never fill it for coverage.** The user reads this file row by
row; a note that tells them nothing costs them attention and buries the notes that matter.

Before writing, ask: *does this say anything the user could not get by reading the row and its
hints?* If no, leave it blank.

- **Never restate the hints.** `"Amazon has 25 category-specific rules; purchase details needed"` is
  just `rules: 25` in prose. So is "no prior-period match", "seen 3 times", "this is a refund". The
  hints file already says all of that, and the user can read it.
- **Do write** when you bring in something the data doesn't carry: what an opaque merchant name
  actually is (`"VIOC is Valvoline Instant Oil Change"`), a reading of the `memo`/tagging text, a
  pattern across rows (`"ids 4, 7, 9 look like one order split across three charges"`), or a genuine
  category guess with a reason that isn't already on screen.
- **When the answer isn't in the data, say nothing.** Amazon order IDs are opaque — no amount of
  reasoning reveals what was bought. That is not a row awaiting a better guess; it is a row only the
  user can answer.
- **Report the pattern once in chat, not N times in the file.** "24 Amazon rows — the category
  depends on the item and isn't in the data; tell me what they were" is useful once and noise
  twenty-four times.

If a pass produces no notes at all, that is a correct result — say so and move on.

Run this pass automatically after reading a freshly generated file (workflow step 4), not only when
asked. Tally never writes `aiNotes` and never overwrites yours — it survives regeneration
until the row is resolved.

## Phrasebook

The user is trading file structure for conversational instruction on purpose — this is the primary
interface, not a fallback. The AI is the collapsing mechanism, not tally: natural-language batching
has to work across *different* descriptions of the same intent, not just the examples below. Treat
these as patterns to generalize from, not a fixed command grammar.

- **"all VIOC is oil change across all sources, process rest of my answers"** — two instructions in
  one. First, find every row whose merchant matches "VIOC" regardless of its `source:` field and
  apply the oil-change categorization (an existing rule via `useRule` if one fits, otherwise
  `edits.category`) to all of them — "across all sources" means don't scope the match to one
  `source:`. Second, "process rest of my answers" means also apply every other row that already has
  an answer filled in, exactly like "process file".
- **"treat 5-10 as Health & Fitness / Tennis, I've answered the rest"** — apply
  `edits.category: Health & Fitness / Tennis` directly to ids 5 through 10 yourself (the user is
  telling you the answer in chat, not asking you to type it into the file), then process every other
  row the user has already filled in, same as "process file".
- **"process file"** — apply every row that has any answer filled in (`useRule`, `newRule`, or any
  `edits.*` field). Entirely blank rows are skipped and reappear next generation.
- **"process 1-5"** — apply only ids 1 through 5. Leave every other row untouched for this pass,
  even ones with answers already filled in — the user is explicitly scoping this batch.
- **"annotate"** — re-run or widen the annotate step from **Policy** on demand, e.g. after editing
  the thresholds, or to cover rows the default pass skipped (`"annotate 5-12"`, `"annotate the
  Amazons"`). It never applies or processes anything; it only fills `aiNotes`.
- **"I've reviewed all, they can stay as matched"** — see **Review rows**. Set `reviewComplete:
  true` in `tally/config/inventory.yaml` for every file currently surfacing a review row, not just one.

## Review rows

Some transactions aren't unknown — they matched a rule the user flagged with `review: true` (a
narrowly-scoped rule, e.g. "Best Buy in December", not the merchant's general rule) — but the file
they came from hasn't been confirmed yet. They surface in the `reviews:` list, carrying `id`, `key`,
`source`, `date`, `merchant`, `amount`, `currently` (how the matched rule categorizes it now), and
`file` (the data-file path to mark complete), plus the same answer fields as an unknown row.

- **Leaving a review row untouched confirms the existing rule.** There is nothing to "apply" — the
  rule already matched and already wrote the categorization. The row is only asking for a look.
- **To change it instead**, the user fills in `useRule`/`newRule`/`edits` exactly like an unknown
  row, and you apply it the same way.
- **Resolution is file-level and indefinite.** A review row keeps reappearing — across months and
  across runs — until someone sets `reviewComplete: true` for that row's `file` in
  `tally/config/inventory.yaml`. Edit that file directly; there is no tally command for this, so do not
  invent one. To close out "I've reviewed all", collect the distinct `file` values across the
  current `reviews:` list and mark every one of them complete.

## Applying answers

For each row with any answer filled in:

| Field | Action |
|---|---|
| `useRule` only | Find that rule in `merchants.rules`. Update its `match:` expression minimally to also match this transaction (add an `or contains(...)` clause). If the rule's match checks `field.tagging` for a `CATEGORY:` or `TAG:` pattern, auto-add that tagging to the CSV row. |
| `newRule` only | Free-form prose — tally emits this field and validates nothing. Parse the instruction yourself and create the rule per **Rule Insertion Logic**. |
| `useRule` **and** `newRule` | Not a conflict — two different axes. `useRule` says *which rule*; `newRule` says *what to do with it*. Picking `useRule` from autocomplete is how the user names a rule unambiguously, so never discard it and re-derive the target from the prose. The prose governs the action: "add to this rule" means widen its `match:`, "also tag it x" means add the tag, and so on. |
| Genuinely contradictory | If the prose asks for something incompatible with the named rule (e.g. `useRule: [Amazon] Shopping / Books` plus "actually make this a new merchant"), **ask** — do not pick one. Silently guessing here is how `merchants.rules` gets quietly wrong. |
| `edits.category` | Add `CATEGORY: X / Y` to the CSV tagging column for that transaction's row. |
| `edits.tags` | For each tag: add `TAG: x` to the CSV tagging column. If the tag doesn't exist as a tag-only rule and isn't within edit-distance 2 of an existing tag, create a new tag-only rule. If within edit-distance 2, warn the user about a possible typo first. Tally emits this field as `[]`, not blank, on a fresh row — treat `[]` as no tags. |
| `edits.memo` | Add text to the CSV memo column for that transaction's row. |
| All blank | Skip — the row reappears next generation, carrying forward untouched. |

Multiple fields can be filled simultaneously (e.g. `newRule` + `edits.category`). Never write to
either file's machine-owned fields.

## Tagging Column Semantics

CSV data files use a `{tagging}` column (separate from `{memo}`) to hold annotation directives that
drive rule matching. The tagging column is never displayed in the report.

- Format: `CATEGORY: Category / Subcategory` and/or `TAG: tagname`
- Multiple entries are comma-separated: `CATEGORY: Health & Fitness / Tennis, TAG: fixed-budget`
- Known shorthand: Normally, `CATEGORY: Health / X` resolves directly to `Health & Fitness / X` and
  is used in match expressions for a specific merchant.

## Rule Insertion Logic

**Never create `[Category Override - ...]` bracket names.** When a non-Amazon CSV row has a
`CATEGORY: X / Y` in its tagging column, add a rule to the `# --- Non-Amazon category overrides ---`
block inside `# === CATEGORY: tagging overrides ===`.
- Bracket name: use merchant's existing name or clean merchant name.
- Match: `contains("DESCRIPTION_PATTERN") and contains(field.tagging, "CATEGORY: X / Y")` with **no
  source filter**.

**Insert into the correct category section — never append.** The `merchants.rules` file has a fixed
layout:
1. Preserved infrastructure at top: Field Transforms, Variables, Tag-only rules, CC Payments /
   Transfers, Family Account Transfers, Check Number / Deposit Reference Rules, CATEGORY: tagging
   overrides. Never add generic merchant rules here.
2. Below: one section per `category:` value (`# === Auto ===`, `# === Food ===`), sorted
   alphabetically.
   - Insert into the matching section. **Never** create `# === X (continued) ===`, batch headers,
     or append to EOF.
   - If the category doesn't exist, create one new `# === Category ===` header in alphabetical
     position.
   - **Same-merchant pairs across categories:** keep adjacent in the primary rule's section.
     `# Category Override` comment above rules whose category differs.
   - **Within-pair order:** specific filters before generic (first-match-wins).

- Add glob pattern examples in settings.yaml.example, clarify formats docs for file globs.
- Add CLI tests covering multi-file matching, no-match behavior, diag visibility, and sorted processing order.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds the editable categorization-review workflow and inventory, atop the stacked report and merchant-key improvements.

Changes:

  • Generates categorization YAML, hints, schema, and file-review inventory.
  • Adds review: rules, composite merchant keys, recurrence analysis, and report fields.
  • Expands report UI, documentation, and automated coverage.

Reviewed changes

Copilot reviewed 37 out of 50 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
src/tally/categorization.py Generates and merges review files.
src/tally/categorization_common.py Defines shared review-file models and labels.
src/tally/categorization_schema.py Builds editor autocomplete schema.
src/tally/inventory.py Maintains data-file review inventory.
src/tally/merchant_engine.py Parses review: and filters empty fields.
src/tally/merchant_utils.py Propagates rule provenance and review state.
src/tally/parsers.py Adds transaction keys, file paths, and report fields.
src/tally/analyzer.py Adds composite keys and recurrence classification.
src/tally/report.py Generates stable merchant IDs and enhanced report data.
src/tally/spending_report.html Reworks filters, charts, KPIs, and details UI.
src/tally/config_loader.py Loads new report and categorization settings.
src/tally/format_parser.py Adds per-format report fields.
src/tally/templates.py Updates starter configuration.
src/tally/cli.py Documents review generation in CLI help.
src/tally/cli_utils.py Updates starter-template formatting.
src/tally/commands/run.py Integrates inventory and review generation.
src/tally/commands/explain.py Supports composite merchant entries.
src/tally/commands/diag.py Reports recurrence override tags.
src/tally/commands/reference.py Documents new rule and report options.
src/tally/commands/workflow.py Adds review workflow guidance.
config/settings.yaml.example Documents new settings and glob support.
tests/test_categorization.py Tests generation, hints, identity, and merging.
tests/test_categorization_review.py Tests review-row lifecycle.
tests/test_categorization_schema.py Tests generated schema behavior.
tests/test_inventory.py Tests inventory and review: parsing.
tests/test_report.py Tests stable merchant IDs.
tests/test_analyzer.py Tests report fields, recurrence, and composite keys.
tests/test_cli.py Tests globbing and explain output.
docs/guide.html Documents categorization review workflow.
docs/formats.html Adds review-file and inventory reference.
docs/reference.html Documents rule and report-field syntax.
docs/charts.html Adds chart behavior documentation.
docs/index.html Updates assets and navigation.
docs/quickstart.html Links chart documentation.
docs/sitemap.xml Registers chart content and corrected assets.
Suppressed comments (3)

src/tally/spending_report.html:90

  • Each month is implemented as a clickable div, which cannot be reached or activated from the keyboard. Render these cells as button type="button" elements (while retaining the existing class) to expose the control semantics automatically.
    src/tally/spending_report.html:151
  • The new chart-panel toggles are click handlers on headings, and the nested Peek Mode span only has role="button"; neither is keyboard-focusable or handles Enter/Space. Use native buttons for every panel toggle and the peek action rather than non-interactive elements with click handlers.
    src/tally/spending_report.html:264
  • The Transaction Details disclosure is a clickable div, so keyboard users cannot collapse or expand it and assistive technology receives no expanded state. Make the header a button and bind aria-expanded to !detailsCollapsed.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/tally/categorization.py
Comment thread src/tally/categorization.py Outdated
Comment thread src/tally/categorization.py Outdated
Comment thread src/tally/parsers.py Outdated
Comment thread src/tally/categorization_common.py Outdated
Comment thread src/tally/report.py Outdated
Comment thread src/tally/spending_report.html Outdated
Comment thread src/tally/merchant_utils.py Outdated
@terryaney terryaney mentioned this pull request Aug 4, 2026
terryaney and others added 12 commits August 4, 2026 16:18
The data-source table's file row used markdown backticks, which render
literally in an HTML page. Every other cell in that table already uses
<code> elements.
`tally up` writes <output>.json beside the HTML and diffs the next run
against it, so the same config and data must produce the same bytes. It
did not: pattern.tags serialized in arbitrary order.

- analyzer.py: sort pattern.tags unconditionally. The isinstance(set)
  guard was dead - merchant_utils.normalize_merchant already does
  list(result.tags), so the value arrives as a list carrying the set's
  arbitrary iteration order. Mirrors the top-level merchant tags field,
  which was already sorted.
- run.py: extract collect_source_names() and dedupe the report subtitle
  sources while preserving settings.yaml declaration order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…le Info

1. BIGGEST UI GAIN: Revamped date filter to be more functional than simply just a month list
2. IMPORTANT DATA FIX: Two data-correctness bugs - in both, the JS classified transactions using
   merchant.tags — the union of every tag across all of a merchant's transactions — instead of the
   transaction's own txn.tags (see Bug Fix Details below))
3. Removed year from title to support multi-year data/reporting and made page title match content title
4. Data from... subtitle is distinct list of source
5. `Include Negatives` has badge count and hides when 0
6. Hide all app components (and added progress bar) while VUE was mounting/initializing
7. Charts UI Polish/Changes
   - Renamed: "Monthly Trend" -> "Cash Flow Trend", "By Category" -> "Spending
     by Category", "Category Trends by Month" -> "Spending by Category Trend".
   - Cash Flow Trend now includes Credits to match cash-flow definition.
   - Removed the synthetic Income/Investment datasets from the category trend chart;
     it is now purely spending by category.
   - Legends are visibility toggles on all three charts. The category trend
     legend previously added a category filter on click.
   - Removed the data-click filter handlers from both category charts. The
     Cash Flow Trend bar click (month filter) is retained.
   - Both category charts share one ranking: top 10 categories plus an "Other" rollup.
   - Series with no data are no longer emitted, so an all-zero Investment
     series no longer leaves a phantom legend entry.
   - Months with nothing to plot are dropped per chart (a transfer-only month
     drew an empty column).
   - Cash Flow Trend bar clicks index into monthlyChartMonths — the months
     actually plotted — instead of availableMonths. Under a date filter the two
     diverged and a bar click filed the wrong month.
8. Transaction Details - container + collapser polish
   - Introduce **containment** — wrap the button row + all category collapsers in one **Transaction Details**
   - Left-align the view buttons; add collapse/expand controls on the right and polish each collapser
   - Fix view-toggle animation cascade in Transaction Details
     - Unify Merchant/Subcategory category views into one keyed v-for
	 - Vue patches <section> nodes in place instead of remounting on a groupByMode toggle
   - Wrap the toggle on mobile (<=640px) — view modes on line 1, actions on line 2; Collapse becomes icon-only
   - Reworked merchant row action presentation: explicit info and filter links
   - Refined expanded transaction row structure for better presentation
   - Fixed 'report fields' badge in transaction detail rendering
   - Adaptive column sizing architecture
     - Introduced dynamic transaction column sizing via CSS variables for
       date, account, and amount tracks.
     - Added startup-time width profiling and debounced resize recalculation
       for desktop-first responsiveness.
9. Rule Info Fixes
   - Added explicit rule provenance into popup data (ruleName, pattern, source)
     so "Why This Matched" reflects the actual classification path.
   - Improved fallback behavior: when no categorization rule matches, promote
     the first matching tag rule so popup rule details remain meaningful.
   - Switched popup tag display to merchant-level union tags (matching the main grid)
   - Cleaned up tag-source reporting to avoid redundant/misleading "from [rule]"
     lines when the displayed rule already explains the tag.
10. Save Layout Settings to Local Storage
   - Transaction Trends collapsed state
   - Transaction Details collapsed state
   - Details view mode (Merchant, Subcategory, View)
   - Include Negatives toggle
   - Per-section collapse state
   - Per-item expansion state (merchant/subcategory rows)
   - Per-section sort settings
11. Fix merchant filter regression and stale tests from row-UX/date polish
- getFilterDescriptor() used item.id (category-specific) instead of
  item.displayName for merchant-type filters, breaking cross-category
  merchant filtering when the same merchant appears in multiple categories.
- Test suite is in line with two intentional behavior changes
  already on this branch that its own tests hadn't caught:
  - Category sections now default to collapsed on a fresh load
    (regardless of "Save Layout Settings to Local Storage")
  - Transaction dates always show the year now

Bug Fix Details:

Both bugs are the same root cause: the JS classified transactions using
merchant.tags — the union of every tag across all of a merchant's
transactions (analyzer.py:122 builds it that way) — instead of the
transaction's own txn.tags, which report.py already emits per transaction.
categorizeAmount() is a strict income -> investment -> transfer -> sign
chain, so one tagged transaction re-bucketed every other transaction at
that merchant. This changed reported dollar amounts; it is not cosmetic.

Bug 1 — charts and the Filtered View tile (filteredViewTotals,
chartAggregations). Every number in all three charts and in the Filtered
View KPI tile was wrong. A merchant whose union is
[monthly-bill, refund, transfer] had all of its ordinary purchases counted
as transfers and vanish from spending. Now classified per transaction, the
JS reconciles with the Python KPIs to the dollar.

Both KPI tiles now agree line-for-line on unfiltered data.

Bug 2 — Section View percentages (grossSpending). grossSpending was
grandTotal + creditsTotal, and both inputs drop a whole merchant when its
tag union is excluded from spending. It feeds the (X%) badge beside each
section header, which renders whenever typeTotals is absent — that is,
Section View always (Category/Subcategory pass server-computed typeTotals
and were never affected). The denominator ran 15% low: $215,322 against a
true $253,781, hiding $6,910 of Amazon spending, $2,448 of Minnesota
Department of Revenue, and more. grossSpending now derives from the same
per-transaction pass and equals Python's spendingTotal exactly. Monthly
Bills reads 23.8% (was 28.0%), Food & Dining 20.0%.

Documented, not fixed: creditMerchants has the same merchant-union bug. It
is unrendered — the "Credits Applied" section was dropped from the template
in ad9477e while docs/reference.html still documents it — so a comment on
the computed records the bug and the two ways to fix it when the section is
restored.
Report title (report.py, spending_report.js)
- Escape the title before it is interpolated into the loading shell's
  <title> and <h1>; a title carrying markup was executable output.
- Escape '</' in the embedded spendingData JSON, so a value containing
  '</script>' cannot close the script element it is embedded in.
- Coerce a non-string title: 'title: 2025' arrives from YAML as an int
  and raised TypeError inside str.replace().
- Resolve the title once and store it back on spendingData, so the
  static shell and the mounted app cannot disagree. The two fallbacks
  differed, so an untitled report was renamed on mount.

Date handling (spending_report.js)
- parseTypedDate now rejects dates a calendar does not have. 2/31/2026,
  month 13 and 99/99/2026 passed the shape regexes and became chips that
  could never match a transaction.
- expandMonthRange validates both halves are YYYY-MM before iterating.
  A hand-edited '#+dr:garbage..garbage' walked to 'NaN-NaN', which
  compares less than 'garba' forever - the report hung with the array
  growing without bound.
- The date popover now edits include-mode filters only. An excluded
  month or range rehydrated into the pending set and came back out of
  Apply as an inclusion, silently inverting the filter.

Percentages (spending_report.js, spending_report.html)
- Section percentages divided a raw total, which includes income,
  investment and transfers, by grossSpending, which excludes them.
  Carry a spending-only subtotal on the same per-transaction basis and
  use it as the numerator.

Performance (spending_report.js)
- Cache transaction column measurements by string. Every transaction
  forced three synchronous layouts, repeated across all three profiles;
  large reports stalled. Cleared per recompute so a resize re-measures.

Accessibility (spending_report.html, spending_report.css)
- Year tabs and month cells are buttons rather than click-only divs, so
  they are keyboard-reachable and announce their selected state.
- Name the reset control with aria-label rather than leaving the glyph
  as its only accessible name.

Config and docs
- Validate report_fields. A bare 'report_fields: memo' was iterated as
  four capture names and an empty value raised TypeError; accept the
  bare string, treat empty as none, and reject other types by name.
- Correct the reference: a field: directive's output is not an input to
  other rules, but the captured column report_fields surfaces is
  available to every rule as field.<name>.
Reimagines the KPI and chart experience in the HTML spending report while preserving the same core budgeting and analysis intent. The functionality is still there, but presented with a different interaction model.

- New KPI dashboard sparkline and consistency layout
- New charts: Spending Seasonality, Expense Volatility, Fixed Spending Audit, and Fixed vs Variable Spending
- Chart Changes
  - By Category becomes Spending By Category column chart (for consistency)
  - Monthly Trend becomes Cash Flow
- All column charts have 'group by' buttons, compare-year toggles, and 'focused' line chart mode
- Improve responsive label behavior, resize rerendering, and compare-year paging
  - Debounced chart rerender on viewport/layout changes via resize + ResizeObserver
  - Disable re-animation for resize-triggered rerenders to avoid redraw jitter
  - Make x-axis labels adaptive (horizontal/angled/vertical) with autoskip under tight widths
  - Expand Playwright chart coverage for compare-year windowing/paging and updated chart controls
- KPI Sparklines are based on last month containing data
- Changed KPI to be month based instead of all time
- Reconcile KPI detail math with trend baseline and clarify secondary labels
  - align 12 Month Avg detail row with trend prior-12 baseline window
  - keep trend on prior-window comparison while preserving anchor-month behavior
  - include credits in Income detail primary values; keep gray secondary informational
  - add a tooltip spelling out what the gray secondary value contributes
  - update KPI styling and report_html coverage for new semantics
- Make Spending by Category legend chips filter the report
  - chips are tri-state (regular / selected / not-selected) rather than show/hide;
    selection is stored as ordinary category filters, so chips and the filter bar
    are the same state. All-on and all-off both collapse back to no filter.
  - clicking a bar segment drills into that category plus that bucket's date range
  - the "Other" chip renders inert; it can never be filtered on
  - the chart's own bars source from a category-exempt aggregation, so selecting a
    category dims its peers instead of dropping them off the canvas
  - a chip toggle flips dataset visibility on the live chart instead of rebuilding
    it, so the stack does not reanimate on every click
- Treat chart-driven filters as transient "peek" state
  - tagged source: 'chart' and deliberately not persisted to the URL hash
  - a Peek Mode badge on the chart title and a Clear Filters button expose and
    clear them; the badge stays visible while the panel is collapsed
  - adding any filter by hand (search, table filter button, date apply) ends peek
- Exclude transfer-only merchants from fixed outputs
  - Build recurring merchant lists from spending-classified transactions only;
    transfer/income/investment-only merchants do not appear in fixed calculations.
  - Update Recurring vs Variable footnote to Top 10 Fixed: ..., + N more, ranked
    by monthly cost so the "+ N more" tail is the cheap end.
  - Update charts documentation wording to match fixed terminology.
- Stabilize chart behavior and default-hide empty legend series
  - Remove split-row and single-expanded reactive layout logic
  - Rerender when a collapsed panel or the whole Transaction Trends section
    expands: a chart measured at zero width bakes rotated, skipped x-axis ticks
    into its config that a plain resize never recomputes
  - Otherwise rerender only when chart-section width changes, or chart toggles change
  - Make chip legends hide zero-data items by default when values are provided
  - Fix cash-flow tooltip Net math to exclude Investments
- Fix subcategory filter chips picking up the "N merchants" summary as their text
Recurrence inference (analyzer.py)
- months_active counts the months a merchant appears in, which says nothing
  about how far apart they are. Require the charges to be dense over the
  merchant's own lifespan as well as covering enough of the reporting period.
  Three January charges span twenty-five months but read as three active
  months, so in a January-only export they cleared the existing test and an
  annual premium was booked as a monthly cost - twelve times its real value.
  The classification also no longer depends on unrelated merchants' data;
  the existing tests needed a dummy monthly merchant present purely to hold
  num_months high enough.

Accessibility (spending_report.html, spending_report.css, spending_report.js)
- Chart panel collapse controls are buttons inside their headings, carrying
  aria-expanded and aria-controls, rather than click handlers on the <h3>.
  Applies to all six panels: category, seasonality, cash, volatility, fixed
  and audit. The heading stays a heading, so document navigation is unchanged.
- The peek badge is a real button rather than a span with role="button", so
  it is reachable and operable from the keyboard.
- A disabled legend chip is now natively disabled instead of only carrying a
  disabled class. The class dropped the click listener but left the control
  focusable and announcing as enabled.

Documentation (docs/charts.html)
- Fixed vs Variable described a mapping of categories. It is a per-merchant
  split driven by inferred recurrence, with fixed/variable rule tags as
  overrides. Document what it actually does, including how the cadence is
  inferred and how to override it.
Allows easier auditing of spending to a merchant.
Of course, if you prefer unique merchant names, that is still possible.

Instead of requiring:

```
[Costco Grocery]
match: contains("COSTCO") and amount <= 200
category: Food
subcategory: Grocery

[Costco Bulk]
match: contains("COSTCO") and amount > 200
category: Shopping
subcategory: Wholesale
```

Can instead be:

```
[Costco]
match: contains("COSTCO") and amount <= 200
category: Food
subcategory: Grocery

[Costco]
match: contains("COSTCO") and amount > 200
category: Shopping
subcategory: Wholesale
```
`tally up` now writes a categorization review file alongside the HTML
report when unknown merchants remain and `merchants_file` is set:

- categorization.yaml - one row per uncategorized transaction; fill in
  useRule, newRule, or edits
- categorization.hints.yaml - deterministic match data (difflib string
  comparison against the user's own rules; no AI, no network),
  regenerated every run, safe to delete
- categorization-schema.json - drives editor autocomplete and hover

On re-run, rows that now match a rule drop out, rows still unknown
keep the user's answers, and id renumbers. Answers reattach by the
stable key, never by id. Malformed YAML hard-fails with line/column,
leaves the file untouched, and the HTML report is still written.

This adds a second, optional workflow alongside the existing one -
neither replaces the other:

- Prose (unchanged): tell an agent "all VIOC is oil change" or "these
  three are groceries." Still the right choice when a decision needs
  judgment.
- File (new): answer in an editor at your own pace with autocomplete
  from your own rules; the agent reads the file back and applies. A
  convenience, not a requirement.

The two can be mixed freely. Set `categorization: false` to disable
the file path entirely.

Added:
- src/tally/categorization.py - identity, deterministic hints, merge,
  YAML emission
- src/tally/categorization_common.py - shared constants,
  CategorizationStatus, CategorizationError, rule-label formatting
- src/tally/categorization_schema.py - draft-07 JSON Schema generator
- tests/test_categorization.py, tests/test_categorization_schema.py -
  51 tests

Modified:
- src/tally/parsers.py - surfaces filepath onto transaction dicts;
  adds transaction_key() (sha1 of source+date+amount+raw_description,
  8 chars) and assign_transaction_keys() (ordinal suffix for exact
  duplicates)
- src/tally/commands/run.py - calls the generator after the HTML
  report is written, HTML path only; prints one status line;
  CategorizationError exits non-zero
- src/tally/config_loader.py - new categorization boolean setting,
  default true
- src/tally/commands/workflow.py - new "Categorization Review File"
  section in `tally workflow`
- src/tally/cli.py - `up` subcommand description
- config/settings.yaml.example, docs/guide.html, docs/formats.html -
  document the new setting and workflow

Risk:
- parsers.py adds keys (filepath, key) to transaction dicts. Verified
  safe: analyzer.py and export_csv build explicit row dicts rather
  than iterating transaction keys, so nothing leaks into the report
  or CSV export.
- `tally up` behavior delta: now writes three files and prints one
  status line when unknowns exist, and can exit non-zero on malformed
  YAML where it previously always exited 0. Opt out with
  `categorization: false`.
- `tally discover`, the HTML report, JSON/CSV/markdown output,
  analysis, and rule matching are all unchanged.
- merchant_engine.py is untouched - no rule-engine change in this
  commit.
- 987 tests pass, including tests/test_rule_snapshots.py. No
  pre-existing test was modified; the only two test files are new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTwQ9oPdGs3KapmDWP8YHu
Rule-engine change first, since it carries the risk:

- merchant_engine.py: new optional `review: bool = False` field on
  MerchantRule. The parser accepts only `review: true` or
  `review: false` - a conditional form like `review: amount > 500` is
  a parse error that points the user at putting the condition in the
  rule's own match: and flagging that rule instead. `_add_rule` passes
  the value through.
- merchant_utils.py: match_info['review'] is set to true when ANY
  matching rule carries the flag, in both the categorization branch
  and the tag-only branch, so a tag-only rule can request review just
  as a categorization rule can.

Risk, stated honestly:
- The parser's hard-failure on unknown properties is unchanged -
  deliberately. A test asserts `reviewed:` (a plausible typo) still
  raises "Unknown property", proving the parser was not loosened to
  get this in.
- `review` defaults to False, so every existing merchants.rules file
  behaves identically. tests/test_rule_snapshots.py passes.
- The new match_info key cannot reach the report: analyzer.py and
  report.py both read match_info through explicit .get() calls into
  freshly-built dicts. Verified empirically - `review` appears zero
  times in generated report JSON and HTML.
- rule_cache.py was left alone; it is referenced only by its own test,
  so no production path reconstructs rules from cache where a new
  property could be silently dropped.
- A bug worth recording: parsing the property was not sufficient -
  MerchantRule is constructed with explicit keyword arguments, so the
  value was silently dropped until _add_rule was updated to pass it.
  It parsed cleanly and did nothing. A test caught it.

Compatibility note for release notes: a merchants.rules containing
`review:` will NOT load on tally builds older than this release,
because the parser hard-fails on unknown properties. This is by
design and was accepted.

New: config/inventory.yaml and confirming a file

- src/tally/inventory.py - reader/writer for config/inventory.yaml,
  tally's own register of data files it has parsed. Exactly four keys
  per entry: path, source, registered, reviewComplete. Tally
  auto-registers files it parses and only ever appends - it never
  modifies or removes an existing entry, so a hand-set reviewComplete
  always survives. Unknown keys are rejected outright (a typo would
  otherwise silently lose the flag). A non-boolean reviewComplete is a
  hard error. Malformed YAML hard-fails, leaves the file untouched,
  and the report is still written.
- reviewComplete is a boolean, not a date, because the only question
  ever asked of it is "is this done?" - and a bare 2026-08-02 loads as
  a YAML date object while "2026-08-02" loads as a string, so a
  hand-typed value would behave differently depending on quoting.
  registered stays a date since tally writes it, never the user.
- categorization.py / categorization_schema.py: categorization.yaml
  gains a top-level reviews: list - transactions matched by a
  review:-flagged rule, carrying `currently` (how it is categorized
  right now - what stands if you do nothing) and `file` (which data
  file to confirm). Ids continue the same sequence as unknowns: so a
  row number is never ambiguous across the two lists.
- run.py registers parsed files on the HTML path, unconditionally, and
  the status line gains the review count, e.g. "Categorization: 36
  unknown (0 new, 36 carried forward), 2 awaiting review - confirm to
  close".
- workflow.py / reference.py / docs: `review:` added to the rule
  reference in `tally reference` and docs/reference.html; a
  "Confirming Categorizations" section added to `tally workflow` and
  docs/guide.html / docs/formats.html.

Settings property renamed: categorization -> generate_categorization_file

- The old name read as "stop categorizing my transactions" - tally's
  entire purpose. `generate_` marks it a boolean; `_file` marks what
  it governs; and it avoids implying a path the way bare
  `categorization_file` would, since `merchants_file` / `views_file`
  in this codebase take paths. Never released, so no migration -
  config_loader.py, cli.py, workflow.py, settings.yaml.example, and
  the docs all move straight to the new name with no fallback for the
  old one.

Field renamed: additionalInfo -> aiNotes, in both unknowns: and
reviews: rows

- A blank additionalInfo read as an unfilled obligation the user
  still owed; aiNotes states ownership, so a blank one means "the
  agent hasn't written anything here." Semantics unchanged:
  agent-owned, filled on request via "annotate", tally never writes or
  overwrites it, preserved across regenerations. categorization.py,
  categorization_common.py (PRESERVED_FIELDS), categorization_schema.py,
  and both docs pages updated. No compatibility shim was kept: the
  feature is unreleased, so no file on disk can carry the old field
  name, and there is no protective code for a case that cannot occur.

New: a stale-file warning when generation is switched off

- Previously, setting the flag to false left generated files on disk
  silently. Now tally prints:

    Categorization: generation is off (generate_categorization_file: false)
      <path>/categorization.yaml is STALE - written <timestamp>, not
      updated since.
      Ignore its contents, or delete it. Re-enable to refresh.

  It never deletes - categorization.yaml holds answers the user may
  have typed, and a config toggle must not destroy data. Reading it is
  read-only and never fails the run, even against a malformed or
  missing file (categorization.stale_file_notice returns None or a
  best-effort notice, never raises).

Reversal: inventory registration is now independent of generation

- register_files no longer lives inside the "generate
  categorization.yaml" branch in run.py; it now runs unconditionally
  on the HTML path, before the generate/stale-notice branch.
  `registered` records when tally first saw a file, so it must keep
  advancing even while review generation is off - otherwise
  re-enabling would backdate every file seen in the meantime to that
  day and lose the real first-seen date. Files registered while off
  carry reviewComplete: false and surface once review resumes.

Workflow notes:
- reviewComplete is review-scoped only and must never gate parsing or
  analysis. Every transaction always feeds report aggregates and
  `tally discover`; confirming a file only stops its review rows
  appearing. A test asserts spending totals are identical before and
  after confirmation, and that the transaction list is never filtered.
- There is deliberately no tally command to confirm a file - the user
  or their agent edits config/inventory.yaml directly. Set
  reviewComplete: false to review a file again after appending rows to
  its CSV.
- Review rows persist indefinitely - across months and files - until
  confirmed. Leaving a row untouched means its existing rule stands.
- Known ordering constraint, accepted: because tally registers files
  during `tally up`, running `tally up` before the file-validation
  step makes a file look already-registered. Recovery is to delete the
  entry from config/inventory.yaml.
- inventory.yaml is hand-editable but PyYAML does not preserve
  comments, so comments added to it are lost on rewrite (ruamel.yaml
  deliberately not added as a dependency).

Testing: 1019 tests pass, including tests/test_rule_snapshots.py. No
test predating this feature was modified - the only test files
touched are the three belonging to it: tests/test_inventory.py,
tests/test_categorization_review.py, and tests/test_categorization.py
(new TestDisabledGeneration cases for the stale-file notice, plus the
additionalInfo -> aiNotes rename in existing assertions).

No tracked issue clearly matches this feature; the open issue list
(checked davidfowl#88 down to davidfowl#27) has nothing about review, inventory,
confirming categorizations, or the settings rename, so none is
referenced here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTwQ9oPdGs3KapmDWP8YHu
review: scope (merchant_utils.py)
- The flag was taken from every rule whose expression matched, including
  rules that lost the specificity contest and set nothing. A broad
  catch-all flagged review: therefore leaked onto every transaction a
  more specific rule had already categorized - the specific-beats-general
  pattern in docs/guide.html working exactly as documented. Take it from
  the rules that actually applied: the category, merchant and subcategory
  winners, plus the rules that contributed tags.

Unknowns and reviews are disjoint (categorization.py)
- A tag-only review: rule can match a still-uncategorized transaction, so
  it appeared in both lists under one key. Both lists share a key space,
  so the blank review row could overwrite an answered unknown row on the
  next merge. An uncategorized transaction belongs in unknowns; it is the
  answer being asked for.
- Independently, never let an unanswered row displace an answered one when
  a key appears twice, so a hand-edited file cannot lose an answer either.

Resolved files are rewritten, not abandoned (categorization.py)
- Resolving the last unknown or review returned early and left the previous
  file on disk, still listing rows that no longer exist - the opposite of
  the documented merge contract, and an agent reading it would act on
  answers already applied. Rewrite it empty instead. The file is never
  deleted; it is the user's. An empty list is written explicitly, because
  a bare 'unknowns:' parses as null and would be rejected on the next read.

Malformed rows fail loudly (categorization.py)
- Only PRESERVED_FIELDS survive a regeneration, so a misspelled answer -
  'useRules:' for 'useRule:' - was not applied and then silently dropped
  the next time the file was written, with no sign it had been there.
  Reject unrecognized field names, naming the field and suggesting the
  intended one. inventory.yaml already rejects unknown keys for exactly
  this reason.

Ambiguous rule labels (categorization_common.py)
- Labels were de-duplicated, but two rules can agree on merchant, category,
  subcategory and tags while matching different expressions. useRule then
  named a rule the agent could not identify and had to guess which to
  widen. Where labels collide, and only there, append the match expression.

Transaction identity width (parsers.py)
- The identity was truncated to 32 bits, where the birthday bound puts a
  collision near 1% by 9,000 distinct transactions. A collision is
  indistinguishable from a genuine duplicate, so the two rows were
  ordinalized, their keys shifted, and a stored answer could reattach to
  the wrong transaction. Use 64 bits.
@terryaney
terryaney force-pushed the feature/categorization branch from ae97423 to 33644a2 Compare August 4, 2026 22:21
terryaney added a commit to terryaney/OpenSource.tally that referenced this pull request Aug 4, 2026
21 code issues fixed across rungs 98/100/101/103, 31 review threads
replied to and resolved, behavior changes added to the davidfowl#100, davidfowl#101 and
davidfowl#103 bodies and pushed.

Records two findings that changed the answer (100-1 was wider than
reported, 101-2's stated scenario does not reproduce) and the rebase
failure mode that stranded two fix commits.
`examples` documents likely values without restricting them, but the
redhat YAML language server won't offer completions from it. A plain
enum completes but rejects categories not yet in merchants.rules.
anyOf gets both: a bare `category: ` parses as null, which only the
enum branch accepts, so the server narrows to it and completes from
its enum, while the open string branch keeps any other value valid.

Categories are offered as "Category / Subcategory" paths plus the bare
category, since Subcategory is its own column in merchants.rules and
is allowed to be empty.

Also rewrites the generated file's header as a field-usage reminder,
and notes that VS Code caches the schema — a run that adds rules
completes from the previous list until the window is reloaded.

Co-Authored-By: Claude Opus 5 <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.

2 participants