okf-kit 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ # Staleness (sources-fresh)
2
+
3
+ `sources-fresh` compares each frontmatter `sources` entry's last git commit time against the doc's `timestamp`, and additionally against the doc file's own last commit time: a source committed at or before the doc file's last commit is treated as fresh even when the frontmatter `timestamp` is older, PROVIDED that same commit actually re-stamped the doc. "Re-stamped" is decided by VALUE, not by diff text: okf-kit reads the doc's frontmatter at that commit and in the commit's first parent, parses both, and compares the `timestamp` VALUES as parsed instants, read at millisecond resolution -- the doc did not exist in the parent at all (it was created there), or the commit's value is strictly LATER than the parent's, and it counts as re-stamped. A value that moves to an EARLIER instant is not a re-stamp, and is additionally reported as its own warning (`re-stamp moved backwards`) naming the previous and new instants. A value that is actually REWRITTEN to the SAME instant in a different raw spelling (adding milliseconds that round to nothing, or switching between a quoted string and a native YAML date) is likewise not a re-stamp, and is additionally reported as its own notice naming both raw spellings; an unchanged value (the frontmatter timestamp is byte-identical across the commit, the common case for a co-committed body edit or formatter run) gets neither the warning nor the notice, since it was never a re-stamp attempt at all. Direction is judged whenever BOTH sides resolve to an instant at all, including a `timestamp` string with no UTC designator or numeric offset -- see **Designator-less timestamps** for how that value is made timezone-invariant; only a side that cannot be parsed to an instant at all takes a raw-identity fallback instead. The lookup follows a rename, so `git mv`ing a doc is not a creation, and it reads trees rather than a patch, so a merge commit (including the `refs/pull/N/merge` ref CI checks out) is assessed like any other commit. That keeps squash-merge PRs honest: when a doc re-stamp lands in the same commit as its changed sources, the merge gives every source a commit time later than any pre-merge `timestamp`, which used to make such docs stale-on-arrival. It does NOT extend to a commit that merely happens to also touch the doc file -- a typo fix, a co-committed prose edit, a repo-wide formatter run -- without moving the stamp strictly forward: that commit carries no verification claim, so staleness stands. "Created" is trusted only in a genuinely unshallow repository: in a shallow clone (`git clone --depth`), a commit with an empty parent list can simply be the boundary history was grafted onto, not the doc's real first commit, so this case gets the same not-assessable notice as any other unanswerable re-stamp question rather than an assumed pass (see [CI usage](ci.md) for the `fetch-depth: 0` remedy). The rule never blocks a doc that has no `sources`, and it never invents an error where git can't give a real answer:
4
+
5
+ | Situation | Severity | Message |
6
+ |-----------|----------|---------|
7
+ | A source path's last commit is newer than the doc's `timestamp` and the doc file's last commit | warning | `STALE: <path> changed <iso> after doc timestamp <iso>` |
8
+ | A source path's last commit is newer than the doc's `timestamp`, at/before the doc file's last commit, AND that commit re-stamped the doc | (nothing) | fresh: doc and source landed together with a real re-stamp (or the doc was committed later, or created there) |
9
+ | A source path's last commit is newer than the doc's `timestamp`, at/before the doc file's last commit, but that commit did NOT re-stamp the doc | warning | `STALE: <path> changed <iso> after doc timestamp <iso>` (co-commit with no re-stamp is not an exception) |
10
+ | Same, but that commit (or, under `--dirty-as-now`, the working tree) moved the `timestamp` value BACKWARDS to an EARLIER instant (when both values resolve to an instant at all; see **Designator-less timestamps**) | warning (STALE) + warning (extra) | the same `STALE: ...` warning above, PLUS one `re-stamp moved backwards: timestamp <prev-iso> -> <new-iso> <where> is not a re-verification` warning per doc |
11
+ | Same, but that commit (or, under `--dirty-as-now`, the working tree) rewrote the `timestamp` value to the SAME instant in a different raw spelling | warning (STALE) + notice (extra) | the same `STALE: ...` warning above, PLUS one `re-stamp did not move the timestamp forward: <prev-raw> was rewritten as <new-raw> <where>, but both name the same instant (<iso>), so this is not a re-verification` notice per doc |
12
+ | A source path's last commit is newer than the doc's `timestamp`, at/before the doc file's last commit, and git could not be asked whether that commit re-stamped the doc | notice | `staleness not assessable: git could not read the doc's own last commit to decide whether it re-stamped the doc` (once per doc, never a STALE warning and never a silent pass) |
13
+ | Same, but the repository is a shallow clone and the doc's last commit is the grafted history boundary (an empty parent list that is not really a root commit) | notice | `staleness not assessable: this is a shallow clone ... use fetch-depth: 0 ... to assess it` (once per doc) |
14
+ | A source path exists but has no git history (untracked) | notice | `untracked by git, staleness unknown: <path>` |
15
+ | The doc's `timestamp` is missing or not a parseable date, while `sources` is present | notice | `staleness not assessable: no valid timestamp` |
16
+ | No repo root available (see [check catalog](check-catalog.md#repo-root-auto-detection)) | notice | `staleness skipped: not inside a git work tree` |
17
+ | A source path does not exist on disk | (nothing) | left to `sources-shape`, not duplicated here |
18
+
19
+ STALE findings are warnings, so they are advisory by default; run with `--strict` to fail the build on them.
20
+
21
+ Known limitation: a `git log` call that fails for a reason other than "no history for this path" (for example a corrupt object or a transient git error) is reported the same way as a genuinely untracked path, the `untracked by git, staleness unknown` notice; okf-kit does not currently distinguish a real git failure from "no commits touch this path".
22
+
23
+ Known limitation: the doc-commit comparison suppresses staleness for every source older than the doc file's last re-stamping commit, not only for sources from the same commit -- provided that commit is the one that moved the stamp strictly forward. For a multi-source doc that means a re-stamp silences drift on every source changed before it, even ones that specific re-stamp did not itself verify. The frontmatter `timestamp` still governs sources changed after the doc's last commit. Also note: the check only looks at WHETHER the `timestamp` value moved to a strictly LATER instant, never at whether the new value is itself plausible -- a hand-typed FORWARD-dated stamp still counts as a re-stamp (`sources-fresh-future` is the rule that catches an implausible future value), while a stamp moved BACKWARDS never does (it stays STALE and gets its own `re-stamp moved backwards` warning, see the table above), however the value was written. "Moved forward" means the parsed frontmatter VALUE, read as an instant at MILLISECOND resolution, is strictly greater: a rewrite to the same instant in another spelling (`...00Z` to `...00.000Z`) is neither forward nor backward, so it does NOT count as a re-stamp even though the raw string differs -- that case gets its own notice (see the table above), while an unchanged value gets neither. Adding or removing quotes around an otherwise-unchanged value likewise counts as neither, and never triggers the notice, since YAML parsing already normalizes those away before the comparison ever sees them. And the comparison is against the FIRST parent only: a merge that takes the doc wholesale from its second parent is judged against the first-parent baseline, which is the baseline the pull request under review is measured against anyway.
24
+
25
+ **Designator-less timestamps** (the one description of this handling; everything else in these docs points here). A `timestamp` string spelled without a UTC designator (`Z`) or a numeric offset (`2026-01-01T13:00:00`) is, read raw, ambiguous: `Date.parse` resolves it in the MACHINE'S own timezone, so the identical repository content would read as one instant on a UTC runner and a different one on a UTC+9 laptop. `sources-fresh` closes that ambiguity by treating such a value as UTC EVERYWHERE it looks at a `timestamp`: in its own day-wide staleness comparison above (the plain `commitEpoch > timestampEpoch` check) just as much as in the re-stamp direction comparison this section is about. This applies to whichever side carries the ambiguity, the new value or the one it replaced, and it means a designator-less value is now judged for direction like any other value -- a backwards move is reported (`re-stamp moved backwards`) exactly as it would be for a `Z`-suffixed pair, it no longer passes silently. A numeric offset (`+02:00`) is unambiguous and parsed as itself, and so is a native YAML date (`!!timestamp`), which carries no designator because it needs none: the parser already resolved it to a fixed instant (YAML 1.1: a zone-less `!!timestamp` is UTC, not local time). Only a value that cannot be parsed to an instant AT ALL (missing, blank, or a string `Date.parse` rejects even when trimmed of surrounding whitespace and with a `Z` appended; the raw value is trimmed before either rule reads it, so a whitespace-padded stamp is assessed exactly like its unpadded form) falls back to the pre-direction-rule behavior of asking only whether the raw value CHANGED -- any textual change counts as a re-stamp, and neither the backwards warning nor the same-instant notice can fire. `sources-fresh-future`'s clock-skew check makes a different call for the SAME ambiguity: its allowance is minutes wide, too narrow to safely absorb an hours-wide timezone shift, so it SKIPS a designator-less timestamp with a notice instead of forcing UTC on it (see that rule's table below) -- `sources-fresh`'s own thresholds are day-wide, wide enough that forcing UTC is both consistent and safe. See `parseTimestampInstantMs`'s doc comment in `src/util.ts` and `compareRestampDirection`'s in `src/rules/sources-fresh.ts` for the exact behavior.
26
+
27
+ **Two-branch squash interaction:** the rule follows one decision procedure, never a catalog of merge shapes, and the procedure is what to read a squash-merge scenario against, not a list of named cases:
28
+
29
+ 1. **The doc's last commit.** `git log -1 -- <doc>` under git's default history simplification: a merge whose result for the doc path is identical to one of its parents is skipped, and the walk continues on that side to the real content-changing commit instead; only a merge that changed the doc against every parent is itself returned. The epoch lookup and the re-stamp check below both start from this same resolved commit.
30
+ 2. **The epoch gate.** A source whose own last commit is at or before the resolved doc commit is judged by step 3; a source committed after the resolved doc commit skips step 3 and is judged by the frontmatter `timestamp` alone: STALE whenever the source change also postdates that stamp (the normal case, since nothing the doc showed as of its last commit could reflect a change that had not happened yet), fresh in the rare case that the stamp itself is later than the source change, because the frontmatter comparison is the first thing the rule evaluates.
31
+ 3. **The re-stamp check (`restampedByOwnLastCommit`).** Compare the resolved commit's parsed frontmatter `timestamp` VALUE against its value in that SAME commit's first parent (rename-aware); a value strictly LATER than the parent's, or the doc being created there, counts as re-stamped -- an earlier or equal value does not.
32
+
33
+ One sentence for the outcome: a source reads fresh when its own last commit is at or before the doc's resolved last commit and that commit moved the `timestamp` value strictly forward (or created the doc), or when the source's own last commit is at or before the frontmatter `timestamp`; every other assessable case is STALE, including a backwards or same-instant "re-stamp" (the not-assessable and no-valid-timestamp notices are neither fresh nor STALE; the backwards warning and the same-instant notice accompany a STALE verdict, they do not replace it).
34
+
35
+ The one consequence that follows for a branch merging a trunk carrying a squash: the squash re-dates every source it touched to the merge instant, and steps 1-3 can only ever see the MERGING branch's own resolved doc commit, never the trunk's route to that squash. So after such a merge, a clean verdict is not evidence the branch's doc was verified against the re-dated source (the known limitation above already says a re-stamp silences drift on sources it never itself checked, and step 3 only asks whether the value moved, never what it verified); a STALE verdict means the branch's resolved doc commit is older than the re-dated source, or newer but carrying no re-stamp of its own. Which of the two the rule reports depends only on where the branch's resolved doc commit falls relative to the squash (steps 1 and 2), never on who edited what:
36
+
37
+ | Branch shape | Doc commit the lookup resolves to | Verdict | Why |
38
+ |---|---|---|---|
39
+ | B never touched the doc | The trunk's squash commit (the merge is skipped: its doc content matches the trunk's) | Fresh for a source at/before the squash; STALE for a source B itself changes after the squash and after the stamp | The squash's own re-stamp (step 3) only covers sources the epoch gate lets reach it (step 2); a later change on B's own branch fails that gate |
40
+ | B carries its own older doc edit and keeps it through the merge | B's own doc-edit commit (the merge is skipped: its doc content matches B's pre-merge copy) | STALE when the edit predates the squash; fresh when it postdates the squash and changed the stamp value (the re-stamp exception); STALE when it postdates the squash but left the stamp alone | Step 3 only asks whether B's edit changed the value against its OWN first parent, never what the edit verified; an edit that lands after the squash and differs from its own prior value passes the gate and reads re-stamped regardless |
41
+
42
+ **Recipe:** after merging a trunk that carries a squash into any branch whose bundle declares the sources that squash re-dated, re-verify each such doc against its current `sources` and bump its `timestamp` in one dedicated commit, whatever `sources-fresh` currently reports for it -- the same recipe as the Authoring guidance below, just triggered by the merge. Seen in practice on a long-lived second branch after a squash-merged re-stamp landed on the trunk; see the CHANGELOG for the concrete case.
43
+
44
+ **Authoring guidance:** when you re-verify a doc against its sources, bump its frontmatter `timestamp` (and add a line to the bundle's `log.md`) so `sources-fresh` reflects that the doc is current again.
45
+
46
+ ## Uncommitted edits (`--dirty-as-now`)
47
+
48
+ `sources-fresh` and `sources-fresh-future` normally judge every path by its last GIT COMMIT time, so a source or doc you have edited but not yet committed still reads by whatever it looked like at its last commit -- often still "fresh" relative to an already-stale doc `timestamp`. Once you commit, that same path gets a brand-new commit time, and CI's freshness guard (typically run in strict mode) can fail on a doc your local, pre-commit `check` run reported as clean. `--dirty-as-now` closes that gap by modeling every uncommitted change -- modified, staged, or untracked, i.e. anything `git status --porcelain --untracked-files=all` reports -- as though it landed in ONE VIRTUAL COMMIT made right now: a dirty `sources` path's epoch becomes now, and a dirty DOC's OWN epoch becomes now too, both read from the exact same shared instant so two dirty paths in one `check` run never disagree about what "now" was. Both rules read a dirty path's epoch this same way -- there is exactly one place in the code that applies the substitution, not a separate patch per rule -- so a pre-commit run reports the same verdict CI will report once the commit lands, for either rule. It has no effect on a path with no uncommitted change (judged exactly as before), and it is entirely opt-in: omit it and both rules are byte-for-byte unchanged. One consequence worth calling out: an untracked source's ordinary `untracked by git, staleness unknown` notice (see the table above) becomes a STALE warning under this flag instead, since an untracked path is itself an uncommitted change; symmetrically, an untracked DOC -- which `sources-fresh-future` otherwise leaves unassessed entirely (see that rule's table below) -- gets a real `sources-fresh-future` verdict too, since its virtual epoch is now a real "now" to compare its `timestamp` against. Both hold for a file inside a BRAND-NEW untracked directory as well, which is why the status read passes `--untracked-files=all`: git's default untracked mode reports a new directory as one single `? newdir/` record and never names the files in it, so a source or doc inside one used to be invisible to this flag (reported merely `untracked by git`, exit 0) right up until the commit that made it STALE in CI. The cost of `--untracked-files=all` is a longer status output in a work tree carrying many untracked, non-ignored files; if it ever exceeds the internal 16 MiB git-output cap, the run reports the not-applied notice below rather than pretending the tree is clean.
49
+
50
+ Three smaller behaviors worth knowing. A `sources` entry spelled `.` or `./` (the repo root itself) is dirty whenever ANY path in the work tree is dirty -- it is matched by containment, since `git status` never reports the root as an entry of its own. `--repo-root` may name a SUBDIRECTORY of the repository: `git status` reports dirty paths relative to the repository's top level, and `git show <rev>:<path>` reads a blob by a top-level-relative path too, so the rule respells every path it hands to either (via `git rev-parse --show-prefix`, read once per run) before matching a dirty path or reading a doc's committed value; dirty-path detection and both re-stamp rescues (the working-tree one here and the committed co-commit one above) then behave under a subdirectory root exactly as under the top level, and a same-named doc at the top level is never read in the subdirectory doc's place. And if git cannot be read at all (`status` failing, output past the cap, git missing), `check` emits one bundle-level `sources-fresh` notice reading "`--dirty-as-now` not applied: git status could not be read, judging by committed history only", and both rules fall back to committed history; the flag never reports a silent all-clear it did not actually verify.
51
+
52
+ The flag also carries a working-tree analogue of the doc-commit re-stamp rescue described above, evaluated against that same virtual commit: a doc re-stamped LOCALLY rescues a dirty source exactly like a real re-stamp landing in the same commit does, judged by the SAME direction rule: the on-disk frontmatter `timestamp` value must be a strictly LATER instant, at millisecond resolution, than the value committed at `HEAD`, and the doc file itself must be dirty for any of this to apply. A backwards move does not count and gets one `re-stamp moved backwards` warning naming the previous and new instants (the source stays STALE); a rewrite to the same instant in a different raw spelling does not count either and gets one `did not move the timestamp forward` notice instead (also STALE); an untracked doc counts as re-stamped, the working-tree equivalent of "the doc being created there". Without the rescue itself, bumping a doc's `timestamp` locally and re-running `check --dirty-as-now` could still report STALE, purely because a few seconds pass between writing the new timestamp and running the check -- defeating the point of the recommended invocation below. A doc that is dirty for some OTHER reason (a body edit that leaves the `timestamp` value unchanged) is not rescued: that case still reports STALE, same as before. Reading the doc's committed value distinguishes a genuinely untracked doc (no entry for it at `HEAD` at all -- always a re-stamp) from a real git failure reading an EXISTING `HEAD` blob (a corrupt object, an unreadable blob): only the former is treated as "created"; the latter falls through to the ordinary `staleness not assessable` notice instead of being silently guessed either way.
53
+
54
+ **Recommended pre-commit invocation**, matching the "re-verify, bump `timestamp`, then commit" discipline above -- run this on the working tree BEFORE committing an edit to any file a bundle lists as a `sources` entry, so a doc that needs re-stamping is caught before the commit that would otherwise make it stale in CI:
55
+
56
+ ```bash
57
+ okf-kit check path/to/bundle --dirty-as-now --strict
58
+ ```
59
+
60
+ If it reports a doc STALE, re-verify that doc against its current `sources` and bump its `timestamp` in the same commit as the source edit (or its own dedicated commit), THEN commit -- exactly the measure-after-commit discipline described above, just checked before the commit exists rather than after CI runs.
61
+
62
+ **What this actually produces for the recipe above, reproduced through the built CLI (source committed, doc committed with a matching `timestamp`, then):**
63
+
64
+ | Working-tree state | `check --dirty-as-now --strict` |
65
+ |---|---|
66
+ | Source edited, doc re-stamped on disk to now (a few seconds to a minute after writing the stamp) | exit 0, clean -- neither `sources-fresh` nor `sources-fresh-future` fires |
67
+ | Source edited, doc left alone (not re-stamped) | exit 1, `sources-fresh` STALE |
68
+ | Source edit and doc re-stamp committed TOGETHER, flag OFF | exit 0, clean (the committed-history co-commit rescue, unaffected by this flag) |
69
+ | Doc dirty for an unrelated reason (a body edit, `timestamp` value unchanged), source edited | exit 1, `sources-fresh` STALE -- not rescued |
70
+ | Doc re-stamped on disk to a value BEFORE the one committed at `HEAD` (a backwards move), source edited (when both values resolve to an instant at all; see **Designator-less timestamps** above) | exit 1, `sources-fresh` STALE, PLUS one `re-stamp moved backwards: timestamp <prev-iso> -> <new-iso> in the working tree is not a re-verification` warning -- a backwards move is NOT a re-verification (see above) |
71
+ | Doc re-stamped on disk to the SAME instant as the one committed at `HEAD`, just in a different raw spelling, source edited | exit 1, `sources-fresh` STALE, PLUS one `re-stamp did not move the timestamp forward: ...` notice -- a rewrite that never moves the instant is NOT a re-verification either (see above) |
72
+ | Source edit committed ALONE, the doc's re-stamp left uncommitted in the working tree | exit 0, clean locally -- but CI is RED (`sources-fresh` STALE) until that re-stamp is committed too |
73
+
74
+ That last row is the one shape where a clean local run does NOT imply a clean CI run, and it is inherent to the model rather than a gap to be fixed: the flag judges the content in YOUR WORKING TREE, while CI judges only what you actually pushed. Commit the source edit without its doc re-stamp and CI sees a source newer than the doc's committed `timestamp`, exactly as it should. The remedy is the one this section already recommends: commit the re-stamp, in the same commit as the source edit or its own.
75
+
76
+ What "matches CI" actually means here: `--dirty-as-now` makes a local run compute the same `sources-fresh`/`sources-fresh-future` VERDICT (fresh, STALE, or not-assessable) that a post-commit `check` run would compute for the same content -- it says nothing about whether a given CI setup turns that verdict into a failed build, and nothing about warning classes outside these two rules. Whether it does depends entirely on how that CI wires up `okf-kit check`: this package's own CI runs it two different ways for the same bundle -- `.github/workflows/okf-staleness.yml` calls `check` warn-only (findings surface in the job summary and as annotations, but the job never fails on them), while `.github/workflows/ci.yml`'s freshness guard step runs `check --require-anchors` and then filters the JSON report through `jq` for `sources-fresh`/`sources-fresh-future` findings, only failing the build on THOSE. `--strict` is broader than either: it fails on every warning-severity finding from EVERY rule (including ones this flag says nothing about, like `citations-resolve`), so a locally-strict, CI-clean bundle is not a contradiction -- it means the CI in question does not block on every warning class `--strict` does. Read the target repo's actual CI wiring before treating `--dirty-as-now --strict` as equivalent to "will this pass CI".
77
+
78
+ `--dirty-as-now` ships in the same `okf-kit` release as everything else in this CHANGELOG entry; a CI step that installs a PINNED `okf-kit` version (as both workflows above do) only gains the flag once that pin is bumped to a release that includes it.
79
+
80
+ **Known limitations, stated rather than hidden.** Ignored files (`.gitignore`) are deliberately NOT treated as dirty here (`git status` is run without `--ignored`): an ignored source keeps its ordinary `untracked by git, staleness unknown` notice rather than becoming newly dirty under this flag. A source living inside a dirty SUBMODULE is likewise not detected as dirty: `git status` reports a submodule with uncommitted changes as one single record for the submodule's own path, not per file inside it, and this flag's path-matching does not currently descend into that report to find the individual dirty files a `sources` entry might name.
81
+
82
+ ## Future-dated timestamps (`sources-fresh-future`)
83
+
84
+ `sources-fresh` catches a `timestamp` that is too OLD relative to a source's last commit. `sources-fresh-future` catches the opposite mistake: a `timestamp` that is too NEW relative to the doc file's OWN last commit -- in practice, almost always a local wall-clock time hand-written with a trailing `Z` (or another UTC marker) it does not actually have, rather than a genuine future date. It never looks at `sources` commit times at all, only at the doc file's own git history, so it has nothing to say about whether any source is stale; the two rules are complementary, not overlapping, and are assessed over the same population of docs (a validly-shaped `sources` list and a repo root available, exactly like `sources-fresh` above).
85
+
86
+ | Situation | Severity | Message |
87
+ |-----------|----------|---------|
88
+ | The doc's `timestamp` is later than the doc file's own last commit by more than the skew allowance | warning | `FUTURE-DATED: doc timestamp <iso> is after the doc's own last commit <iso> (skew allowance <n>s)` |
89
+ | The doc's `timestamp` is at or within the skew allowance of the doc file's own last commit | (nothing) | fresh: an ordinary write-then-commit gap, not a mistake |
90
+ | The doc's `timestamp` string has no `Z`/UTC designator or numeric offset (e.g. `2026-01-01T00:00:00`, no offset) | notice | future-dated check skipped: timestamp has no UTC designator (`Z`) or numeric offset, can't be compared reliably across timezones |
91
+ | The doc has no git history yet (uncommitted) | (nothing) | unknown, not flagged: there is no real commit time to compare against -- UNLESS `--dirty-as-now` is passed, in which case a dirty (including untracked) doc gets a real verdict against the shared virtual-commit "now" instant instead (see "Uncommitted edits" above) |
92
+ | The doc's `timestamp` is missing or not a parseable date | (nothing) | left to `sources-fresh`'s own notice, not duplicated here |
93
+ | No repo root available (see [check catalog](check-catalog.md#repo-root-auto-detection)) | (nothing) | left to `sources-fresh`'s single bundle-level notice, not duplicated here |
94
+
95
+ The skew allowance defaults to 10 minutes (600 seconds), absorbing the ordinary gap between writing a timestamp and the commit that carries it landing; override it with `--future-skew-minutes <n>`:
96
+
97
+ ```bash
98
+ # narrow sources-fresh-future's default 10-minute clock-skew allowance
99
+ okf-kit check path/to/bundle --future-skew-minutes 2
100
+ ```
101
+
102
+ Like `STALE` findings, `FUTURE-DATED` findings are warnings, advisory by default; run with `--strict` to fail the build on either.
103
+
104
+ **The measure-after-commit discipline both rules enforce:** re-verify the doc against its sources, THEN bump `timestamp` to the real instant of that verification (`new Date().toISOString()` or equivalent, never a hand-written value), THEN commit -- committing without re-stamping is what `sources-fresh` catches, and hand-writing a local time with a `Z` suffix it doesn't have is what `sources-fresh-future` catches.
105
+
106
+ **Why a bare datetime is skipped here:** this rule's clock-skew allowance is minutes wide, too narrow to safely absorb the hours a timezone can shift a designator-less value by, so it skips such a value with a notice rather than guess. `sources-fresh` faces the identical ambiguity in its own comparisons and makes the opposite call: its thresholds are day-wide, so it forces UTC on a designator-less value instead of skipping it. See **Designator-less timestamps** above for the full rationale and why the two rules land on different responses to the same edge case.
107
+
108
+ **What these two rules catch, precisely, and what they don't:** a source path committed after the doc's `timestamp` (`sources-fresh`'s base case); a source and the doc co-committed together where that commit did NOT re-stamp the doc (`sources-fresh`'s narrowed co-commit exception, see above); and a local wall-clock time hand-written with a `Z`/UTC suffix it doesn't actually have (`sources-fresh-future`). What they do NOT catch: a doc-only prose edit that leaves `sources` untouched and the `timestamp` stale -- neither rule has a source-side signal to compare against in that case, so it is not mechanically assessable and stays a reviewer judgment call, the same as before this pair of rules existed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okf-kit",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "CLI that validates OKF v0.1 knowledge bundles for structural correctness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,8 @@
8
8
  },
9
9
  "files": [
10
10
  "dist",
11
+ "docs",
12
+ "templates",
11
13
  "README.md",
12
14
  "CHANGELOG.md"
13
15
  ],
@@ -0,0 +1,168 @@
1
+ name: OKF staleness
2
+
3
+ # GENERATED FROM THE OKF-KIT TEMPLATE `templates/okf-staleness.yml` of the
4
+ # okf-kit package (LanNguyenSi/agent-dx, packages/okf-kit; the template
5
+ # first ships in the okf-kit release after 0.16.0), pinned to okf-kit
6
+ # 0.16.0 below. Do not hand-edit this file outside the lines marked
7
+ # "REPO-SPECIFIC": re-sync it from the template whenever the pin moves.
8
+ # A repo that deviates documents the deviation in a comment block at the
9
+ # deviating spot, next to the REPO-SPECIFIC marker.
10
+ #
11
+ # Warn-only drift watch for the docs/okf knowledge bundle: surfaces STALE
12
+ # curated docs (source files that changed after the doc's verification
13
+ # timestamp) on every PR without ever blocking a merge. Deliberately a
14
+ # separate workflow: the repo's blocking CI must stay a
15
+ # blocking gate, this one never is. Never mark this check as required in
16
+ # branch protection: a PR that removes or renames docs/okf goes red here
17
+ # by design (tool-error posture, exit 2), and warn-only must stay
18
+ # non-blocking. The same file ships in every repo that carries a docs/okf
19
+ # bundle (find them by this workflow's file name).
20
+
21
+ on:
22
+ pull_request:
23
+ # REPO-SPECIFIC (1 of 2): the target repo's default branch; a wrong
24
+ # name here means the watch silently never runs.
25
+ branches: [main]
26
+
27
+ permissions:
28
+ contents: read
29
+
30
+ jobs:
31
+ okf-staleness:
32
+ name: OKF bundle staleness (warn-only)
33
+ runs-on: ubuntu-latest
34
+
35
+ env:
36
+ # REPO-SPECIFIC (2 of 2): path of the knowledge bundle to check
37
+ # (`knowledge` in .ai/workflow/manifest.json; default docs/okf).
38
+ BUNDLE_PATH: docs/okf
39
+
40
+ steps:
41
+ - uses: actions/checkout@v5
42
+ with:
43
+ # sources-fresh derives per-path change times from `git log %ct`;
44
+ # a shallow clone silently reports wrong staleness verdicts.
45
+ # Note: on pull_request this checks out the ephemeral merge ref
46
+ # (PR merged into base), so verdicts can differ from a local run
47
+ # on the branch tip when the base has advanced. Deterministic,
48
+ # just a different commit than a naive local run.
49
+ fetch-depth: 0
50
+
51
+ - uses: actions/setup-node@v5
52
+ with:
53
+ # 22 = active LTS.
54
+ node-version: 22
55
+
56
+ - name: Install okf-kit (exact pin)
57
+ # Exact pin: okf-kit@0.3.0 on npm is a deprecated silent no-op
58
+ # (exit 0, no output). The output assertion below exists to catch
59
+ # any regression of that failure mode: never assert on exit code
60
+ # alone here. Move this pin deliberately, never as an automatic
61
+ # upgrade, and record each move in the bundle's log.md together with
62
+ # the old and the new version's measured verdict on this bundle.
63
+ run: npm install -g okf-kit@0.16.0 --no-audit --no-fund
64
+
65
+ - name: Assert CLI is alive (output, not exit code)
66
+ shell: bash
67
+ run: |
68
+ v="$(okf-kit --version)"
69
+ test -n "$v"
70
+ echo "okf-kit ${v}"
71
+
72
+ - name: Check bundle (warn-only)
73
+ shell: bash
74
+ run: |
75
+ # Exit contract of `okf-kit check` (no --strict): 0 = clean or
76
+ # warnings/notices only (STALE is a warning), 1 = structural
77
+ # errors present, 2 = usage/tool error. 0 and 1 are bundle
78
+ # findings: report them and stay green. Anything else means the
79
+ # check itself broke and must fail red, a broken check never
80
+ # looks green.
81
+ # --require-anchors: every full line citation must carry a
82
+ # #"anchor" (anchor-required), and a continuation must be lifted
83
+ # into a standalone anchored citation
84
+ # (anchor-required-continuation), so a bare `path:N` citation
85
+ # that would otherwise resolve cleanly still surfaces here as a
86
+ # warning, before it can drift silently on a future line shift.
87
+ # Still warn-only, same as every other rule this job runs.
88
+ set +e
89
+ okf-kit check --json --require-anchors "${BUNDLE_PATH}" > okf-report.json
90
+ code=$?
91
+ set -e
92
+
93
+ if [ "${code}" -ne 0 ] && [ "${code}" -ne 1 ]; then
94
+ echo "::error title=OKF staleness::okf-kit check exited ${code} (tool/usage error, not bundle findings)"
95
+ cat okf-report.json || true
96
+ exit "${code}"
97
+ fi
98
+
99
+ # Truncated or non-JSON output is a tool failure, not findings.
100
+ jq -e . okf-report.json > /dev/null
101
+
102
+ errors=$(jq '.summary.errors' okf-report.json)
103
+ warnings=$(jq '.summary.warnings' okf-report.json)
104
+ notices=$(jq '.summary.notices' okf-report.json)
105
+
106
+ {
107
+ echo "## OKF bundle check (\`${BUNDLE_PATH}\`), warn-only"
108
+ echo ""
109
+ echo "errors: ${errors} · warnings: ${warnings} · notices: ${notices}"
110
+ echo ""
111
+ if [ "$(jq '.findings | length' okf-report.json)" -gt 0 ]; then
112
+ echo '```'
113
+ jq -r '.findings[] | "\(.severity | ascii_upcase) \(.ruleId) \(.file): \(.message)"' okf-report.json
114
+ echo '```'
115
+ echo ""
116
+ echo "A sources-fresh STALE warning means a \`sources:\` path"
117
+ echo "changed after the doc's verification timestamp: re-verify"
118
+ echo "the doc against the source, then update its timestamp."
119
+ echo "A sources-fresh-future FUTURE-DATED warning means the doc's"
120
+ echo "\`timestamp\` is later than the doc file's own last commit,"
121
+ echo "past a small clock-skew allowance: almost always a local"
122
+ echo "wall-clock time hand-written with a Z/UTC suffix it does"
123
+ echo "not actually have; re-stamp with the real verification"
124
+ echo "instant instead. Findings are matched by rule id, whichever"
125
+ echo "the installed okf-kit emits, so a future pin bump needs no"
126
+ echo "edit here."
127
+ echo "A citations-resolve warning (including blank-start-line,"
128
+ echo "closing-brace-start-line, missing-file, inverted-range,"
129
+ echo "range-exceeds-file, path-traversal-rejected) means a"
130
+ echo "\`path:N\`/\`path:N-M\` citation"
131
+ echo "in the doc's prose no longer points at real content, even"
132
+ echo "though the source file itself is not stale: re-check the"
133
+ echo "citation against the file's current line numbers and"
134
+ echo "correct it (no timestamp bump needed for this rule alone)."
135
+ echo "A citations-resolve notice (ambiguous target,"
136
+ echo "unreadable-target) is informational only and never counts"
137
+ echo "toward --strict. An anchor-required or"
138
+ echo "anchor-required-continuation warning (from --require-anchors,"
139
+ echo "above) means a citation resolves fine but carries no"
140
+ echo "#\"anchor\": lift it into the full \`path:N-M#\"anchor\"\` form"
141
+ echo "(a continuation cannot carry its own #anchor; fold it into a"
142
+ echo "standalone anchored citation instead). This job never blocks"
143
+ echo "either way."
144
+ else
145
+ echo "Clean, no findings."
146
+ fi
147
+ } >> "${GITHUB_STEP_SUMMARY}"
148
+
149
+ # GitHub caps workflow-command annotations at 10 per type per
150
+ # step, so aggregate per doc (not per finding); the complete list
151
+ # is in the step summary either way.
152
+ jq -r '
153
+ [.findings[] | select(.severity == "warning")]
154
+ | group_by(.file)[]
155
+ | "\(.[0].file): \(length) warning(s) (stale sources, unresolved citations and/or missing anchors), full list in the job summary"
156
+ ' okf-report.json | while IFS= read -r line; do
157
+ echo "::warning title=OKF bundle drift::${line}"
158
+ done
159
+
160
+ jq -r '
161
+ [.findings[] | select(.severity == "error")]
162
+ | group_by(.file)[]
163
+ | "\(.[0].file): \(length) structural error(s), full list in the job summary"
164
+ ' okf-report.json | while IFS= read -r line; do
165
+ echo "::error title=OKF bundle broken::${line}"
166
+ done
167
+
168
+ exit 0