lint srf files in audit / doctor commands

This also provides ensurance through zig build test that all
fields are documented in markdown so they do not get out of sync
This commit is contained in:
Emil Lerch 2026-09-24 06:39:23 -07:00
parent 41dd290648
commit 40869cb3a4
Signed by: lobo
GPG key ID: A7B62D657EF764F8
21 changed files with 3392 additions and 11 deletions

View file

@ -517,6 +517,16 @@ User input -> main.zig (CLI dispatch) or tui.zig (TUI event loop)
- **Internal imports use file paths, not module names.** Only external dependencies (`srf`, `vaxis`, `z2d`) use `@import("name")`. Internal code uses relative paths like `@import("Date.zig")` or `@import("models/portfolio.zig")`. This is intentional - it lets `refAllDecls` in the test binary discover all tests across the entire source tree.
- **User-authored SRF schemas live with their models.** Each
hand-edited file's model module declares `pub const srf_schema`
(record type, reference-doc path, optional per-discriminator
`field_rules`, optional `semanticCheck`); `src/srf_lint.zig` is the
registry and walker, holding no rules of its own. Valid-name sets are
derived from `std.meta.fields`, `field_rules` is comptime-exhaustive
over the struct, and a doc-sync test ties each model to its reference
page - so none of the three can drift from the fields. See "Adding a
field to a user-authored SRF model".
- **DataService is the sole data source.** Both CLI and TUI go through `DataService` for all fetched data. Never call provider APIs directly from commands or TUI tabs.
- **Providers are lazily initialized.** `DataService` fields like `td`, `pg`, `fh` start as `null` and are created on first use via `getProvider()`. The provider field name is derived from the type name at comptime.
@ -544,7 +554,7 @@ User input -> main.zig (CLI dispatch) or tui.zig (TUI event loop)
| `src/tui/` | Nine-tab interactive TUI. Each tab is a separate file conforming to the framework contract documented in `tab_framework.zig`: `portfolio_tab.zig`, `analysis_tab.zig`, `review_tab.zig`, `projections_tab.zig`, `history_tab.zig`, `quote_tab.zig`, `performance_tab.zig`, `earnings_tab.zig`, `options_tab.zig`. Plus `keybinds.zig` (configurable input + scoped bindings), `theme.zig` (configurable colors), `chart.zig` (Kitty graphics chart renderer), `projection_chart.zig` (percentile-band overlay), `input_buffer.zig` (modal text-input state machine). The `App` orchestrator lives in the parent `src/tui.zig`. |
| `src/cache/` | `store.zig`: SRF cache read/write with TTL freshness checks. |
| `src/net/` | `http.zig`: HTTP client with retry and error classification. `RateLimiter.zig`: token-bucket rate limiter. |
| `build/` | Build-time support: `Coverage.zig` (kcov integration), `download_kcov.zig` (kcov binary fetcher), `gen_shiller.zig` (CSV -> comptime data converter), `bcov.css` (kcov report styling). |
| `build/` | Build-time support: `Coverage.zig` (kcov integration), `download_kcov.zig` (kcov binary fetcher), `gen_shiller.zig` (CSV -> comptime data converter), `gen_config_docs.zig` (extracts backticked identifiers from `docs/reference/config/*.md` for the schema doc-sync test, since `@embedFile` cannot escape `src/`), `bcov.css` (kcov report styling). |
## Code patterns and conventions
@ -753,6 +763,61 @@ snapshot`, and that is the correct single-file load. The
distinction: snapshot files are immutable historical records,
not the user's currently-edited portfolio.
### Adding a field to a user-authored SRF model
Every hand-edited SRF file has a schema that its own model module
declares as `pub const srf_schema`, registered in `src/srf_lint.zig`'s
`schemas`. The build enforces the contract, so you will be told what
to do rather than having to remember:
1. **Add the field to the model struct.** Nothing else, then
`zig build test`.
2. If the model declares `field_rules` (only `Lot` does today), the
build **fails** with the field name and three copy-pasteable
options: read for every discriminator value, read only for some
(`.only = &.{...}`), or `.derived = true`. Classify it.
3. The doc-sync test then **fails** until the field appears in the
model's reference page (`doc_path`), or is added to `undocumented`
with a comment saying why it is not user-facing.
Both failures are the point. Rules and docs that live away from the
fields they describe drift from them - `portfolio-srf.md` documented
14 of `Lot`'s 22 fields before this existed, and adding a field had no
consequence.
Why this matters: SRF's `fields.to(T)` **silently discards** any record
key that names no field of `T` (`field_match` in srf.zig is write-only
- see the module doc on `src/srf_lint.zig`). 19 of `Lot`'s 22 fields
have defaults, so a typo'd key is indistinguishable from an omitted
one. `zfin doctor` reports these per-file with a reference-page
pointer; `zfin audit`'s Section 8 is the routine-use summary, silent
when clean.
The suggestion matchers are deliberately conservative - case-insensitive
equality and either-direction prefix, no edit distance - so they cannot
produce a wrong guess. They therefore **miss mid-word substitutions**
(`update_cadance` -> `update_cadence` gets no suggestion). The backstop
is that `audit` prints the file's full valid-name set, derived from
`std.meta.fields`, whenever a finding is about a field name
(`Result.hasNameFindings`), and `doctor` points at the reference page,
which the doc-sync test guarantees is complete. Keep both if you touch
the renderers; they are what make the conservatism affordable.
`Rule.only` is a claim that the value is **silently ignored** for every
other discriminator value, so each one must cite the gate that proves
it. When a field's reach is ambiguous, leave it permissive - a missed
nudge costs nothing, a false "zfin ignores this" sends someone chasing
a bug that does not exist.
Rules that aren't about a single field's name go in the schema's
`semanticCheck(rec: Record, ctx: srf_lint.Context, sink: *srf_lint.Sink) !void`
(the signature is comptime-checked). `ctx.today` is the real current
day - the date rules (`Lot`'s future `close_date` / `open_date` /
`price_date`) are nonsensical against a back-dated reference. The same
permissive rule applies: `Lot`'s "close_date without close_price" fires
only for stock lots because realized P&L is only computed for them, and
each such scope limit cites its gate in a comment.
### Adding a new provider
1. Create `src/providers/newprovider.zig` following the existing struct pattern

View file

@ -73,6 +73,7 @@ pub fn build(b: *std.Build) void {
.{ .name = "websocket", .module = websocket_dep.module("websocket") },
.{ .name = "build_info", .module = build_info },
.{ .name = "shiller_year", .module = shiller_mod },
.{ .name = "config_docs", .module = configDocsModule(b) },
};
// Generate Shiller annual returns data from ie_data.csv.
@ -164,6 +165,57 @@ pub fn build(b: *std.Build) void {
}
}
/// Produce the `config_docs` module: the set of backticked identifiers
/// in each `docs/reference/config/*.md` reference page, extracted by
/// `build/gen_config_docs.zig`.
///
/// Consumed by `src/srf_lint.zig`'s doc-sync test, which asserts every
/// field of every user-authored model appears in that model's reference
/// page. `@embedFile` cannot reach outside `src/`, hence the generator.
///
/// The directory is ENUMERATED rather than listed, so adding a new
/// reference page needs no edit here. Each page is passed as a file arg
/// so the build re-runs when one changes.
fn configDocsModule(b: *std.Build) *std.Build.Module {
const rel = "docs/reference/config";
const gen = b.addExecutable(.{
.name = "gen_config_docs",
.root_module = b.createModule(.{
.root_source_file = b.path("build/gen_config_docs.zig"),
.target = b.graph.host,
}),
});
const run = b.addRunArtifact(gen);
const output = run.addOutputFileArg("config_docs.zig");
var names: std.ArrayList([]const u8) = .empty;
if (b.build_root.handle.openDir(b.graph.io, rel, .{ .iterate = true })) |dir| {
var d = dir;
defer d.close(b.graph.io);
var it = d.iterate();
while (it.next(b.graph.io) catch null) |entry| {
if (entry.kind != .file) continue;
if (!std.mem.endsWith(u8, entry.name, ".md")) continue;
names.append(b.allocator, b.dupe(entry.name)) catch @panic("OOM");
}
} else |_| {}
// Sorted so the generated file - and therefore the build cache
// hash - does not depend on directory-iteration order.
std.mem.sort([]const u8, names.items, {}, struct {
fn lessThan(_: void, a: []const u8, c: []const u8) bool {
return std.mem.lessThan(u8, a, c);
}
}.lessThan);
for (names.items) |n| {
run.addFileArg(b.path(b.fmt("{s}/{s}", .{ rel, n })));
}
return b.createModule(.{ .root_source_file = output });
}
/// Produce the `build_info` module exposing `version` (derived from `git
/// describe`) and `build_timestamp` (committer timestamp of HEAD).
/// Consumed as `@import("build_info")` from `src/version.zig`.

139
build/gen_config_docs.zig Normal file
View file

@ -0,0 +1,139 @@
/// Build-time generator: extracts the set of backticked identifiers
/// from each `docs/reference/config/*.md` reference page and emits them
/// as a Zig source file.
///
/// Exists because `@embedFile` cannot escape the package path, so
/// `src/` code has no way to read `docs/`. The alternative - reading
/// the markdown at test time via cwd - would make the doc-sync check
/// dependent on where the test binary was launched from, and a check
/// that can silently skip is a check that rots.
///
/// Only the NAME SET is extracted, never the prose. That keeps the
/// output free of string-escaping concerns and small (a few hundred
/// identifiers), and it is exactly what the consumer needs:
/// `srf_lint`'s doc-sync test asserts every field of each model appears
/// in that model's reference page.
///
/// An "identifier" here is a backtick-delimited token matching
/// `[a-z][a-z0-9_]*` - the shape of every SRF field name. Prose words
/// in backticks (`true`, `null`, `stock`) come along harmlessly; the
/// test only ever asks whether a known field name is PRESENT, never
/// whether an extracted name is a real field.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const allocator = init.arena.allocator();
const io = init.io;
const args = try init.minimal.args.toSlice(allocator);
if (args.len < 3) {
var stderr_buf: [160]u8 = undefined;
var stderr = std.Io.File.stderr().writer(io, &stderr_buf);
try stderr.interface.writeAll("Usage: gen_config_docs <output.zig> <doc.md>...\n");
try stderr.interface.flush();
std.process.exit(1);
}
const out_file = try std.Io.Dir.cwd().createFile(io, args[1], .{});
defer out_file.close(io);
var out_buf: [4096]u8 = undefined;
var file_writer = out_file.writer(io, &out_buf);
const w = &file_writer.interface;
try w.writeAll(
\\// Auto-generated from docs/reference/config/*.md - do not edit.
\\// Regenerate: zig build (runs build/gen_config_docs.zig)
\\
\\/// One reference page's backticked identifiers.
\\pub const Doc = struct {
\\ /// Basename, e.g. "portfolio-srf.md".
\\ file: []const u8,
\\ names: []const []const u8,
\\};
\\
\\pub const docs = [_]Doc{
\\
);
for (args[2..]) |path| {
const body = try std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(4 * 1024 * 1024));
const base = std.fs.path.basename(path);
var names: std.ArrayList([]const u8) = .empty;
try collectIdents(allocator, body, &names);
try w.print(" .{{ .file = \"{s}\", .names = &.{{", .{base});
for (names.items, 0..) |n, i| {
if (i % 6 == 0) try w.writeAll("\n ");
try w.print("\"{s}\", ", .{n});
}
try w.writeAll("\n } },\n");
}
try w.writeAll(
\\};
\\
\\/// The `Doc` for `file`, or null when that page was not built in.
\\pub fn find(file: []const u8) ?Doc {
\\ for (docs) |d| {
\\ if (std.mem.eql(u8, d.file, file)) return d;
\\ }
\\ return null;
\\}
\\
\\const std = @import("std");
\\
);
try w.flush();
}
/// Append every deduplicated backticked `[a-z][a-z0-9_]*` token in
/// `body` to `out`. Slices borrow from `body`.
fn collectIdents(
allocator: std.mem.Allocator,
body: []const u8,
out: *std.ArrayList([]const u8),
) !void {
var i: usize = 0;
while (i < body.len) {
if (body[i] != '`') {
i += 1;
continue;
}
// Skip fenced code blocks wholesale - a ```zig sample can
// mention a field in a context the reference table does not,
// and counting it would let a field be "documented" by an
// example alone.
if (std.mem.startsWith(u8, body[i..], "```")) {
const rest = body[i + 3 ..];
const close = std.mem.indexOf(u8, rest, "```") orelse break;
i += 3 + close + 3;
continue;
}
const rest = body[i + 1 ..];
const close = std.mem.indexOfScalar(u8, rest, '`') orelse break;
const raw = rest[0..close];
i += 1 + close + 1;
// The docs name a field both bare (`symbol`, in reference
// tables) and on-wire (`symbol::`, `shares:num:100`, in prose
// and examples). Cut at the first separator so both spellings
// count as documenting the field.
const tok = raw[0 .. std.mem.indexOfScalar(u8, raw, ':') orelse raw.len];
if (!isIdent(tok)) continue;
for (out.items) |seen| {
if (std.mem.eql(u8, seen, tok)) break;
} else {
try out.append(allocator, tok);
}
}
}
fn isIdent(tok: []const u8) bool {
if (tok.len == 0) return false;
if (tok[0] < 'a' or tok[0] > 'z') return false;
for (tok) |c| {
const ok = (c >= 'a' and c <= 'z') or (c >= '0' and c <= '9') or c == '_';
if (!ok) return false;
}
return true;
}

View file

@ -102,6 +102,8 @@ With no flags, `zfin audit` first prints a health report:
- **Accounts overdue for update** -- accounts past their
`update_cadence` (see [accounts.srf](set-up-accounts.md#3-tune-the-maintenance-cadence)).
- **Brokerage files** it discovered, which it then reconciles.
- **Config file problems** -- field-name typos in your `.srf` files, impossible
lot dates, and `accounts.srf` values zfin had to ignore.
```
Portfolio hygiene
@ -117,6 +119,108 @@ With no flags, `zfin audit` first prints a health report:
"No update history found" is a nudge, not an error -- silence accounts
you don't actively track with `update_cadence::none`.
### Config file problems
Your `.srf` files can be wrong in ways that don't stop them loading.
zfin reads what it understands and quietly ignores or works around the
rest, so a mistake can change your numbers with no error at all.
`zfin audit` checks `portfolio*.srf`, `accounts.srf`, `metadata.srf`,
`transaction_log.srf`, `projections.srf`, and `watchlist.srf` for three
kinds of problem, and prints nothing when there aren't any:
```
Config file problems
portfolio.srf
line 3: unrecognized field 'price_dat' - did you mean 'price_date'?
line 4: BND close_date 2062-03-14 is in the future - the lot is still counted as held until then
line 5: AAPL has close_date but no close_price - the sale's realized gain is recorded as 0
valid fields:
symbol shares open_date open_price close_date close_price note
label account security_type maturity_date rate drip ticker price
price_date price_ratio split_factor underlying strike multiplier
option_type
accounts.srf
line 2: account 'Sample Brokerage': audit_large_lot_threshold must be > 0 (got 0); ignored, so the audit uses its default
line 4: account 'Sample Roth': tax_mix_* must not name the account's own tax_type; the tax mix is ignored and the account counts wholly as roth
```
Each finding names the line of the record it came from, and the date
and account findings also say what zfin is doing instead of what you
wrote.
#### 1. Field names (every file)
SRF matches record keys against field names exactly. A key that matches
nothing is **discarded without complaint** -- a misspelled `price_dat::`
is indistinguishable from having left the price date out. Most fields
have defaults, so most typos change behavior with no output at all.
| Report | Meaning |
|-----------------------------|----------------------------------------------------------------------------------------|
| `did you mean '...'?` | A dropped or doubled character. The suggested name is a real field. |
| `differs only by case` | `Account` is not `account`. Matching is case-sensitive, so this silently does nothing. |
| `ignored for security_type` | A real field that this kind of lot never reads -- e.g. `rate` outside a `cd`. |
| `appears twice` | SRF keeps the **first** occurrence. Editing the second one has no effect. |
| `derived by zfin` | zfin computes this field; a hand-written value is overwritten. Delete it. |
| `unrecognized field` | No match and no close relative. Check it against the `valid fields` list. |
The suggestions are deliberately cautious -- they only fire when a real
field name is a prefix of what you typed, or differs from it only by
case -- so a suggestion is never a guess. That means a typo in the
*middle* of a name (`update_cadance`) gets no suggestion, which is why
the **`valid fields` list** is printed whenever a field name is in
question. It comes straight from the code, so unlike any document it
cannot be out of date.
#### 2. Lot dates (`portfolio*.srf`)
These fields parse fine but describe a lot that can't exist. They matter
more than they look: most of zfin decides whether a lot is held by
comparing `close_date` to today, but `zfin contributions` treats a lot
as sold the moment it has any `close_date` at all -- so a bad date makes
the two disagree.
| Report | What zfin does with it |
|--------------------------------------|--------------------------------------------------------------------------------------------|
| `close_date ... is in the future` | Counts the lot as held until that date. Usually a mistyped year. |
| `close_date ... is before open_date` | The lot is never counted as held on any date. |
| `has close_date but no close_price` | Stock lots only. The sale's realized gain is recorded as 0. |
| `has close_price but no close_date` | The lot stays held. On a stock lot, its row shows the close price instead of the live one. |
| `open_date ... is in the future` | The lot is left out of your positions until that date. |
| `price_date ... is in the future` | Stock lots only. The manual price counts as fresh, so it is never flagged as stale. |
A close dated today is already closed, and a same-day buy-and-sell
(`close_date` equal to `open_date`) is fine. A future `maturity_date` is
normal -- that is the field for a CD or option that ends on a known
date. Watch lots are exempt, since zfin reads only their `symbol`. See
[Closed lots](../reference/config/portfolio-srf.md#closed-lots).
#### 3. Account values (`accounts.srf`)
Values zfin rejects and replaces with a default, so the account loads --
just not the way you wrote it.
| Report | What zfin does instead |
|-----------------------------------------------|-------------------------------------------------------------------------|
| `audit_large_lot_threshold must be > 0` | Uses the audit's built-in threshold. |
| `harvested must be a finite number` | Drops the harvested figure (only reachable by typing `inf` or `nan`). |
| `tax_mix_* ...` (any of the four rules below) | Ignores the whole tax mix; the account counts wholly as its `tax_type`. |
The four tax-mix rules: every `tax_mix_*` carve-out must be above 0 and
a finite number, none may name the account's own `tax_type`, and
together they must sum to less than 100. See
[`accounts.srf`](../reference/config/accounts-srf.md).
These three are also logged to stderr whenever `accounts.srf` loads;
the audit is where they get a line number and can't scroll past.
#### Also in `zfin doctor`
`zfin doctor` reports the same findings one file at a time, with a link
to each file's reference page, and also covers `keys.srf`, `theme.srf`,
and `acknowledgments.srf`. Neither command changes its exit code over
any of these -- they're nudges, not failures.
## Auto-discovery (and your download folder)
With no `--fidelity`/`--schwab` flag, zfin looks for exports in two

View file

@ -18,6 +18,12 @@ One binding per line:
action::ACTION_NAME,key::KEY_STRING[,scope::SCOPE]
```
| Field | Type | Required | Description |
|----------|--------|----------|---------------------------------------------------------------------------------------------------------|
| `action` | string | Yes | The action to bind. Global actions are listed below; a tab-local `scope` changes which names are valid. |
| `key` | string | Yes | The key combination, e.g. `q`, `ctrl+c`, `F5`, `page_down`. |
| `scope` | string | No | Omitted or `global` for a global binding; a tab name (e.g. `options`) for a tab-local one. |
- **Modifiers:** `ctrl+`, `alt+`, `shift+` (e.g. `ctrl+c`).
- **Special keys:** `tab`, `enter`, `escape`, `space`, `backspace`,
`left`, `right`, `up`, `down`, `page_up`, `page_down`, `home`,

View file

@ -42,6 +42,7 @@ symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,account::
| `security_type` | string | No | `stock` (default), `option`, `cd`, `cash`, `illiquid`, `watch`. |
| `account` | string | No | Account name. Should match an `account::` entry in [`accounts.srf`](accounts-srf.md). |
| `note` | string | No | Free-text note (shown in cash/CD/illiquid tables). |
| `label` | string | No | Display name for the symbol column, overriding `symbol`/`ticker` |
| `ticker` | string | No | Ticker alias used for price fetching when `symbol` is a CUSIP. |
| `price` | number | No | Manual price override (for securities the providers don't cover). |
| `price_date` | string | No | Date of the manual price (`YYYY-MM-DD`), for staleness display. |
@ -110,6 +111,22 @@ two things beyond bookkeeping:
`close_price` the current market price stands in, which is close
enough for a recent sale and wrong for an old one.
A `close_date` must not be in the future. A close dated today is already
closed, but a later date leaves the lot counted as held until then --
while `zfin contributions` treats it as sold the moment the field
appears. For a scheduled end (a CD or option), use `maturity_date`.
`zfin audit` and `zfin doctor` flag these lot-lifecycle mistakes:
- `close_date` in the future, or before `open_date`.
- `close_date` without `close_price` on a stock lot (the realized gain
is recorded as 0), or `close_price` without `close_date` on any lot.
- `open_date` in the future (the lot is not counted until then).
- `price_date` in the future on a stock lot (the manual price is never
flagged as stale).
Watch lots are exempt; zfin reads only their `symbol`.
You can either edit the lot where it sits or move it to a sibling file
such as `portfolio_closed.srf` -- the `portfolio*.srf` glob picks it up
either way, so realized gains and back-dated (`--as-of`) views stay

View file

@ -46,6 +46,7 @@ negative::#e06c75
| `info` | Informational highlights |
| `select_bg` / `select_fg` | Selected row |
| `border` | Borders and rules |
| `bar_fill` | Bar-chart fill |
Run `zfin interactive --default-theme` for the full key list with the
default values filled in.

View file

@ -47,6 +47,7 @@ transfer::2026-05-02,type::cash,amount:num:4700,from::Sample IRA,to::Sample Brok
| `from` | string | Yes | Source account name (matches an `account::` in your portfolio). |
| `to` | string | Yes | Destination account name. |
| `dest_lot` | string | Yes | Where it landed: `cash`, or `SYMBOL@YYYY-MM-DD` for a specific lot. |
| `note` | string | No | Free-text reminder of why the transfer happened. |
## `type::cash` vs `type::in_kind`

View file

@ -12,6 +12,7 @@ const ClassificationEntry = @import("../models/classification.zig").Classificati
const Portfolio = @import("../models/portfolio.zig").Portfolio;
const Date = @import("../Date.zig");
const fmt = @import("../format.zig");
const srf_lint = @import("../srf_lint.zig");
const log = std.log.scoped(.accounts);
@ -368,6 +369,94 @@ pub const AccountTaxEntry = struct {
}
};
/// Every semantic problem `accounts.srf` validation can find in one
/// entry. All three fall back to a safe default rather than failing the
/// parse, which is exactly why they need reporting: the account still
/// loads, just not the way the user wrote it.
///
/// Single source of truth for these rules. `parseAccountsFile` logs
/// them to stderr as it parses; `srf_schema.semanticCheck` surfaces the
/// same set through `zfin doctor` / `zfin audit`, where they are
/// attached to a line number and cannot scroll past. Both call
/// `checkEntry` - the conditions are written once.
pub const EntryProblems = struct {
/// `audit_large_lot_threshold` was zero or negative. Zero would
/// flag every new lot; negative is meaningless.
bad_lot_threshold: ?f64 = null,
/// `harvested` was `inf`/`nan` - only reachable by hand-typing it.
non_finite_harvested: ?f64 = null,
/// A declared `tax_mix_*` set that breaks the carve-out rules, so
/// the account reverts to its bare `tax_type`.
tax_mix: ?TaxMixProblem = null,
pub fn any(self: EntryProblems) bool {
return self.bad_lot_threshold != null or
self.non_finite_harvested != null or
self.tax_mix != null;
}
};
/// Validate one parsed `accounts.srf` entry. Pure - no logging, no
/// allocation - so both the parser and the linter can call it.
pub fn checkEntry(entry: AccountTaxEntry) EntryProblems {
var p: EntryProblems = .{};
if (entry.audit_large_lot_threshold) |t| {
if (t <= 0) p.bad_lot_threshold = t;
}
if (entry.harvested) |h| {
if (!std.math.isFinite(h)) p.non_finite_harvested = h;
}
p.tax_mix = entry.taxMixChecked().problem;
return p;
}
/// Schema contract for `accounts.srf`. See `srf_lint.validateSchema`.
///
/// No `field_rules`: nothing here is conditional on another field's
/// value. The `tax_mix_*` family IS interdependent, but that is a
/// field-COMBINATION rule rather than a per-field applicability one, so
/// it goes through `semanticCheck` instead.
pub const srf_schema = struct {
pub const Record = AccountTaxEntry;
pub const file_label: []const u8 = "accounts.srf";
pub const doc_path: []const u8 = "reference/config/accounts-srf.md";
/// Surface `checkEntry`'s findings through the lint channel.
///
/// These were already detected at parse time and logged with
/// `std.log.warn`, which still happens - but a stderr line emitted
/// mid-render, with no file or line number, is how they came to be
/// ignored. Reporting them here attaches each to the record that
/// caused it and puts it in a report the user is reading on purpose.
pub fn semanticCheck(rec: Record, ctx: srf_lint.Context, sink: *srf_lint.Sink) !void {
// No date rules here: `doctor`'s Section B already reports a
// future `harvested_date` / `tax_mix_date`, grouped by account.
_ = ctx;
const p = checkEntry(rec);
if (p.bad_lot_threshold) |t| {
try sink.addOwned(.semantic, "audit_large_lot_threshold", try std.fmt.allocPrint(
sink.allocator,
"account '{s}': audit_large_lot_threshold must be > 0 (got {d}); ignored, so the audit uses its default",
.{ rec.account, t },
));
}
if (p.non_finite_harvested) |h| {
try sink.addOwned(.semantic, "harvested", try std.fmt.allocPrint(
sink.allocator,
"account '{s}': harvested must be a finite number (got {d}); ignored",
.{ rec.account, h },
));
}
if (p.tax_mix) |problem| {
try sink.addOwned(.semantic, "tax_mix", try std.fmt.allocPrint(
sink.allocator,
"account '{s}': {s}; the tax mix is ignored and the account counts wholly as {s}",
.{ rec.account, problem.label(), @tagName(rec.tax_type) },
));
}
}
};
/// Update cadence for manual account maintenance. Parsed from accounts.srf.
/// Default is `weekly` (fail-open: every account nags until explicitly silenced).
pub const UpdateCadence = enum {
@ -559,39 +648,43 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
continue;
};
// Validate once, report twice: these same problems are surfaced
// with line numbers by `srf_schema.semanticCheck` (see
// `src/srf_lint.zig`). The conditions live in `checkEntry` so
// the two channels cannot disagree about what is wrong.
const problems = checkEntry(entry);
// A zero/negative large-lot threshold is nonsensical (zero
// flags every new lot; negative is meaningless). Reject it and
// treat the account as unset so the audit uses its default.
const lot_threshold: ?f64 = if (entry.audit_large_lot_threshold) |t| blk: {
if (t > 0) break :blk t;
const lot_threshold: ?f64 = if (problems.bad_lot_threshold) |t| blk: {
// No-op under `zig build test`: the parser's own tests feed
// invalid thresholds (0, negative) on purpose to verify they
// are rejected, and the warn spam pollutes test output.
if (!builtin.is_test)
log.warn("accounts.srf: account '{s}': audit_large_lot_threshold must be > 0 (got {d}); ignoring", .{ entry.account, t });
break :blk null;
} else null;
} else entry.audit_large_lot_threshold;
// `harvested` is a magnitude: the annotation's parens carry the
// "this is a loss" convention, so accept either sign from the
// user and normalize. A non-finite value can only arrive from a
// hand-typed `inf`/`nan`; there is nothing sensible to display
// for it, so drop the field rather than render garbage.
const harvested: ?f64 = if (entry.harvested) |h| blk: {
if (std.math.isFinite(h)) break :blk @abs(h);
const harvested: ?f64 = if (problems.non_finite_harvested) |h| blk: {
// Silent under `zig build test`: the parser's own tests feed
// non-finite values on purpose, and the warn spam pollutes
// test output.
if (!builtin.is_test)
log.warn("accounts.srf: account '{s}': harvested must be a finite number (got {d}); ignoring", .{ entry.account, h });
break :blk null;
} else null;
} else if (entry.harvested) |h| @abs(h) else null;
// A declared tax mix that breaks the rules falls back to the
// bare `tax_type`. Warn so a typo isn't invisible; the raw
// fields are deliberately left in place so `zfin doctor` can
// report the same problem against the file the user edits.
if (entry.taxMixChecked().problem) |p| {
if (problems.tax_mix) |p| {
// Silent under `zig build test`: the parser's own tests feed
// invalid mixes on purpose to verify the fallback, and the
// warn spam pollutes test output.
@ -3318,3 +3411,91 @@ test "abbreviateSector: known long labels collapse, others pass through" {
try std.testing.expectEqualStrings("Equity / Corporate", abbreviateSector("Equity / Corporate"));
try std.testing.expectEqualStrings("", abbreviateSector(""));
}
// ── checkEntry / srf_schema.semanticCheck ──
/// Lint `data` as accounts.srf and return only the semantic findings
/// (the raw field-name pass is `srf_lint`'s own territory).
fn lintAccounts(data: []const u8) !srf_lint.Result {
var r = try srf_lint.check(std.testing.allocator, data, srf_lint.shapeOfSchema(srf_schema));
errdefer r.deinit();
try srf_lint.checkSemantic(srf_schema, &r, data, .{ .today = Date.fromYmd(2026, 1, 1) });
return r;
}
test "checkEntry: a clean entry has no problems" {
const e = AccountTaxEntry{ .account = "Sample IRA", .tax_type = .traditional };
try std.testing.expect(!checkEntry(e).any());
}
test "checkEntry: zero and negative large-lot thresholds are rejected, positive kept" {
const zero = AccountTaxEntry{ .account = "Sample IRA", .tax_type = .roth, .audit_large_lot_threshold = 0 };
try std.testing.expectEqual(@as(?f64, 0), checkEntry(zero).bad_lot_threshold);
const neg = AccountTaxEntry{ .account = "Sample IRA", .tax_type = .roth, .audit_large_lot_threshold = -5 };
try std.testing.expectEqual(@as(?f64, -5), checkEntry(neg).bad_lot_threshold);
const ok = AccountTaxEntry{ .account = "Sample IRA", .tax_type = .roth, .audit_large_lot_threshold = 25_000 };
try std.testing.expectEqual(@as(?f64, null), checkEntry(ok).bad_lot_threshold);
}
test "checkEntry: non-finite harvested is rejected, a negative magnitude is not" {
const inf = AccountTaxEntry{ .account = "Sample Brokerage", .tax_type = .taxable, .harvested = std.math.inf(f64) };
try std.testing.expect(checkEntry(inf).non_finite_harvested != null);
const nan = AccountTaxEntry{ .account = "Sample Brokerage", .tax_type = .taxable, .harvested = std.math.nan(f64) };
try std.testing.expect(checkEntry(nan).non_finite_harvested != null);
// A negative figure is legal - the parser normalizes with @abs.
const neg = AccountTaxEntry{ .account = "Sample Brokerage", .tax_type = .taxable, .harvested = -1234.0 };
try std.testing.expectEqual(@as(?f64, null), checkEntry(neg).non_finite_harvested);
}
test "checkEntry: a tax mix naming the account's own tax_type is a problem" {
const e = AccountTaxEntry{ .account = "Sample IRA", .tax_type = .traditional, .tax_mix_traditional = 30 };
try std.testing.expectEqual(TaxMixProblem.redundant_primary, checkEntry(e).tax_mix.?);
}
test "srf_schema.semanticCheck: reports the bad large-lot threshold with the account name" {
var r = try lintAccounts("#!srfv1\naccount::Sample Brokerage,tax_type::taxable,audit_large_lot_threshold:num:0\n");
defer r.deinit();
try std.testing.expectEqual(@as(usize, 1), r.findings.len);
try std.testing.expectEqual(srf_lint.Kind.semantic, r.findings[0].kind);
var buf: [srf_lint.Finding.describe_max]u8 = undefined;
const msg = r.findings[0].describe(&buf);
try std.testing.expect(std.mem.indexOf(u8, msg, "Sample Brokerage") != null);
try std.testing.expect(std.mem.indexOf(u8, msg, "must be > 0") != null);
}
test "srf_schema.semanticCheck: reports non-finite harvested" {
var r = try lintAccounts("#!srfv1\naccount::Sample Trust,tax_type::taxable,harvested::inf\n");
defer r.deinit();
var buf: [srf_lint.Finding.describe_max]u8 = undefined;
try std.testing.expectEqual(@as(usize, 1), r.findings.len);
const msg = r.findings[0].describe(&buf);
try std.testing.expect(std.mem.indexOf(u8, msg, "Sample Trust") != null);
try std.testing.expect(std.mem.indexOf(u8, msg, "finite") != null);
}
test "srf_schema.semanticCheck: reports a broken tax mix and names the fallback" {
var r = try lintAccounts("#!srfv1\naccount::Sample IRA,tax_type::traditional,tax_mix_traditional:num:30\n");
defer r.deinit();
var buf: [srf_lint.Finding.describe_max]u8 = undefined;
try std.testing.expectEqual(@as(usize, 1), r.findings.len);
const msg = r.findings[0].describe(&buf);
try std.testing.expect(std.mem.indexOf(u8, msg, "Sample IRA") != null);
// The fallback behavior is the actionable part, not just "invalid".
try std.testing.expect(std.mem.indexOf(u8, msg, "counts wholly as traditional") != null);
}
test "srf_schema.semanticCheck: a clean accounts.srf reports nothing" {
var r = try lintAccounts(
\\#!srfv1
\\account::Sample Brokerage,tax_type::taxable,harvested:num:1200,harvested_date::2025-01-01
\\account::Sample IRA,tax_type::traditional,tax_mix_roth:num:20
\\
);
defer r.deinit();
try std.testing.expect(r.isClean());
}

View file

@ -722,6 +722,20 @@ const SrfProjection = union(enum) {
event: SrfEvent,
};
/// Schema contract for `projections.srf`. See
/// `srf_lint.validateSchema`.
///
/// The highest-value file in the registry to lint: all 17 `SrfConfig`
/// fields have defaults, so SRF silently discards a typo'd one, and
/// `parseProjectionsConfig` has an infallible signature that degrades
/// every failure to defaults. A misspelled `target_spending` therefore
/// changes projected retirement math with no output whatsoever.
pub const srf_schema = struct {
pub const Record = SrfProjection;
pub const file_label: []const u8 = "projections.srf";
pub const doc_path: []const u8 = "reference/config/projections-srf.md";
};
/// Clamp on the magnitude of `spending_change` (10%/yr real, in
/// either direction). A larger drift is almost certainly a units
/// typo - someone entering a fraction (0.02) where a whole percent

View file

@ -20,6 +20,10 @@ const contributions = @import("../contributions.zig");
const Money = @import("../../Money.zig");
const analysis = @import("../../analytics/analysis.zig");
const portfolio_mod = @import("../../models/portfolio.zig");
const classification = @import("../../models/classification.zig");
const transaction_log = @import("../../models/transaction_log.zig");
const projections = @import("../../analytics/projections.zig");
const srf_lint = @import("../../srf_lint.zig");
const Date = @import("../../Date.zig");
const srf = @import("srf");
const git = @import("../../git.zig");
@ -1265,9 +1269,112 @@ pub fn runHygieneCheck(
}
} else |_| {}
// ── Section 8: Config file problems ──
//
// SRF discards a record key that names no field of the target
// struct, silently: `price_dat::2024-01-01` is indistinguishable
// from having omitted the price date. 19 of `Lot`'s 22 fields have
// defaults, so most typos here change behavior with no output at
// all. Rules live with the models (`srf_schema`); `srf_lint` walks
// the records. `zfin doctor` reports the same findings per-file with
// a reference-page pointer; this is the routine-use summary.
//
// Silent when clean.
//
// `as_of` is passed as the lint's `today`: its date rules ("a
// close_date in the future") only make sense against the real
// current day, and audit sets `as_of = ctx.today` unconditionally
// (`commands/audit.zig`) - there is no `--as-of` flag to back-date it.
try printFieldNameSection(io, allocator, portfolio_paths, portfolio_path, as_of, color, out);
try out.print("\n", .{});
}
/// Section 8: field names that no model explains, plus each model's
/// `semanticCheck` rules (e.g. `Lot`'s impossible lifecycle dates).
///
/// Covers every `portfolio*.srf` in the glob plus the portfolio's
/// siblings. `keys.srf` / `theme.srf` are deliberately EXCLUDED - they
/// live under `~/.config/zfin` and configure the TUI, not the
/// portfolio, so they are `doctor`'s business. This section is
/// "portfolio hygiene".
fn printFieldNameSection(
io: std.Io,
allocator: std.mem.Allocator,
portfolio_paths: []const []const u8,
anchor: []const u8,
today: Date,
color: bool,
out: *std.Io.Writer,
) !void {
var header_shown = false;
for (portfolio_paths) |p| {
try lintOne(io, allocator, portfolio_mod.srf_schema, p, today, color, &header_shown, out);
}
const siblings = .{
.{ analysis.srf_schema, "accounts.srf" },
.{ classification.srf_schema, "metadata.srf" },
.{ transaction_log.srf_schema, "transaction_log.srf" },
.{ projections.srf_schema, "projections.srf" },
.{ cli.srf_schema, "watchlist.srf" },
};
inline for (siblings) |pair| {
if (cli.siblingPath(allocator, anchor, pair[1])) |path| {
defer allocator.free(path);
try lintOne(io, allocator, pair[0], path, today, color, &header_shown, out);
} else |_| {}
}
}
/// Lint one file, emitting the shared section header on first finding so
/// a clean run prints nothing at all.
fn lintOne(
io: std.Io,
allocator: std.mem.Allocator,
comptime S: type,
path: []const u8,
today: Date,
color: bool,
header_shown: *bool,
out: *std.Io.Writer,
) !void {
const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(64 * 1024 * 1024)) catch return;
defer allocator.free(bytes);
var result = srf_lint.check(allocator, bytes, comptime srf_lint.shapeOfSchema(S)) catch return;
defer result.deinit();
// Model-owned semantic rules (a second, typed pass - SRF's
// iterators are single-pass, so `to()` cannot share the raw walk).
try srf_lint.checkSemantic(S, &result, bytes, .{ .today = today });
result.sort();
if (result.findings.len == 0) return;
if (!header_shown.*) {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Config file problems\n", .{});
header_shown.* = true;
}
try cli.printFg(out, color, cli.CLR_HEADER, " {s}\n", .{std.fs.path.basename(path)});
var buf: [srf_lint.Finding.describe_max]u8 = undefined;
for (result.findings) |f| {
try cli.printFg(out, color, cli.CLR_WARNING, " line {d}: {s}\n", .{ f.first_line, f.describe(&buf) });
}
if (result.truncated) {
try cli.printFg(out, color, cli.CLR_WARNING, " (more suppressed; fix these first)\n", .{});
}
// The authoritative field list, derived from the struct - so unlike
// a doc page it cannot be out of date. Printed whenever there is
// any NAME finding, which is what lets the suggestion matcher stay
// conservative: even with no guess to offer, the user gets the set.
// Skipped for lifecycle-only reports, where it would be noise.
if (result.hasNameFindings()) {
try cli.printFg(out, color, cli.CLR_MUTED, " valid fields:\n", .{});
try srf_lint.writeValidNames(out, result.shape, 8, 76);
}
}
// ── Tests ────────────────────────────────────────────────────
test "accumulatePresent: unions account numbers across calls" {
@ -2284,3 +2391,167 @@ test "findLastUpdateTimestamps: resolves an account changed in a non-newest comm
try std.testing.expectEqual(commits[0].timestamp, roth_ts); // C
try std.testing.expect(ira_ts < roth_ts);
}
// ── Section 8: unrecognized field names ──
/// Shared harness for the Section 8 integration tests. Writes
/// `portfolio.srf` + `accounts.srf` into a tmpdir, runs the full
/// hygiene check, and returns the whole report for the caller to slice.
///
/// Caller owns the returned bytes.
fn runSection8(
allocator: std.mem.Allocator,
portfolio_srf: []const u8,
accounts_srf: []const u8,
) ![]u8 {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = portfolio_srf });
try tmp.dir.writeFile(io, .{ .sub_path = "accounts.srf", .data = accounts_srf });
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir = path_buf[0..dir_len];
// No API keys / no server -> hermetic.
var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
defer svc.deinit();
var env = try std.testing.environ.createMap(allocator);
defer env.deinit();
const pf_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
defer allocator.free(pf_path);
var aw: std.Io.Writer.Allocating = .init(allocator);
defer aw.deinit();
try runHygieneCheck(io, allocator, &env, &svc, pf_path, &.{pf_path}, 3, false, zfin.Date.fromYmd(2026, 1, 1), 1_767_225_600, false, .never, &aw.writer);
return allocator.dupe(u8, aw.written());
}
const section8_header = "Config file problems";
test "runHygieneCheck: Section 8 is silent for a clean portfolio" {
const allocator = std.testing.allocator;
const out = try runSection8(
allocator,
\\#!srfv1
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,account::Sample Brokerage
\\
,
"#!srfv1\naccount::Sample Brokerage,tax_type::taxable\n",
);
defer allocator.free(out);
try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio hygiene") != null);
try std.testing.expect(std.mem.indexOf(u8, out, section8_header) == null);
}
test "runHygieneCheck: Section 8 reports a typo in portfolio.srf with a suggestion" {
const allocator = std.testing.allocator;
const out = try runSection8(
allocator,
\\#!srfv1
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,price_dat::2025-01-01
\\
,
"#!srfv1\n",
);
defer allocator.free(out);
const start = std.mem.indexOf(u8, out, section8_header) orelse return error.Section8Missing;
const sec = out[start..];
try std.testing.expect(std.mem.indexOf(u8, sec, "portfolio.srf") != null);
try std.testing.expect(std.mem.indexOf(u8, sec, "price_dat") != null);
try std.testing.expect(std.mem.indexOf(u8, sec, "did you mean 'price_date'?") != null);
// The valid-name footer is what makes the conservative matcher
// sufficient, so it must actually be emitted.
try std.testing.expect(std.mem.indexOf(u8, sec, "valid fields:") != null);
try std.testing.expect(std.mem.indexOf(u8, sec, "option_type") != null);
}
test "runHygieneCheck: Section 8 covers accounts.srf, not just the portfolio" {
const allocator = std.testing.allocator;
const out = try runSection8(
allocator,
\\#!srfv1
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,account::Sample IRA
\\
,
"#!srfv1\naccount::Sample IRA,tax_type::traditional,tax_mix_rothh:num:20\n",
);
defer allocator.free(out);
const start = std.mem.indexOf(u8, out, section8_header) orelse return error.Section8Missing;
const sec = out[start..];
try std.testing.expect(std.mem.indexOf(u8, sec, "accounts.srf") != null);
try std.testing.expect(std.mem.indexOf(u8, sec, "did you mean 'tax_mix_roth'?") != null);
}
test "runHygieneCheck: Section 8 flags a field that does not apply to the security_type" {
const allocator = std.testing.allocator;
const out = try runSection8(
allocator,
\\#!srfv1
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,rate:num:4.25
\\
,
"#!srfv1\n",
);
defer allocator.free(out);
const start = std.mem.indexOf(u8, out, section8_header) orelse return error.Section8Missing;
const sec = out[start..];
try std.testing.expect(std.mem.indexOf(u8, sec, "'rate' ignored for security_type stock") != null);
try std.testing.expect(std.mem.indexOf(u8, sec, "read only for cd") != null);
}
test "runHygieneCheck: Section 8 flags a future close_date" {
// `runSection8` pins as_of to 2026-01-01, which the lint uses as
// `today`. A close in 2062 is the typo'd-year case.
const allocator = std.testing.allocator;
const out = try runSection8(
allocator,
\\#!srfv1
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,close_date::2062-03-14,close_price:num:300
\\
,
"#!srfv1\n",
);
defer allocator.free(out);
const start = std.mem.indexOf(u8, out, section8_header) orelse return error.Section8Missing;
const sec = out[start..];
try std.testing.expect(std.mem.indexOf(u8, sec, "line 2: VTI close_date 2062-03-14 is in the future") != null);
}
test "runHygieneCheck: Section 8 stays silent for a correctly closed lot" {
const allocator = std.testing.allocator;
const out = try runSection8(
allocator,
\\#!srfv1
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,close_date::2025-03-14,close_price:num:300
\\
,
"#!srfv1\n",
);
defer allocator.free(out);
try std.testing.expect(std.mem.indexOf(u8, out, section8_header) == null);
}
test "runHygieneCheck: Section 8 omits the valid-field list for date-only findings" {
const allocator = std.testing.allocator;
const out = try runSection8(
allocator,
\\#!srfv1
\\symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,close_date::2062-03-14,close_price:num:300
\\
,
"#!srfv1\n",
);
defer allocator.free(out);
const start = std.mem.indexOf(u8, out, section8_header) orelse return error.Section8Missing;
try std.testing.expect(std.mem.indexOf(u8, out[start..], "valid fields:") == null);
}

View file

@ -1127,14 +1127,27 @@ pub fn resolveAsOfOrExplain(
// ── Watchlist loading ────────────────────────────────────────
/// One record from `watchlist.srf`.
///
/// Named (rather than declared inline in `loadWatchlist`) so
/// `srf_schema` below can derive the valid-field set from it - an
/// anonymous struct would leave the lint with a hand-copied name list
/// that could drift from the parser.
pub const WatchEntry = struct { symbol: []const u8 };
/// Schema contract for `watchlist.srf`. See `srf_lint.validateSchema`.
pub const srf_schema = struct {
pub const Record = WatchEntry;
pub const file_label: []const u8 = "watchlist.srf";
pub const doc_path: []const u8 = "reference/config/watchlist-srf.md";
};
/// Load a watchlist SRF file containing symbol records.
/// Returns owned symbol strings. Returns null if file missing or empty.
pub fn loadWatchlist(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ?[][]const u8 {
const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch return null;
defer allocator.free(file_data);
const WatchEntry = struct { symbol: []const u8 };
var reader = std.Io.Reader.fixed(file_data);
var it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch return null;
defer it.deinit();

View file

@ -32,11 +32,15 @@ const fmt = cli.fmt;
const Config = zfin.Config;
const Lot = @import("../models/portfolio.zig").Lot;
const portfolio_mod = @import("../models/portfolio.zig");
const srf_lint = @import("../srf_lint.zig");
const Date = @import("../Date.zig");
const cache = @import("../cache/store.zig");
const freshness = @import("../cache/freshness.zig");
const classification = @import("../models/classification.zig");
const analysis = @import("../analytics/analysis.zig");
const projections = @import("../analytics/projections.zig");
const Journal = @import("../data/Journal.zig");
const transaction_log = @import("../models/transaction_log.zig");
const imported_values = @import("../data/imported_values.zig");
const history = @import("../history.zig");
@ -514,6 +518,66 @@ fn checkSrfFile(
}
}
/// Lint one user-authored SRF file's FIELD NAMES, appending one `.warn`
/// row per rolled-up finding plus a pointer to the reference page.
///
/// Distinct from the `checkSrfFile` row above it, which answers "does
/// this file parse". A file can parse perfectly and still be wrong:
/// SRF discards a key that names no field of the target struct without
/// any error, so `price_dat::2024-01-01` is indistinguishable from
/// having omitted the price date entirely. See `src/srf_lint.zig`.
///
/// Silent when the file is absent, unreadable, or clean - its presence
/// and parseability are already reported by the row above, and
/// repeating that here would double every message.
///
/// `.warn`, never `.fail`: a typo is a nudge, and `doctor`'s exit code
/// is reserved for a file that exists but cannot be parsed at all.
fn appendFieldNameChecks(
io: std.Io,
arena: std.mem.Allocator,
checks: *std.ArrayList(Check),
comptime S: type,
path: []const u8,
today: Date,
) !void {
const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(64 * 1024 * 1024)) catch return;
var result = srf_lint.check(arena, bytes, comptime srf_lint.shapeOfSchema(S)) catch return;
defer result.deinit();
// Model-owned semantic rules (a second, typed pass - SRF's
// iterators are single-pass, so `to()` cannot share the raw walk).
try srf_lint.checkSemantic(S, &result, bytes, .{ .today = today });
result.sort();
if (result.findings.len == 0) return;
const base = std.fs.path.basename(path);
for (result.findings) |f| {
// `describe` copies into arena memory, which outlives
// `result.deinit()` below; `f.detail` would not.
const buf = try arena.alloc(u8, srf_lint.Finding.describe_max);
try checks.append(arena, .{
.status = .warn,
.label = try std.fmt.allocPrint(arena, " {s}:{d}", .{ base, f.first_line }),
.detail = f.describe(buf),
});
}
if (result.truncated) {
try checks.append(arena, .{
.status = .warn,
.label = try std.fmt.allocPrint(arena, " {s}", .{base}),
.detail = "more findings suppressed; fix these first",
});
}
// The reference page is guaranteed complete - `srf_lint`'s doc-sync
// test fails the build if a model field is missing from it - so
// pointing there is sound advice rather than a shrug.
try checks.append(arena, .{
.status = .info,
.label = " field reference",
.detail = try std.fmt.allocPrint(arena, "docs/{s}", .{S.doc_path}),
});
}
/// Join a sibling filename onto the anchor portfolio's directory.
// ── run ───────────────────────────────────────────────────────
@ -556,6 +620,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
for (pf.paths) |rp| {
const c = try checkPortfolioFile(io, arena, rp.path, &all_lots);
try checks.append(arena, c);
try appendFieldNameChecks(io, arena, &checks, portfolio_mod.srf_schema, rp.path, ctx.today);
}
}
@ -564,6 +629,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
{
const r = checkSrfFile(io, arena, "accounts.srf", try cli.siblingPath(arena, a, "accounts.srf"), .optional, vAccounts);
try checks.append(arena, r);
try appendFieldNameChecks(io, arena, &checks, analysis.srf_schema, try cli.siblingPath(arena, a, "accounts.srf"), ctx.today);
if (r.status == .ok) {
const path = try cli.siblingPath(arena, a, "accounts.srf");
if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| {
@ -575,6 +641,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
{
const r = checkSrfFile(io, arena, "metadata.srf", try cli.siblingPath(arena, a, "metadata.srf"), .optional, vMetadata);
try checks.append(arena, r);
try appendFieldNameChecks(io, arena, &checks, classification.srf_schema, try cli.siblingPath(arena, a, "metadata.srf"), ctx.today);
if (r.status == .ok) {
const path = try cli.siblingPath(arena, a, "metadata.srf");
if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| {
@ -586,6 +653,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
{
const r = checkSrfFile(io, arena, "transaction_log.srf", try cli.siblingPath(arena, a, "transaction_log.srf"), .optional, vTransfers);
try checks.append(arena, r);
try appendFieldNameChecks(io, arena, &checks, transaction_log.srf_schema, try cli.siblingPath(arena, a, "transaction_log.srf"), ctx.today);
if (r.status == .ok) {
const path = try cli.siblingPath(arena, a, "transaction_log.srf");
if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| {
@ -594,6 +662,12 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
}
}
try checks.append(arena, checkSrfFile(io, arena, "projections.srf", try cli.siblingPath(arena, a, "projections.srf"), .optional, validateSrf));
try appendFieldNameChecks(io, arena, &checks, projections.srf_schema, try cli.siblingPath(arena, a, "projections.srf"), ctx.today);
// `watchlist.srf` has no parse-check row of its own -
// `loadWatchlist` degrades to null - but its one field is
// as typo-able as any other.
try appendFieldNameChecks(io, arena, &checks, cli.srf_schema, try cli.siblingPath(arena, a, "watchlist.srf"), ctx.today);
try appendFieldNameChecks(io, arena, &checks, Journal.srf_schema, try cli.siblingPath(arena, a, "acknowledgments.srf"), ctx.today);
// imported_values.srf and the snapshots both live under
// <portfolio_dir>/history/, NOT directly beside the
// portfolio file.
@ -609,6 +683,12 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
// keys.srf / theme.srf live under $HOME/.config/zfin.
try checks.append(arena, checkUserConfigFiles(io, arena, config, .keys));
try checks.append(arena, checkUserConfigFiles(io, arena, config, .theme));
if (userConfigPath(arena, config, "keys.srf")) |p| {
try appendFieldNameChecks(io, arena, &checks, keybinds.srf_schema, p, ctx.today);
}
if (userConfigPath(arena, config, "theme.srf")) |p| {
try appendFieldNameChecks(io, arena, &checks, theme.srf_schema, p, ctx.today);
}
try sections.append(arena, .{ .title = "Files", .checks = checks.items });
}
@ -729,10 +809,28 @@ fn checkPortfolioFile(io: std.Io, arena: std.mem.Allocator, path: []const u8, al
if (err == error.FileNotFound) return .{ .status = .warn, .label = "portfolio.srf", .detail = "not found" };
return .{ .status = .fail, .label = "portfolio.srf", .detail = std.fmt.allocPrint(arena, "unreadable: {s}", .{@errorName(err)}) catch "unreadable" };
};
const pf = cache.deserializePortfolio(arena, bytes) catch |err| {
// Ask for diagnostics, not just the lot count. A record that fails
// to coerce is SKIPPED, not fatal, so the plain
// `deserializePortfolio` reports `.ok` with a quietly-smaller
// number - and nobody has a baseline for "how many lots should
// there be". Reporting the skip count is the only way a dropped
// record is visible at all.
var diags: cache.ParseDiagnostics = .empty;
const pf = cache.deserializePortfolioDiag(arena, bytes, &diags) catch |err| {
return .{ .status = .fail, .label = path, .detail = std.fmt.allocPrint(arena, "parse error: {s}", .{@errorName(err)}) catch "parse error" };
};
try all_lots.appendSlice(arena, pf.lots);
if (diags.items.len > 0) {
return .{
.status = .warn,
.label = path,
.detail = std.fmt.allocPrint(
arena,
"{d} lots; {d} record(s) SKIPPED - {s}",
.{ pf.lots.len, diags.items.len, diags.items[0] },
) catch "records skipped",
};
}
return .{ .status = .ok, .label = path, .detail = std.fmt.allocPrint(arena, "{d} lots", .{pf.lots.len}) catch "" };
}
@ -807,6 +905,15 @@ const UserConfigKind = enum { keys, theme };
/// Check `$HOME/.config/zfin/{keys,theme}.srf`. These resolve from
/// $HOME only (not ZFIN_HOME / cwd), mirroring the TUI loader.
/// `$HOME/.config/zfin/<filename>`, or null when HOME is unset.
/// Mirrors the path `checkUserConfigFiles` builds, so the field-name
/// lint reads the same file the parse-check reported on.
fn userConfigPath(arena: std.mem.Allocator, config: Config, filename: []const u8) ?[]const u8 {
const home = if (config.environ_map) |em| em.get("HOME") else null;
if (home == null) return null;
return std.fs.path.join(arena, &.{ home.?, ".config", "zfin", filename }) catch null;
}
fn checkUserConfigFiles(io: std.Io, arena: std.mem.Allocator, config: Config, kind: UserConfigKind) Check {
const filename = switch (kind) {
.keys => "keys.srf",
@ -1617,3 +1724,182 @@ test "checkCandleFreshness: an untracked laggard does not warn" {
const c = try checkCandleFreshness(arena, &store, &tracked, &.{}, now_s);
try testing.expectEqual(Status.ok, c.status);
}
// ── appendFieldNameChecks ──
/// Fixed `today` so the date rules' verdicts don't drift with the clock.
const fixed_today = Date.fromYmd(2026, 1, 1);
/// Write `data` to a tmpdir and collect the field-name checks it
/// produces for schema `S`. Caller owns nothing - everything lives in
/// the arena the caller passes.
fn fieldChecksFor(
arena: std.mem.Allocator,
comptime S: type,
filename: []const u8,
data: []const u8,
) !std.ArrayList(Check) {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = filename, .data = data });
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const path = try std.fs.path.join(arena, &.{ path_buf[0..dir_len], filename });
var checks: std.ArrayList(Check) = .empty;
try appendFieldNameChecks(io, arena, &checks, S, path, fixed_today);
return checks;
}
test "appendFieldNameChecks: clean file adds nothing" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const checks = try fieldChecksFor(
arena,
portfolio_mod.srf_schema,
"portfolio.srf",
"#!srfv1\nsymbol::VTI,shares:num:1,open_date::2024-01-01,open_price:num:1\n",
);
try std.testing.expectEqual(@as(usize, 0), checks.items.len);
}
test "appendFieldNameChecks: missing file adds nothing" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var checks: std.ArrayList(Check) = .empty;
try appendFieldNameChecks(std.testing.io, arena, &checks, portfolio_mod.srf_schema, "/nonexistent/portfolio.srf", fixed_today);
try std.testing.expectEqual(@as(usize, 0), checks.items.len);
}
test "appendFieldNameChecks: a typo warns and points at the reference page" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const checks = try fieldChecksFor(
arena,
portfolio_mod.srf_schema,
"portfolio.srf",
"#!srfv1\nsymbol::VTI,shares:num:1,open_date::2024-01-01,open_price:num:1,price_dat::2025-01-01\n",
);
// One finding row plus the reference-page pointer.
try std.testing.expectEqual(@as(usize, 2), checks.items.len);
try std.testing.expectEqual(Status.warn, checks.items[0].status);
try std.testing.expect(std.mem.indexOf(u8, checks.items[0].label, "portfolio.srf:2") != null);
try std.testing.expect(std.mem.indexOf(u8, checks.items[0].detail, "did you mean 'price_date'?") != null);
// `.info`, not `.warn` - the pointer is not itself a problem.
try std.testing.expectEqual(Status.info, checks.items[1].status);
try std.testing.expect(std.mem.indexOf(u8, checks.items[1].detail, "portfolio-srf.md") != null);
}
test "appendFieldNameChecks: never returns .fail, so a typo cannot change the exit code" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// One of every finding kind the walker can produce.
const checks = try fieldChecksFor(
arena,
portfolio_mod.srf_schema,
"portfolio.srf",
\\#!srfv1
\\shares:num:1,open_date::2024-01-01,open_price:num:1,price_dat::2025-01-01
\\shares:num:1,open_date::2024-01-01,open_price:num:1,Account::X
\\shares:num:1,open_date::2024-01-01,open_price:num:1,rate:num:5
\\shares:num:1,open_date::2024-01-01,open_price:num:1,split_factor:num:2
\\shares:num:1,open_date::2024-01-01,open_price:num:1,nonsense::x
\\
,
);
try std.testing.expect(checks.items.len > 1);
for (checks.items) |c| {
try std.testing.expect(c.status != .fail);
}
// And `countByStatus` agrees, which is what gates the exit code.
const sections = [_]Section{.{ .title = "Files", .checks = checks.items }};
try std.testing.expectEqual(@as(usize, 0), countByStatus(&sections, .fail));
}
test "checkPortfolioFile: a skipped record warns instead of quietly lowering the count" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Second record omits `open_price`, which has no default - SRF
// cannot coerce it, so the loader drops the record. Before this
// check asked for diagnostics the report said `[OK] 1 lots`.
try tmp.dir.writeFile(io, .{
.sub_path = "portfolio.srf",
.data =
\\#!srfv1
\\symbol::VTI,shares:num:1,open_date::2024-01-01,open_price:num:1
\\symbol::SPY,shares:num:2,open_date::2024-01-02
\\
,
});
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const path = try std.fs.path.join(arena, &.{ path_buf[0..dir_len], "portfolio.srf" });
var lots: std.ArrayList(Lot) = .empty;
const c = try checkPortfolioFile(io, arena, path, &lots);
try std.testing.expectEqual(Status.warn, c.status);
try std.testing.expect(std.mem.indexOf(u8, c.detail, "SKIPPED") != null);
try std.testing.expectEqual(@as(usize, 1), lots.items.len);
}
// ── userConfigPath ──
test "userConfigPath: builds the $HOME/.config/zfin path" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var env = try std.testing.environ.createMap(std.testing.allocator);
defer env.deinit();
try env.put("HOME", "/home/placeholder");
const p = userConfigPath(arena, .{ .cache_dir = "/tmp/placeholder-cache", .environ_map = &env }, "keys.srf");
try std.testing.expectEqualStrings("/home/placeholder/.config/zfin/keys.srf", p.?);
}
test "userConfigPath: null without an environment, so the lint is skipped rather than guessing" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
// No environ map at all - the same branch a HOME-less environment
// takes. Returning null means `run` skips the keys/theme lint
// instead of inventing a path.
try std.testing.expectEqual(
@as(?[]const u8, null),
userConfigPath(arena_state.allocator(), .{ .cache_dir = "/tmp/placeholder-cache" }, "theme.srf"),
);
}
test "appendFieldNameChecks: lot lifecycle rules reach doctor as warnings" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const checks = try fieldChecksFor(
arena,
portfolio_mod.srf_schema,
"portfolio.srf",
"#!srfv1\nsymbol::VTI,shares:num:1,open_date::2024-01-01,open_price:num:1,close_date::2062-03-14,close_price:num:2\n",
);
// One finding plus the reference-page pointer.
try std.testing.expectEqual(@as(usize, 2), checks.items.len);
try std.testing.expectEqual(Status.warn, checks.items[0].status);
try std.testing.expect(std.mem.indexOf(u8, checks.items[0].detail, "close_date 2062-03-14 is in the future") != null);
}

View file

@ -107,6 +107,19 @@ const JournalRecord = union(enum) {
note: NoteRecord,
};
/// Schema contract for `acknowledgments.srf`. See
/// `srf_lint.validateSchema`.
///
/// `Acknowledgment` does NOT redeclare the `type` tag field, unlike
/// `projections.srf`'s variants - SRF's `to()` consumes the tag before
/// recursing into the variant, so both spellings are legal. The shape
/// builder accepts `type::` for every variant either way.
pub const srf_schema = struct {
pub const Record = JournalRecord;
pub const file_label: []const u8 = "acknowledgments.srf";
pub const doc_path: []const u8 = "reference/config/acknowledgments-srf.md";
};
/// In-memory ack with its notes already grouped. Built by `load`;
/// consumed by callers that want "the ack and its reasoning together."
pub const Entry = struct {

View file

@ -53,6 +53,13 @@ pub const ClassificationEntry = struct {
splits_current_through: ?Date = null,
};
/// Schema contract for `metadata.srf`. See `srf_lint.validateSchema`.
pub const srf_schema = struct {
pub const Record = ClassificationEntry;
pub const file_label: []const u8 = "metadata.srf";
pub const doc_path: []const u8 = "reference/config/metadata-srf.md";
};
/// Parsed classification data for the entire portfolio.
pub const ClassificationMap = struct {
entries: []ClassificationEntry,

View file

@ -3,6 +3,7 @@ const Date = @import("../Date.zig");
const Candle = @import("candle.zig").Candle;
const split = @import("split.zig");
const Split = split.Split;
const srf_lint = @import("../srf_lint.zig");
// ── Pricing model ────────────────────────────────────────────
//
@ -448,6 +449,199 @@ pub const Lot = struct {
}
};
/// Schema contract for `portfolio.srf`, consumed by `srf_lint` and
/// rendered by `zfin doctor` / `zfin audit`.
///
/// Lives here rather than in `srf_lint.zig` on purpose: rules kept away
/// from the fields they describe drift from them. `field_rules` is
/// checked for exhaustiveness over `std.meta.fields(Lot)` at comptime
/// in both directions, so adding a field to `Lot` fails the build until
/// it is classified here, and renaming one fails the build too.
pub const srf_schema = struct {
pub const Record = Lot;
pub const file_label: []const u8 = "portfolio.srf";
pub const doc_path: []const u8 = "reference/config/portfolio-srf.md";
/// Fields deliberately absent from the reference doc. Everything
/// else must appear there or the doc-sync test fails.
pub const undocumented = [_][]const u8{
// DERIVED by `enrichSplits`; documenting it would invite
// hand-editing, which `field_rules` flags below.
"split_factor",
};
pub const scope_discriminator: []const u8 = "security_type";
/// Which `LotType`s each field is actually read for.
///
/// A `.only` entry is a claim that the value is SILENTLY IGNORED
/// for every other type, so each one cites the gate that proves it.
/// When a field's reach is ambiguous the entry is deliberately
/// permissive (no `.only`) - a missed nudge costs nothing, while a
/// false "zfin ignores this" would send someone chasing a
/// non-existent bug.
pub const field_rules = [_]srf_lint.Rule{
.{ .name = "symbol" },
.{ .name = "shares" },
.{ .name = "open_date" },
.{ .name = "open_price" },
.{ .name = "close_date" },
.{ .name = "close_price" },
.{ .name = "note" },
.{ .name = "label" },
.{ .name = "account" },
.{ .name = "security_type" },
// Read ungated by `lotIsOpenAsOf` (this file, "Matured on or
// before `as_of`"), so a maturity on a stock lot really does
// close it. NOT option/cd-only despite the doc comment.
.{ .name = "maturity_date" },
// CD yield. Read only by `analytics/reconcile/common.zig`'s
// CD-allowance math and the CD table's rate column in
// `views/portfolio_sections.zig`.
.{ .name = "rate", .only = &.{"cd"} },
// Dividend-reinvestment grouping is a stock-table concern, but
// `commands/contributions.zig`'s lot differ reads it for any
// type. Permissive.
.{ .name = "drip" },
// Pricing fields. `analytics/valuation.zig`'s
// `buildFallbackPrices` gates manual prices to `.stock`, but
// `commands/audit.zig`'s reconcile price map reads `lot.price`
// ungated - so a manual price on a non-stock lot is not
// reliably ignored. Permissive until those two agree.
.{ .name = "ticker" },
.{ .name = "price" },
.{ .name = "price_date" },
.{ .name = "price_ratio" },
// Populated by `enrichSplits`, never by hand.
.{ .name = "split_factor", .derived = true },
// Option mechanics. Every read is behind an `.option` gate:
// `models/option.zig`'s lot matcher, `analytics/valuation.zig`'s
// covered-call detection, and the three `@abs(shares) *
// open_price * multiplier` sites (this file's
// `totalOptionCostAsOf`, `analytics/analysis.zig`, and
// `commands/snapshot.zig`).
.{ .name = "underlying", .only = &.{"option"} },
.{ .name = "strike", .only = &.{"option"} },
.{ .name = "multiplier", .only = &.{"option"} },
.{ .name = "option_type", .only = &.{"option"} },
};
/// Lot lifecycle rules - dates and close fields that parse fine but
/// describe a lot that cannot exist.
///
/// These matter more than they look because zfin has THREE
/// definitions of "closed", and bad lifecycle data is exactly what
/// makes them disagree:
///
/// - `lotIsOpenAsOf` (holdings, snapshots, analysis): closed iff
/// `close_date <= as_of`.
/// - `commands/contributions.zig`'s lot differ and
/// `commands/import.zig`: closed iff `close_date != null`.
/// - `views/portfolio_sections.zig`'s `effectivePriceFor` (lot
/// rows): priced at `close_price` whenever it is set.
///
/// A future `close_date` is therefore held by the first, sold by the
/// second, and priced as sold by the third. Stopping the input here
/// is the cheap fix; reconciling the three definitions is separate
/// work.
///
/// Watch lots are skipped throughout: `Portfolio.watchSymbols` reads
/// only `symbol`, so their dates are inert.
pub fn semanticCheck(rec: Record, ctx: srf_lint.Context, sink: *srf_lint.Sink) !void {
if (rec.security_type == .watch) return;
const a = sink.allocator;
const name = lotName(rec);
if (rec.close_date) |cd| {
// End-of-day semantics: a close dated today is already
// closed, so only strictly-later dates are wrong.
if (ctx.today.lessThan(cd)) {
try sink.addOwned(.semantic, "close_date", try std.fmt.allocPrint(
a,
"{s} close_date {f} is in the future - the lot is still counted as held until then",
.{ name, cd },
));
}
// Equal is a same-day round trip, which is legitimate.
if (cd.lessThan(rec.open_date)) {
try sink.addOwned(.semantic, "close_date", try std.fmt.allocPrint(
a,
"{s} close_date {f} is before open_date {f} - the lot can never have been held",
.{ name, cd, rec.open_date },
));
}
// Stock-only: both position builders skip non-stock lots,
// so realized P&L is a stock concept. A cash or CD lot
// closed without a price is plausible - its shares ARE its
// dollar value.
if (rec.close_price == null and rec.security_type == .stock) {
try sink.addOwned(.semantic, "close_price", try std.fmt.allocPrint(
a,
"{s} has close_date but no close_price - the sale's realized gain is recorded as 0",
.{name},
));
}
} else if (rec.close_price != null) {
// Only a stock lot's row consults `close_price` while open
// (`effectivePriceFor`); for every other type it is inert.
const consequence: []const u8 = if (rec.security_type == .stock)
"the lot is still held, and its row shows the close price instead of the live one"
else
"the lot is still held and the close price is ignored";
try sink.addOwned(.semantic, "close_price", try std.fmt.allocPrint(
a,
"{s} has close_price but no close_date - {s}",
.{ name, consequence },
));
}
if (ctx.today.lessThan(rec.open_date)) {
try sink.addOwned(.semantic, "open_date", try std.fmt.allocPrint(
a,
"{s} open_date {f} is in the future - the lot is not counted as held until then",
.{ name, rec.open_date },
));
}
// Stock-only: manual-price staleness (`commands/audit/hygiene.zig`)
// only examines stock lots, so that is the only place a future
// date suppresses anything.
if (rec.security_type == .stock) {
if (rec.price_date) |pd| {
if (ctx.today.lessThan(pd)) {
try sink.addOwned(.semantic, "price_date", try std.fmt.allocPrint(
a,
"{s} price_date {f} is in the future - the manual price counts as fresh, so it will not be flagged stale",
.{ name, pd },
));
}
}
}
}
/// How a finding names the lot. Cash lots often have no symbol, so
/// fall back to the type rather than printing an empty string - the
/// line number already pins down which record it is.
fn lotName(lot: Lot) []const u8 {
const s = lot.displaySymbol();
if (s.len > 0) return s;
return switch (lot.security_type) {
.cash => "cash lot",
.cd => "CD lot",
.option => "option lot",
.illiquid => "illiquid lot",
.stock => "stock lot",
.watch => "watch lot",
};
}
};
/// Populate each open stock lot's `split_factor` from the fetched split
/// `corpus` (keyed by `priceSymbol()`), in place.
///
@ -2163,3 +2357,179 @@ test "extraPriceSymbols: order is stable - watch lots first, then the watchlist
try std.testing.expectEqualStrings("QTUM", out[3]);
}
}
// ── srf_schema.semanticCheck: lot lifecycle rules ──
/// `today` for these tests. Fixed, so a verdict can't flip as the
/// calendar moves past a fixture date.
const lint_today = Date.fromYmd(2026, 1, 1);
/// Run both lint passes over `data` as portfolio.srf and return only
/// the semantic findings' descriptions, joined by newlines. Caller
/// frees.
fn lifecycleFindings(data: []const u8) ![]u8 {
const allocator = std.testing.allocator;
var r = try srf_lint.check(allocator, data, srf_lint.shapeOfSchema(srf_schema));
defer r.deinit();
try srf_lint.checkSemantic(srf_schema, &r, data, .{ .today = lint_today });
r.sort();
var aw: std.Io.Writer.Allocating = .init(allocator);
errdefer aw.deinit();
var buf: [srf_lint.Finding.describe_max]u8 = undefined;
for (r.findings) |f| {
if (f.kind != .semantic) continue;
try aw.writer.print("{d}: {s}\n", .{ f.first_line, f.describe(&buf) });
}
return aw.toOwnedSlice();
}
fn expectLifecycle(data: []const u8, want: []const []const u8) !void {
const got = try lifecycleFindings(data);
defer std.testing.allocator.free(got);
var lines: usize = 0;
var it = std.mem.splitScalar(u8, std.mem.trimEnd(u8, got, "\n"), '\n');
while (it.next()) |l| {
if (l.len > 0) lines += 1;
}
for (want) |w| {
// On a miss, let `expectEqualStrings` print both sides: it always
// fails here (a substring was not found), and it reports through
// `std.testing` rather than a hand-rolled print.
if (std.mem.indexOf(u8, got, w) == null) return std.testing.expectEqualStrings(w, got);
}
if (lines != want.len) {
const joined = try std.mem.join(std.testing.allocator, "\n", want);
defer std.testing.allocator.free(joined);
return std.testing.expectEqualStrings(joined, got);
}
}
test "lifecycle: a fully valid open and closed lot are clean" {
try expectLifecycle(
\\#!srfv1
\\symbol::VTI,shares:num:10,open_date::2024-01-15,open_price:num:220
\\symbol::SPY,shares:num:5,open_date::2024-01-15,open_price:num:470,close_date::2025-06-01,close_price:num:560
\\
, &.{});
}
test "lifecycle: future close_date is flagged" {
try expectLifecycle(
\\#!srfv1
\\symbol::VTI,shares:num:10,open_date::2024-01-15,open_price:num:220,close_date::2062-03-14,close_price:num:300
\\
, &.{"2: VTI close_date 2062-03-14 is in the future - the lot is still counted as held until then"});
}
test "lifecycle: a close dated today is already closed, not future" {
// End-of-day semantics, matching `lotIsOpenAsOf`.
try expectLifecycle(
\\#!srfv1
\\symbol::VTI,shares:num:10,open_date::2024-01-15,open_price:num:220,close_date::2026-01-01,close_price:num:300
\\
, &.{});
}
test "lifecycle: close before open is flagged; a same-day round trip is not" {
try expectLifecycle(
\\#!srfv1
\\symbol::VTI,shares:num:10,open_date::2024-06-01,open_price:num:220,close_date::2024-05-01,close_price:num:230
\\symbol::SPY,shares:num:10,open_date::2024-06-01,open_price:num:470,close_date::2024-06-01,close_price:num:471
\\
, &.{"2: VTI close_date 2024-05-01 is before open_date 2024-06-01"});
}
test "lifecycle: stock lot closed without a price loses its realized gain" {
try expectLifecycle(
\\#!srfv1
\\symbol::SPY,shares:num:5,open_date::2024-01-15,open_price:num:470,close_date::2025-06-01
\\
, &.{"2: SPY has close_date but no close_price - the sale's realized gain is recorded as 0"});
}
test "lifecycle: cash and CD lots closed without a price are fine" {
// Their shares ARE their dollar value; there is no realized P&L to lose.
try expectLifecycle(
\\#!srfv1
\\security_type::cash,shares:num:5000,open_date::2024-01-01,open_price:num:1,close_date::2025-01-01,account::Sample Brokerage
\\security_type::cd,symbol::CD1,shares:num:10000,open_date::2024-01-01,open_price:num:10000,close_date::2025-01-01
\\
, &.{});
}
test "lifecycle: close_price without close_date - stock row shows the wrong price" {
try expectLifecycle(
\\#!srfv1
\\symbol::SPY,shares:num:5,open_date::2024-01-15,open_price:num:470,close_price:num:560
\\
, &.{"2: SPY has close_price but no close_date - the lot is still held, and its row shows the close price instead of the live one"});
}
test "lifecycle: close_price without close_date - non-stock says it is ignored" {
try expectLifecycle(
\\#!srfv1
\\security_type::cd,symbol::CD1,shares:num:10000,open_date::2024-01-01,open_price:num:10000,close_price:num:10000
\\
, &.{"2: CD1 has close_price but no close_date - the lot is still held and the close price is ignored"});
}
test "lifecycle: future open_date is flagged" {
try expectLifecycle(
\\#!srfv1
\\symbol::VTI,shares:num:10,open_date::2062-01-15,open_price:num:220
\\
, &.{"2: VTI open_date 2062-01-15 is in the future - the lot is not counted as held until then"});
}
test "lifecycle: future price_date is flagged on stock lots only" {
// Manual-price staleness only ever examines stock lots, so a future
// price_date on a CD suppresses nothing and is left alone.
try expectLifecycle(
\\#!srfv1
\\symbol::NON40,shares:num:10,open_date::2024-01-15,open_price:num:150,price:num:163,price_date::2062-02-27
\\symbol::NON41,shares:num:10,open_date::2024-01-15,open_price:num:150,price:num:163,price_date::2025-12-31
\\security_type::cd,symbol::CD1,shares:num:10000,open_date::2024-01-01,open_price:num:10000,price_date::2062-01-01
\\
, &.{"2: NON40 price_date 2062-02-27 is in the future - the manual price counts as fresh"});
}
test "lifecycle: watch lots are exempt" {
// `Portfolio.watchSymbols` reads only `symbol`; dates are inert.
try expectLifecycle(
\\#!srfv1
\\security_type::watch,symbol::NVDA,shares:num:0,open_date::2062-01-01,open_price:num:0,close_date::2063-01-01
\\
, &.{});
}
test "lifecycle: maturity_date in the future is legitimate" {
try expectLifecycle(
\\#!srfv1
\\security_type::cd,symbol::CD1,shares:num:10000,open_date::2025-06-01,open_price:num:10000,maturity_date::2027-06-01,rate:num:4.5
\\
, &.{});
}
test "lifecycle: two bad lots stay two findings, and one lot can have several" {
// Rollup identity includes `detail`, which names the symbol, so the
// same rule on different lots must not collapse into one line.
try expectLifecycle(
\\#!srfv1
\\symbol::VTI,shares:num:10,open_date::2024-01-15,open_price:num:220,close_date::2062-03-14
\\symbol::SPY,shares:num:5,open_date::2024-01-15,open_price:num:470,close_date::2062-03-14,close_price:num:1
\\
, &.{
"2: VTI close_date 2062-03-14 is in the future",
"2: VTI has close_date but no close_price",
"3: SPY close_date 2062-03-14 is in the future",
});
}
test "lifecycle: a lot without a symbol is named by its type" {
try expectLifecycle(
\\#!srfv1
\\security_type::cash,shares:num:5000,open_date::2062-01-01,open_price:num:1,account::Sample Brokerage
\\
, &.{"2: cash lot open_date 2062-01-01 is in the future"});
}

View file

@ -184,6 +184,14 @@ pub const TransferRecord = struct {
}
};
/// Schema contract for `transaction_log.srf`. See
/// `srf_lint.validateSchema`.
pub const srf_schema = struct {
pub const Record = TransferRecord;
pub const file_label: []const u8 = "transaction_log.srf";
pub const doc_path: []const u8 = "reference/config/transaction-log-srf.md";
};
/// Parsed transaction log. `transfers` is allocator-owned; all string
/// fields on each record (including the symbol inside `dest_lot.lot`)
/// are also owned by the log's allocator.

1802
src/srf_lint.zig Normal file

File diff suppressed because it is too large Load diff

View file

@ -18,6 +18,17 @@ const srf = @import("srf");
/// `projections.srf`, `imported_values.srf`, `acknowledgments.srf`, and
/// the keybind config.
///
/// **`grep -rn srf_opts.user_edited src/` is the authoritative index of
/// hand-edited SRF files, and `src/srf_lint.zig`'s `schemas` registry
/// must cover the same set.** A new parse site here needs a matching
/// `srf_schema` on its model and an entry in that registry, or the
/// file's field names go unchecked - SRF silently discards a key that
/// names no field of the target struct, so a typo in an unregistered
/// file is invisible. (The one deliberate exception is
/// `imported_values.srf`, which is machine-generated; see the registry's
/// doc comment.) Zig cannot enforce this - there is no way to ask which
/// files reference a constant - so it is a convention, hence this note.
///
/// Accepts a string where a number was declared, because a hand-typed
/// `close_price::200.00` instead of `close_price:num:200.00` is a
/// slip, not a different intent. SRF's own doc says as much: strict

View file

@ -427,6 +427,13 @@ const RawRecord = struct {
scope: ?[]const u8 = null,
};
/// Schema contract for `keys.srf`. See `srf_lint.validateSchema`.
pub const srf_schema = struct {
pub const Record = RawRecord;
pub const file_label: []const u8 = "keys.srf";
pub const doc_path: []const u8 = "reference/config/keys-srf.md";
};
/// Load keybindings from an SRF file. Returns null if the file doesn't exist
/// or can't be parsed. On success, the caller owns the returned KeyMap and
/// must call deinit().

View file

@ -224,6 +224,19 @@ const field_names = [_]struct { name: []const u8, offset: usize }{
.{ .name = "bar_fill", .offset = @offsetOf(Theme, "bar_fill") },
};
/// Schema contract for `theme.srf`. See `srf_lint.validateSchema`.
///
/// `Record = Theme` rather than a parallel name list: `loadFromData`
/// matches keys against the hand-maintained `field_names` table above,
/// but the authoritative set is `std.meta.fields(Theme)`, and deriving
/// from the struct means the lint cannot disagree with the type even if
/// `field_names` falls behind it.
pub const srf_schema = struct {
pub const Record = Theme;
pub const file_label: []const u8 = "theme.srf";
pub const doc_path: []const u8 = "reference/config/theme-srf.md";
};
fn colorPtr(theme: *Theme, offset: usize) *Color {
const bytes: [*]u8 = @ptrCast(theme);
return @ptrCast(@alignCast(bytes + offset));