dates-le 0.2.2

Extract every date and timestamp, and the exact instant each one resolves to
Documentation
# Changelog

The Rust CLI and MCP server. The VS Code extension has its own
[CHANGELOG](../CHANGELOG.md) and its own version — the two products in
this repository release on their own cadence.

Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.2] - 2026-08-15

### Added

- **The crates.io page carries a demo of the CLI.** It had the icon and
  nothing else, because the only recording in the repository was of the
  VS Code extension reading an editor buffer — a clip of something this
  binary does not do. `assets/demo.tape` records the real binary against
  the files in `assets/demo/`, so the clip is reproducible (`cd assets
  && vhs demo.tape`) rather than an artifact nobody can regenerate.

## [0.2.1] - 2026-08-15

### Fixed

- **The crates.io page shows the icon.** It lived only in the repository
  README, and that file is not the one `cargo publish` ships — the
  published README is this directory's. A relative path would not have
  fixed it: the crate is published from `crate/`, so crates.io resolves
  a relative link against `path_in_vcs` and looks for the asset below
  the crate directory rather than beside it. The image is an absolute
  URL, which every surface renders.

  No demo goes with it. `src/assets/images/demo.gif` records the
  extension reading an editor buffer, which is not what this binary
  does; the demo that belongs here is a recording of the CLI, and there
  is not one yet.

## [0.2.0] - 2026-08-14

The release where this reads a whole repository rather than the parts of
one it happened to recognise, and where the numbers it reports about
digits stop being generous.

**Expect the count to go up, and expect none of the old ones to be
missing.** A large tree that reported 488 dates reports 615 now; every
date the previous version found is still found, and the difference is
files that were never opened plus notations that were never read.

### Added

- **Every text file is read.** The walk skipped any file whose name
  resolved to none of nine formats, so a repository of Python, Go, Rust,
  TOML and Markdown was walked and almost entirely unread — and the
  reader saw a clean report rather than a skipped one. A format only ever
  *adds* patterns to the shared ones, so the shared scan is the correct
  reading of a document nobody named: `resolve_format` now yields
  `unknown` instead of nothing, `extract` has a fallback arm, and
  `has_a_format()` is gone from `walk.rs`. `toml`, `ini`, `cfg`, `conf`,
  `properties`, `markdown` and `md` are named formats rather than
  unknowns; `toml` and `markdown` join the tool schema's enum.
- **Four date shapes that appear in real files and that JavaScript
  cannot read at all**: ISO 8601 week dates (`2024-W03`, `2024-W03-1`),
  ordinal dates (`2024-015`), the basic format (`20240115`,
  `20240115T103045Z`), and the timezone abbreviations `CEST CET BST JST
  AEST IST` as fixed offsets. Every one of them is `NaN` to `Date.parse`,
  so every one of them used to be dropped.

  `week`, `ordinal` and `basic` are new `format` values in the output,
  which is worth knowing if you filter on that field. Each shape is
  normalised into a string V8 *does* read rather than resolved here, so a
  week date is UTC for the same reason `2024-01-15` is, and the CLI and
  the editor answer identically. `IST` names three different zones —
  India, Ireland and Israel — and India is the one taken; SPEC.md says so
  plainly rather than leaving it to be discovered.
- **Unix epochs in microseconds and nanoseconds** — 16 and 19 digits,
  same plausible-range floor, converted by taking the leading 13
  *characters* rather than by dividing, because 19 digits do not fit a
  double and a division would round in JavaScript and not in Rust.
- Corpus documents `settings.py`, `handler.go`, `release-notes.md`,
  `pyproject.toml` and `notations.txt`, and MCP cases for each.
- Corpus document `calendar.js`, so `javascript` is a format the two
  frontends have actually been compared on. The tool schema had
  advertised it since 0.1.0 with nothing behind it — `dates.ts` covers
  `typescript`, and the two are separate keys on purpose.
- **Six CI jobs, and every one of them found something.** `hazards` and
  `platform` run the built binary on all three operating systems over
  trees built at run time — byte-order marks, CRLF, lone carriage
  returns, NUL bytes, undecodable text, a megabyte-long line, a hundred
  thousand lines, symlink loops, FIFOs, unreadable files, reserved
  Windows names and paths past 260 characters — and assert the process
  never panics, never hangs and never leaves by a signal. `differential`
  generates over twelve hundred documents from a printed seed and
  requires both servers' `extract_dates` to answer byte-identically.
  `fuzz` mutates the V8 oracle's own inputs against the parsers and the
  scan for sixty seconds a target. `budget` puts a wall-clock ceiling
  and a linearity check on a generated 500-file tree. `coverage-matrix`
  requires a report line for every extension in the alias table and a
  corpus document for every advertised format. Each job names the input,
  the platform and the seed that broke it.

### Fixed

- **A date after a megabyte of anything else was silently dropped.**
  `fancy-regex` stops after a million backtracking steps by default and
  an unanchored search spends one per starting position, so past about a
  megabyte every pattern stopped matching — and the scan read the
  engine's error as "no more matches" and reported the file as clean. The
  patterns are built with no backtrack limit now (none of them nests a
  quantifier; `tests/scenarios.rs` holds the megabyte-scale documents
  that would show it if one did), and the error is no longer swallowed.
  Found by `hazards`.
- **The process aborted on a word with an accent in it.** `Date.parse`'s
  keyword table matches on a word's first three *characters* and the
  crate sliced three *bytes*, so `new Date('Jaé…')` or
  `datetime="Café day"` killed the run — both of those hand the parser
  arbitrary text. Found by `fuzz`.
- **Words were read with Rust's notion of a letter rather than V8's.**
  V8 scans a word while the character is `A` or above, so an accent, a
  non-breaking space, an ideographic space and a byte-order mark are all
  *inside* a word: `Jané 15 2024` is January and `Jan 15 2024\u{a0}` is a
  refusal. Cases added to the oracle for all of it, which now holds 178.
- **The two servers disagreed about an unclosed XML comment.** The
  extension masks with a regex that requires the closing marker, so
  `<a>1</a><!-- 2024-01-15` still yields that date; this swallowed the
  rest of the document. The opening `<!--` can no longer close itself
  either — `<!-->` is not an empty comment. Found by `differential`.
- **The two servers disagreed about whitespace.** JavaScript's
  whitespace set includes U+FEFF and excludes U+0085; Rust's Unicode
  `White_Space` does the opposite. `extract_dates` with
  `format: "\u{feff}json"` resolved to `json` in the editor and
  `unknown` here, and `datetime\u{feff}="March 5, 2024"` was a date in
  one frontend and not the other. A new `extract/js.rs` defines
  JavaScript's set once, in two forms held equal by a test, and the
  format normaliser and every `\s` in the patterns go through it. The
  normaliser also strips one leading dot rather than all of them, which
  is what `replace(/^\./, '')` does. Found by `differential`.
- **A nineteen-digit year overflowed the calendar arithmetic.**
  `week_date`, `ordinal_date` and `basic_format` accepted any numeral
  `parse` would swallow, where the extension's anchored regexes accept
  exactly four digits and three. The widths are checked now. Found by
  `fuzz`.
- **Report paths used the platform separator.** `file` in every report
  line — stdout is protocol — was spelled with `\` on Windows and `/`
  everywhere else, so the same tree produced JSON only one consumer
  could read. One spelling now, on every platform.

### Changed

- **The MCP envelope keeps the schema's field order.** `serde_json`
  sorts object keys by default, so an agent reading `content[0].text`
  from this server got them alphabetised where the npm server gave them
  in the documented order — the same answer, spelled two ways, from what
  is meant to be one tool. `serde_json` gains the `preserve_order`
  feature (and with it `indexmap`) so the claim of byte-identical output
  is true rather than nearly true.

- **`--format` no longer refuses a name it does not recognise**, and
  `--stdin` no longer requires one: both fall back to the shared
  patterns. The same on the MCP surface — `extract_dates` with no
  `format` and no `filename`, and `dates_le_scan` with an unrecognised
  `format`, were refusals and are answers now, carrying
  `fileType: "unknown"`. The two corpus cases that pinned those refusals
  were updated deliberately.
- **A binary file is skipped silently rather than reported.** Widening
  the walk means a PNG now reaches the reader; a NUL byte in the first
  8KB (ripgrep's heuristic) means the file was never a text candidate, so
  it produces no report line and does not affect `--strict` — which
  would otherwise exit 2 on any repository containing an image. They are
  counted in the stderr summary (`, 14 binary files skipped`) so the
  coverage is still stated. A file that *is* text and cannot be read
  keeps its named `skipped` diagnostic and still fails `--strict`.
- **A long run of digits now has to be a plausible date, not merely the
  right length.** This is the one change here that can remove something
  from your output, and it has two halves.

  What it removes: a 13-, 16- or 19-digit run is read as a timestamp only
  if it lands on or after 2001-09-09 and before 2100-01-01. Card numbers,
  request ids and large constants are not dates any more — a card number
  was being reported as 2113, `Number.MAX_SAFE_INTEGER` as 2255, and a
  13-digit `request_id` in this project's own fixture as December 2282.

  What it costs: a genuinely far-future timestamp written in
  milliseconds, microseconds or nanoseconds is no longer reported. Those
  units are machine-stamped — `Date.now()`, `time.time_ns()`,
  `UnixNano()` — and record when a program ran; a cutoff a person wrote
  is a date or a seconds epoch. If you keep the year 2200 in
  milliseconds, this release stops seeing it.

  The reason the rule has to exist at all: past ten digits the digit
  count stops bounding anything. At ten digits it does — the widest
  ten-digit numeral is the year 2286 — but at thirteen, sixteen and
  nineteen *every* numeral of that width already lands in 2001–2286, so
  the old floor excluded nothing whatsoever.

- **The ten-digit rule is unchanged, and it still has one honest false
  positive.** A ten-digit phone number is a valid seconds epoch and
  reads as 2145-11-29. Nothing about its shape or its instant can tell it
  from a real timestamp, so it is pinned in the corpus as the false
  positive it is rather than hidden by a rule that would also throw away
  real dates. `1111111111111111111` is the same story: it is 2005-03-18,
  and only its digits say otherwise.

## [0.1.0] - 2026-08-11

First release. The extension's extraction engine, ported and pinned
against a shared corpus, over a tree instead of one open document.

### Added

- **Extraction for all nine format names** the extension reads — JSON,
  YAML, CSV, XML, log, plaintext, JavaScript, TypeScript and HTML —
  reproducing its values, notations, instants and positions for every
  case in `fixtures/`. `typescript` and `plaintext` stay their own keys
  rather than aliases of `javascript` and `log`, because the key is
  user-visible as `fileType` and the two servers must not disagree about
  what they just read.
- **`Date.parse` as V8 implements it**, both parsers: the ECMA-262 Date
  Time String Format and the legacy one no standard describes. Its rules
  were established by asking V8 and are pinned in
  `fixtures/date-parse.json`, 140 cases that `cargo test` replays —
  garbage words legal before the first number and fatal after it,
  weekday names read and discarded, months matched on three letters,
  `EST` a fixed −5 rather than a zone, a two-digit year 1900s from 50,
  parenthesised comments skipped closed or not, and the ISO parser
  committing at the `T` so that `2024-01-15T10:30:45 GMT` is a refusal
  while `2024-01-15 10:30:45` is not.
- **Local time, honestly.** Four of the six shapes carry no timezone, so
  their instant is a property of the machine; `TZ` is honoured and the
  corpus pins a zone with daylight saving, because no other kind can
  catch a wrong conversion. At a transition the offset in force before
  it wins, at both edges.
- **`--tz <zone>`**, naming the zone that dates without one resolve in.
  A timestamp read on a laptop in Chicago and on a server in UTC are
  different instants, and for the reviews this is built for that
  difference is the finding rather than a detail. It applies to
  `--after` and `--before` as well, so where on the command line it
  appears cannot change an answer.
- **`--after` / `--before` / `--sort`**, which spend the instant
  extraction already resolved, and read their own boundaries with the
  same parser they read documents with.
- **`--iso`**, **`--dedupe`**, **`--values`**, **`--year`**, `--stdin`,
  `--hidden`, `--no-ignore`. Exit codes follow grep: 0 found, 1 none,
  2 malformed.
- **An MCP server** (`dates-le mcp`) offering `extract_dates`, shared
  byte-for-byte with the extension's server and held there by the same
  corpus, and `dates_le_scan` for files and directories.

### Notes

Two findings from the port, neither visible to a passing test suite.

**Positions were quadratic on a document with one line.** Resolving a
column counts UTF-16 units, and counting them from the start of the line
is fine until the file is one line long: a log with 200,000 timestamps
on it did not finish. Offsets now resolve together in a single ordered
pass. Ninety unit tests were green while the binary hung.

**Masking an XML comment cannot preserve both lengths.** Keeping the
byte length is mandatory — a two-byte `é` replaced by one space slides
every offset after it, far enough to slice mid-character and abort — but
padding to the byte length changes the UTF-16 length, and with it every
column after the comment on that line. Positions are therefore located
against the original document rather than the mask. The corpus carries
an accented inline comment with a date after it on the same line, since
a shift before a line start cancels out in the column and would have
proved nothing.

### Fixed

- **A leading byte-order mark is no longer part of the document.** Three
  invisible bytes, added by Notepad, Excel and a PowerShell redirect, and
  stripped by VS Code before the extension ever sees a file — so the two
  frontends read the same file differently. It shifted every column on
  line one, and before a `{` it made a structured parser reject the whole
  document, which is indistinguishable from a file with no dates in it.

- **A file that cannot be read no longer fails the run.** Every
  repository has a PNG, a zip and something the runner lacks permission
  for. Exiting 2 on those made the tool unusable in CI, which is the one
  place it is most worth running. Such a file is now named on stderr and
  carried in the report with a `skipped` diagnostic, and the exit code
  reflects what was found. `--strict` restores the old behaviour for a
  pipeline that wants zero tolerance.

- **A file that is not text is named rather than dropped.** It used to
  vanish from the report entirely, which reads to whoever ran it as
  "that file was clean".

[0.2.2]: https://crates.io/crates/dates-le/0.2.2
[0.2.1]: https://crates.io/crates/dates-le/0.2.1
[0.2.0]: https://crates.io/crates/dates-le/0.2.0
[0.1.0]: https://crates.io/crates/dates-le/0.1.0