heeded 0.1.0 → 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/README.md +48 -28
- package/dist/check.js +2 -0
- package/dist/cli.js +57 -14
- package/dist/report.js +15 -2
- package/dist/rules.js +27 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,16 +12,16 @@ $ npx heeded
|
|
|
12
12
|
3 files · 41 rules · 12 checked · 29 need a judge
|
|
13
13
|
|
|
14
14
|
CLAUDE.md
|
|
15
|
-
✗ never git add -A kept 95% of 214 broken 9× last 2 days ago
|
|
15
|
+
✗ :12 never git add -A kept 95% of 214 broken 9× last 2 days ago
|
|
16
16
|
nov 8 19df6c20 git add -A && git commit -m "fix: …"
|
|
17
17
|
nov 2 8c0e8fab git add -A
|
|
18
|
-
✓ never force-push kept 100% of 61
|
|
19
|
-
○ use pnpm, not npm never applied ~40 tokens every session
|
|
20
|
-
· failing test first kept 80% of 20 judged broken 4× last 3 days ago
|
|
18
|
+
✓ :14 never force-push kept 100% of 61
|
|
19
|
+
○ :21 use pnpm, not npm never applied ~40 tokens every session
|
|
20
|
+
· :30 failing test first kept 80% of 20 judged broken 4× last 3 days ago
|
|
21
21
|
~/.claude/CLAUDE.md
|
|
22
|
-
✗ answer in italian kept 96% of 3,812 broken 152× last today
|
|
22
|
+
✗ :3 answer in italian kept 96% of 3,812 broken 152× last today
|
|
23
23
|
after compaction kept 69% of 410
|
|
24
|
-
○ ask before deploys never applied in 8 judged ~30 tokens every session
|
|
24
|
+
○ :17 ask before deploys never applied in 8 judged ~30 tokens every session
|
|
25
25
|
|
|
26
26
|
kept by context depth 0–100k 100–200k 200–400k 400k+
|
|
27
27
|
98% 97% 91% 84%
|
|
@@ -44,6 +44,8 @@ $ npx heeded
|
|
|
44
44
|
- `after compaction`: the same rule, counted only after claude code compacted the context.
|
|
45
45
|
- each rule is under the file it is written in: the folder's files first, then the others (`~/.claude/CLAUDE.md` is
|
|
46
46
|
loaded in every project, so a rule there can be never applied here and needed elsewhere).
|
|
47
|
+
- `:12`: the line the rule starts on in that file, as the file is now. no number: its words aren't in the file any
|
|
48
|
+
more.
|
|
47
49
|
- `kept by context depth`: every rule together, by how full the context was at the time.
|
|
48
50
|
- `never applied in 8 judged`: none of the 8 cases the judge read was about the rule.
|
|
49
51
|
- `rules no call or reply shows`: rules about how to work ("plan before coding"), which no single call or reply can
|
|
@@ -51,6 +53,34 @@ $ npx heeded
|
|
|
51
53
|
|
|
52
54
|
## use
|
|
53
55
|
|
|
56
|
+
needs node 22 or later and claude code, signed in: heeded calls your own `claude` to read rules and to judge.
|
|
57
|
+
|
|
58
|
+
1. **run it in a project** you've worked on with claude code:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
cd ~/code/my-app
|
|
62
|
+
npx heeded
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
2. **say yes, or not yet.** reading the rules and judging cost tokens from your claude account, so heeded shows the
|
|
66
|
+
estimate and asks before spending anything:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
11 rule sections to read, then cases to judge · claude sonnet and haiku · up to ~175k tokens
|
|
70
|
+
go ahead? [y/N]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`y` reads the rules, judges a first round of cases and prints the report. enter spends nothing: the report shows
|
|
74
|
+
what is already cached, on a first run the sessions found and the sections still to read.
|
|
75
|
+
|
|
76
|
+
3. **act on the report.** each rule is under its file, with its line (`:12`). a rule broken often may need
|
|
77
|
+
rewriting, or saying why it matters. a rule never applied costs its tokens in every session: move it to the project
|
|
78
|
+
it's for, or remove it. `heeded rules` shows how each rule is checked, and the ones no call or reply can show.
|
|
79
|
+
|
|
80
|
+
4. **run it again** whenever you like: after more sessions, or after editing an instruction file. every run reads the
|
|
81
|
+
transcripts again, for free, and asks only when something new costs tokens. what was read or judged is cached and
|
|
82
|
+
never paid for twice; with nothing new, the report ends `up to date`.
|
|
83
|
+
|
|
54
84
|
```sh
|
|
55
85
|
npx heeded # the report, for the claude code sessions run in this folder
|
|
56
86
|
npx heeded rules # the rules found, and how each one is checked
|
|
@@ -58,7 +88,7 @@ npx heeded --all # judge every case waiting, not one run's 150k tokens
|
|
|
58
88
|
npx heeded --json # everything, as json
|
|
59
89
|
```
|
|
60
90
|
|
|
61
|
-
|
|
91
|
+
to type `heeded` instead of `npx heeded`, install it: `npm install -g heeded`.
|
|
62
92
|
|
|
63
93
|
## how it works
|
|
64
94
|
|
|
@@ -78,23 +108,13 @@ needs node 22 or later and claude code, signed in: heeded calls your own `claude
|
|
|
78
108
|
itself. it answers broken, kept, or doesn't apply. only your words grant a permission; an agent's never do. each
|
|
79
109
|
verdict is cached. a rule the judge decides is tried on 8 of its cases first: if none is about it, it stops there;
|
|
80
110
|
if some are, it goes on to 20 cases that applied, or 40 judged. up to 3 calls run at once.
|
|
81
|
-
5. **report.** each rule under its file: kept, broken with the evidence, never
|
|
82
|
-
compaction.
|
|
83
|
-
|
|
84
|
-
steps 2 and 4 cost tokens. heeded shows the estimate and asks once, before spending anything:
|
|
85
|
-
|
|
86
|
-
```text
|
|
87
|
-
11 rule sections to read, then cases to judge · claude sonnet and haiku · up to ~175k tokens
|
|
88
|
-
go ahead? [y/N]
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
a run judges within 150k tokens and nothing is paid for twice, so coverage grows run after run. `heeded --all` goes to
|
|
92
|
-
the end instead: its question shows what waits now and the most it can reach, then it judges round after round until
|
|
93
|
-
nothing waits. off a terminal (ci, a script) it never spends.
|
|
111
|
+
5. **report.** each rule under its file, at its line in the file as it is now: kept, broken with the evidence, never
|
|
112
|
+
applied; by context depth and after compaction.
|
|
94
113
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
114
|
+
steps 2 and 4 cost tokens: heeded asks once, before spending anything. a run judges within 150k tokens and nothing is
|
|
115
|
+
paid for twice, so coverage grows run after run. `heeded --all` goes to the end instead: its question shows what waits
|
|
116
|
+
now and the most it can reach, then it judges round after round until nothing waits. off a terminal (ci, a script) it
|
|
117
|
+
never spends.
|
|
98
118
|
|
|
99
119
|
`heeded rules` shows what each rule became:
|
|
100
120
|
|
|
@@ -104,11 +124,11 @@ $ npx heeded rules
|
|
|
104
124
|
~/repos/app · 1 file · 5 rules
|
|
105
125
|
|
|
106
126
|
CLAUDE.md
|
|
107
|
-
✓ never git add -A Bash command when (^|\s)git\s+add(\s|$) broken \s(-A|--all)\b
|
|
108
|
-
✓ answer in italian language it
|
|
109
|
-
· commit only when asked judged on Bash command when (^|\s)git\s+commit(\s|$)
|
|
110
|
-
· say what you changed judged on replies
|
|
111
|
-
! never kill the dev server failed its examples: keeps "pkill -f vite-test"
|
|
127
|
+
✓ :8 never git add -A Bash command when (^|\s)git\s+add(\s|$) broken \s(-A|--all)\b
|
|
128
|
+
✓ :12 answer in italian language it
|
|
129
|
+
· :15 commit only when asked judged on Bash command when (^|\s)git\s+commit(\s|$)
|
|
130
|
+
· :19 say what you changed judged on replies
|
|
131
|
+
! :23 never kill the dev server failed its examples: keeps "pkill -f vite-test"
|
|
112
132
|
```
|
|
113
133
|
|
|
114
134
|
`✓` a pattern decides it: `when` picks the calls it applies to, `broken` the ones that break it. `·` the judge
|
package/dist/check.js
CHANGED
|
@@ -455,6 +455,8 @@ export function counter(rulesOf, verdictOf = () => null) {
|
|
|
455
455
|
name: rule.name,
|
|
456
456
|
quote: rule.quote,
|
|
457
457
|
files: [...files],
|
|
458
|
+
heading: rule.heading,
|
|
459
|
+
lines: {},
|
|
458
460
|
status: rule.kind === 'judgment' ? 'judgment' : rule.demoted ? 'demoted'
|
|
459
461
|
: all.broken ? 'broken' : all.applied ? 'kept' : unjudged ? 'to-confirm' : 'never-applied',
|
|
460
462
|
...(rule.demoted || rule.noCases ? { reason: rule.demoted ?? rule.noCases } : {}),
|
package/dist/cli.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// The heeded command: arguments, the two commands, the question before spending, exit codes.
|
|
3
|
-
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
-
import { join } from 'node:path';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
6
|
import { createInterface } from 'node:readline/promises';
|
|
7
7
|
import { parseArgs } from 'node:util';
|
|
8
8
|
import { counter } from './check.js';
|
|
9
9
|
import { read } from './claude-code.js';
|
|
10
10
|
import { BUDGET, estimate as judgingEstimate, JUDGE_MODEL, judge, select, verdicts } from './judge.js';
|
|
11
11
|
import { approx, json, rules, text } from './report.js';
|
|
12
|
-
import {
|
|
12
|
+
import { redact } from './redact.js';
|
|
13
|
+
import { estimate, extract, lineOf, MODEL, remember, rulesOf } from './rules.js';
|
|
13
14
|
const HELP = `heeded — did your coding agent follow its instructions?
|
|
14
15
|
|
|
15
16
|
usage
|
|
@@ -25,12 +26,22 @@ reads CLAUDE_CONFIG_DIR, else ~/.claude. caches rules and verdicts in XDG_CACHE_
|
|
|
25
26
|
`;
|
|
26
27
|
/** One pass over the folder's transcripts with the rules and verdicts cached so far: the report, the sections to read,
|
|
27
28
|
* the cases waiting for the judge, and what the most the rules may still take would cost. */
|
|
28
|
-
async function scan(configDir, folder, cacheDir) {
|
|
29
|
+
async function scan(configDir, folder, cacheDir, home) {
|
|
29
30
|
const count = counter(rulesOf(cacheDir), verdicts(cacheDir));
|
|
30
31
|
const { unrecognized } = await read(configDir, folder, count.on);
|
|
31
32
|
for (const v of count.readVersions())
|
|
32
33
|
remember(cacheDir, v.identity, v.section.hash, v.at);
|
|
33
34
|
const data = count.result();
|
|
35
|
+
// Where each rule is written now, what the user opens to rewrite or remove it.
|
|
36
|
+
// Claude Code reads the user's own file from ~/.claude whatever the config dir.
|
|
37
|
+
const content = onDisk(folder, [configDir, join(home, '.claude')]);
|
|
38
|
+
for (const rule of data.rules) {
|
|
39
|
+
for (const file of rule.files) {
|
|
40
|
+
const line = lineOf(content(file), rule.quote, rule.heading);
|
|
41
|
+
if (line)
|
|
42
|
+
rule.lines[file] = line;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
34
45
|
const waiting = count.waiting();
|
|
35
46
|
// A tool rule lists 40 of its candidates: the others cost what those listed cost on average.
|
|
36
47
|
const listed = [...waiting.tool, ...waiting.judgment].flat();
|
|
@@ -38,6 +49,30 @@ async function scan(configDir, folder, cacheDir) {
|
|
|
38
49
|
return { report: { folder, configDir, unrecognized, ...data, toJudgeTokens: Math.round(each * data.toJudge) },
|
|
39
50
|
pending: count.pending(), waiting, mostTokens: Math.round(each * data.atMost) };
|
|
40
51
|
}
|
|
52
|
+
/** Instruction files as they are on disk now, secrets redacted as in what their rules were read from (a redaction
|
|
53
|
+
* keeps every line); empty for a file gone. Read only in the folder, the folders above it and the agent's own: where
|
|
54
|
+
* the user already gave access, so no privacy prompt for another folder. And only a regular file under 1 MB: a path
|
|
55
|
+
* from a transcript may name a pipe, which would never end. */
|
|
56
|
+
function onDisk(folder, own) {
|
|
57
|
+
const read = new Map();
|
|
58
|
+
const allowed = (file) => [folder, ...own].some((dir) => file.startsWith(`${dir}/`))
|
|
59
|
+
|| `${folder}/`.startsWith(dirname(file).replace(/\/?$/, '/'));
|
|
60
|
+
return (file) => {
|
|
61
|
+
if (!read.has(file)) {
|
|
62
|
+
let text = '';
|
|
63
|
+
try {
|
|
64
|
+
const stat = allowed(file) ? statSync(file) : null;
|
|
65
|
+
if (stat?.isFile() && stat.size < 1_000_000)
|
|
66
|
+
text = redact(readFileSync(file, 'utf8'));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// gone, or not readable: no line
|
|
70
|
+
}
|
|
71
|
+
read.set(file, text);
|
|
72
|
+
}
|
|
73
|
+
return read.get(file);
|
|
74
|
+
};
|
|
75
|
+
}
|
|
41
76
|
const thousands = (n) => n.toLocaleString('en-US');
|
|
42
77
|
const plural = (n, word) => `${thousands(n)} ${word}${n === 1 ? '' : 's'}`;
|
|
43
78
|
const clock = (ms) => `${Math.floor(ms / 60_000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`;
|
|
@@ -79,6 +114,8 @@ export async function main(argv, io) {
|
|
|
79
114
|
return usage(`unknown command ${command}`);
|
|
80
115
|
if (rest.length)
|
|
81
116
|
return usage(`unexpected ${rest.join(' ')}`);
|
|
117
|
+
if (command === 'rules' && args.values.all)
|
|
118
|
+
return usage('--all judges cases, and heeded rules never judges');
|
|
82
119
|
if (args.values.help) {
|
|
83
120
|
io.out(HELP);
|
|
84
121
|
return 0;
|
|
@@ -101,7 +138,7 @@ async function run(command, asJson, all, io) {
|
|
|
101
138
|
const home = io.env.HOME || homedir();
|
|
102
139
|
const configDir = io.env.CLAUDE_CONFIG_DIR || join(home, '.claude');
|
|
103
140
|
const cacheDir = io.env.XDG_CACHE_HOME || join(home, '.cache');
|
|
104
|
-
let { report, pending, waiting, mostTokens } = await scan(configDir, io.cwd, cacheDir);
|
|
141
|
+
let { report, pending, waiting, mostTokens } = await scan(configDir, io.cwd, cacheDir, home);
|
|
105
142
|
let code = 0;
|
|
106
143
|
const failed = (errors) => {
|
|
107
144
|
for (const e of errors)
|
|
@@ -120,9 +157,15 @@ async function run(command, asJson, all, io) {
|
|
|
120
157
|
io.err(`read ${plural(found.read, 'section')} · ${thousands(found.tokens)} tokens\n`);
|
|
121
158
|
failed(found.errors);
|
|
122
159
|
if (found.read)
|
|
123
|
-
({ report, waiting, mostTokens } = await scan(configDir, io.cwd, cacheDir));
|
|
160
|
+
({ report, waiting, mostTokens } = await scan(configDir, io.cwd, cacheDir, home));
|
|
124
161
|
return found.read > 0;
|
|
125
162
|
};
|
|
163
|
+
/** Asks about the new sections alone, and reads them on yes. */
|
|
164
|
+
const askToRead = async (then) => {
|
|
165
|
+
io.err(`${plural(distinct.length, 'rule section')} to read${then} · claude ${MODEL}`
|
|
166
|
+
+ ` · ${approx(estimate(distinct).tokens)} tokens\n`);
|
|
167
|
+
return await yes() && await readSections();
|
|
168
|
+
};
|
|
126
169
|
/** Judges the cases a round picks, then counts again; with `all`, again while cases wait and the last round judged
|
|
127
170
|
* some. One line for every round. */
|
|
128
171
|
const judgeCases = async () => {
|
|
@@ -138,7 +181,7 @@ async function run(command, asJson, all, io) {
|
|
|
138
181
|
tokens += done.tokens;
|
|
139
182
|
errors.push(...done.errors);
|
|
140
183
|
if (done.judged)
|
|
141
|
-
({ report, waiting, mostTokens } = await scan(configDir, io.cwd, cacheDir));
|
|
184
|
+
({ report, waiting, mostTokens } = await scan(configDir, io.cwd, cacheDir, home));
|
|
142
185
|
if (!all || !done.judged)
|
|
143
186
|
break;
|
|
144
187
|
}
|
|
@@ -148,7 +191,12 @@ async function run(command, asJson, all, io) {
|
|
|
148
191
|
};
|
|
149
192
|
// Reading sections and judging cases spend the user's quota: only when asked on a terminal, after what the run may
|
|
150
193
|
// spend. Every call costs ~4k tokens: an estimate is never under a thousand.
|
|
151
|
-
if (io.terminal &&
|
|
194
|
+
if (io.terminal && command === 'rules') {
|
|
195
|
+
// The rules and how each is checked: no verdict changes them, only new sections do.
|
|
196
|
+
if (distinct.length)
|
|
197
|
+
await askToRead('');
|
|
198
|
+
}
|
|
199
|
+
else if (io.terminal && (distinct.length || report.toJudge > 0)) {
|
|
152
200
|
if (!all) {
|
|
153
201
|
// One question. The cases of the rules about to be read aren't known yet: with sections to read, it shows the
|
|
154
202
|
// ceiling.
|
|
@@ -170,12 +218,7 @@ async function run(command, asJson, all, io) {
|
|
|
170
218
|
}
|
|
171
219
|
else {
|
|
172
220
|
// Every case: what judging them costs is known once the new sections are read, so the reading is asked first.
|
|
173
|
-
|
|
174
|
-
if (distinct.length) {
|
|
175
|
-
io.err(`${plural(distinct.length, 'rule section')} to read first · claude ${MODEL}`
|
|
176
|
-
+ ` · ${approx(estimate(distinct).tokens)} tokens\n`);
|
|
177
|
-
reading = await yes() && await readSections();
|
|
178
|
-
}
|
|
221
|
+
const reading = !distinct.length || await askToRead(' first');
|
|
179
222
|
if (reading && report.toJudge > 0) {
|
|
180
223
|
const more = report.atMost > report.toJudge;
|
|
181
224
|
io.err(`${plural(report.toJudge, 'case')} to judge${more ? ', more if their rules apply' : ''}`
|
package/dist/report.js
CHANGED
|
@@ -30,6 +30,16 @@ const inOrder = (r) => {
|
|
|
30
30
|
};
|
|
31
31
|
/** The file a rule is listed under: of the files it is written in, the first listed. */
|
|
32
32
|
const under = (order, x) => order.find((f) => x.files.includes(f));
|
|
33
|
+
/** Each rule's line in the file it is listed under, as `:12`, padded to the widest: where the user goes to rewrite or
|
|
34
|
+
* remove it. No column when no rule has one (its file gone, or edited since and its words with it). */
|
|
35
|
+
const lineCells = (order, rules) => {
|
|
36
|
+
const line = (x) => {
|
|
37
|
+
const n = x.lines[under(order, x) ?? ''];
|
|
38
|
+
return n ? `:${n}` : '';
|
|
39
|
+
};
|
|
40
|
+
const width = Math.max(0, ...rules.map((x) => line(x).length));
|
|
41
|
+
return new Map(rules.map((x) => [x, width ? `${line(x).padEnd(width)} ` : '']));
|
|
42
|
+
};
|
|
33
43
|
/** A file as the user finds it: by its path in the folder, or from home. */
|
|
34
44
|
const where = (r, path, home) => (path.startsWith(`${r.folder}/`) ? path.slice(r.folder.length + 1) : tilde(path, home));
|
|
35
45
|
/** Floored, so never 100% with a violation; and never 0% with a case kept. */
|
|
@@ -86,7 +96,9 @@ export function text(r, o) {
|
|
|
86
96
|
: [`kept ${kept(x.applied, x.broken)} of ${thousands(x.applied)}${x.status === 'judgment' ? ' judged' : ''}`,
|
|
87
97
|
...(x.broken ? [`broken ${thousands(x.broken)}×`, `last ${ago(x.evidence[0].at, o.now)}`] : [])]),
|
|
88
98
|
...(x.status !== 'judgment' && x.toJudge ? [`${thousands(x.toJudge)} to confirm`] : [])]);
|
|
89
|
-
const
|
|
99
|
+
const at = lineCells(order, shown);
|
|
100
|
+
const laid = (width) => columns(rows.map(([name, ...rest]) => [cut(name, width).padEnd(width), ...rest]))
|
|
101
|
+
.map((l, i) => `${at.get(shown[i])}${l}`);
|
|
90
102
|
let width = Math.max(...rows.map((row) => row[0].length));
|
|
91
103
|
while (width > 10 && Math.max(...laid(width).map((l) => l.length + 6)) > o.columns)
|
|
92
104
|
width--;
|
|
@@ -157,6 +169,7 @@ export function rules(r, o) {
|
|
|
157
169
|
const width = Math.max(...r.rules.map((x) => x.name.length));
|
|
158
170
|
const lines = ['', ` ${tilde(r.folder, o.home)} · ${plural(r.files.length, 'file')} · ${plural(r.rules.length, 'rule')}`, ''];
|
|
159
171
|
const order = inOrder(r);
|
|
172
|
+
const at = lineCells(order, r.rules);
|
|
160
173
|
for (const file of order) {
|
|
161
174
|
const mine = r.rules.filter((x) => under(order, x) === file);
|
|
162
175
|
if (mine.length)
|
|
@@ -164,7 +177,7 @@ export function rules(r, o) {
|
|
|
164
177
|
for (const x of mine) {
|
|
165
178
|
const mark = x.status === 'judgment' ? paint(o.color, 2, 22, '·') : x.status === 'demoted' ? paint(o.color, 33, 39, '!')
|
|
166
179
|
: paint(o.color, 32, 39, '✓');
|
|
167
|
-
lines.push(` ${mark} ${x.name.padEnd(width + 2)}${how(x)}`);
|
|
180
|
+
lines.push(` ${mark} ${at.get(x)}${x.name.padEnd(width + 2)}${how(x)}`);
|
|
168
181
|
}
|
|
169
182
|
}
|
|
170
183
|
if (r.notRead || r.toReread)
|
package/dist/rules.js
CHANGED
|
@@ -46,6 +46,31 @@ export function sections(content) {
|
|
|
46
46
|
return out;
|
|
47
47
|
}
|
|
48
48
|
const collapse = (text) => text.replace(/\s+/g, ' ').trim();
|
|
49
|
+
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
50
|
+
const WORD = /[\p{L}\p{N}_]/u;
|
|
51
|
+
/** The line a rule's words start on in a file's text, however they are spaced and wrapped there: under its section's
|
|
52
|
+
* headings (` › ` between them), as whole words, so the same words earlier (an intro, another section) aren't taken
|
|
53
|
+
* for it. Null when the text no longer holds them. What the user opens to rewrite or remove the rule. */
|
|
54
|
+
export function lineOf(content, quote, heading) {
|
|
55
|
+
const words = quote.split(/\s+/).filter(Boolean);
|
|
56
|
+
if (!words.length)
|
|
57
|
+
return null;
|
|
58
|
+
// Down the section's headings, as far as the file still has them: from the line of the last one found.
|
|
59
|
+
let from = 0;
|
|
60
|
+
let next = 0;
|
|
61
|
+
for (const title of heading ? heading.split(' › ') : []) {
|
|
62
|
+
const at = new RegExp(`^#{1,6}[ \\t]+${escape(title)}[ \\t]*\\r?$`, 'mu').exec(content.slice(next));
|
|
63
|
+
if (!at)
|
|
64
|
+
break;
|
|
65
|
+
from = next + at.index;
|
|
66
|
+
next = from + at[0].length;
|
|
67
|
+
}
|
|
68
|
+
const [first, last] = [[...words[0]][0], [...words.at(-1)].at(-1)];
|
|
69
|
+
const whole = new RegExp(`${WORD.test(first) ? '(?<![\\p{L}\\p{N}_])' : ''}${words.map(escape).join('\\s+')}`
|
|
70
|
+
+ `${WORD.test(last) ? '(?![\\p{L}\\p{N}_])' : ''}`, 'u');
|
|
71
|
+
const at = whole.exec(content.slice(from))?.index;
|
|
72
|
+
return at === undefined ? null : content.slice(0, from + at).split('\n').length;
|
|
73
|
+
}
|
|
49
74
|
/**
|
|
50
75
|
* A rule as the extraction returned it, checked here: its quote must be in its section as the model read it (secrets
|
|
51
76
|
* redacted), word for word (spacing aside), or the rule is dropped; a check that can't be trusted is demoted, with the
|
|
@@ -56,7 +81,8 @@ export function verify(raw, section) {
|
|
|
56
81
|
if (typeof r?.quote !== 'string' || !collapse(redact(section.text)).includes(collapse(r.quote)))
|
|
57
82
|
return null;
|
|
58
83
|
const id = createHash('sha256').update(collapse(r.quote).toLowerCase()).digest('hex').slice(0, 8);
|
|
59
|
-
const
|
|
84
|
+
const chars = [...String(r.name).replace(/[\p{Cc}\s]+/gu, ' ').trim()];
|
|
85
|
+
const name = chars.length > 40 ? `${chars.slice(0, 39).join('').trimEnd()}…` : chars.join('');
|
|
60
86
|
const own = /^#{1,6}[ \t]+(.+)$/.exec(section.text.split('\n')[0])?.[1].trim();
|
|
61
87
|
const heading = [...section.path, ...(own ? [own] : [])].join(' › ');
|
|
62
88
|
// What the section says around the rule: from 500 characters before its words to 1,000 after.
|