Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions scripts/wiki/lint.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@
repository the evidence can cite: a citation from a repository left off the list reads dead.

EVIDENCE LINT CANNOT CHECK IS COUNTED AS UNCHECKED, never as passing and never as dead: an Owner
ruling, a `memory:` source, any citation when no -EvidenceRepo is given, and a pull request
without -Online. The report prints the count, so a zero dead count over nothing checked cannot
be read as clean.
ruling, a `memory:` source, any citation when no -EvidenceRepo is given, a pull request
without -Online, and an all-digit token of more than eight digits that resolves as no commit
(a comment or run id). The report prints the count, so a zero dead count over nothing checked
cannot be read as clean.

PROMOTION IS LISTED, NEVER ACTED ON. Drafting the playbook pull request is the scheduled Claude
Code job's work. It opens that pull request as a DRAFT titled `wiki promotion: <lesson>` from a
Expand Down Expand Up @@ -237,6 +238,10 @@ foreach ($k in $byKey.Keys) {

# ------------------------------------------------------------------------------------ 2. dead evidence
$shaRx = [regex]'\b(?=[0-9a-f]*[0-9])[0-9a-f]{7,40}\b'
# More than eight digits and no letter is a GitHub comment or Actions run id far more often than a
# commit (wiki event 20261017T191602866Z-e4idhk). It is still looked up, so a real all-digit short
# sha resolves; one that resolves nowhere is unchecked rather than dead.
$digitIdRx = [regex]'^\d{9,}$'
$refPathRx = [regex]'(?<![A-Za-z0-9_./-])([A-Za-z0-9_./-]+):([A-Za-z0-9_-]*[/.][A-Za-z0-9_./-]*[A-Za-z0-9_])'
# A `#N` after a word such as BACKLOG or ADR names a ledger row, not a pull request, and so does
# every number in the run that follows it: `BACKLOG #1250, #1754`, `items #1089-#1093`. Looked up
Expand Down Expand Up @@ -290,6 +295,8 @@ function Get-LintCitation {
if ($v -match '^\d{8}$' -and [datetime]::TryParseExact($v, 'yyyyMMdd', [cultureinfo]::InvariantCulture,
[System.Globalization.DateTimeStyles]::None, [ref]$d) -and $d.Year -ge 1990 -and $d.Year -le 2100) {
& $take $m @{ Kind = 'ambiguous'; Text = $v }
} elseif ($digitIdRx.IsMatch($v)) {
& $take $m @{ Kind = 'digitid'; Text = $v }
} else {
& $take $m @{ Kind = 'commit'; Text = $v }
}
Expand Down Expand Up @@ -353,7 +360,7 @@ $found = [System.Collections.Generic.Dictionary[string, bool]]::new([System.Stri
if ($repos.Count -gt 0) {
$lines = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($c in $cites) {
if ($c.Kind -eq 'commit') { [void]$lines.Add("$($c.Text)^{commit}") }
if ($c.Kind -in 'commit', 'digitid') { [void]$lines.Add("$($c.Text)^{commit}") }
elseif ($c.Kind -eq 'refpath') {
[void]$lines.Add("$($c.Ref)^{commit}")
if (-not (Test-LeavesRepo $c.Path)) { [void]$lines.Add("$($c.Ref):$($c.Path)") }
Expand Down Expand Up @@ -411,18 +418,23 @@ foreach ($c in $cites) {
if (Test-Found "$($c.Text)^{commit}") { $evResolved++; continue }
$dead = "commit $($c.Text) resolves in none of $($repos.Count) evidence repo(s)"
}
'digitid' {
if ($repos.Count -eq 0) { Add-Unchecked 'no -EvidenceRepo given'; continue }
if (Test-Found "$($c.Text)^{commit}") { $evResolved++; continue }
Add-Unchecked 'more than eight digits that resolve as no commit, read as a comment or run id'; continue
}
'refpath' {
if ($repos.Count -eq 0) { Add-Unchecked 'no -EvidenceRepo given'; continue }
if (Test-LeavesRepo $c.Path) {
# The path cannot be asked, but a sha ref can: a missing commit is dead either way.
if ($shaRx.Match($c.Ref).Value -ceq $c.Ref -and -not (Test-Found "$($c.Ref)^{commit}")) {
if ($shaRx.Match($c.Ref).Value -ceq $c.Ref -and -not $digitIdRx.IsMatch($c.Ref) -and -not (Test-Found "$($c.Ref)^{commit}")) {
$dead = "commit $($c.Ref) resolves in none of $($repos.Count) evidence repo(s)"
} else { Add-Unchecked 'a path that leaves the repository'; continue }
}
elseif (Test-Found "$($c.Ref):$($c.Path)") { $evResolved++; continue }
elseif (Test-Found "$($c.Ref)^{commit}") {
$dead = "path '$($c.Path)' is not at '$($c.Ref)' in any of $($repos.Count) evidence repo(s) where that ref resolves"
} elseif ($shaRx.Match($c.Ref).Value -ceq $c.Ref) {
} elseif ($shaRx.Match($c.Ref).Value -ceq $c.Ref -and -not $digitIdRx.IsMatch($c.Ref)) {
$dead = "commit $($c.Ref) resolves in none of $($repos.Count) evidence repo(s)"
} else { Add-Unchecked 'a ref that resolves in no evidence repo'; continue }
}
Expand Down
2 changes: 1 addition & 1 deletion tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ directory is written.
| `test_wiki_compile.py` | `scripts/wiki/compile.ps1`, **executed** against a throwaway bare remote and a clone of it: inbox events are filed by month, one page per key prefix with front matter, an index and exactly one log line, committed on `wiki/compile` over Base; an inbox file survives compile and is deleted only after the simulated merge; a second run with the same inbox and Base leaves the same tree and adds no commit; the rebuild test deletes the pages and index, runs `-RebuildOnly`, gets back the same blobs, and a query for A's own words returns B through A while `-History` shows A `historical` with a pointer to B; two live events on one key list a conflict in `index.md`; an id collision with different content exits 1 and moves nothing; the record clone's own status, HEAD and branch are unchanged; every relative link in every page and in `index.md` resolves to a file inside `wiki/` (Story 6); the leak hold (FR-028): an event the record repository's scanner flags, or one carrying an email address, stays in the inbox, unfiled and unrendered, while a clean event beside it is filed; the scanner runs once per compile; the scan fails closed with exit 2 and nothing pushed when the scanner is absent, exits 2, crashes, or exits 1 with an unreadable, unstaged, miscounted or uncounted hit; a `\uXXXX` escape, a UTF-16 file, a NUL in decoded text, a name beside an allowlisted phrase and an address split by an invisible character do not slip past, while a `path@ref` and a decorator line are not taken for addresses; a held marker and a standing branch carrying a now-held event are each warned about; the matched token reaches no output and no commit; a landed inbox file, byte-identical or CRLF against an LF blob, is cleared with no `git show`, and one differing in trailing blank lines is read back once and cleared; `-Timings` names each phase, closes a stopped phase as `until-stop`, and prints nothing without the switch | a rebuild from a log resurrected every superseded event when the flag lived only in the index (spec, *What the outside record says*). An absent A cannot tell the guard hid it from the renderer never wrote it, so the same events are rendered with the guard switched off and A MUST appear there (spec SC-001). The idempotency case has its own control: one more inbox event does move the tree. The rebuild comparison stages the files first, because a `core.autocrlf=true` checkout makes `git status` list a file whose only difference is a line ending. The link check finds each link kind the renderer writes among the links it read, and reports a planted dead link, a planted link out of `wiki/`, a drive-letter link, and exactly the links to a removed event file. The hold's scanner is a stub with an invented token, because the record repository's patterns are private. It keeps the real one's exit codes, hit-line shape and closing count, and its binary and allowlist skips. Echoing its stderr, or ignoring a crash, each turns a case red |
| `test_wiki_import.py` | `scripts/wiki/import.ps1`, **executed** over temporary stores built to a real note's shape: each field maps as its header says, across the front-matter shapes real notes carry; a store not passed in `-Store` is never read, and neither is `MEMORY.md` nor a subdirectory; each event carries its note file's day as `noted`, and a future file date is read as today and counted; two stores holding one name and one text give one event plus a merge event, and one name with two texts gives one event, the newer note's whichever store is named first, plus an outranked record, with lint filing no conflict; a tie goes to the store named first; an older note edited but still older changes nothing live, and one edited to be newest supersedes; a moved date alone writes nothing, and every record is written once; a seat's event on a memory key is never superseded, holds the key when it is newer, and is not taken for the import's own record because it cites a `memory:` note; a rename keeps a shared old key live for the other store, and a rename with nothing written on the new key is carried by a `supersede` on the old one; an older note from a store named in a later run does not replace a newer live one; a note renamed back to its first key is live there; an edit to the note behind the kept event hands the key to the winner, and a merge into an event the run replaces or renames away is recorded again; a rename into a retired key does not bring back the winner's withdrawn text; an unchanged re-run writes zero events, and an edited note writes one that supersedes its own earlier import; a planted credential is refused and listed by class, never by value; the home directory becomes `~` in every spelling, and another account's does not; two projects under one account keep apart; a replaced text is not merged into; a re-run stays quiet for a note named `merge`, a file name with a space and a timestamp-shaped description; a batch that failed part-way is reported per note; a renamed note supersedes its old import; a re-run never brings back a text the Owner retired or superseded; `-WhatIf` writes nothing | the spec's own count is 964 notes in six stores that never meet, and an import that re-wrote them on every run, or read a store nobody named, would fill the wiki with duplicates or with content the Owner never chose. Every absence has a control that finds the same thing when it is there: the unlisted store's token is found once the store is listed, the refused note's harmless twin is written, and the text that normalisation rescues is refused by `write.ps1` without it |
| `test_wiki_write_batch.py` | `scripts/wiki/write.ps1 -FromJson`, **executed**: a batch event is written byte-for-byte as the single write writes it but for `id` and `ts`; every refusal carries the single write's own reason; one refusal does not stop the rest, and a scan hit in the third chunk of 200 refuses that event alone; an event cannot carry `id`, `ts` or `seat`; a `memory:` citation is evidence and a bare one is not; `-CheckOnly` writes nothing | a process per event cost about 0.6 seconds, so the import needs a batch, and a batch that applied rules of its own would be a second write path. So every case compares the batch with a single write of the same input rather than with an expected value |
| `test_wiki_lint.py` | `scripts/wiki/lint.ps1`, **executed**: a lesson is stale 46 days after its `noted` day, however recently it was written; a world planted with exactly one of each class -- conflict, dead evidence, stale, orphan page, promotion candidate -- reports each once with its stable id and event ids; a superseded pair and a retired key are not conflicts, and the same pair without the supersede is; a commit present in a throwaway evidence repository is not dead and an absent one is; an Owner ruling, a `memory:` source, a pull request offline and any citation with no evidence repository count as unchecked, never as dead; with `-Online`, a stand-in `gh` first on PATH makes a closed pull request dead and leaves a `BACKLOG #N` unasked; every file under the state root and the record repository is byte-identical after a run, and an `-Out` inside either wiki tree is refused | a lint that read nothing reports zero of everything. So the clean control world asserts the events it read and the citations it RESOLVED, not only its zeros, and unchecked evidence is printed rather than folded into a pass. Two sabotages were run while this file was written: a lint that touched the state root, and one that passed an Owner ruling as resolved. Each turned exactly its own case red |
| `test_wiki_lint.py` | `scripts/wiki/lint.ps1`, **executed**: a lesson is stale 46 days after its `noted` day, however recently it was written; a world planted with exactly one of each class -- conflict, dead evidence, stale, orphan page, promotion candidate -- reports each once with its stable id and event ids; a superseded pair and a retired key are not conflicts, and the same pair without the supersede is; a commit present in a throwaway evidence repository is not dead and an absent one is; an Owner ruling, a `memory:` source, a pull request offline, any citation with no evidence repository, and an all-digit token of nine or more digits that resolves as no commit (a comment or run id) count as unchecked, never as dead, while a missing short hex sha is still dead; with `-Online`, a stand-in `gh` first on PATH makes a closed pull request dead and leaves a `BACKLOG #N` unasked; every file under the state root and the record repository is byte-identical after a run, and an `-Out` inside either wiki tree is refused | a lint that read nothing reports zero of everything. So the clean control world asserts the events it read and the citations it RESOLVED, not only its zeros, and unchecked evidence is printed rather than folded into a pass. Two sabotages were run while this file was written: a lint that touched the state root, and one that passed an Owner ruling as resolved. Each turned exactly its own case red |
| `test_wiki_cycle.py` | `scripts/wiki/cycle.ps1` and `scripts/wiki/register-cycle-task.ps1`, **executed** against throwaway bare remotes and detached clones, with stub wiki scripts: a run moves the korus checkout to `origin/main` and runs import, compile and lint from it in that order, then appends one JSON line with its start, end, korus SHA, each step's exit code and key counts; the import is skipped on another day, forced by `-Import`, skipped by `-NoImport`, and given `-RecordRepo` only once the reader's `origin/main` holds `wiki/events`; a missed import day is caught up once at the next run, but only when the log shows the cycle ran before that day, and an import that could not run is still owed; a due import with no `-Store` fails that step alone; a tracked edit, a staged change, a branch checkout, a local commit in either checkout, a reader of another repository, a store path holding a comma, a subdirectory and a missing path are each refused with exit 2 and nothing moved or run, while an untracked file is not, and a head another ref still holds, as after a squash merge, is moved; a live lock refuses the run and is left alone, a three-hour lock is still live, and one over four hours old is cleared and logged; each step's time limit and the run's budget are in the `-WhatIf` plan, and the budget fits inside the registrar's task limit; a failing step gives exit 1, is logged as returned, and the steps after it still run; `-WhatIf` plans and fetches, moves and writes nothing; the registrar's `-WhatIf -Json` names every task setting, the battery settings, the hidden window and priority 4 among them, refuses a day list, a day number and a path holding a comma, doubles a trailing backslash before its closing quote, and its command line, run as the scheduler would, plans a cycle that would exit 0; a decoy task name is never registered, beside a control where the same probe finds a task that exists; `-Status` of an unregistered task prints two lines; without `-Json` a run prints one summary line | the old mechanism ran only while one desktop app was open, and a scheduled run nobody watches must refuse loudly rather than move somebody's checkout. The stubs on the remote are one version newer than the checkout holds, and the checkout is asserted to hold the older one first, so a call reporting the newer version shows the scripts ran at `origin/main`. The tracked-edit refusal has a control: the same run goes through once the edit is gone. Two sabotages were run while this file was written: a cycle that never moved the checkout, and one that always passed `-RecordRepo`. Each turned its own case red |
| `test_no_live_page_names_the_vault_by_its_old_owner.py` | that no tracked page outside `roles/retired/` names the vault by its old `wshallwshall` slug unless the same paragraph or table row names `MEFORORG/MessageFoundry-vault` too, or the block right after it does. Controls: the real `roles/LANDER.md` with its vault slug reverted must trip, a wrapped retraction naming both must not, a current slug in an earlier table row must not excuse a later one, a dated correction in the next row must, and the scan must find the current slug somewhere | measured 2026-09-24 at `9c26644`: `roles/LANDER.md` and `roles/MANAGER.md` named the old slug while `roles/BUILDER.md` named the new one. `gh repo view` on the old slug returns `MEFORORG/MessageFoundry-vault` and exits 0, so a seat using it sees success and nothing reports the drift. The block is a paragraph because prose wraps, but a table row on its own, because one current slug anywhere in a long table would otherwise excuse every stale row in it. Only the next block counts as the correction, which is the shape `roles/MANAGER.md` uses |

Expand Down
23 changes: 23 additions & 0 deletions tests/test_wiki_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,29 @@ def test_an_all_digit_date_is_not_called_a_dead_commit(self):
doc = self.lint_json()
self.assertEqual((1, 0, 1), (doc["evidence"]["resolved"], doc["evidence"]["dead"], doc["evidence"]["unchecked"]))

def test_a_comment_or_run_id_is_unchecked_not_a_dead_commit(self):
"""Wiki event 20261017T191602866Z-e4idhk: comment and run ids read as dead commits."""
self.log(2, key="d/ids", evidence="ids 611792484, 6117924849 and 37118736851, see " + self.sha)
self.log(2, key="d/ref", evidence="run 37118736851:job.log")
doc = self.lint_json()
self.assertEqual([], doc["findings"])
self.assertEqual((1, 0, 4), (doc["evidence"]["resolved"], doc["evidence"]["dead"], doc["evidence"]["unchecked"]))
self.assertEqual(3, sum(n for why, n in doc["evidence"]["unchecked_by_reason"].items() if "run id" in why))

def test_an_all_digit_name_that_resolves_is_still_resolved(self):
"""The digits are still looked up: a name git resolves counts as resolved, not unchecked."""
w.git(self.repo, "tag", "123456789")
self.log(2, key="d/tag", evidence="see 123456789")
doc = self.lint_json()
self.assertEqual((1, 0, 0), (doc["evidence"]["resolved"], doc["evidence"]["dead"], doc["evidence"]["unchecked"]))

def test_control_a_missing_short_hex_sha_and_eight_non_date_digits_are_still_dead(self):
e = self.log(2, key="d/hex", evidence=f"{MISSING_SHA[:9]} and 12345678")
doc = self.lint_json()
self.assertEqual(sorted([f"dead-evidence:{e['id']}:{MISSING_SHA[:9]}", f"dead-evidence:{e['id']}:12345678"]),
sorted(f["id"] for f in doc["findings"]))
self.assertEqual(0, doc["evidence"]["unchecked"])


class Online(_LintCase):
"""-Online asks `gh pr view`. A stand-in `gh` first on PATH answers CLOSED for 13, MERGED for
Expand Down
Loading