@nightlab-agent/still-true 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -13
- package/package.json +1 -1
- package/src/cli.mjs +99 -55
- package/src/compare.mjs +2 -2
- package/src/extract.mjs +100 -29
- package/src/github.mjs +4 -3
package/README.md
CHANGED
|
@@ -1,31 +1,49 @@
|
|
|
1
1
|
# still-true
|
|
2
2
|
|
|
3
|
+
Checks a handoff note's pull request and issue claims against GitHub.
|
|
4
|
+
|
|
3
5
|
Your handoff note says "waiting on #12". #12 merged yesterday. The next session plans around it anyway.
|
|
4
6
|
|
|
5
|
-
npx @nightlab-agent/still-true STATE.md --repo owner/name
|
|
7
|
+
npx @nightlab-agent/still-true STATE.md --repo owner/name
|
|
8
|
+
|
|
9
|
+
Use your own note and repository. Try it first on a note that is out of date:
|
|
10
|
+
|
|
11
|
+
dir="$(mktemp -d)" && cd "$dir" && printf '# Now\n\nhslee16/Archy#827 open\n' > note.md
|
|
12
|
+
npx @nightlab-agent/still-true note.md
|
|
13
|
+
|
|
14
|
+
Every option and exit code:
|
|
15
|
+
|
|
16
|
+
npx @nightlab-agent/still-true --help
|
|
6
17
|
|
|
7
|
-
`still-true` reads a note, finds the sentences that say a pull request or issue is open, merged, closed or being waited on, and asks GitHub. It prints
|
|
18
|
+
`still-true` reads a note, finds the sentences that say a pull request or issue is open, merged, closed or being waited on, and asks GitHub. It also checks references named as queued review work. It prints contradictions and things to look at:
|
|
8
19
|
|
|
9
|
-
|
|
20
|
+
note.md:3 said open, but hslee16/Archy#827 is merged 2026-11-05 https://github.com/hslee16/Archy/pull/827
|
|
10
21
|
"hslee16/Archy#827 open"
|
|
11
|
-
|
|
22
|
+
1 claim checked, 1 contradicted
|
|
12
23
|
|
|
13
|
-
|
|
24
|
+
With one thing to look at, the last line gains `, 1 to look at`.
|
|
25
|
+
|
|
26
|
+
The real miss behind it: an agent's handoff went on counting 5 open pull requests through six session ends after one had merged. A bare count names no pull request, so still-true needs the note to name it.
|
|
14
27
|
|
|
15
28
|
## Use it
|
|
16
29
|
|
|
17
|
-
Give it one or more Markdown files. For a bare `#12` it uses `--repo owner/name`, or the `origin` remote of the folder you run it in. For a short name like `Archy#827`, give `--alias Archy=owner/name`, or it prints `not checked` for that line.
|
|
30
|
+
Give it one or more Markdown files. For a bare `#12` it uses `--repo owner/name`, or the `origin` remote of the folder you run it in. For a short name like `Archy#827`, give `--alias Archy=owner/name`, or a `--repo` with that repo name; otherwise it prints `not checked` for that line.
|
|
18
31
|
|
|
19
|
-
Set `GITHUB_TOKEN` for private repositories and a higher rate limit. Without one, GitHub allows 60 requests an hour. Needs Node 20 or newer. Behind a proxy, run with `NODE_USE_ENV_PROXY=1
|
|
32
|
+
Set `GITHUB_TOKEN` for private repositories and a higher rate limit. Without one, GitHub allows 60 requests an hour. Needs Node 20 or newer, tested on 20 and 22. Behind a proxy, run with `NODE_USE_ENV_PROXY=1` on Node 22; Node prints an experimental warning. Node 20 does not read that setting, and the run stops with `fetch failed`.
|
|
20
33
|
|
|
21
|
-
In a Markdown file with headings, it reads a section only if its heading starts with Now, Handoff, State, Status or Current, and any heading above it does too, or is the file's level 1 title. Plans and history stay out. Text before the first heading is read, and a file with no headings is read whole.
|
|
34
|
+
In a Markdown file with headings, it reads a section only if its heading starts with Now, Handoff, State, Status or Current, and any heading above it does too, or is the file's first level 1 heading. A first level 1 heading starting with History, Archive or Old is not a title. Plans and history stay out. Text before the first heading is read, and a file with no headings is read whole. If it finds no claims, it names the sections it skipped.
|
|
22
35
|
|
|
23
36
|
## What each line means
|
|
24
37
|
|
|
25
38
|
- `said open, but ... is merged`: a contradiction. The note and GitHub disagree.
|
|
26
39
|
- `look: waits on ..., which is merged`: the note waits on something finished. It may be waiting for a release or a reply, so read it. This does not fail the run.
|
|
40
|
+
- `look: queued work on ..., which is closed (not_planned)`: the note queues review work for a finished reference. Read whether that work still belongs in the queue. This does not fail the run.
|
|
27
41
|
- `not checked`: it could not place the reference, or GitHub has no such issue, or it is private. Nothing was verified for that line.
|
|
28
42
|
|
|
43
|
+
Queued work needs a reference at the start of a clause, optionally labelled PR, issue or pull request, then an optional commit hash and a counted review obligation such as `argent#1768 81004b3 owes one critic` or `argent#1768, one review owed`. Counts are a, an, one, two, three or a positive integer; the nouns are critic(s) or review(s). `still` may precede the obligation. One comma may separate the reference/hash from it, and a following wait may start with `and` or a comma. An open reference stays quiet. A reference alone, a past-tense obligation, or other unquoted trailing prose in that clause does not qualify. Quoted text and inline code are masked before matching: `argent#1768 owes one critic "not anymore"` and ``argent#1768 owes one critic `done` `` still qualify. A wholly quoted obligation stays out, as do historical and planning scopes. Blockquoted lines contribute no claims, including when the quote follows nested list markers. A quote does not silence the preceding paragraph or an independent list item after it; an immediately following unmarked prose line can still continue the quote.
|
|
44
|
+
|
|
45
|
+
Look and contradiction lines include GitHub's `state_reason` when supplied, such as `closed (not_planned)` or `closed (completed)`. The reason adds context; it does not change the verdict.
|
|
46
|
+
|
|
29
47
|
## Exit status
|
|
30
48
|
|
|
31
49
|
- `0`: nothing contradicted. Look lines may have printed.
|
|
@@ -34,6 +52,26 @@ In a Markdown file with headings, it reads a section only if its heading starts
|
|
|
34
52
|
|
|
35
53
|
An unreadable file or failed read after a contradiction exits `1` and still says the run is not complete.
|
|
36
54
|
|
|
55
|
+
Queued reads follow the same stop-on-failure rule: a failed queued read stops before later claims, exiting `2` if no contradiction has been found yet. Adding queued claims can therefore change the exit status for the same note. A queued line can also let a neighbouring state claim be read where it was previously suppressed as adjacent nonclaim prose: `argent#1768 owes a review` followed immediately by `#12 open` now checks both, and exits `1` if #12 has merged. A look alone still exits `0`.
|
|
56
|
+
|
|
57
|
+
## JSON output
|
|
58
|
+
|
|
59
|
+
Add `--json` for one JSON object on stdout, with no text lines. Exit codes stay the same.
|
|
60
|
+
|
|
61
|
+
npx @nightlab-agent/still-true STATE.md more.md --repo owner/name --json
|
|
62
|
+
|
|
63
|
+
The object has `schema: 1`, `complete`, `reason` (`null` on success), `counts` and `claims`.
|
|
64
|
+
Counts are `checked`, `contradicted`, `looks` and `notChecked`; checked includes contradictions, looks and ok claims.
|
|
65
|
+
Each claim has `file`, a 1-based `line`, `reference` (resolved to `owner/repo#number` when possible), `said`, `actual` (`open`, `merged`, `closed` or `null` when unknown), `kind` (`contradiction`, `look`, `not checked` or `ok`), and `url` when known.
|
|
66
|
+
If no claim was found and a section was skipped, `skipped_sections` lists those sections, each with its file when you gave more than one. `complete` is still `true` and the exit is `0`, so a script that must know a note was really read should check `counts.checked` too.
|
|
67
|
+
`said` can also be `queued`, for the review obligations above. A nonempty GitHub `state_reason` is included as an optional string field; null or absent reasons are omitted.
|
|
68
|
+
|
|
69
|
+
A failed read or unreadable file sets `complete: false` and gives the reason, keeping earlier results. The run stops there; claims already extracted from the current file that were not reached have kind `not checked`. Later files have no entries. A complete run can still have unchecked claims, such as an unresolved alias or a private issue. No claims gives an empty array and zero counts. `--json --help` returns the help text in a `help` field.
|
|
70
|
+
|
|
71
|
+
## Remove it
|
|
72
|
+
|
|
73
|
+
`npx` installs nothing in your project; it keeps a copy under `~/.npm/_npx`, which is safe to delete. If you installed it with `npm install`, use `npm uninstall @nightlab-agent/still-true`. It writes no files of its own.
|
|
74
|
+
|
|
37
75
|
## When it is not for you
|
|
38
76
|
|
|
39
77
|
- Silence does not mean your note is true. It means no contradiction was found in the sentences it understood. It stays quiet when unsure, on purpose.
|
|
@@ -43,7 +81,11 @@ An unreadable file or failed read after a contradiction exits `1` and still says
|
|
|
43
81
|
|
|
44
82
|
## How well it reads
|
|
45
83
|
|
|
46
|
-
|
|
84
|
+
On 17 hand-labelled sentences kept out of its 40 development claims, it found 16, 0 wrong. A sample of 30 claims from six days of logs held 2 wrong, both bare numbers (`#3`, `#4`) that named findings. All of that wording is one agent's, and those two sets are not in this repository, so expect less on yours.
|
|
85
|
+
|
|
86
|
+
The queued-review extension finds the reported subject and preserves its dependency. In `test/fixtures`, the development handoff scores 13/13 required claims with 0 extras. The second handoff, held out from development of this extension, retains 28/28 required claims plus 2 permitted optional claims, with 0 extras. It has no counted review obligations: two separately labelled current-work references (a fix ready on a branch and fixes waiting on a date) still go unread, so new-work recall there is 0/2. This supports keeping the rule narrow, not treating every named reference as current work. From a source checkout, `node test/score-queued.mjs` reproduces that supplemental score; checking every uncovered mention instead adds 22 unlabelled references in the first handoff and 12 in the second. These are small samples of the same author's notes, not evidence of recall on other writers.
|
|
87
|
+
|
|
88
|
+
The rule also misses `needs review`, uncounted obligations, labels before the reference, shared subjects and wrapped obligations. A historical mention without any historical wording or scope can still look current. State wordings it does not read yet: a full GitHub URL, a Markdown link, `**#12**` in bold, a checkbox line, a table row, `Blocked on #12`, `#12 has been merged`. Silence remains an incomplete reading of the note.
|
|
47
89
|
|
|
48
90
|
## The contract between the two halves
|
|
49
91
|
|
|
@@ -53,14 +95,14 @@ An earlier parser scored 0 of 17 on hand-labelled sentences it had not seen. Rew
|
|
|
53
95
|
|
|
54
96
|
- `line`: 1-based line number in the note.
|
|
55
97
|
- `ref`: `{ owner, repo, number }`, or `{ repo, number }` for the short form `Archy#827`, or `{ number }` for `#12`.
|
|
56
|
-
- `said`: `"open"`, `"merged"`, `"closed"` or `"waiting"
|
|
98
|
+
- `said`: `"open"`, `"merged"`, `"closed"` or `"waiting"` for a present-state claim, or `"queued"` for a counted review obligation.
|
|
57
99
|
- `quote`: the sentence, trimmed to 160 characters.
|
|
58
100
|
|
|
59
|
-
A sentence
|
|
101
|
+
A sentence needs a present-state claim or the narrow queued-review form above to give a claim.
|
|
60
102
|
|
|
61
|
-
`src/github.mjs` (network) turns a ref into `{ state: "open" | "merged" | "closed", kind, at, url }
|
|
103
|
+
`src/github.mjs` (network) turns a ref into `{ state: "open" | "merged" | "closed", kind, at, url }`, with an optional nonempty `state_reason` string from GitHub.
|
|
62
104
|
|
|
63
|
-
`src/compare.mjs` decides: `said` against `state`. "closed" said of a merged PR is not a contradiction.
|
|
105
|
+
`src/compare.mjs` decides: `said` against `state`. "closed" said of a merged PR is not a contradiction. Waiting and queued review work on a closed or merged reference are advisory looks.
|
|
64
106
|
|
|
65
107
|
## Credit
|
|
66
108
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nightlab-agent/still-true",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Check a handoff note against GitHub: finds what it says about PRs and issues and prints the claims that are no longer true. Exits 1 on a contradiction.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": { "still-true": "bin/still-true.js" },
|
package/src/cli.mjs
CHANGED
|
@@ -3,7 +3,9 @@ import { spawnSync } from 'node:child_process';
|
|
|
3
3
|
import { compare } from './compare.mjs';
|
|
4
4
|
import { readState, resolveRef } from './github.mjs';
|
|
5
5
|
|
|
6
|
-
const USAGE = 'usage: still-true NOTE.md [more.md] [--repo owner/name] [--alias Name=owner/name]';
|
|
6
|
+
const USAGE = 'usage: still-true NOTE.md [more.md] [--repo owner/name] [--alias Name=owner/name] [--json]';
|
|
7
|
+
const JSON_HELP = '--json: print one JSON object (schema 1) with completeness, counts and every extracted claim.';
|
|
8
|
+
const EXIT_HELP = 'exit 0: nothing contradicted, look lines may have printed\nexit 1: at least one contradiction\nexit 2: bad usage, or an unreadable file or failed read before any contradiction; the run is not complete';
|
|
7
9
|
|
|
8
10
|
export function repoFromRemote(url) {
|
|
9
11
|
const m = /^(?:[a-z+]+:\/\/(?:[^@/\s]+@)?|[^@/\s]+@)?(?:www\.)?github\.com(?::\d+)?[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?\s*$/i.exec(url ?? '');
|
|
@@ -13,7 +15,7 @@ export function repoFromRemote(url) {
|
|
|
13
15
|
const FULL = /^[^/\s=]+\/[^/\s=]+$/;
|
|
14
16
|
|
|
15
17
|
export function parseArgs(argv) {
|
|
16
|
-
const a = { files: [], aliases: {}, repo: undefined, bad: null };
|
|
18
|
+
const a = { files: [], aliases: {}, repo: undefined, bad: null, json: argv.includes('--json') };
|
|
17
19
|
for (let i = 0; i < argv.length; i++) {
|
|
18
20
|
if (argv[i] === '--repo') {
|
|
19
21
|
a.repo = argv[++i];
|
|
@@ -22,7 +24,7 @@ export function parseArgs(argv) {
|
|
|
22
24
|
const [name, full, more] = (argv[++i] ?? '').split('=');
|
|
23
25
|
if (name && FULL.test(full ?? '') && more === undefined) a.aliases[name] = full;
|
|
24
26
|
else a.bad ??= '--alias needs Name=owner/name';
|
|
25
|
-
} else a.files.push(argv[i]);
|
|
27
|
+
} else if (argv[i] !== '--json') a.files.push(argv[i]);
|
|
26
28
|
}
|
|
27
29
|
return a;
|
|
28
30
|
}
|
|
@@ -38,68 +40,110 @@ const name = (ref) => (ref.owner ? `${ref.owner}/${ref.repo}#${ref.number}` : re
|
|
|
38
40
|
export async function run(argv, deps) {
|
|
39
41
|
const { out, extract, readFile = fsRead, read, token, defaultRepo = originRepo, env = process.env } = deps;
|
|
40
42
|
const args = parseArgs(argv);
|
|
41
|
-
if (
|
|
43
|
+
const print = (line) => { if (!args.json) out(line); };
|
|
44
|
+
const entries = [];
|
|
45
|
+
let checked = 0, contradicted = 0, looks = 0, unplaced = 0, failure = null;
|
|
46
|
+
const finish = (code, reason = null, extra = {}) => {
|
|
47
|
+
if (args.json) out(JSON.stringify({
|
|
48
|
+
schema: 1, complete: reason === null, reason,
|
|
49
|
+
counts: { checked, contradicted, looks, notChecked: entries.length - checked },
|
|
50
|
+
claims: entries, ...extra,
|
|
51
|
+
}));
|
|
52
|
+
return code;
|
|
53
|
+
};
|
|
54
|
+
if (argv.includes('--help')) {
|
|
55
|
+
print(USAGE); print(JSON_HELP); print(EXIT_HELP);
|
|
56
|
+
return finish(0, null, { help: `${USAGE}\n${JSON_HELP}\n${EXIT_HELP}` });
|
|
57
|
+
}
|
|
42
58
|
const odd = args.files.find((f) => f.startsWith('--'));
|
|
43
|
-
if (odd) {
|
|
44
|
-
if (args.bad) {
|
|
45
|
-
if (!args.files.length) {
|
|
46
|
-
const repo = args.repo ?? (typeof defaultRepo === 'function' ? defaultRepo() : defaultRepo);
|
|
59
|
+
if (odd) { print(`unknown option ${odd}`); print(USAGE); return finish(2, `unknown option ${odd}`); }
|
|
60
|
+
if (args.bad) { print(args.bad); print(USAGE); return finish(2, args.bad); }
|
|
61
|
+
if (!args.files.length) { print(USAGE); return finish(2, USAGE); }
|
|
47
62
|
const seen = new Map();
|
|
48
|
-
|
|
63
|
+
const skipped = [];
|
|
49
64
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (!ref) {
|
|
61
|
-
unplaced++;
|
|
62
|
-
const hint = claim.ref.repo ? `--alias ${claim.ref.repo}=owner/name` : '--repo owner/name';
|
|
63
|
-
out(`${file}:${claim.line} not checked: ${name(claim.ref)} (use ${hint})`);
|
|
64
|
-
continue;
|
|
65
|
+
try {
|
|
66
|
+
const repo = args.repo ?? (typeof defaultRepo === 'function' ? defaultRepo() : defaultRepo);
|
|
67
|
+
for (const file of args.files) {
|
|
68
|
+
let text;
|
|
69
|
+
try { text = await readFile(file, 'utf8'); }
|
|
70
|
+
catch (e) {
|
|
71
|
+
const reason = `${file}: cannot read: ${e instanceof Error ? e.message : String(e)}`;
|
|
72
|
+
print(reason);
|
|
73
|
+
print('This run is not complete.');
|
|
74
|
+
return finish(contradicted ? 1 : 2, reason);
|
|
65
75
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
76
|
+
// Record all claims in this file before reading GitHub, including those a failure may leave unchecked.
|
|
77
|
+
const here = [];
|
|
78
|
+
const claims = extract(text, { aliases: args.aliases, skipped: here }).map((claim) => {
|
|
79
|
+
const ref = resolveRef(claim.ref, { defaultRepo: repo, aliases: args.aliases });
|
|
80
|
+
const entry = args.json ? {
|
|
81
|
+
file, line: claim.line, reference: name(ref ?? claim.ref), said: claim.said,
|
|
82
|
+
actual: null, kind: 'not checked',
|
|
83
|
+
} : null;
|
|
84
|
+
if (entry) entries.push(entry);
|
|
85
|
+
return { claim, ref, entry };
|
|
86
|
+
});
|
|
87
|
+
skipped.push(...here.map((section) => (args.files.length > 1 ? `${file}: ${section}` : section)));
|
|
88
|
+
for (const { claim, ref, entry } of claims) {
|
|
89
|
+
if (!ref) {
|
|
90
|
+
unplaced++;
|
|
91
|
+
const hint = claim.ref.repo ? `--alias ${claim.ref.repo}=owner/name` : '--repo owner/name';
|
|
92
|
+
print(`${file}:${claim.line} not checked: ${name(claim.ref)} (use ${hint})`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
let real;
|
|
96
|
+
try {
|
|
97
|
+
if (!seen.has(key(ref))) seen.set(key(ref), await read(ref, { token }));
|
|
98
|
+
real = seen.get(key(ref));
|
|
99
|
+
} catch (e) { failure = e instanceof Error ? e.message : String(e); break; }
|
|
100
|
+
const r = compare(claim, real);
|
|
101
|
+
if (entry) {
|
|
102
|
+
entry.actual = real?.state ?? null;
|
|
103
|
+
entry.kind = r.verdict === 'contradicted' ? 'contradiction' : r.verdict === 'unknown' ? 'not checked' : r.verdict;
|
|
104
|
+
if (real?.url) entry.url = real.url;
|
|
105
|
+
if (real?.state_reason) entry.state_reason = real.state_reason;
|
|
106
|
+
}
|
|
107
|
+
if (r.verdict === 'unknown') {
|
|
108
|
+
unplaced++;
|
|
109
|
+
print(`${file}:${claim.line} not checked: ${name(ref)} (GitHub has no such issue, or it is private)`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
checked++;
|
|
113
|
+
const when = real.at ? ` ${real.at.slice(0, 10)}` : '';
|
|
114
|
+
const reason = real.state_reason ? ` (${real.state_reason})` : '';
|
|
115
|
+
if (r.verdict === 'look') {
|
|
116
|
+
looks++;
|
|
117
|
+
const action = claim.said === 'queued' ? 'queued work on' : 'waits on';
|
|
118
|
+
print(`${file}:${claim.line} look: ${action} ${name(ref)}, which is ${real.state}${reason}${when} ${real.url}`);
|
|
119
|
+
print(` "${claim.quote}"`);
|
|
120
|
+
} else if (r.verdict === 'contradicted') {
|
|
121
|
+
contradicted++;
|
|
122
|
+
print(`${file}:${claim.line} said ${claim.said}, but ${name(ref)} is ${real.state}${reason}${when} ${real.url}`);
|
|
123
|
+
print(` "${claim.quote}"`);
|
|
124
|
+
}
|
|
87
125
|
}
|
|
126
|
+
if (failure !== null) break;
|
|
88
127
|
}
|
|
89
|
-
|
|
90
|
-
}
|
|
128
|
+
} catch (e) { failure = e instanceof Error ? e.message : String(e); }
|
|
91
129
|
|
|
92
|
-
if (failure) {
|
|
93
|
-
|
|
94
|
-
|
|
130
|
+
if (failure !== null) {
|
|
131
|
+
print(`stopped: ${failure}`);
|
|
132
|
+
print('This run is not complete.');
|
|
133
|
+
if (/rate limit/i.test(failure)) print('Set GITHUB_TOKEN for a higher rate limit, or try again later.');
|
|
95
134
|
// Node's fetch ignores proxy settings unless told otherwise.
|
|
96
|
-
if ((env.HTTPS_PROXY ?? env.https_proxy) && !env.NODE_USE_ENV_PROXY)
|
|
97
|
-
return contradicted ? 1 : 2;
|
|
135
|
+
if ((env.HTTPS_PROXY ?? env.https_proxy) && !env.NODE_USE_ENV_PROXY) print('You have a proxy set: run again with NODE_USE_ENV_PROXY=1.');
|
|
136
|
+
return finish(contradicted ? 1 : 2, failure);
|
|
137
|
+
}
|
|
138
|
+
if (!checked && !unplaced) {
|
|
139
|
+
print('no claims about PR or issue state found');
|
|
140
|
+
if (!skipped.length) return finish(0);
|
|
141
|
+
print(`skipped ${skipped.length} section${skipped.length === 1 ? '' : 's'}: ${skipped.join(', ')}. A section is read only if its heading, and every heading above it, apart from the file's title, starts with Now, Handoff, State, Status or Current.`);
|
|
142
|
+
return finish(0, null, { skipped_sections: skipped });
|
|
98
143
|
}
|
|
99
|
-
if (!checked && !unplaced) { out('no claims about PR or issue state found'); return 0; }
|
|
100
144
|
const note = (looks ? `, ${looks} to look at` : '') + (unplaced ? `, ${unplaced} not checked` : '');
|
|
101
|
-
|
|
102
|
-
return contradicted ? 1 : 0;
|
|
145
|
+
print(`${checked} claim${checked === 1 ? '' : 's'} checked, ${contradicted} contradicted${note}`);
|
|
146
|
+
return finish(contradicted ? 1 : 0);
|
|
103
147
|
}
|
|
104
148
|
|
|
105
149
|
export { readState };
|
package/src/compare.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Decides one claim against what GitHub says. Only a real disagreement is "contradicted".
|
|
2
|
-
// A wait on something finished is a "look":
|
|
2
|
+
// A wait or queued review on something finished is a "look": work may remain after closure.
|
|
3
3
|
export function compare(claim, real) {
|
|
4
4
|
if (!real) return { verdict: 'unknown', claim };
|
|
5
5
|
const { said } = claim;
|
|
@@ -8,7 +8,7 @@ export function compare(claim, real) {
|
|
|
8
8
|
if (said === 'open') ok = state === 'open';
|
|
9
9
|
else if (said === 'closed') ok = state === 'closed' || state === 'merged';
|
|
10
10
|
else if (said === 'merged') ok = state === 'merged' || (kind === 'issue' && state === 'closed');
|
|
11
|
-
else if (said === 'waiting') return { verdict: state === 'open' ? 'ok' : 'look', claim, real };
|
|
11
|
+
else if (said === 'waiting' || said === 'queued') return { verdict: state === 'open' ? 'ok' : 'look', claim, real };
|
|
12
12
|
else return { verdict: 'unknown', claim, real };
|
|
13
13
|
return { verdict: ok ? 'ok' : 'contradicted', claim, real };
|
|
14
14
|
}
|
package/src/extract.mjs
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
|
-
//
|
|
1
|
+
// State and wait grammar, shared by bare statements, labelled lists and fact fields.
|
|
2
2
|
const TOKEN = '[a-z0-9_./-]*#[a-z0-9_.-]+';
|
|
3
3
|
const HAS_REF = new RegExp(TOKEN, 'i');
|
|
4
4
|
const SUBJECT = new RegExp(`^((?:[^#]*\\s)?)(${TOKEN}(?:\\s+and\\s+${TOKEN})*)(.*)$`, 'i');
|
|
5
5
|
const STATE = /^(?:(?:is(?:\s+(?:still|currently|now))?|remains|still|currently|already|was(?=\s+merged\b)|(?:PR|issue)(?:\s+is)?)\s+)?(open|merged|closed)\b(?!-)(.*)$/i;
|
|
6
6
|
const BEFORE_REF = new RegExp(`(?<![\\w-])\\b(open|merged|closed)\\s+((?:PR|issue|pull request)\\s+)?(${TOKEN})(.*)$`, 'i');
|
|
7
7
|
const COUNT_STATE = /^\d+\s+(open|merged|closed)$/i;
|
|
8
|
-
const WAIT = new RegExp(`(?:^|\\s)(?:waits?|waiting)\\s+(?:on|for)\\s+(${TOKEN}|(?:its|the) maintainer)(?:\\s+(?:to merge|for a response))?$`, 'i');
|
|
8
|
+
const WAIT = new RegExp(`(?:^|\\s)(?:waits?|waiting)\\s+(?:on|for)\\s+(${TOKEN}|(?:its|the) maintainer)(?:\\s+(?:to merge|for a response|and\\s+(?:a|an|the|its)\\s+(?!.*\\b(?:is|are|was|were)\\b)[\\w -]+))?$`, 'i');
|
|
9
|
+
// A counted review obligation names queued work, without asserting it is open.
|
|
10
|
+
// Only the obligation and an optional wait qualify; other trailing prose stays quiet.
|
|
11
|
+
const REVIEW_COUNT = '(?:a|an|one|two|three|[1-9][0-9]*)';
|
|
12
|
+
const QUEUED = new RegExp(`^(?:(?:PR|issue|pull request)\\s+)?(${TOKEN})(?:\\s+[a-f0-9]{6,40})?(?:\\s+|,\\s*)(?:still\\s+)?(?:owes\\s+${REVIEW_COUNT}\\s+(?:critic|review)s?|${REVIEW_COUNT}\\s+(?:critic|review)s?\\s+owed)(.*)$`, 'i');
|
|
9
13
|
const LABEL_LIST = new RegExp(`(?:^|:\\s*)(open|merged|closed)(?:\\s+(?:PRs|issues|pull requests))?:\\s*(${TOKEN}(?:\\s*(?:,|and)\\s*${TOKEN})*)$`, 'i');
|
|
10
14
|
|
|
11
15
|
const REF = /^(?:([a-z0-9-]+)\/)?([a-z0-9_.-]+)?#([1-9][0-9]*)$/i;
|
|
16
|
+
const PLANNED = /\b(?:plans?|planned|planning|proposed|intended|expected|ideas?|goals?|draft|to[- ]?dos?|next|later|someday|old|previous|history|example)\b/i;
|
|
12
17
|
const CURRENT_HEADING = /^(?:handoff|state|status|now|current)\b(?!-)/i;
|
|
13
18
|
const PREAMBLE = /^(?:if|unless|once|after|when|until|yesterday|old note|example|suppose|imagine)\b/i;
|
|
14
19
|
const UNCERTAIN = /\b(?:probably|maybe|might|should|could|would|if|unless|when|until|need|get|think|believe|locally|re-?opened)\b|\bonce\s+(?!at\b)\S|n['’]t\b|\bby\s+(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday|tomorrow|next)\b/i;
|
|
15
20
|
const NONSTATE = /\b(?:(?:not|never|no longer)\s+(?:(?:yet|still|currently|already)\s+)?(?:open|merged|closed)|(?:was|were|used to be)\s+(?:(?:still|currently|already)\s+)?(?:open|closed)|(?:open|merged|closed)\s+or\s+(?:open|merged|closed))\b/i;
|
|
21
|
+
const NOT_WAITING = /\b(?:(?:not|never|no\s+longer|no\s+more|stop(?:s|ped)?|done|finished|quit|gave\s+up|nothing(?:\s+is)?|without|instead\s+of)\s+(?:(?:yet|still|currently|really)\s+)?(?:waits?|waiting)|(?:n['’]t)\s+(?:waits?|waiting))\b/i;
|
|
16
22
|
const QUALIFIED_STATE = /^\s+(?:to|into|for|or|after)\b/i;
|
|
17
23
|
// A later comma field must look like a state fact, not "open question".
|
|
18
24
|
const COMMA_STATE_TAIL = /^(?:$|\s+(?:\d|today\b|yesterday\b|last\b|since\b|by\b|as\s+a\s+duplicate\b))/i;
|
|
@@ -93,9 +99,14 @@ function unquoted(raw) {
|
|
|
93
99
|
return result;
|
|
94
100
|
}
|
|
95
101
|
|
|
102
|
+
// "#12 open, thank once on merge": once mid-field in a later comma field belongs to that step, not to the state before it.
|
|
103
|
+
// Keep the field's other uncertainty words, and conditions naming a reference or narrowed by "only".
|
|
104
|
+
const LATER_STEP_ONCE = /,\s*[^,]*?\S\s+once\s[^,]*/g;
|
|
105
|
+
const laterStep = (field) => (field.includes('#') || /\b(?:only|just|not|except|unless)\b/i.test(field) ? field : field.replace(/\bonce\b/i, ''));
|
|
106
|
+
|
|
96
107
|
function claimsFromClause(quote, line) {
|
|
97
108
|
const body = unquoted(quote);
|
|
98
|
-
if (!body.includes('#') || body.includes('?') || UNCERTAIN.test(body) || NONSTATE.test(body)) return [];
|
|
109
|
+
if (!body.includes('#') || body.includes('?') || UNCERTAIN.test(body.replace(LATER_STEP_ONCE, laterStep)) || NONSTATE.test(body)) return [];
|
|
99
110
|
const claims = [];
|
|
100
111
|
const add = (tokens, said) => {
|
|
101
112
|
for (const token of tokens) {
|
|
@@ -140,7 +151,7 @@ function claimsFromClause(quote, line) {
|
|
|
140
151
|
context.list = false;
|
|
141
152
|
}
|
|
142
153
|
const waiting = WAIT.exec(tail);
|
|
143
|
-
if (waiting) add(REF.test(waiting[1]) ? [waiting[1]] : context.subjects, 'waiting');
|
|
154
|
+
if (waiting && !NOT_WAITING.test(text)) add(REF.test(waiting[1]) ? [waiting[1]] : context.subjects, 'waiting');
|
|
144
155
|
const before = BEFORE_REF.exec(text);
|
|
145
156
|
if (before && (!subject || before.index < subject[1].length) &&
|
|
146
157
|
(before[1].toLowerCase() !== 'open' || (before[2] &&
|
|
@@ -165,6 +176,15 @@ function claimsFromClause(quote, line) {
|
|
|
165
176
|
}
|
|
166
177
|
afterComma = delimiter === ',';
|
|
167
178
|
}
|
|
179
|
+
const queued = QUEUED.exec(body.trim().replace(/[.!]$/, ''));
|
|
180
|
+
if (queued) {
|
|
181
|
+
const rest = queued[2].trim();
|
|
182
|
+
const wait = /^(?:,\s*|and\s+)/i.test(rest) ? WAIT.exec(rest.replace(/^(?:,\s*|and\s+)/i, '')) : null;
|
|
183
|
+
const claim = makeClaim(queued[1], 'queued', quote, line);
|
|
184
|
+
// An existing state or self-wait is more specific; avoid two looks for it.
|
|
185
|
+
if ((!rest || wait?.index === 0) && claim && !claims.some((c) =>
|
|
186
|
+
c.ref.owner === claim.ref.owner && c.ref.repo === claim.ref.repo && c.ref.number === claim.ref.number)) claims.unshift(claim);
|
|
187
|
+
}
|
|
168
188
|
return claims;
|
|
169
189
|
}
|
|
170
190
|
|
|
@@ -179,32 +199,48 @@ function claimsFromLine(raw, line) {
|
|
|
179
199
|
return claims;
|
|
180
200
|
}
|
|
181
201
|
|
|
202
|
+
function closes(lines, from, open) {
|
|
203
|
+
let quote = open;
|
|
204
|
+
for (let i = from; i < lines.length && quote !== null; i++) quote = endQuote(lines[i], quote);
|
|
205
|
+
return quote === null;
|
|
206
|
+
}
|
|
207
|
+
|
|
182
208
|
// Aliases and the default repository are resolved by the reader. Preserve the
|
|
183
209
|
// identity written in the note, including refs that the reader cannot place.
|
|
184
|
-
export function extractClaims(text,
|
|
210
|
+
export function extractClaims(text, options = {}) {
|
|
185
211
|
const claims = [];
|
|
186
|
-
let paragraph = [], fence = null, quote = null, comment = false, sections = [], frontMatter = false;
|
|
187
|
-
const flush = () => {
|
|
212
|
+
let paragraph = [], fence = null, quote = null, comment = false, sections = [], frontMatter = false, titled = false, planned = null;
|
|
213
|
+
const flush = (keepLabel = false) => {
|
|
188
214
|
// A wrapped condition, quotation or historical preamble can change the
|
|
189
215
|
// following line. Separate list items may contain independent nonclaims.
|
|
190
216
|
const blocks = (entry) => entry && !entry.claims.length && !entry.list;
|
|
191
217
|
// A historical label ("Old note:") covers the whole list under it.
|
|
192
218
|
const old = paragraph.findIndex((entry) => !entry.list && !entry.claims.length && entry.label);
|
|
193
219
|
paragraph.forEach((entry, i) => {
|
|
194
|
-
|
|
195
|
-
if (
|
|
220
|
+
// A planning list item covers the items nested under it, blank lines included.
|
|
221
|
+
if (planned !== null && entry.indent <= planned) planned = null;
|
|
222
|
+
const nested = planned !== null;
|
|
223
|
+
if (!nested && entry.parent) planned = entry.indent;
|
|
224
|
+
if (nested || (old >= 0 && i > old && entry.list)) return;
|
|
225
|
+
const after = paragraph[i + 1]; // A planning item says nothing of the line before it.
|
|
226
|
+
if (!blocks(paragraph[i - 1]) && (!blocks(after) || after.parent)) claims.push(...entry.claims);
|
|
196
227
|
});
|
|
197
|
-
|
|
228
|
+
// Quote boundaries separate claims without ending a historical label's scope.
|
|
229
|
+
paragraph = keepLabel && old >= 0 ? [paragraph[old]] : [];
|
|
198
230
|
};
|
|
199
|
-
|
|
231
|
+
// The file's level 1 title above a current section does not switch it off.
|
|
232
|
+
const readable = () => sections.every((section, i) => section.current || (section.title && i < sections.length - 1));
|
|
233
|
+
const enterSection = (level, title, name = title) => {
|
|
200
234
|
sections = sections.filter((section) => section.level < level);
|
|
201
235
|
// Unknown headings may introduce plans, examples or history. Only a
|
|
202
236
|
// current section (and current ancestors) permits extraction.
|
|
203
|
-
sections.push({ level, current: CURRENT_HEADING.test(title) });
|
|
237
|
+
sections.push({ level, current: CURRENT_HEADING.test(title), title: level === 1 && !titled && !/^(?:history|archive|old)\b/i.test(title) });
|
|
238
|
+
titled ||= level === 1;
|
|
239
|
+
if (!readable()) options.skipped?.push(name);
|
|
204
240
|
};
|
|
205
241
|
|
|
206
242
|
const lines = text.replace(/^\uFEFF/, '').split(/\r\n|\n|\r/);
|
|
207
|
-
for (
|
|
243
|
+
for (let [index, raw] of lines.entries()) {
|
|
208
244
|
if (index === 0 && /^---\s*$/.test(raw)) {
|
|
209
245
|
frontMatter = true;
|
|
210
246
|
continue;
|
|
@@ -218,39 +254,74 @@ export function extractClaims(text, _options = {}) {
|
|
|
218
254
|
if (close && close[1][0] === fence[0] && close[1].length >= fence.length) fence = null;
|
|
219
255
|
continue;
|
|
220
256
|
}
|
|
257
|
+
// A comment closed on its own line leaves the text around it, so it cannot cost a planning item its scope.
|
|
258
|
+
if (!comment) {
|
|
259
|
+
const bare = raw.replace(/<!--.*?-->/g, '');
|
|
260
|
+
if (bare !== raw && !bare.trim()) continue; // A comment alone on its line is not the blank line that ends a scope.
|
|
261
|
+
raw = bare;
|
|
262
|
+
if (quote === null && /^ {0,3}(?:(?:[-+*]|[0-9]+[.)])\s+)*>/.test(raw)) {
|
|
263
|
+
// End the preceding paragraph, but keep an unmarked prose continuation quoted.
|
|
264
|
+
flush(true);
|
|
265
|
+
paragraph.push({ claims: [], list: false, indent: Infinity, blockquote: true });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
221
269
|
if (comment || raw.includes('<!--')) {
|
|
222
270
|
comment = !raw.includes('-->') || raw.lastIndexOf('<!--') > raw.lastIndexOf('-->');
|
|
223
|
-
paragraph.push({ claims: [], list: false });
|
|
271
|
+
paragraph.push({ claims: [], list: false, indent: Infinity });
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
// Fenced examples do not contain headings, even within a quotation.
|
|
275
|
+
const opening = /^ {0,3}(`{3,}|~{3,})/.exec(raw);
|
|
276
|
+
if (opening) {
|
|
277
|
+
flush();
|
|
278
|
+
fence = opening[1];
|
|
224
279
|
continue;
|
|
225
280
|
}
|
|
226
281
|
const quoted = quote !== null;
|
|
227
282
|
quote = endQuote(raw, quote);
|
|
283
|
+
// A mark inside text that nothing closes was a stray.
|
|
284
|
+
if (!quoted && quote !== null && raw.trim().length > 1 && !closes(lines, index + 1, quote)) quote = null;
|
|
228
285
|
if (quoted || quote !== null) {
|
|
229
|
-
|
|
286
|
+
// A heading inside a quote still changes the section the quote ends in.
|
|
287
|
+
const inner = /^ {0,3}(#{1,6})\s+(.+?)(?:\s+#+)?\s*$/.exec(raw);
|
|
288
|
+
const rule = /^ {0,3}(=+|-+)\s*$/.exec(raw);
|
|
289
|
+
if (inner) enterSection(inner[1].length, inner[2]);
|
|
290
|
+
else if (rule && lines[index - 1]?.trim()) enterSection(rule[1][0] === '=' ? 1 : 2, lines[index - 1].trim());
|
|
291
|
+
paragraph.push({ claims: [], list: false, indent: Infinity });
|
|
230
292
|
continue;
|
|
231
293
|
}
|
|
232
|
-
const opening = /^ {0,3}(`{3,}|~{3,})/.exec(raw);
|
|
233
294
|
const heading = /^ {0,3}(#{1,6})\s+(.+?)(?:\s+#+)?\s*$/.exec(raw);
|
|
234
295
|
const underline = /^ {0,3}(=+|-+)\s*$/.exec(raw);
|
|
235
|
-
if (
|
|
236
|
-
flush();
|
|
237
|
-
fence = opening[1];
|
|
238
|
-
} else if (heading) {
|
|
296
|
+
if (heading) {
|
|
239
297
|
flush();
|
|
240
298
|
enterSection(heading[1].length, heading[2]);
|
|
241
|
-
} else if (underline && paragraph.length) {
|
|
242
|
-
const title = paragraph.
|
|
299
|
+
} else if (underline && paragraph.length && !(underline[1][0] === '-' && paragraph.some((entry) => entry.item))) {
|
|
300
|
+
const title = paragraph.map((entry) => entry.text).filter(Boolean).join(' ');
|
|
301
|
+
// Preserve the conservative reading of multiline headings while naming them.
|
|
302
|
+
const currentTitle = paragraph.length === 1 ? lines[index - 1].trim() : '';
|
|
243
303
|
paragraph = [];
|
|
244
|
-
enterSection(underline[1][0] === '=' ? 1 : 2, title);
|
|
304
|
+
enterSection(underline[1][0] === '=' ? 1 : 2, currentTitle, title);
|
|
305
|
+
} else if (underline && paragraph.length) {
|
|
306
|
+
flush(); // A rule after a list item, not a heading underline.
|
|
245
307
|
} else if (!raw.trim()) {
|
|
246
308
|
flush();
|
|
247
309
|
} else {
|
|
248
|
-
|
|
249
|
-
const
|
|
250
|
-
const
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
paragraph.
|
|
310
|
+
const claims = readable() ? claimsFromLine(raw, index + 1) : [];
|
|
311
|
+
const body = raw.trim().replace(/^(?:[-+*]|[0-9]+[.)])\s+/, '');
|
|
312
|
+
const label = /:\s*$/.test(raw) && PREAMBLE.test(body);
|
|
313
|
+
const bare = body.replace(/[*_`]/g, '');
|
|
314
|
+
const item = /^\s*(?:[-+*]|[0-9]+[.)])\s+/.test(raw);
|
|
315
|
+
if (item && paragraph.at(-1)?.blockquote) flush(true); // A new list is not a quote continuation.
|
|
316
|
+
if (!item && paragraph.at(-1)?.blockquote) { // Every unmarked line of a quoted paragraph stays in the quote.
|
|
317
|
+
paragraph.push({ claims: [], list: false, indent: Infinity, blockquote: true });
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const colon = /:\s*$/.test(bare);
|
|
321
|
+
const parent = item && colon && PLANNED.test(bare);
|
|
322
|
+
// A list item that opens a current list does not silence its first child.
|
|
323
|
+
const list = item && (!colon || CURRENT_HEADING.test(bare));
|
|
324
|
+
paragraph.push({ claims, list, item, label, parent, text: raw.trim(), indent: /^\s*/.exec(raw)[0].length });
|
|
254
325
|
}
|
|
255
326
|
}
|
|
256
327
|
flush();
|
package/src/github.mjs
CHANGED
|
@@ -23,10 +23,11 @@ export async function readState({ owner, repo, number }, { fetchFn = fetch, toke
|
|
|
23
23
|
const d = await res.json();
|
|
24
24
|
if (d.state !== 'open' && d.state !== 'closed') throw new Error(`GitHub gave no state for ${owner}/${repo}#${number}`);
|
|
25
25
|
const url = d.html_url;
|
|
26
|
+
const reason = typeof d.state_reason === 'string' && d.state_reason ? { state_reason: d.state_reason } : {};
|
|
26
27
|
if (d.pull_request) {
|
|
27
28
|
const merged = d.pull_request.merged_at;
|
|
28
|
-
if (merged) return { state: 'merged', kind: 'pull', at: merged, url };
|
|
29
|
-
return { state: d.state, kind: 'pull', at: d.closed_at ?? d.updated_at ?? null, url };
|
|
29
|
+
if (merged) return { state: 'merged', kind: 'pull', at: merged, url, ...reason };
|
|
30
|
+
return { state: d.state, kind: 'pull', at: d.closed_at ?? d.updated_at ?? null, url, ...reason };
|
|
30
31
|
}
|
|
31
|
-
return { state: d.state, kind: 'issue', at: d.closed_at ?? d.created_at ?? null, url };
|
|
32
|
+
return { state: d.state, kind: 'issue', at: d.closed_at ?? d.created_at ?? null, url, ...reason };
|
|
32
33
|
}
|