okf-kit 0.16.0 → 0.17.1
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.
- package/CHANGELOG.md +17 -0
- package/README.md +45 -316
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +3 -3
- package/dist/cli.js.map +1 -1
- package/dist/rules/citations-resolve.d.ts +3 -3
- package/dist/rules/citations-resolve.js +4 -4
- package/dist/rules/prose-line-references.js +5 -5
- package/dist/rules/sources-fresh.d.ts +1 -1
- package/dist/rules/sources-fresh.js +4 -4
- package/dist/rules/sources-fresh.js.map +1 -1
- package/dist/util.d.ts +3 -3
- package/dist/util.js +7 -7
- package/docs/check-catalog.md +35 -0
- package/docs/ci.md +21 -0
- package/docs/citations.md +81 -0
- package/docs/docs-for.md +27 -0
- package/docs/init.md +28 -0
- package/docs/prose-line-references.md +34 -0
- package/docs/staleness.md +108 -0
- package/package.json +3 -1
- package/templates/okf-staleness.yml +168 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.17.1] - 2026-10-05
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- The `templates/okf-staleness.yml` pin (install line and header) is now rewritten to the release version by `scripts/bump-okf-kit-pin.mjs` at each release cut, so it no longer needs a manual bump. The script also re-syncs the header of this repo's generated copy `.github/workflows/okf-staleness.yml` and fails before writing when the template lacks exactly one install pin and one header. For consumers: the template shipped in 0.17.0 still pinned okf-kit 0.16.0; the 0.17.1 template pins the release that ships it.
|
|
15
|
+
|
|
16
|
+
## [0.17.0] - 2026-10-03
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `templates/okf-staleness.yml`: the canonical warn-only `okf-staleness.yml` GitHub Actions workflow for a repo that carries an OKF bundle (exact pin, `--require-anchors`, exit-code contract that stays green on findings and fails red on a tool error). Only the default-branch line and the `BUNDLE_PATH` line are marked `REPO-SPECIFIC`; consuming repos copy it, set those two lines, and re-sync on each pin bump instead of calling it a "canonical pattern, keep in sync". The npm package ships `templates/`. See [CI usage](docs/ci.md).
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- `--require-anchors`, `--prose-line-references`, and `--dirty-as-now` CLI help text now points at the specific `docs/*.md` file that covers each flag, instead of a generic "see README" pointer, matching where the package README's own reference sections moved to. Text-only; no behaviour change.
|
|
25
|
+
- The npm package now ships `docs/` (the reference files the README and the CLI help text link to), so those links resolve in an installed copy.
|
|
26
|
+
|
|
10
27
|
## [0.16.0] - 2026-09-25
|
|
11
28
|
|
|
12
29
|
### Changed
|
package/README.md
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
# okf-kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
CLI that validates OKF v0.1 knowledge bundles for structural correctness, staleness, and citation drift.
|
|
4
4
|
|
|
5
5
|
Part of [agent-dx](https://github.com/LanNguyenSi/agent-dx), playbooks and tooling for teams shipping with AI agents.
|
|
6
6
|
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
`okf-kit` checks knowledge bundles against the [Open Knowledge Format (OKF) v0.1 spec](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md), a shape for markdown-plus-frontmatter knowledge bundles meant to be readable by both humans and agents. The check catalog was shaped by the Phase-0 OKF pilot in agent-tasks ([PR #385](https://github.com/LanNguyenSi/agent-tasks/pull/385)), where a few structural mistakes (bad links, absolute paths) turned out to be easy to make and easy to catch mechanically.
|
|
10
|
+
|
|
11
|
+
It validates frontmatter shape, link resolution, staleness against git history, and citation drift (a `path:N` citation whose line numbers shifted under it); `init` scaffolds a starter bundle and `docs-for` answers "which docs claim this source path".
|
|
12
|
+
|
|
13
|
+
`okf-kit` is the producer-side check: it validates a bundle you are authoring or maintaining. Consuming a bundle at query time (loading, indexing, ranking passages for an agent) lives in [codebase-oracle](https://github.com/LanNguyenSi/codebase-oracle), a separate tool.
|
|
14
|
+
|
|
15
|
+
## Key features
|
|
16
|
+
|
|
17
|
+
- 9 rules covering frontmatter shape, reserved files, link resolution, and staleness/citation drift assessed against git history; see the [check catalog](docs/check-catalog.md).
|
|
18
|
+
- `init` scaffolds a starter bundle: `index.md`, `log.md`, and one template doc per concept type.
|
|
19
|
+
- `docs-for` reverse lookup: given one or more changed paths, which bundle docs claim them as `sources`.
|
|
20
|
+
- A citation can carry an anchor (`#heading` or `#"text"`) so a `path:N-M` citation survives content moving above it; see [Citation resolution](docs/citations.md).
|
|
21
|
+
- `--dirty-as-now` makes a pre-commit `check` run report the same staleness verdict CI reports after the commit lands; see [Staleness](docs/staleness.md).
|
|
22
|
+
- `--require-anchors` and `--prose-line-references` opt into stricter checks for a bundle whose citations are already anchored.
|
|
23
|
+
|
|
7
24
|
## Install
|
|
8
25
|
|
|
9
26
|
```bash
|
|
@@ -16,14 +33,14 @@ npm install -g okf-kit
|
|
|
16
33
|
|
|
17
34
|
Requires Node >= 20.
|
|
18
35
|
|
|
19
|
-
##
|
|
36
|
+
## Usage
|
|
20
37
|
|
|
21
38
|
```bash
|
|
22
39
|
okf-kit check path/to/bundle
|
|
23
40
|
|
|
24
41
|
# explicit repo root, used for both sources-shape existence checks and
|
|
25
|
-
# sources-fresh staleness checks (see "repo-root
|
|
26
|
-
# what happens when you omit this)
|
|
42
|
+
# sources-fresh staleness checks (see docs/check-catalog.md's "repo-root
|
|
43
|
+
# auto-detection" for what happens when you omit this)
|
|
27
44
|
okf-kit check path/to/bundle --repo-root /path/to/repo
|
|
28
45
|
|
|
29
46
|
# JSON output for tooling
|
|
@@ -31,341 +48,53 @@ okf-kit check path/to/bundle --json
|
|
|
31
48
|
|
|
32
49
|
# fail on warnings too, not just errors (STALE and FUTURE-DATED findings are warnings)
|
|
33
50
|
okf-kit check path/to/bundle --strict
|
|
34
|
-
|
|
35
|
-
# narrow sources-fresh-future's default 10-minute clock-skew allowance
|
|
36
|
-
okf-kit check path/to/bundle --future-skew-minutes 2
|
|
37
|
-
|
|
38
|
-
# pre-commit: treat an uncommitted source edit as happening now, so this run
|
|
39
|
-
# reports the same staleness CI will report once you commit
|
|
40
|
-
okf-kit check path/to/bundle --dirty-as-now
|
|
41
51
|
```
|
|
42
52
|
|
|
43
|
-
|
|
53
|
+
`check` exits `0` with no errors (and, under `--strict`, no warnings), `1` when it finds one, and `2` on a CLI invocation error; see [check catalog](docs/check-catalog.md#exit-codes) for the full table.
|
|
54
|
+
|
|
55
|
+
Other commands:
|
|
44
56
|
|
|
45
57
|
```bash
|
|
46
58
|
# scaffold docs/okf (the default target, relative to the current directory)
|
|
47
59
|
okf-kit init
|
|
48
60
|
|
|
49
|
-
# scaffold a specific directory instead
|
|
50
|
-
okf-kit init path/to/bundle
|
|
51
|
-
|
|
52
|
-
# an existing, non-empty target directory is refused (exit 2) unless forced;
|
|
53
|
-
# --force overwrites only the files init owns, nothing else in the directory
|
|
54
|
-
okf-kit init path/to/bundle --force
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
`init` writes `index.md`, `log.md`, and one template doc per concept type: `overview-template.md`, `module-template.md`, `invariant-template.md`, `runbook-template.md`, plus `benchmark-template.md` for measuring whether the bundle helps. `index.md` and `log.md` carry no frontmatter (`reserved-files-bare`); every template doc carries full frontmatter (`type`, `title`, `description`, `tags`, `timestamp`, and, except for the benchmark template, `sources`) plus inline HTML-comment guidance on writing dense, source-verified, pointer-carrying docs instead of filler. All generated links are same-directory relative (`name.md`), never a leading-slash form.
|
|
58
|
-
|
|
59
|
-
### Placeholder sources are intentional
|
|
60
|
-
|
|
61
|
-
Every template doc except `benchmark-template.md` ships with `sources: [path/to/covered/source]`, a placeholder, not a real path. Running `okf-kit check` against the freshly scaffolded bundle (with a repo root available, explicit or auto-detected) will report that placeholder as a `sources-shape` "does not exist" error on every template doc. That is intentional: it is the tool telling you which docs still need a real source path, not a bug in the scaffold. The `init` completion message repeats this so it isn't missed. Replace each placeholder with the real repo-root-relative path(s) the doc describes as you write it, and the error clears doc by doc.
|
|
62
|
-
|
|
63
|
-
### Authoring guidance
|
|
64
|
-
|
|
65
|
-
- **`timestamp` means "last verified against sources," not "created on."** Bump it, and add a line to `log.md`, every time you re-verify a doc against its sources. Always use the real instant of verification (`new Date().toISOString()` or equivalent); never hand-write an artificial midnight datetime, or a local wall-clock time with a `Z` suffix it doesn't actually have -- `sources-fresh` and `sources-fresh-future` staleness comparisons both depend on it being real. See "Future-dated timestamps (`sources-fresh-future`)" below for the measure-after-commit discipline this enforces.
|
|
66
|
-
- **Never list the bundle's own directory in `sources`.** A bundle directory changes on every doc edit inside it, so a self-referential `sources` entry goes permanently stale. This happened to the OKF pilot's own `BENCHMARK.md` (`agent-tasks` `docs/okf/BENCHMARK.md`, `sources: [docs/okf/]`); `benchmark-template.md` here omits `sources` entirely for the same reason, since a benchmark record measures the bundle rather than describing a piece of the codebase.
|
|
67
|
-
- **Keep all links same-directory relative.** Use `name.md`, not `/name.md`; see `no-absolute-links` above for why a leading slash breaks once the bundle is viewed outside its own repository.
|
|
68
|
-
- **Write a sibling short-form citation as a connective-led `:N-M`, not `(N-M)`.** When a paragraph cites several sub-ranges of a source already named by a full `path:N-M` citation earlier in the same paragraph, write each later one as a `:N-M` led by one of the serial connectives the gate accepts -- `,`, `;`, `(`, or a trailing `and`/`or` -- right after the phrase it points at (e.g. `review finding L1 (:1170-1227, ...)`). `citations-resolve` only recognises the colon form; a parenthesized `(N-M)` is never checked, so it can drift silently. This convention is not demonstrated by any scaffolded template; it is a `citations-resolve` authoring rule. See "Citation resolution (citations-resolve)" below.
|
|
69
|
-
|
|
70
|
-
## Check catalog
|
|
71
|
-
|
|
72
|
-
| Rule | Severity | What it enforces |
|
|
73
|
-
|------|----------|-------------------|
|
|
74
|
-
| `frontmatter-required` | error | Every non-reserved `.md` file has a frontmatter block that parses as YAML and carries a non-empty string `type`. |
|
|
75
|
-
| `reserved-files-bare` | error | Reserved files (`index.md`, `log.md`, at any depth) must not carry a frontmatter block. |
|
|
76
|
-
| `links-resolve` | error | Markdown links to other `.md` files in the bundle must resolve to a real file. Relative targets resolve against the containing file's directory; targets starting with `/` resolve against the bundle root. A relative target that climbs out of the bundle directory (`../outside.md`) and still resolves on disk is accepted; the rule checks resolution, not containment. |
|
|
77
|
-
| `no-absolute-links` | warning | Link targets should not start with `/`. GitHub resolves a leading slash against the repository root, not the bundle root, so an absolute link 404s once the bundle is viewed outside its own repository. Use a same-directory relative link instead. |
|
|
78
|
-
| `sources-shape` | error | Frontmatter `sources`, when present, must be a non-empty array of non-empty strings. With a repo root (explicit or auto-detected), each listed path (file or directory) must also exist under it. |
|
|
79
|
-
| `sources-fresh` | warning / notice | For docs with a `sources` list and a repo root, flags a source path whose last git commit is newer than both the doc's `timestamp` and the doc file's own last re-stamping commit (re-stamp decided by comparing frontmatter `timestamp` values, read as instants at millisecond resolution, across that commit's first-parent boundary: the new value must be strictly LATER, a backwards move is not a re-stamp and is separately reported as a warning, a rewrite to the same instant is separately reported as a notice). With `--dirty-as-now`, a source with an uncommitted change is judged as of right now instead of its last commit. See "Staleness (sources-fresh)" below. |
|
|
80
|
-
| `sources-fresh-future` | warning | For the same docs as `sources-fresh`, flags a `timestamp` later than the doc file's own last commit by more than a clock-skew allowance (default 10 minutes, `--future-skew-minutes`). Catches a local wall-clock time mistakenly written with a `Z`/UTC suffix. See "Staleness (sources-fresh)" below. |
|
|
81
|
-
| `citations-resolve` | warning / notice | For docs with a repo root, flags a `` `path:N`/`path:N-M` `` citation (and its `` `:N` ``/`` -`M` ``/`` (`N`) `` continuations, and bare paragraph-bound short forms `:N-M`/`(N-M)`) whose target file is missing, whose range is inverted or exceeds the file, or whose start line is blank or (for a non-markdown target) only a closing brace. A full citation may also carry an optional `#anchor` (e.g. `` `CHANGELOG.md:50-144#0.24.0` ``), checked against the target's own structure/content instead of just its line numbers. A backtick-delimited `` `path:#heading` `` citation (`.md` targets only) resolves to a whole Markdown section instead of a line range, immune to every line-number shift above it; see "Heading-section citations" below. A short-form citation's range into a test file is also checked for a describe/it block boundary. `--require-anchors` opts into five additional checks; see "Anchor strictness (opt-in, `--require-anchors`)" below. See "Citation resolution (citations-resolve)" below. |
|
|
82
|
-
| `prose-line-references` | (opt-in, `--prose-line-references`) warning / notice | Off by default. Flags a prose-embedded line reference outside `citations-resolve`'s own backtick grammar (`line N`, `lines N-M`, `lines N to M`) that is drifted, unresolvable, or ambiguous once bound to the nearest named file. `--prose-line-references-strict` additionally flags every such reference as a formatting policy violation. See "Prose line references (opt-in, `--prose-line-references`)" below. |
|
|
83
|
-
|
|
84
|
-
## repo-root auto-detection
|
|
85
|
-
|
|
86
|
-
**Behavior change:** when `--repo-root` is omitted, okf-kit runs `git rev-parse --show-toplevel` from the bundle directory and uses the result if it succeeds. A bundle that lives inside a git work tree therefore gets `sources-shape` existence checks and `sources-fresh` staleness checks by default now, not just when you pass `--repo-root` explicitly.
|
|
87
|
-
|
|
88
|
-
If the bundle is not inside a git work tree (or `git` is unavailable), repo-root stays unset: `sources-shape` skips existence checks exactly as before, and `sources-fresh` emits a single notice (`staleness skipped: not inside a git work tree`) rather than silently reporting nothing, so a "clean" run is never a fake pass.
|
|
89
|
-
|
|
90
|
-
Pass `--repo-root` explicitly to pin a specific root (useful in CI when the bundle and the code it documents live in different checkouts) or to opt out of the ambient repo (point it at the bundle directory itself to disable both checks' access to the rest of the repo).
|
|
91
|
-
|
|
92
|
-
## Reverse lookup (`docs-for`)
|
|
93
|
-
|
|
94
|
-
`okf-kit docs-for <bundleDir> <path>...` answers the opposite question from `check`: given one or more paths, which bundle docs claim them as `sources`? Useful for a slicer or CI step that needs to know which knowledge-bundle docs are affected by a set of changed files, without reimplementing frontmatter parsing.
|
|
95
|
-
|
|
96
|
-
```bash
|
|
97
61
|
# which docs claim src/foo.ts (or a directory it lives under) as a source?
|
|
98
|
-
okf-kit docs-for path/to/bundle src/foo.ts
|
|
99
|
-
|
|
100
|
-
# JSON output for tooling
|
|
101
|
-
okf-kit docs-for path/to/bundle src/foo.ts --json
|
|
102
|
-
|
|
103
|
-
# explicit repo root, like `check` (auto-detected via `git rev-parse --show-toplevel` when omitted)
|
|
104
|
-
okf-kit docs-for path/to/bundle src/foo.ts --repo-root /path/to/repo
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
**Population and matching:** `docs-for` looks at the exact same docs `sources-shape`/`sources-fresh` do -- every doc whose frontmatter `sources` is a validly-shaped (non-empty array of non-empty strings) list; a doc with no `sources` key, or a malformed one, contributes no matches (that shape error is `check`'s job to report, not this command's). A given `<path>` argument matches a doc's `sources` entry when, after resolving both against `--repo-root` (exactly as `check`'s `sources-shape` resolves `sources` entries, see below):
|
|
108
|
-
|
|
109
|
-
- the two paths are identical, or
|
|
110
|
-
- the `sources` entry resolves to a path that is a DIRECTORY on disk, and the given path resolves to that directory itself or anything underneath it.
|
|
111
|
-
|
|
112
|
-
There is no glob support: a `sources` entry is a plain path everywhere else in this package (`sources-shape`'s existence check is a bare `fs.existsSync`, never glob expansion), so `docs-for` matches nothing wider than what `check` already validates against. A `sources` entry that does not exist on disk (already flagged by `sources-shape`) can only match by exact string equality, never by directory containment, since there is nothing to inspect there.
|
|
113
|
-
|
|
114
|
-
**Resolution:** every `sources` entry is resolved with `path.join(repoRoot, source)`, exactly as `check`'s `sources-shape` rule resolves it -- a leading slash in the frontmatter spelling (`/src/foo.ts`) is repo-relative, not filesystem-absolute, so `check` and `docs-for` always agree on what a given `sources` entry points at. A given `<path>` argument, relative or absolute, is resolved by ONE rule regardless of spelling: `path.resolve(repoRoot, path)` (a relative argument joins onto `repoRoot`; an absolute one is used as-is, exactly as `path.resolve` treats a second absolute argument). Either way, a leading `./` and a trailing slash are normalized away before comparing, on both the given path and the `sources` entry. A resolved given path that lies outside `--repo-root` -- an absolute path elsewhere on disk (including `--repo-root`'s own parent), or a relative `../` escape -- is a usage error (exit 2), never a silent empty result. `--repo-root` itself, given directly as a `<path>` argument, is accepted (not an error); it only matches a `sources` entry that itself resolves to `--repo-root` (`.` or `./`). The containment check compares path spellings, not real paths: an absolute `<path>` that reaches the repo through a different spelling (a symlinked checkout, or macOS `/tmp` versus `/private/tmp`) is rejected as outside. An auto-detected repo root is git's resolved `--show-toplevel`, so prefer relative `<path>` arguments, or use that same spelling.
|
|
115
|
-
|
|
116
|
-
**Output:** text output is one line per matching doc, bundle-relative path (the same convention `check`'s own findings use), followed by the `sources` entries of its that matched (`doc.md: src/foo.ts, src/bar/`); an empty result says so explicitly rather than printing nothing. `--json` gives `{ bundleDir, matches: [{ doc, sources: [...] }] }`, one entry per matching doc, sorted by `doc`, each entry's `sources` deduplicated and sorted; `bundleDir` is the ABSOLUTE, machine-bound path `docs-for` resolved the bundle directory argument to (not repo-relative), so a consumer that stores the JSON output should not expect it to be portable across machines or checkouts. **Exit codes:** 0 whether or not anything matched; 2 for a usage error (unknown/missing bundle directory, no `<path>` arguments given, no repo root determinable, or a given path outside `--repo-root`, relative or absolute).
|
|
117
|
-
|
|
118
|
-
**Symlinked directory sources:** a `sources` entry that is a symlink to a directory is followed (`fs.statSync`, not `fs.lstatSync`), so it is treated as a directory and containment is checked against ITS OWN spelling: a given path underneath the symlink's spelling matches, but the same file addressed through the real directory's path does not (and vice versa) -- the two spellings are never treated as equivalent. This mirrors how `sources-fresh`'s own `git log` pathspec lookup only ever sees the spelling actually committed in frontmatter.
|
|
119
|
-
|
|
120
|
-
## Staleness (sources-fresh)
|
|
121
|
-
|
|
122
|
-
`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" below 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:
|
|
123
|
-
|
|
124
|
-
| Situation | Severity | Message |
|
|
125
|
-
|-----------|----------|---------|
|
|
126
|
-
| 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>` |
|
|
127
|
-
| 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) |
|
|
128
|
-
| 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) |
|
|
129
|
-
| 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 |
|
|
130
|
-
| 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 |
|
|
131
|
-
| 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) |
|
|
132
|
-
| 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) |
|
|
133
|
-
| A source path exists but has no git history (untracked) | notice | `untracked by git, staleness unknown: <path>` |
|
|
134
|
-
| The doc's `timestamp` is missing or not a parseable date, while `sources` is present | notice | `staleness not assessable: no valid timestamp` |
|
|
135
|
-
| No repo root available (see auto-detection above) | notice | `staleness skipped: not inside a git work tree` |
|
|
136
|
-
| A source path does not exist on disk | (nothing) | left to `sources-shape`, not duplicated here |
|
|
137
|
-
|
|
138
|
-
STALE findings are warnings, so they are advisory by default; run with `--strict` to fail the build on them.
|
|
139
|
-
|
|
140
|
-
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".
|
|
141
|
-
|
|
142
|
-
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.
|
|
143
|
-
|
|
144
|
-
**Designator-less timestamps** (the one description of this handling; everything else in this README 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.
|
|
145
|
-
|
|
146
|
-
**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:
|
|
147
|
-
|
|
148
|
-
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.
|
|
149
|
-
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.
|
|
150
|
-
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.
|
|
151
|
-
|
|
152
|
-
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).
|
|
153
|
-
|
|
154
|
-
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:
|
|
155
|
-
|
|
156
|
-
| Branch shape | Doc commit the lookup resolves to | Verdict | Why |
|
|
157
|
-
|---|---|---|---|
|
|
158
|
-
| 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 |
|
|
159
|
-
| 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 |
|
|
160
|
-
|
|
161
|
-
**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.
|
|
162
|
-
|
|
163
|
-
**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.
|
|
164
|
-
|
|
165
|
-
### Uncommitted edits (`--dirty-as-now`)
|
|
166
|
-
|
|
167
|
-
`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 above) -- 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.
|
|
168
|
-
|
|
169
|
-
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.
|
|
170
|
-
|
|
171
|
-
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.
|
|
172
|
-
|
|
173
|
-
**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:
|
|
174
|
-
|
|
175
|
-
```bash
|
|
176
|
-
okf-kit check path/to/bundle --dirty-as-now --strict
|
|
62
|
+
okf-kit docs-for path/to/bundle src/foo.ts
|
|
177
63
|
```
|
|
178
64
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
**What this actually produces for the recipe above, reproduced through the built CLI (source committed, doc committed with a matching `timestamp`, then):**
|
|
182
|
-
|
|
183
|
-
| Working-tree state | `check --dirty-as-now --strict` |
|
|
184
|
-
|---|---|
|
|
185
|
-
| 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 |
|
|
186
|
-
| Source edited, doc left alone (not re-stamped) | exit 1, `sources-fresh` STALE |
|
|
187
|
-
| Source edit and doc re-stamp committed TOGETHER, flag OFF | exit 0, clean (the committed-history co-commit rescue, unaffected by this flag) |
|
|
188
|
-
| Doc dirty for an unrelated reason (a body edit, `timestamp` value unchanged), source edited | exit 1, `sources-fresh` STALE -- not rescued |
|
|
189
|
-
| 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** under "Staleness (sources-fresh)") | 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) |
|
|
190
|
-
| 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) |
|
|
191
|
-
| 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 |
|
|
192
|
-
|
|
193
|
-
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.
|
|
194
|
-
|
|
195
|
-
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".
|
|
196
|
-
|
|
197
|
-
`--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.
|
|
198
|
-
|
|
199
|
-
**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.
|
|
200
|
-
|
|
201
|
-
### Future-dated timestamps (`sources-fresh-future`)
|
|
202
|
-
|
|
203
|
-
`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).
|
|
204
|
-
|
|
205
|
-
| Situation | Severity | Message |
|
|
206
|
-
|-----------|----------|---------|
|
|
207
|
-
| 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)` |
|
|
208
|
-
| 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 |
|
|
209
|
-
| 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 |
|
|
210
|
-
| 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" below) |
|
|
211
|
-
| The doc's `timestamp` is missing or not a parseable date | (nothing) | left to `sources-fresh`'s own notice, not duplicated here |
|
|
212
|
-
| No repo root available (see auto-detection above) | (nothing) | left to `sources-fresh`'s single bundle-level notice, not duplicated here |
|
|
213
|
-
|
|
214
|
-
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>`. Like `STALE` findings, `FUTURE-DATED` findings are warnings, advisory by default; run with `--strict` to fail the build on either.
|
|
215
|
-
|
|
216
|
-
**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.
|
|
217
|
-
|
|
218
|
-
**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** under "Staleness (sources-fresh)" for the full rationale and why the two rules land on different responses to the same edge case.
|
|
219
|
-
|
|
220
|
-
**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 "Staleness (sources-fresh)" 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.
|
|
221
|
-
|
|
222
|
-
## Citation resolution (citations-resolve)
|
|
223
|
-
|
|
224
|
-
`sources-fresh` catches a source file changing after a doc's `timestamp`, but it is structurally blind to an edit that shifts *line numbers* inside a still-fresh file: a doc citing `path:42` keeps citing line 42 even after an edit moves the referenced content to line 50. `citations-resolve` finds every `` `path:N` ``/`` `path:N-M` `` citation in a doc, resolves `path` to a real file, and flags a citation that clearly cannot be pointing at real content any more. It is mechanical only (no symbol/AST resolution): it does not verify the cited line is *semantically* the right one, only that the target exists, the range is sound, and the start line is not blank or (for a non-markdown target) a lone closing brace/bracket.
|
|
225
|
-
|
|
226
|
-
| Rule id | Meaning |
|
|
227
|
-
|---------|---------|
|
|
228
|
-
| `missing-file` | The cited path could not be resolved to a real file (see path resolution below). |
|
|
229
|
-
| `path-traversal-rejected` | The cited path contains a `..` segment; rejected without ever being resolved. |
|
|
230
|
-
| `inverted-range` | A range's end line is before its start line. |
|
|
231
|
-
| `range-exceeds-file` | The cited line (or the end of a range) is past the end of the resolved file. |
|
|
232
|
-
| `blank-start-line` | The start line resolves to a blank line. A citation whose start line is blank is flagged; cite the first content line instead. Consumers enabling this rule on an existing bundle should expect to fix citations like this once, the first time they turn it on. |
|
|
233
|
-
| `closing-brace-start-line` | For a non-markdown target, the start line is only a closing brace/bracket/paren (`}`, `)`, `]`, optionally with a trailing `,`/`;`) -- a common signature of a cited block having moved. |
|
|
234
|
-
| `unresolved-ambiguous` | More than one file in the repo matches the cited path; reported as a `notice` (never counted toward `--strict`), not guessed at. |
|
|
235
|
-
| `unreadable-target` | The resolved target file exists but could not be read (e.g. permission denied); reported as a `notice` (never counted toward `--strict`) with the OS error code in `detail`, since an unreadable file is not evidence the citation itself is wrong. |
|
|
236
|
-
| `short-form-unbound` | A short-form citation (see below) has no full citation earlier in its own paragraph to bind to; reported as a `notice` (never counted toward `--strict`). |
|
|
237
|
-
| `test-range-start-not-head` | A short-form citation's range into a test file does not start on a `describe(`/`it(` head line. A wrong start is strong drift evidence, so this is a **warning**. |
|
|
238
|
-
| `test-range-end-not-closing` | A short-form citation's range into a test file has a correct start (a real `describe(`/`it(` head) but does not end on a matching closing `});` line; reported as a `notice` (never counted toward `--strict`), since a correct start with a short end is also consistent with a deliberate partial citation. |
|
|
239
|
-
| `markdown-range-boundary-bracket-or-fence` | A short-form citation's range into a Markdown target starts or ends on a bare bracket, or (except see the fence-opening exception below) a bare code-fence line; reported as a `notice` (never counted toward `--strict`). |
|
|
240
|
-
| `anchor-heading-mismatch` | A heading-anchored citation's (see below) nearest enclosing heading does not contain the anchor text -- the range now lands in the wrong section. **Warning**. |
|
|
241
|
-
| `anchor-heading-does-not-enclose` | A heading-anchored citation's anchor text matches its nearest enclosing heading, but the range runs past that heading's own section (a heading of the same or shallower level starts before the range ends). **Warning**. |
|
|
242
|
-
| `anchor-heading-not-found` | A heading-anchored citation has no heading (level 1 or 2) anywhere before its start line to anchor against; use a string anchor (`#"..."`) instead against a target with no heading structure. **Warning**. |
|
|
243
|
-
| `anchor-not-found-in-range` | A string-anchored citation's (see below) anchor text does not occur, verbatim, on any line of the cited range. **Warning**. |
|
|
244
|
-
| `anchor-malformed` | A `#` immediately follows a citation's range, but the text after it does not parse as either anchor form (unbalanced quotes, a backtick inside a quoted anchor, or nothing at all after the `#`). The citation is still checked as an ordinary anchorless citation; reported as a `notice` (never counted toward `--strict`) so a typo in an anchor does not silently turn off the very check it was written for. |
|
|
245
|
-
| `heading-section-not-found` | A heading-section citation's (see below) heading text does not match any level 1 or 2 heading in the target. **Warning**. |
|
|
246
|
-
| `heading-section-ambiguous` | A heading-section citation's heading text matches more than one level 1 or 2 heading in the target; reported rather than silently resolved to the first match. **Warning**. |
|
|
247
|
-
| `heading-section-empty` | A heading-section citation resolved to a single heading, but that heading's section has no non-blank content before the next heading of the same or shallower level. **Warning**. |
|
|
248
|
-
| `heading-section-content-anchor-not-found` | A heading-section citation's optional content anchor (`` `path:#heading#"text"` ``) does not occur on any line of the resolved section. **Warning**. |
|
|
249
|
-
| `heading-section-content-anchor-ambiguous` | A heading-section citation's content anchor occurs on more than one line of the resolved section; expected exactly one. **Warning**. |
|
|
250
|
-
| `heading-section-malformed` | A backtick-delimited `` `path:#...` `` opener does not parse as a well-formed heading-section citation (an unterminated or empty content-anchor quote, an unquoted third segment, or a non-`.md` target, including a non-lowercase `.MD` extension); reported as a `notice` (never counted toward `--strict`) so a typo does not silently vanish. |
|
|
251
|
-
| `anchor-required` | (opt-in, `--require-anchors`) An in-repo full citation carries no `#anchor` at all. **Warning**. |
|
|
252
|
-
| `anchor-not-on-last-line` | (opt-in, `--require-anchors`) A string-anchored citation's anchor text was found in the cited range, but not on the range's own last CONTENT line (see below). **Warning**. |
|
|
253
|
-
| `anchor-not-unique-in-range` | (opt-in, `--require-anchors`) A string-anchored citation's anchor text occurs on more than one line of the cited range. **Warning**. |
|
|
254
|
-
| `test-range-straddles-block` | (opt-in, `--require-anchors`) A full citation's own range into a `.test.`/`.spec.` target (`.ts`, `.js`, `.mjs`) contains a `describe`/`it`/`test` block-head line, at the same or a shallower indent than the range's own first line, on any line other than that first line. **Warning**. |
|
|
255
|
-
| `anchor-required-continuation` | (opt-in, `--require-anchors`) A continuation (any of `` `:N` ``/`` -`M` ``/`` (`N`) ``) or a bound paragraph-bound short-form `:N-M` citation whose governing citation resolves in-repo -- it cannot carry a `#anchor` of its own. **Warning**. |
|
|
256
|
-
|
|
257
|
-
**Anchored citations.** A full citation may carry an anchor directly after its range: `` `path:N-M#anchor` ``, e.g. `` `CHANGELOG.md:50-144#0.24.0` ``. This closes a gap the checks above cannot: a CHANGELOG.md that grows by inserting each new release at the top shifts every later entry's absolute line numbers, so a citation that lands 15 lines off in the *wrong* release section is exactly as green as before the shift -- none of `missing-file`/`inverted-range`/`range-exceeds-file`/`blank-start-line`/`closing-brace-start-line` can tell the difference. An anchor pins the citation to a piece of the target's own structure or content that a pure line-shift does not preserve. Two forms, told apart by the anchor text itself:
|
|
258
|
-
|
|
259
|
-
- **Heading form** (bare/unquoted, e.g. `#0.24.0` or `#[0.24.0]`): the citation's nearest *enclosing* Markdown heading (level 1 or 2 only -- see below) must contain the anchor text, and the range must not run past that heading's own section (no heading of the same or shallower level may start before the range's end line). Capped at heading level 2 deliberately: a Keep-a-Changelog `CHANGELOG.md` nests `## [x.y.z]` release headings around identically-named `### Added`/`### Changed`/`### Fixed` subsections repeated in every release, so matching "the nearest heading of any level" would make this check nearly useless there (it would match the wrong release's own `### Changed` just as readily as the right one's); deeper subsection headings are transparent to the search instead. The anchor text itself may contain word characters, `.`, and `-`, but never starts or ends on a `.` or `-`, so a trailing sentence period or a following `,`/`)` never becomes part of it (e.g. `` `path:7-8#0.24.0.` `` at the end of a sentence captures `0.24.0`, not `0.24.0.`); a hyphenated token like `0.24.0-rc1` is captured whole. A fenced code block inside the *target* (e.g. a `` ```bash `` example containing a `#`-led comment) is excluded from the heading search the same way a citing doc's own fences are already excluded from short-form matching (see below): a comment line inside an example is never mistaken for a real heading, on either end of the enclosure check.
|
|
260
|
-
- **String form** (double-quoted, e.g. `#"reproduction requirement"`): the anchor text must occur, verbatim, on at least one line of the cited range itself -- "occurs inside it" rather than "encloses it", so this form also works against a non-Markdown target (`.ts`/`.js`/...) where "enclosing heading" has no meaning. The quoted text cannot cross a line break or a backtick: an unterminated opening quote (a typo) fails to parse as an anchor at all (the citation is then checked as if no anchor had been written, with no diagnostic) rather than greedily consuming the rest of the document up to some unrelated later quote character, which would otherwise silently hide every citation in between from this rule entirely.
|
|
261
|
-
|
|
262
|
-
Anchors are checked only once the base checks above (blank-start-line, closing-brace-start-line, inverted-range, range-exceeds-file) already came back clean for that citation. Anchors are full-citation-only: a continuation or a short-form citation never carries its own path, so there is nowhere natural to hang one on -- under `--require-anchors`, such a continuation or bound short-form is instead flagged `anchor-required-continuation` (see below) rather than silently exempted. The `#anchor` suffix is entirely optional and strictly additive: an existing anchorless citation matches and is checked exactly as it was before this feature existed.
|
|
263
|
-
|
|
264
|
-
**Anchor syntax note.** Any `#` immediately following a citation's range is read as the start of an anchor, with no other signal required -- there is no way to write a bare range immediately followed by an unrelated `#` and have it ignored. This means a Markdown link like `` [note](path:1-2#x) `` or an editor-style fragment such as `path:1-2#L7` is interpreted as an anchored citation (`x` and `L7` respectively), whether that was intended or not. The recommended, unambiguous form is the one used throughout this doc: the whole citation, range and anchor together, inside a single pair of backticks, e.g. `` `path:1-2#L7` ``.
|
|
265
|
-
|
|
266
|
-
**Known limitations.** The heading form's `heading.text.includes(anchor.text)` check is a plain substring match against the heading's raw text, not a token-boundary match: an anchor `0.1` matches a heading containing `[0.10.0]` (and, by the same construction, `[0.24.0]` "contains" `24.0`). This is a deliberate simplification, not implemented as token-boundary matching in this round; treat a heading-anchored citation as a strong drift signal, not a semantic guarantee, the same way the rest of this rule's checks are mechanical rather than semantic. Separately, the heading form runs `MD_HEADING_RE` (a bare `` `#{1,6}\s+...` `` match) against the target's raw lines regardless of the target's own file type, so a `# comment` line in a `.yml` or `.json` target is matched as if it were a Markdown heading; restricting the heading form to `.md` targets is left as a known limitation rather than implemented here. Both limitations stand as documented rather than implemented; both remain easy to tighten later without touching anchor syntax or backward compatibility.
|
|
267
|
-
|
|
268
|
-
**Heading-section citations.** `` `path:#heading` `` resolves to a whole Markdown section instead of a line range, so an edit anywhere above the cited section (e.g. inserting a new release at the top of a CHANGELOG) changes nothing about the citation -- see the doc comment above `HEADING_SECTION_CITATION_RE` in `src/rules/citations-resolve.ts` for the full rationale, including why the grammar requires the `:#` colon-hash and is restricted to `.md` targets. Grammar: `` `path:#heading` `` or, with an optional content anchor, `` `path:#heading#"text"` `` (always the quoted form). The heading text must match exactly one level 1 or 2 heading in the target (same containment check and same level cap as the line-range anchor's heading form above); the resolved section must be non-empty; the content anchor, when given, must occur on exactly one line inside that section. A malformed attempt (an unterminated or empty content-anchor quote, an unquoted third segment, or a non-`.md` target) is reported rather than silently dropped -- see `heading-section-malformed` in the table above. An unbalanced bracket in the heading position (`` `path:#[unclosed` ``) is not malformed by this definition: it parses as a heading named `[unclosed` and is reported as `heading-section-not-found`, the same way the line-range anchor form treats it. A bare `` `path#heading` `` without the colon is never a citation (it is the shape of an ordinary Markdown link target written in prose). See the rule table above for the exact finding names.
|
|
269
|
-
|
|
270
|
-
**Continuation citations.** Once a sentence states a full `path:N` citation, prose commonly repeats just the line (or range) for a later reference in the same sentence: `` `:N` ``/`` `:N-M` `` (bare colon-prefixed), `` -`M` ``/`` –`M` `` (hyphen- or en-dash-led, the tail of a split range like `` `path:N`-`M` ``), or `` (`N`) `` (parenthesized). Each resolves against the nearest preceding citation in the same doc that resolved to a real file; a continuation right after an unresolved, ambiguous, or out-of-scope citation is silently skipped, not misattributed to an earlier, unrelated file. A heading-section citation (see above) carries no line number and is never a continuation's target: it never sets or clears which citation a later continuation resolves against.
|
|
271
|
-
|
|
272
|
-
**Anchored path-less continuation form: not recognised (bundle-local).** The `orchestrator-workflow` package's own dogfood bundle writes a backtick-wrapped, path-less continuation with a content anchor, e.g. `` `:913-923#"..."` ``, distinct from the two forms above. `CONT_COLON_RE` needs its closing backtick immediately after the digit range, so a trailing `#"anchor"` before the backtick never matches it; `SHORT_FORM_COLON_RE` excludes any match immediately preceded by a backtick and otherwise requires a serial-connective prefix, neither of which this form carries. So this rule sees the form as nothing at all, not merely as unresolved. Recognising it (an opt-in rule, or folding it into this one) would move the cost to okf-kit's maintainers: the rule itself, an allowlist/anchor-geometry contract as public API, and a fleet-wide pin bump on every fix. Declaring it bundle-local instead leaves the cost where it already sits: only the `orchestrator-workflow` package's own `test/docs-consistency.test.ts` guard checks the form, and every other fleet bundle that might someday write it stays unguarded until it grows the same check by hand. Port trigger, restated from the orchestrator-workflow CHANGELOG's "Citation-sibling-drift guard, okf-kit-porting decision" entry: a second fleet bundle observed carrying real sibling-citation drift in a review pass (not merely plausible in the abstract) reopens the port decision. A fleet count at the time of this decision found the live form only in that one bundle (`docs/okf/model-preselection.md`, 4 occurrences); other apparent hits were historical prose in a `log.md` narrating a re-point, not live citations.
|
|
273
|
-
|
|
274
|
-
**Short-form citations.** Distinct from a continuation above: a *bare* (no backtick, no file name at all) colon-range `:N-M`, e.g. `:580-588` in running prose. A short-form citation binds to **the last full `path:N-M` citation named earlier in the same paragraph** -- not the nearest preceding citation anywhere in the doc (that is what a continuation does); a paragraph boundary is a blank (empty or whitespace-only) line. A short-form citation with no full citation earlier in its own paragraph is reported `short-form-unbound`, not silently skipped. Only a range is recognised, never a bare single number (`:5`): a bare number is too easily an unrelated enumeration marker (e.g. a numbered list item) to detect mechanically without a large false-positive cost. A resolved short-form citation gets every check a full citation gets, plus the test-file/Markdown block-boundary check above (`test-range-*` / `markdown-range-boundary-bracket-or-fence`).
|
|
275
|
-
|
|
276
|
-
**Serial-connective gate, and why the paren form is not collected at all.** Three earlier rounds each tried to separate a real short-form citation from ordinary prose that merely contains an N-M-shaped number pair ("the window (2026-2027)", "follow steps (2-4)", "three engineers (1-3)") by deciding from the range's *values*: an inverted-pair check, a span cap plus a year/well-known-port plausibility gate, then a containment-or-adjacency check against the paragraph's last full citation. All three were eventually defeated by the same class of false positive, because every real paren-form citation this rule was ever built against has the identical shape `<English word> (N-M)` as ordinary prose -- there is no lexical signal that tells them apart. The bare parenthesized form `(N-M)` is therefore not collected as a short-form citation candidate at all; measured against this repo's own dogfood bundle, dropping it changed nothing (39 findings / 17 warnings / 22 notices, identical with and without it).
|
|
277
|
-
|
|
278
|
-
The colon form does not have this problem, but still needs a gate: a candidate `:N-M` is collected only when the nearest preceding non-whitespace text, after trimming whitespace, is one of `,`, `;`, `(`, or ends in the word `and`/`or` -- the shape a short-form citation takes in a serial list of sub-ranges ("the `TODO` cells, :72-74 (the ...), and :92-97 (the ...)", "review finding L1 (:1170-1227, ...)"). This is a measured, not a guessed, gate: see the CHANGELOG for the corpus sample it was calibrated against. The comma is load-bearing, not just `(` and `and`; do not narrow the gate to those two. A candidate the gate rejects is dropped before any paragraph-binding is attempted -- it is not a citation, and produces nothing at all, not even `short-form-unbound`.
|
|
65
|
+
See [Scaffold a bundle](docs/init.md) and [Reverse lookup](docs/docs-for.md) for the full command reference, including flags and output formats.
|
|
279
66
|
|
|
280
|
-
|
|
67
|
+
### Use in CI
|
|
281
68
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
**Markdown fence-opening exception.** A range boundary landing on a bare bracket line is always a drift signal. A bare code-fence delimiter (```` ``` ```` or `~~~`) at the range's END is also always a drift signal, but at the range's START it is exempted when that line is a genuine *opening* fence (determined by replaying the doc's own fence open/close state from the top, not by the line's text alone, since an untagged opening fence and a closing fence are lexically identical): citing a fenced code block starting at its own opening delimiter is the natural, correct way to cite it, not drift.
|
|
285
|
-
|
|
286
|
-
**Hard-wrapped prose.** A doc that hard-wraps prose at a fixed column can split a hyphenated filename across a line break right after its trailing `-` (e.g. `run-state-lifecycle-and-markers.md` wrapping to `run-state-lifecycle-and-\nmarkers.md`). A `` `path:N` `` match is skipped entirely (not checked, not counted as a citation) when it starts at column 0 of its line -- optionally after only whitespace or a list/quote marker (`-`, `*`, `>`, digits, `.`) -- and the previous line ends with `-`/`–`: the signature of a wrapped continuation rather than a genuine citation to a short bare filename.
|
|
287
|
-
|
|
288
|
-
**Path resolution**, tried in order: (1) the doc's own frontmatter `sources` list, matched by exact suffix, only when exactly one source matches; (2) for a citedPath with no `/` only, doc-relative and then each ancestor directory of the doc up to the repo root, nearest first (so a bare filename like `README.md` prefers the README *for the package the doc lives in* over a same-named file that merely happens to also sit at the repo root); (3) repo-root-relative; (4) doc-relative; (5) the nearest earlier citation in the same doc whose path contains a `/` and ends with the same suffix (the "full path was mentioned earlier" convention); (6) a repo-wide search by basename. A cited path starting with `/` is treated as an absolute or placeholder path (e.g. inside a fabricated example stack trace) and skipped without a finding.
|
|
289
|
-
|
|
290
|
-
Like `sources-fresh`, this rule requires a repo root (explicit `--repo-root` or auto-detected): without one, it emits a single bundle-level notice (`citation resolution skipped: not inside a git work tree`) rather than silently reporting nothing.
|
|
291
|
-
|
|
292
|
-
### Anchor strictness (opt-in, `--require-anchors`)
|
|
293
|
-
|
|
294
|
-
Everything above is on by default. `--require-anchors` opts into five additional, stricter checks against `citations-resolve`, all `warning`-severity, off unless the flag is passed -- an existing bundle's findings under a plain `check` are byte-for-byte unaffected by this section existing. `--require-anchors` (and its sibling `--require-anchors-allow`) is a CLI flag only; okf-kit has no bundle-level config file today (no `.okf.yml`, no `okf-kit.config`, no frontmatter field), so there is no other way to turn this on. All five checks are also exempt for a citing doc that is a reserved file (`index.md`, `log.md`), the same carve-out `citations-resolve` already gives reserved docs for short-form matching elsewhere in this doc -- a reserved doc's citations still get the unconditional base checks (`missing-file`, `inverted-range`, `anchor-not-found-in-range`, and so on), just none of the five opt-in ones:
|
|
295
|
-
|
|
296
|
-
- **`anchor-required`.** An in-repo full citation (resolved to a real target -- an unresolved one already gets its own `missing-file`/`unresolved-ambiguous` finding, not this one) carrying no `#anchor` at all is flagged. Also exempt (beyond the reserved-doc carve-out above): a citedPath matching one of `--require-anchors-allow`'s patterns (repeatable/space-separated, exact string or a `*`/`?` glob against the citation's raw, as-written path text), for a doc category not meant to be anchor-checked, e.g. `--require-anchors-allow README.md INSTALL-AGENT.md`. A target cited under several different spellings in the same bundle (`init.ts` in one paragraph, `src/init.ts` in another) needs one allow pattern per spelling, or a `*basename` glob that covers all of them -- the match is against the citation's own raw text, not the path it resolves to. `--require-anchors-allow` is commander-variadic: its list stops at the next `-`-prefixed token, so flags placed after it parse normally, but it swallows the positional bundle path if placed before it (`check --require-anchors-allow README.md <bundleDir>` fails with a missing-argument error); pass it after the bundle path.
|
|
297
|
-
- **`anchor-not-on-last-line`.** A string-anchored citation (`` `path:N-M#"text"` ``) whose anchor text was found in the cited range, but not on the range's own last CONTENT line. A content line is any line whose trimmed text is not empty and not composed only of closing brackets/braces/parens plus an optional trailing `,`/`;` (`}`, `});`, `]);`, `}),`, and similar); a range ending on one or more such boilerplate lines (the common shape of a `});` that closes a whole cited block) resolves to the real content line before them instead. An anchor sitting on the range's first line survives a small insertion above the range (the shifted window still contains the anchor's original content, just at a different offset); anchoring on the last content line closes that, since the original last content line falls out of the shifted window on any insertion at all.
|
|
298
|
-
- **`anchor-not-unique-in-range`.** A string-anchored citation whose anchor text occurs on more than one line of the cited range -- ambiguous evidence for which occurrence is the one actually pinning the citation. A count of zero is unaffected (already `anchor-not-found-in-range`, unconditionally).
|
|
299
|
-
- **`test-range-straddles-block`.** A FULL citation's own range into a `.test.`/`.spec.` target (`.ts`, `.js`, `.mjs`) must not run into a sibling or outer `describe`/`it`/`test` block (including `.only`/`.skip`/`.each` variants): a block-head line at the same or a shallower indent than the range's own start line, found on any line of the range OTHER than that start line, means the citation straddled out into a sibling or outer block. A block-head line indented STRICTLY DEEPER than the start line is a nested block (e.g. every `it(` inside a `describe(` the range cites in full) and is not a straddle -- citing a whole block is expected to contain every head line nested inside it. The start line itself is never checked here -- it is either a legitimate block head (the range correctly starts a block) or legitimately inside a block's body (a deliberate partial citation), and both are fine. A range that leaves its block without a later block-head line inside it (ending on an outer block's closing line) is not detected by this line-based check. Deliberately scoped to test-file targets only, and to this same opt-in: a full citation into a Markdown target legitimately cites a couple of arbitrary lines all the time (the same reasoning that keeps the Markdown half of `test-range-start-not-head`/`test-range-end-not-closing` scoped to short-form citations only, see above), and turning this on unconditionally for every existing full citation would silently regress an already-green bundle for every consumer, not just one that opted in.
|
|
300
|
-
- **`anchor-required-continuation`.** A continuation (`` `:N` ``/`` `:N-M` ``, `` -`M` ``/`` –`M` ``, `` (`N`) ``) or a bound paragraph-bound short-form `:N-M` citation whose governing citation (the full citation it is chained to, or the paragraph's last-named full range for a short form) resolves in-repo is flagged: it structurally cannot carry a `#anchor` of its own (see "Anchored citations" above), so it is invisible to every other check in this section, including when its governing full citation is itself already anchored -- an anchor on the full citation does not cover a later continuation of it. Same exemptions as `anchor-required`: a reserved citing doc, and `requireAnchors.allow`, matched against the GOVERNING citation's raw citedPath (a continuation carries no path of its own to match). The remedy is always the same: lift the continuation into its own full `path:N-M#anchor` citation. This closes a gap the other four checks leave open: they all fire only for a "full" citation, so a continuation chained off an ALREADY-anchored full citation was previously invisible to `--require-anchors` -- a line-shift landing it on still non-blank, in-bounds content is exactly the drift class `anchor-required` exists to catch for a full citation, just unreachable through it for a continuation.
|
|
301
|
-
|
|
302
|
-
`--require-anchors` is off by default and, once on, surfaces a real backlog the first time it is run against an existing, unaudited bundle -- the same posture `blank-start-line` already documents for `citations-resolve` itself. `anchor-required` in particular is only useful once a bundle's citations have actually been anchored; running it against a bundle that predates anchoring will flag most of that bundle's full citations.
|
|
303
|
-
|
|
304
|
-
## Prose line references (opt-in, `--prose-line-references`)
|
|
305
|
-
|
|
306
|
-
`citations-resolve` only sees a citation written in its own backtick grammar (`` `path:N` ``). Prose habitually names a line number a different way instead -- "lines 496-498", "generate-codex-config.ts lines 129-132" -- and those shapes are structurally invisible to `citations-resolve`'s `CITATION_RE`, which requires a literal `:` between the path and the digits. A doc can be re-verified, re-stamped, and pass `check` with 0 findings while its prose line numbers are drifted, because nothing ever looked at them. `--prose-line-references` closes that gap. Off by default; every finding below is `prose-line-references`, off unless the flag is passed, so an existing bundle's `check` output is byte-for-byte unaffected by this section existing.
|
|
307
|
-
|
|
308
|
-
**Extraction grammar** (conservative -- deliberately narrower than everything actually seen in the wild): `line N`, `lines N-M` (hyphen, en-dash, or em-dash), and `lines N to M`. Deliberately NOT matched:
|
|
309
|
-
|
|
310
|
-
- `L N` / `L1` -- not observed in the corpus this rule was measured against, and more ambiguous than `line N` (`L1` already means "review finding 1" in this package's own short-form-citation authoring convention, see "Authoring guidance" above).
|
|
311
|
-
- `<file>:N` outside backticks -- already matched by `citations-resolve`'s own `CITATION_RE`, which has no backtick requirement (only its heading-section form does). Matching it again here would double-report the same drift under two rule ids.
|
|
312
|
-
- a comma-separated list of several numbers/ranges after one `lines` keyword (e.g. "lines 178, 234, 265-268"), or a second, unlabelled range chained by "vs"/"and" onto an already-extracted one (e.g. "lines 676-698 vs 564-591" only extracts `676-698`) -- only the first number/range is extracted; the rest are silently under-extracted, never mis-parsed. Under-extraction, never mis-binding, matches this rule's "never guess" posture throughout.
|
|
313
|
-
|
|
314
|
-
A reference inside a fenced code block, an indented code block, an inline code span, or a Markdown table row is excluded from extraction, same as `citations-resolve`'s own short-form matching. So is a reference falling inside a real `citations-resolve` full citation's own span (mostly relevant to a citation's quoted string anchor, e.g. `` `path.md:10-20#"see line 5 above"` ``), a reference inside an HTML comment (e.g. `` <!-- see line 5 above for the earlier draft --> ``, which narrates a past state rather than citing current content), and a reference on a line that is itself a Markdown ATX heading (e.g. `## Line 3 semantics`, which names a section about a line rather than citing it). A URL with a port (`host:8080`), an ISO timestamp, and a version string (`1.2.3`) need no special-casing at all: the grammar above requires the literal word `line`/`lines` immediately before the digits, which none of those three shapes contain. A hyphen-joined compound word ("in-line 999", "multi-line 999", "command-line 999") is also excluded: the leading boundary check rejects a match starting right after a `-`.
|
|
315
|
-
|
|
316
|
-
**Binding rule** (conservative -- never guessed across a paragraph boundary): a reference is bound to the nearest "file mention" -- a path-like token with one of `citations-resolve`'s own recognised extensions (`.ts`, `.js`, `.mjs`, `.md`, `.yml`, `.yaml`, `.json`) that resolves to a real file under the bundle's repo root, using `citations-resolve`'s own path-resolution rules verbatim (frontmatter `sources` match, ancestor climb for a bare filename, repo-root-relative, doc-relative, "last full path mentioned", repo-wide basename search):
|
|
317
|
-
|
|
318
|
-
1. the nearest file mention in the same sentence, preceding first, then following (a rough sentence-boundary heuristic: a `.`/`!`/`?` followed by whitespace and then a capital letter, digit, backtick, quote, or open bracket, or the end of the paragraph);
|
|
319
|
-
2. otherwise the nearest PRECEDING file mention in the same paragraph;
|
|
320
|
-
3. otherwise `unresolvable` -- never guessed, never silently bound to the wrong file.
|
|
321
|
-
|
|
322
|
-
A candidate file-mention token that itself fails to resolve, or resolves to more than one real file (two files sharing a basename), is not skipped in favor of a farther candidate: "nearest wins" is taken literally, so the outcome is `unresolvable`/`ambiguous` rather than quietly falling through to a second-nearest mention the prose did not actually name. `ambiguous` covers only that one case, a single mention whose own basename collides across files; two DISTINCT mentions that each resolve cleanly (e.g. "`` `src/a.ts` `` sets it up; `` `src/b.ts` `` line 5 reads it") are resolved by nearest-wins as usual and are deliberately never reported as `ambiguous` -- the binding rule picked one of them on purpose, it did not fail to pick. A bare backtick-wrapped filename (`` `src/cli.ts` ``) counts as a file mention exactly the way a bare filename in running prose does -- unlike the base extraction grammar above, mention detection deliberately does NOT exclude inline code, since a backtick-wrapped filename is this package's own normal, encouraged way to name a file in prose (see "Authoring guidance" above). `index.md`/`log.md` (reserved citing docs) are skipped entirely, the same carve-out `citations-resolve` already gives them: an append-only narrative journal routinely narrates historical line-number deltas as prose about the past, not live citations against current content.
|
|
323
|
-
|
|
324
|
-
**Findings:**
|
|
325
|
-
|
|
326
|
-
| Reason | Meaning | Severity |
|
|
327
|
-
|--------|---------|----------|
|
|
328
|
-
| `out-of-bounds` | The bound reference's range is inverted, or its start (or end) line exceeds the bound file's line count. | Warning |
|
|
329
|
-
| `blank-start-line` | The bound reference's start line is blank. | Warning |
|
|
330
|
-
| `unresolvable` | No file mention could be bound (no candidate in the sentence or paragraph, the nearest candidate does not resolve to a real file, or the resolved target could not be read). | Notice |
|
|
331
|
-
| `ambiguous` | The nearest file mention's basename resolves to more than one real file; not evaluated. The finding names both (or all) candidates. | Notice |
|
|
332
|
-
|
|
333
|
-
`unresolvable` and `ambiguous` are notice-severity rather than warning: "line" occurs constantly in ordinary English with no adjacent file nearby ("in line with", "product line"), so "no file mention nearby" is weaker evidence of actual drift than a reference that resolved cleanly and then turned out wrong. `out-of-bounds` and `blank-start-line` are warning-severity, mirroring `citations-resolve`'s own "a wrong start/target is strong drift evidence" posture once a reference DOES resolve to a single real file.
|
|
334
|
-
|
|
335
|
-
**Strict mode (`--prose-line-references-strict`, ignored unless `--prose-line-references` is also passed):** flags EVERY extracted reference, resolved or not, with its own warning-severity `prose-line-reference-not-anchored` finding and a fixed remedy: lift it into a backtick `path:N-M` citation (so `citations-resolve` can verify it going forward), or de-precise it to a symbol name instead of a line number. This is additive, not a replacement for the base checks above -- a drifted reference under strict mode gets both its `out-of-bounds`/etc. finding AND the policy finding, since both facts are independently true of it.
|
|
336
|
-
|
|
337
|
-
`--prose-line-references` is off by default and, once on, surfaces a real backlog the first time it is run against an existing, unaudited bundle -- the same posture `--require-anchors` already documents above. Known false-positive category: the binding rule conflates "the file this sentence is about" with "the file these line numbers belong to" when they differ, e.g. "`06-handoff.md`'s `## Accepted Waivers` heading ... (test lines 132-135)" binds `test lines 132-135` to `06-handoff.md` even though the line numbers actually belong to an unnamed test file discussed elsewhere in the same sentence -- a real limitation of a conservative, non-semantic heuristic, not a bug; expect some of this class of noise on a first run against an existing corpus.
|
|
338
|
-
|
|
339
|
-
## Exit codes
|
|
340
|
-
|
|
341
|
-
| Code | Meaning |
|
|
342
|
-
|------|---------|
|
|
343
|
-
| 0 | No errors (and, under `--strict`, no warnings either). |
|
|
344
|
-
| 1 | At least one error (or, under `--strict`, at least one warning). |
|
|
345
|
-
| 2 | CLI invocation error: bundle directory does not exist, `init`'s target directory is non-empty without `--force`, or a commander usage error (unknown option, missing argument, missing/unknown command). `--help` and `--version` still exit 0. |
|
|
346
|
-
|
|
347
|
-
## CI usage
|
|
348
|
-
|
|
349
|
-
This is advisory: don't fail the build on warnings unless you pass `--strict`. Use a normal (non-shallow) checkout of the repo that owns the bundle: repo-root detection runs `git rev-parse --show-toplevel` from the `path/to/bundle` argument itself, not from the shell's working directory, and `sources-fresh` reads `git log`. A shallow clone (`actions/checkout`'s default `fetch-depth: 1`, or any `git clone --depth`) does NOT report paths as untracked -- every path is still tracked at the boundary commit's own commit time. What it DOES cost: `sources-fresh`'s re-stamp check (see "Staleness (sources-fresh)" above) can no longer tell a doc's real root commit from the grafted boundary commit history was cut off at, so a doc whose own last commit lands there gets a `staleness not assessable` notice instead of a real STALE/pass verdict. Pass `fetch-depth: 0` (a full checkout) to get a real verdict there too.
|
|
69
|
+
Pin the exact version and use a full (non-shallow) checkout; [CI usage](docs/ci.md) explains why:
|
|
350
70
|
|
|
351
71
|
```yaml
|
|
352
72
|
- uses: actions/checkout@v5
|
|
353
73
|
with:
|
|
354
74
|
fetch-depth: 0
|
|
355
75
|
- name: OKF bundle check
|
|
356
|
-
run: npx okf-kit@0.
|
|
76
|
+
run: npx okf-kit@0.17.1 check path/to/bundle
|
|
357
77
|
```
|
|
358
78
|
|
|
359
|
-
|
|
79
|
+
## Documentation
|
|
360
80
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
`
|
|
81
|
+
- [Check catalog](docs/check-catalog.md): all 9 rules, repo-root auto-detection, exit codes.
|
|
82
|
+
- [Scaffold a bundle (`init`)](docs/init.md): generated files, placeholder sources, authoring guidance.
|
|
83
|
+
- [Reverse lookup (`docs-for`)](docs/docs-for.md): matching rules, path resolution, output formats.
|
|
84
|
+
- [Staleness (`sources-fresh`)](docs/staleness.md): the re-stamp rule, designator-less timestamps, squash-merge interaction, `--dirty-as-now`, `sources-fresh-future`.
|
|
85
|
+
- [Citation resolution (`citations-resolve`)](docs/citations.md): every finding id, anchor forms, continuation and short-form citations, `--require-anchors`.
|
|
86
|
+
- [Prose line references](docs/prose-line-references.md): the opt-in `--prose-line-references` check for line numbers written outside `citations-resolve`'s own grammar.
|
|
87
|
+
- [CI usage](docs/ci.md): shallow-clone caveats, the pinned-version rationale, the release-time pin bump, and the `templates/okf-staleness.yml` warn-only workflow template.
|
|
365
88
|
|
|
366
|
-
##
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npm install
|
|
93
|
+
npm run build
|
|
94
|
+
npm test
|
|
95
|
+
```
|
|
367
96
|
|
|
368
|
-
|
|
97
|
+
See [`../../CONTRIBUTING.md`](../../CONTRIBUTING.md) for issue and PR conventions, and the release process (including `CONTRIBUTING.md`'s "Releasing okf-kit" section for bumping this repo's own `okf-kit@<version>` pins).
|
|
369
98
|
|
|
370
99
|
## License
|
|
371
100
|
|
package/dist/cli.d.ts
CHANGED
|
@@ -48,7 +48,7 @@ export interface CheckOptions {
|
|
|
48
48
|
* commit time from that same shared instant, so a pre-commit run reports
|
|
49
49
|
* the verdict CI will report once the commit lands -- for either rule.
|
|
50
50
|
* Stored on `ctx.dirtyAsNow`; see its jsdoc in `src/types.ts` (the
|
|
51
|
-
* canonical description of the model) and
|
|
51
|
+
* canonical description of the model) and docs/staleness.md's "Uncommitted
|
|
52
52
|
* edits (`--dirty-as-now`)" section.
|
|
53
53
|
*/
|
|
54
54
|
dirtyAsNow?: boolean;
|
package/dist/cli.js
CHANGED
|
@@ -67,18 +67,18 @@ program
|
|
|
67
67
|
.option("-j, --json", "Output findings as JSON")
|
|
68
68
|
.option("-s, --strict", "Also fail (exit 1) when warnings are present")
|
|
69
69
|
.option("--require-anchors", "citations-resolve: also require every in-repo full citation to carry a #anchor, and check " +
|
|
70
|
-
"a string anchor lands uniquely on the last line of its range (opt-in, see
|
|
70
|
+
"a string anchor lands uniquely on the last line of its range (opt-in, see docs/citations.md)")
|
|
71
71
|
.option("--require-anchors-allow <patterns...>", "citations-resolve: citedPath glob/exact patterns (e.g. README.md) exempt from " +
|
|
72
72
|
"--require-anchors' anchor-required check")
|
|
73
73
|
.option("--prose-line-references", "prose-line-references: flag a drifted, unresolvable, or ambiguous prose-embedded line " +
|
|
74
|
-
'reference outside citations-resolve\'s own backtick grammar, e.g. "lines 129-132" (opt-in, see
|
|
74
|
+
'reference outside citations-resolve\'s own backtick grammar, e.g. "lines 129-132" (opt-in, see docs/prose-line-references.md)')
|
|
75
75
|
.option("--prose-line-references-strict", "prose-line-references: also flag every prose line reference, not only a drifted one, with " +
|
|
76
76
|
"the remedy to lift it into a backtick citation or a symbol name (ignored unless --prose-line-references is also passed)")
|
|
77
77
|
.option("--future-skew-minutes <n>", "sources-fresh-future: clock-skew allowance in minutes before a doc `timestamp` later than " +
|
|
78
78
|
"the doc's own last commit is flagged as future-dated (default 10)")
|
|
79
79
|
.option("--dirty-as-now", "sources-fresh + sources-fresh-future: model every uncommitted change (modified, staged, or " +
|
|
80
80
|
"untracked) -- a `sources` path and the doc itself alike -- as one virtual commit made right " +
|
|
81
|
-
"now, so a pre-commit run matches what CI reports after the commit lands (opt-in, see
|
|
81
|
+
"now, so a pre-commit run matches what CI reports after the commit lands (opt-in, see docs/staleness.md)")
|
|
82
82
|
.exitOverride()
|
|
83
83
|
.action((bundleDir, opts) => {
|
|
84
84
|
try {
|