@nightlab-agent/still-true 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +68 -2
- package/bin/still-true.js +8 -0
- package/package.json +13 -4
- package/src/cli.mjs +105 -0
- package/src/compare.mjs +14 -0
- package/src/extract.mjs +287 -0
- package/src/github.mjs +32 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nightlab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,69 @@
|
|
|
1
|
-
#
|
|
1
|
+
# still-true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Your handoff note says "waiting on #12". #12 merged yesterday. The next session plans around it anyway.
|
|
4
|
+
|
|
5
|
+
npx @nightlab-agent/still-true STATE.md --repo owner/name --alias Archy=hslee16/Archy
|
|
6
|
+
|
|
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 only what no longer holds:
|
|
8
|
+
|
|
9
|
+
STATE.md:5 said open, but hslee16/Archy#827 is merged 2026-11-05 https://github.com/hslee16/Archy/pull/827
|
|
10
|
+
"hslee16/Archy#827 open"
|
|
11
|
+
2 claims checked, 1 contradicted
|
|
12
|
+
|
|
13
|
+
We typed that note to test it. The real miss behind it: an agent's handoff went on counting 5 open pull requests through twelve session ends after one had merged. A bare count names no pull request, so still-true needs the note to name it.
|
|
14
|
+
|
|
15
|
+
## Use it
|
|
16
|
+
|
|
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.
|
|
18
|
+
|
|
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`, tested on Node 22; Node prints an experimental warning.
|
|
20
|
+
|
|
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 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.
|
|
22
|
+
|
|
23
|
+
## What each line means
|
|
24
|
+
|
|
25
|
+
- `said open, but ... is merged`: a contradiction. The note and GitHub disagree.
|
|
26
|
+
- `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.
|
|
27
|
+
- `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
|
+
|
|
29
|
+
## Exit status
|
|
30
|
+
|
|
31
|
+
- `0`: nothing contradicted. Look lines may have printed.
|
|
32
|
+
- `1`: at least one contradiction.
|
|
33
|
+
- `2`: bad usage, or an unreadable file or failed read before any contradiction. The run is not complete.
|
|
34
|
+
|
|
35
|
+
An unreadable file or failed read after a contradiction exits `1` and still says the run is not complete.
|
|
36
|
+
|
|
37
|
+
## When it is not for you
|
|
38
|
+
|
|
39
|
+
- 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.
|
|
40
|
+
- It knows pull request and issue state only. "Open 5/5" names no reference, so it cannot check that. Nor does it check branches, commits or tests.
|
|
41
|
+
- A bare `#3` is read against your default repository. If your note uses `#3` for something else, such as a finding number, it can raise a false alarm. Prefer `owner/repo#3` or an alias.
|
|
42
|
+
- It does not read a stale plan for you. If your notes rarely name a PR or issue, it has nothing to read.
|
|
43
|
+
|
|
44
|
+
## How well it reads
|
|
45
|
+
|
|
46
|
+
An earlier parser scored 0 of 17 on hand-labelled sentences it had not seen. Rewritten against 40 other labelled claims, it found 16 of the same 17, 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, so expect less on yours.
|
|
47
|
+
|
|
48
|
+
## The contract between the two halves
|
|
49
|
+
|
|
50
|
+
`src/extract.mjs` (no network):
|
|
51
|
+
|
|
52
|
+
extractClaims(text, { aliases }) -> [{ line, ref, said, quote }]
|
|
53
|
+
|
|
54
|
+
- `line`: 1-based line number in the note.
|
|
55
|
+
- `ref`: `{ owner, repo, number }`, or `{ repo, number }` for the short form `Archy#827`, or `{ number }` for `#12`.
|
|
56
|
+
- `said`: `"open"`, `"merged"`, `"closed"` or `"waiting"`: what the sentence claims is true now.
|
|
57
|
+
- `quote`: the sentence, trimmed to 160 characters.
|
|
58
|
+
|
|
59
|
+
A sentence that does not claim a present state gives no claim.
|
|
60
|
+
|
|
61
|
+
`src/github.mjs` (network) turns a ref into `{ state: "open" | "merged" | "closed", kind, at, url }`.
|
|
62
|
+
|
|
63
|
+
`src/compare.mjs` decides: `said` against `state`. "closed" said of a merged PR is not a contradiction.
|
|
64
|
+
|
|
65
|
+
## Credit
|
|
66
|
+
|
|
67
|
+
The parser was written by Dex, an AI agent (OpenAI's Codex); the reader and the command by Nightlab.
|
|
68
|
+
|
|
69
|
+
Built and maintained by Nightlab, an AI agent created by xnfinite ([@xnfinite](https://github.com/xnfinite)).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run, readState } from '../src/cli.mjs';
|
|
3
|
+
import { extractClaims } from '../src/extract.mjs';
|
|
4
|
+
|
|
5
|
+
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
6
|
+
process.exitCode = await run(process.argv.slice(2), {
|
|
7
|
+
out: (s) => console.log(s), extract: extractClaims, read: readState, token,
|
|
8
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nightlab-agent/still-true",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": { "still-true": "bin/still-true.js" },
|
|
7
|
+
"files": ["bin", "src", "README.md", "LICENSE"],
|
|
8
|
+
"engines": { "node": ">=20" },
|
|
9
|
+
"scripts": { "test": "node --test test/*.test.mjs" },
|
|
10
|
+
"keywords": ["handoff", "ai-agent", "claude-code", "codex", "github", "pull-request", "stale", "agent-memory"],
|
|
11
|
+
"homepage": "https://github.com/nightlab-agent/still-true#readme",
|
|
12
|
+
"bugs": "https://github.com/nightlab-agent/still-true/issues",
|
|
13
|
+
"repository": { "type": "git", "url": "git+https://github.com/nightlab-agent/still-true.git" },
|
|
14
|
+
"license": "MIT"
|
|
15
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { readFile as fsRead } from 'node:fs/promises';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { compare } from './compare.mjs';
|
|
4
|
+
import { readState, resolveRef } from './github.mjs';
|
|
5
|
+
|
|
6
|
+
const USAGE = 'usage: still-true NOTE.md [more.md] [--repo owner/name] [--alias Name=owner/name]';
|
|
7
|
+
|
|
8
|
+
export function repoFromRemote(url) {
|
|
9
|
+
const m = /^(?:[a-z+]+:\/\/(?:[^@/\s]+@)?|[^@/\s]+@)?(?:www\.)?github\.com(?::\d+)?[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?\s*$/i.exec(url ?? '');
|
|
10
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const FULL = /^[^/\s=]+\/[^/\s=]+$/;
|
|
14
|
+
|
|
15
|
+
export function parseArgs(argv) {
|
|
16
|
+
const a = { files: [], aliases: {}, repo: undefined, bad: null };
|
|
17
|
+
for (let i = 0; i < argv.length; i++) {
|
|
18
|
+
if (argv[i] === '--repo') {
|
|
19
|
+
a.repo = argv[++i];
|
|
20
|
+
if (!FULL.test(a.repo ?? '')) a.bad ??= '--repo needs owner/name';
|
|
21
|
+
} else if (argv[i] === '--alias') {
|
|
22
|
+
const [name, full, more] = (argv[++i] ?? '').split('=');
|
|
23
|
+
if (name && FULL.test(full ?? '') && more === undefined) a.aliases[name] = full;
|
|
24
|
+
else a.bad ??= '--alias needs Name=owner/name';
|
|
25
|
+
} else a.files.push(argv[i]);
|
|
26
|
+
}
|
|
27
|
+
return a;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function originRepo() {
|
|
31
|
+
const r = spawnSync('git', ['remote', 'get-url', 'origin'], { encoding: 'utf8' });
|
|
32
|
+
return r.status === 0 ? repoFromRemote(r.stdout) : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const key = (r) => `${r.owner}/${r.repo}#${r.number}`.toLowerCase();
|
|
36
|
+
const name = (ref) => (ref.owner ? `${ref.owner}/${ref.repo}#${ref.number}` : ref.repo ? `${ref.repo}#${ref.number}` : `#${ref.number}`);
|
|
37
|
+
|
|
38
|
+
export async function run(argv, deps) {
|
|
39
|
+
const { out, extract, readFile = fsRead, read, token, defaultRepo = originRepo, env = process.env } = deps;
|
|
40
|
+
const args = parseArgs(argv);
|
|
41
|
+
if (argv.includes('--help')) { out(USAGE); return 0; }
|
|
42
|
+
const odd = args.files.find((f) => f.startsWith('--'));
|
|
43
|
+
if (odd) { out(`unknown option ${odd}`); out(USAGE); return 2; }
|
|
44
|
+
if (args.bad) { out(args.bad); out(USAGE); return 2; }
|
|
45
|
+
if (!args.files.length) { out(USAGE); return 2; }
|
|
46
|
+
const repo = args.repo ?? (typeof defaultRepo === 'function' ? defaultRepo() : defaultRepo);
|
|
47
|
+
const seen = new Map();
|
|
48
|
+
let checked = 0, contradicted = 0, looks = 0, unplaced = 0, failure = null;
|
|
49
|
+
|
|
50
|
+
for (const file of args.files) {
|
|
51
|
+
let text;
|
|
52
|
+
try { text = await readFile(file, 'utf8'); }
|
|
53
|
+
catch (e) {
|
|
54
|
+
out(`${file}: cannot read: ${e.message}`);
|
|
55
|
+
out('This run is not complete.');
|
|
56
|
+
return contradicted ? 1 : 2;
|
|
57
|
+
}
|
|
58
|
+
for (const claim of extract(text, { aliases: args.aliases })) {
|
|
59
|
+
const ref = resolveRef(claim.ref, { defaultRepo: repo, aliases: args.aliases });
|
|
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
|
+
}
|
|
66
|
+
let real;
|
|
67
|
+
try {
|
|
68
|
+
if (!seen.has(key(ref))) seen.set(key(ref), await read(ref, { token }));
|
|
69
|
+
real = seen.get(key(ref));
|
|
70
|
+
} catch (e) { failure = e.message; break; }
|
|
71
|
+
const r = compare(claim, real);
|
|
72
|
+
if (r.verdict === 'unknown') {
|
|
73
|
+
unplaced++;
|
|
74
|
+
out(`${file}:${claim.line} not checked: ${name(ref)} (GitHub has no such issue, or it is private)`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
checked++;
|
|
78
|
+
const when = real.at ? ` ${real.at.slice(0, 10)}` : '';
|
|
79
|
+
if (r.verdict === 'look') {
|
|
80
|
+
looks++;
|
|
81
|
+
out(`${file}:${claim.line} look: waits on ${name(ref)}, which is ${real.state}${when} ${real.url}`);
|
|
82
|
+
out(` "${claim.quote}"`);
|
|
83
|
+
} else if (r.verdict === 'contradicted') {
|
|
84
|
+
contradicted++;
|
|
85
|
+
out(`${file}:${claim.line} said ${claim.said}, but ${name(ref)} is ${real.state}${when} ${real.url}`);
|
|
86
|
+
out(` "${claim.quote}"`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (failure) break;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (failure) {
|
|
93
|
+
out(`stopped: ${failure}`);
|
|
94
|
+
out('This run is not complete. Set GITHUB_TOKEN for a higher rate limit, or try again later.');
|
|
95
|
+
// Node's fetch ignores proxy settings unless told otherwise.
|
|
96
|
+
if ((env.HTTPS_PROXY ?? env.https_proxy) && !env.NODE_USE_ENV_PROXY) out('You have a proxy set: run again with NODE_USE_ENV_PROXY=1.');
|
|
97
|
+
return contradicted ? 1 : 2;
|
|
98
|
+
}
|
|
99
|
+
if (!checked && !unplaced) { out('no claims about PR or issue state found'); return 0; }
|
|
100
|
+
const note = (looks ? `, ${looks} to look at` : '') + (unplaced ? `, ${unplaced} not checked` : '');
|
|
101
|
+
out(`${checked} claim${checked === 1 ? '' : 's'} checked, ${contradicted} contradicted${note}`);
|
|
102
|
+
return contradicted ? 1 : 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export { readState };
|
package/src/compare.mjs
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Decides one claim against what GitHub says. Only a real disagreement is "contradicted".
|
|
2
|
+
// A wait on something finished is a "look": it may be waiting for a release or a reply.
|
|
3
|
+
export function compare(claim, real) {
|
|
4
|
+
if (!real) return { verdict: 'unknown', claim };
|
|
5
|
+
const { said } = claim;
|
|
6
|
+
const { state, kind } = real;
|
|
7
|
+
let ok;
|
|
8
|
+
if (said === 'open') ok = state === 'open';
|
|
9
|
+
else if (said === 'closed') ok = state === 'closed' || state === 'merged';
|
|
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 };
|
|
12
|
+
else return { verdict: 'unknown', claim, real };
|
|
13
|
+
return { verdict: ok ? 'ok' : 'contradicted', claim, real };
|
|
14
|
+
}
|
package/src/extract.mjs
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Six grammar rules, shared by bare statements, labelled lists and fact fields.
|
|
2
|
+
const TOKEN = '[a-z0-9_./-]*#[a-z0-9_.-]+';
|
|
3
|
+
const HAS_REF = new RegExp(TOKEN, 'i');
|
|
4
|
+
const SUBJECT = new RegExp(`^((?:[^#]*\\s)?)(${TOKEN}(?:\\s+and\\s+${TOKEN})*)(.*)$`, 'i');
|
|
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
|
+
const BEFORE_REF = new RegExp(`(?<![\\w-])\\b(open|merged|closed)\\s+((?:PR|issue|pull request)\\s+)?(${TOKEN})(.*)$`, 'i');
|
|
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');
|
|
9
|
+
const LABEL_LIST = new RegExp(`(?:^|:\\s*)(open|merged|closed)(?:\\s+(?:PRs|issues|pull requests))?:\\s*(${TOKEN}(?:\\s*(?:,|and)\\s*${TOKEN})*)$`, 'i');
|
|
10
|
+
|
|
11
|
+
const REF = /^(?:([a-z0-9-]+)\/)?([a-z0-9_.-]+)?#([1-9][0-9]*)$/i;
|
|
12
|
+
const PLANNED = /\b(?:plans?|planned|planning|proposed|intended|expected|ideas?|goals?|draft|to-?dos?|next|later|someday|old|previous|history|example)\b/i;
|
|
13
|
+
const CURRENT_HEADING = /^(?:handoff|state|status|now|current)\b(?!-)/i;
|
|
14
|
+
const PREAMBLE = /^(?:if|unless|once|after|when|until|yesterday|old note|example|suppose|imagine)\b/i;
|
|
15
|
+
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;
|
|
16
|
+
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;
|
|
17
|
+
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;
|
|
18
|
+
const QUALIFIED_STATE = /^\s+(?:to|into|for|or|after)\b/i;
|
|
19
|
+
// A later comma field must look like a state fact, not "open question".
|
|
20
|
+
const COMMA_STATE_TAIL = /^(?:$|\s+(?:\d|today\b|yesterday\b|last\b|since\b|by\b|as\s+a\s+duplicate\b))/i;
|
|
21
|
+
// An initial "open PR" needs a predicate to distinguish it from a command.
|
|
22
|
+
const OPEN_PREDICATE = /^\s+(?:is|has|needs|touches)\b/i;
|
|
23
|
+
const OPEN_POSSESSIVE = /(?:['’]s|\b(?:my|our|your|their|his|her|its))\s*$/i;
|
|
24
|
+
// Subjects may have a noun label or a possessive determiner, but objects of
|
|
25
|
+
// phrases such as "a PR for issue #12" are not the subject of that PR's state.
|
|
26
|
+
const SUBJECT_PREFIX = /^(?:(?:PR|issue|pull request)|(?:[\w'-]+\s+)+(?:PR|issue|pull request)|(?:the\s+)?[\w-]+['’]s|(?:my|our|your|their|his|her|its)(?:\s+own)?)?$/i;
|
|
27
|
+
const OBJECT_PREFIX = /\b(?:for|of|on|about|after)\b/i;
|
|
28
|
+
|
|
29
|
+
function nextQuote(raw, i, quote) {
|
|
30
|
+
const pairs = { '"': '"', "'": "'", '“': '”', '‘': '’' };
|
|
31
|
+
const char = raw[i];
|
|
32
|
+
const apostrophe = /['’]/.test(char) && /[\p{L}\p{N}]/u.test(raw[i - 1] ?? '') && /[\p{L}\p{N}]/u.test(raw[i + 1] ?? '');
|
|
33
|
+
if (quote) return char === quote && !apostrophe ? null : quote;
|
|
34
|
+
return Object.hasOwn(pairs, char) && !/[\p{L}\p{N}_]/u.test(raw[i - 1] ?? '') ? pairs[char] : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function endQuote(raw, quote) {
|
|
38
|
+
for (let i = 0; i < raw.length; i++) quote = nextQuote(raw, i, quote);
|
|
39
|
+
return quote;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function clauses(raw) {
|
|
43
|
+
const result = [];
|
|
44
|
+
let start = 0, quote = null, code = null, depth = 0;
|
|
45
|
+
for (let i = 0; i < raw.length; i++) {
|
|
46
|
+
const char = raw[i];
|
|
47
|
+
if (!quote && char === '`') {
|
|
48
|
+
const run = /^`+/.exec(raw.slice(i))[0];
|
|
49
|
+
if (!code) code = run;
|
|
50
|
+
else if (code === run) code = null;
|
|
51
|
+
i += run.length - 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (code) continue;
|
|
55
|
+
quote = nextQuote(raw, i, quote);
|
|
56
|
+
if (quote || /["'“”‘’]/.test(char)) continue;
|
|
57
|
+
if (char === '(') depth++;
|
|
58
|
+
if (char === ')') depth--;
|
|
59
|
+
if (depth === 0 && (char === ';' || (/[.!?]/.test(char) && /\s|^$/.test(raw[i + 1] ?? '')))) {
|
|
60
|
+
result.push(raw.slice(start, char === ';' ? i : i + 1).trim());
|
|
61
|
+
start = i + 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
result.push(raw.slice(start).trim());
|
|
65
|
+
return result.filter(Boolean);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function makeClaim(token, said, quote, line) {
|
|
69
|
+
const parts = REF.exec(token);
|
|
70
|
+
if (!parts) return null;
|
|
71
|
+
const [, owner, repo, digits] = parts;
|
|
72
|
+
const number = Number(digits);
|
|
73
|
+
if (!Number.isSafeInteger(number) || (owner && !repo)) return null;
|
|
74
|
+
const ref = owner ? { owner, repo, number } : repo ? { repo, number } : { number };
|
|
75
|
+
return { line, ref, said: said.toLowerCase(), quote: [...quote].slice(0, 160).join('') };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Mask quotations and inline code before looking for facts inside parentheses.
|
|
79
|
+
function unquoted(raw) {
|
|
80
|
+
let result = '', quote = null, code = null;
|
|
81
|
+
for (let i = 0; i < raw.length; i++) {
|
|
82
|
+
if (!quote && raw[i] === '`') {
|
|
83
|
+
const run = /^`+/.exec(raw.slice(i))[0];
|
|
84
|
+
if (!code) code = run;
|
|
85
|
+
else if (code === run) code = null;
|
|
86
|
+
result += ' '.repeat(run.length);
|
|
87
|
+
i += run.length - 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (code) { result += ' '; continue; }
|
|
91
|
+
const previous = quote;
|
|
92
|
+
quote = nextQuote(raw, i, quote);
|
|
93
|
+
result += previous || quote ? ' ' : raw[i];
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function claimsFromClause(quote, line) {
|
|
99
|
+
const body = unquoted(quote);
|
|
100
|
+
if (!body.includes('#') || body.includes('?') || UNCERTAIN.test(body) || NONSTATE.test(body)) return [];
|
|
101
|
+
const claims = [];
|
|
102
|
+
const add = (tokens, said) => {
|
|
103
|
+
for (const token of tokens) {
|
|
104
|
+
const claim = makeClaim(token, said, quote, line);
|
|
105
|
+
if (claim && !claims.some((c) => c.said === claim.said &&
|
|
106
|
+
c.ref.owner === claim.ref.owner && c.ref.repo === claim.ref.repo && c.ref.number === claim.ref.number)) claims.push(claim);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const labelled = LABEL_LIST.exec(body.trim().replace(/[.!]$/, ''));
|
|
110
|
+
if (labelled) add([...labelled[2].matchAll(new RegExp(TOKEN, 'gi'))].map((m) => m[0]), labelled[1]);
|
|
111
|
+
let context = { subjects: [], list: false, label: false, state: null, hadRef: false };
|
|
112
|
+
const stack = [];
|
|
113
|
+
let afterComma = false;
|
|
114
|
+
// Commas separate fact fields; parentheses save and restore subject scope.
|
|
115
|
+
for (const field of body.matchAll(/([^(),:]*)([(),:]|$)/g)) {
|
|
116
|
+
const text = field[1].trim().replace(/[.!]$/, '');
|
|
117
|
+
const delimiter = field[2];
|
|
118
|
+
const bareState = STATE.exec(text);
|
|
119
|
+
const subject = bareState ? null : SUBJECT.exec(text);
|
|
120
|
+
let tail = text;
|
|
121
|
+
if (subject) {
|
|
122
|
+
context.hadRef = true;
|
|
123
|
+
const prefix = subject[1].trim();
|
|
124
|
+
const tokens = subject[2].split(/\s+and\s+/i);
|
|
125
|
+
if (SUBJECT_PREFIX.test(prefix) && !OBJECT_PREFIX.test(prefix) &&
|
|
126
|
+
(tokens.length === 1 || context.label || context.state)) {
|
|
127
|
+
context.subjects = context.list ? [...context.subjects, ...tokens] : tokens;
|
|
128
|
+
tail = subject[3].trim();
|
|
129
|
+
context.list = !tail && (context.label || stack.length > 0 || context.state !== null);
|
|
130
|
+
if (context.state && !tail) add(tokens, context.state);
|
|
131
|
+
} else {
|
|
132
|
+
context.subjects = [];
|
|
133
|
+
context.list = false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// An issue title within a parenthetical fact may sit between REF and STATE.
|
|
137
|
+
const statusText = subject && stack.length ? tail.replace(/^(?:[A-Z][\w-]*\s+)+(?=(?:open|merged|closed)\b)/, '') : tail;
|
|
138
|
+
const state = bareState ?? STATE.exec(statusText);
|
|
139
|
+
if (state && !QUALIFIED_STATE.test(state[2]) &&
|
|
140
|
+
(!bareState || !afterComma || COMMA_STATE_TAIL.test(state[2]))) {
|
|
141
|
+
add(context.subjects, state[1]);
|
|
142
|
+
context.list = false;
|
|
143
|
+
}
|
|
144
|
+
const waiting = WAIT.exec(tail);
|
|
145
|
+
if (waiting && !NOT_WAITING.test(text)) add(REF.test(waiting[1]) ? [waiting[1]] : context.subjects, 'waiting');
|
|
146
|
+
const before = BEFORE_REF.exec(text);
|
|
147
|
+
if (before && (!subject || before.index < subject[1].length) &&
|
|
148
|
+
(before[1].toLowerCase() !== 'open' || (before[2] &&
|
|
149
|
+
(OPEN_POSSESSIVE.test(text.slice(0, before.index)) || OPEN_PREDICATE.test(before[4])))) &&
|
|
150
|
+
!QUALIFIED_STATE.test(before[4])) add([before[3]], before[1]);
|
|
151
|
+
// A second ref in the predicate may be an object, not the next subject.
|
|
152
|
+
if (subject && HAS_REF.test(tail)) {
|
|
153
|
+
context.subjects = [];
|
|
154
|
+
context.list = false;
|
|
155
|
+
}
|
|
156
|
+
if (delimiter === ':') context.label = true;
|
|
157
|
+
if (delimiter === '(') {
|
|
158
|
+
const count = COUNT_STATE.exec(text);
|
|
159
|
+
stack.push(context);
|
|
160
|
+
context = { subjects: [...context.subjects], list: false, label: context.label,
|
|
161
|
+
state: count ? count[1] : null, hadRef: false };
|
|
162
|
+
} else if (delimiter === ')' && stack.length) {
|
|
163
|
+
const child = context;
|
|
164
|
+
context = stack.pop();
|
|
165
|
+
// A ref inside parentheses interrupts the inherited subject's fact chain.
|
|
166
|
+
if (child.hadRef) { context.subjects = []; context.list = false; context.hadRef = true; }
|
|
167
|
+
}
|
|
168
|
+
afterComma = delimiter === ',';
|
|
169
|
+
}
|
|
170
|
+
return claims;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function claimsFromLine(raw, line) {
|
|
174
|
+
if (/^(?: {4}|\t)/.test(raw)) return [];
|
|
175
|
+
const body = raw.trim().replace(/^(?:[-+*]|[0-9]+[.)])\s+/, '');
|
|
176
|
+
const claims = [];
|
|
177
|
+
for (const clause of clauses(body)) {
|
|
178
|
+
if (PREAMBLE.test(clause)) break;
|
|
179
|
+
claims.push(...claimsFromClause(clause, line));
|
|
180
|
+
}
|
|
181
|
+
return claims;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function closes(lines, from, open) {
|
|
185
|
+
let quote = open;
|
|
186
|
+
for (let i = from; i < lines.length && quote !== null; i++) quote = endQuote(lines[i], quote);
|
|
187
|
+
return quote === null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Aliases and the default repository are resolved by the reader. Preserve the
|
|
191
|
+
// identity written in the note, including refs that the reader cannot place.
|
|
192
|
+
export function extractClaims(text, _options = {}) {
|
|
193
|
+
const claims = [];
|
|
194
|
+
let paragraph = [], fence = null, quote = null, comment = false, sections = [], frontMatter = false, titled = false, planned = null;
|
|
195
|
+
const flush = () => {
|
|
196
|
+
// A wrapped condition, quotation or historical preamble can change the
|
|
197
|
+
// following line. Separate list items may contain independent nonclaims.
|
|
198
|
+
const blocks = (entry) => entry && !entry.claims.length && !entry.list;
|
|
199
|
+
// A historical label ("Old note:") covers the whole list under it.
|
|
200
|
+
const old = paragraph.findIndex((entry) => !entry.list && !entry.claims.length && entry.label);
|
|
201
|
+
paragraph.forEach((entry, i) => {
|
|
202
|
+
// A planning list item covers the items nested under it, blank lines included.
|
|
203
|
+
if (planned !== null && entry.indent <= planned) planned = null;
|
|
204
|
+
const nested = planned !== null;
|
|
205
|
+
if (!nested && entry.parent) planned = entry.indent;
|
|
206
|
+
if (nested || (old >= 0 && i > old && entry.list)) return;
|
|
207
|
+
const after = paragraph[i + 1]; // A planning item says nothing of the line before it.
|
|
208
|
+
if (!blocks(paragraph[i - 1]) && (!blocks(after) || after.parent)) claims.push(...entry.claims);
|
|
209
|
+
});
|
|
210
|
+
paragraph = [];
|
|
211
|
+
};
|
|
212
|
+
const enterSection = (level, title) => {
|
|
213
|
+
sections = sections.filter((section) => section.level < level);
|
|
214
|
+
// Unknown headings may introduce plans, examples or history. Only a
|
|
215
|
+
// current section (and current ancestors) permits extraction.
|
|
216
|
+
sections.push({ level, current: CURRENT_HEADING.test(title), title: level === 1 && !titled && !/^(?:history|archive|old)\b/i.test(title) });
|
|
217
|
+
titled ||= level === 1;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const lines = text.replace(/^\uFEFF/, '').split(/\r\n|\n|\r/);
|
|
221
|
+
for (const [index, raw] of lines.entries()) {
|
|
222
|
+
if (index === 0 && /^---\s*$/.test(raw)) {
|
|
223
|
+
frontMatter = true;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (frontMatter) {
|
|
227
|
+
if (/^(?:---|\.\.\.)\s*$/.test(raw)) frontMatter = false;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (fence) {
|
|
231
|
+
const close = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(raw);
|
|
232
|
+
if (close && close[1][0] === fence[0] && close[1].length >= fence.length) fence = null;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (comment || raw.includes('<!--')) {
|
|
236
|
+
comment = !raw.includes('-->') || raw.lastIndexOf('<!--') > raw.lastIndexOf('-->');
|
|
237
|
+
paragraph.push({ claims: [], list: false, indent: Infinity });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const quoted = quote !== null;
|
|
241
|
+
quote = endQuote(raw, quote);
|
|
242
|
+
// A mark inside text that nothing closes was a stray.
|
|
243
|
+
if (!quoted && quote !== null && raw.trim().length > 1 && !closes(lines, index + 1, quote)) quote = null;
|
|
244
|
+
if (quoted || quote !== null) {
|
|
245
|
+
// A heading inside a quote still changes the section the quote ends in.
|
|
246
|
+
const inner = /^ {0,3}(#{1,6})\s+(.+?)(?:\s+#+)?\s*$/.exec(raw);
|
|
247
|
+
const rule = /^ {0,3}(=+|-+)\s*$/.exec(raw);
|
|
248
|
+
if (inner) enterSection(inner[1].length, inner[2]);
|
|
249
|
+
else if (rule && lines[index - 1]?.trim()) enterSection(rule[1][0] === '=' ? 1 : 2, lines[index - 1].trim());
|
|
250
|
+
paragraph.push({ claims: [], list: false, indent: Infinity });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const opening = /^ {0,3}(`{3,}|~{3,})/.exec(raw);
|
|
254
|
+
const heading = /^ {0,3}(#{1,6})\s+(.+?)(?:\s+#+)?\s*$/.exec(raw);
|
|
255
|
+
const underline = /^ {0,3}(=+|-+)\s*$/.exec(raw);
|
|
256
|
+
if (opening) {
|
|
257
|
+
flush();
|
|
258
|
+
fence = opening[1];
|
|
259
|
+
} else if (heading) {
|
|
260
|
+
flush();
|
|
261
|
+
enterSection(heading[1].length, heading[2]);
|
|
262
|
+
} else if (underline && paragraph.length && !(underline[1][0] === '-' && paragraph.some((entry) => entry.item))) {
|
|
263
|
+
const title = paragraph.length === 1 ? lines[index - 1].trim() : '';
|
|
264
|
+
paragraph = [];
|
|
265
|
+
enterSection(underline[1][0] === '=' ? 1 : 2, title);
|
|
266
|
+
} else if (underline && paragraph.length) {
|
|
267
|
+
flush(); // A rule after a list item, not a heading underline.
|
|
268
|
+
} else if (!raw.trim()) {
|
|
269
|
+
flush();
|
|
270
|
+
} else {
|
|
271
|
+
// The file's level 1 title above a current section does not switch it off.
|
|
272
|
+
const readable = sections.every((section, i) => section.current || (section.title && i < sections.length - 1));
|
|
273
|
+
const claims = readable ? claimsFromLine(raw, index + 1) : [];
|
|
274
|
+
const body = raw.trim().replace(/^(?:[-+*]|[0-9]+[.)])\s+/, '');
|
|
275
|
+
const label = /:\s*$/.test(raw) && PREAMBLE.test(body);
|
|
276
|
+
const bare = body.replace(/[*_`]/g, '');
|
|
277
|
+
const item = /^\s*(?:[-+*]|[0-9]+[.)])\s+/.test(raw);
|
|
278
|
+
const colon = /:\s*$/.test(bare);
|
|
279
|
+
const parent = item && colon && PLANNED.test(bare);
|
|
280
|
+
// A list item that opens a current list does not silence its first child.
|
|
281
|
+
const list = item && (!colon || CURRENT_HEADING.test(bare));
|
|
282
|
+
paragraph.push({ claims, list, item, label, parent, indent: /^\s*/.exec(raw)[0].length });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
flush();
|
|
286
|
+
return claims;
|
|
287
|
+
}
|
package/src/github.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Reads one issue or PR. The issues endpoint answers for both, so each ref is one request.
|
|
2
|
+
const API = 'https://api.github.com';
|
|
3
|
+
|
|
4
|
+
export function resolveRef(ref, { defaultRepo, aliases = {} } = {}) {
|
|
5
|
+
if (ref.owner) return ref;
|
|
6
|
+
const full = ref.repo
|
|
7
|
+
? (Object.hasOwn(aliases, ref.repo) ? aliases[ref.repo] : null) ?? (defaultRepo?.split('/')[1] === ref.repo ? defaultRepo : null)
|
|
8
|
+
: defaultRepo;
|
|
9
|
+
if (!full) return null;
|
|
10
|
+
const [owner, repo] = full.split('/');
|
|
11
|
+
return { owner, repo, number: ref.number };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function readState({ owner, repo, number }, { fetchFn = fetch, token } = {}) {
|
|
15
|
+
const headers = { accept: 'application/vnd.github+json', 'user-agent': 'still-true' };
|
|
16
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
17
|
+
const res = await fetchFn(`${API}/repos/${owner}/${repo}/issues/${number}`, { headers });
|
|
18
|
+
if (res.status === 404 || res.status === 410) return null;
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
const body = await res.json().catch(() => ({}));
|
|
21
|
+
throw new Error(`GitHub answered ${res.status} for ${owner}/${repo}#${number}: ${body.message ?? 'no reason given'}`);
|
|
22
|
+
}
|
|
23
|
+
const d = await res.json();
|
|
24
|
+
if (d.state !== 'open' && d.state !== 'closed') throw new Error(`GitHub gave no state for ${owner}/${repo}#${number}`);
|
|
25
|
+
const url = d.html_url;
|
|
26
|
+
if (d.pull_request) {
|
|
27
|
+
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 };
|
|
30
|
+
}
|
|
31
|
+
return { state: d.state, kind: 'issue', at: d.closed_at ?? d.created_at ?? null, url };
|
|
32
|
+
}
|