atris 3.46.1 → 3.48.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.
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ // det/json.js — deterministic JSON reshaping. The reformat/validate/flatten asks
3
+ // an LLM does by hand (and mis-escapes). Reads JSON on stdin, writes stdout.
4
+ //
5
+ // Usage:
6
+ // cat data.json | node json.js pretty # 2-space indent
7
+ // node json.js min < data.json # minified, one line
8
+ // node json.js validate < data.json # prints "valid" or errors (exit 2)
9
+ // node json.js keys < data.json # top-level keys, one per line
10
+ // node json.js csv < array.json # array of objects -> RFC-4180 CSV
11
+ //
12
+ // Modes: pretty | min | validate | keys | csv
13
+ // Exit 0 on success, 2 on invalid JSON or bad mode/shape.
14
+
15
+ 'use strict';
16
+
17
+ function parse(text) {
18
+ try {
19
+ return { ok: true, value: JSON.parse(text) };
20
+ } catch (e) {
21
+ return { ok: false, error: e.message };
22
+ }
23
+ }
24
+
25
+ // RFC-4180: quote a field if it holds comma, quote, CR or LF; double inner quotes.
26
+ function csvField(v) {
27
+ let s;
28
+ if (v === null || v === undefined) s = '';
29
+ else if (typeof v === 'object') s = JSON.stringify(v);
30
+ else s = String(v);
31
+ if (/[",\r\n]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"';
32
+ return s;
33
+ }
34
+
35
+ function toCsv(arr) {
36
+ if (!Array.isArray(arr)) throw new Error('csv mode needs a JSON array of objects');
37
+ if (arr.length === 0) return '';
38
+ // Column order = first-seen key order across all rows (stable, deterministic).
39
+ const cols = [];
40
+ const seen = new Set();
41
+ for (const row of arr) {
42
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) {
43
+ throw new Error('csv mode needs each item to be an object');
44
+ }
45
+ for (const k of Object.keys(row)) {
46
+ if (!seen.has(k)) {
47
+ seen.add(k);
48
+ cols.push(k);
49
+ }
50
+ }
51
+ }
52
+ const lines = [cols.map(csvField).join(',')];
53
+ for (const row of arr) {
54
+ lines.push(cols.map((c) => csvField(row[c])).join(','));
55
+ }
56
+ return lines.join('\n');
57
+ }
58
+
59
+ // Returns { text } on success or { error } on failure. Pure — unit-testable.
60
+ function run(mode, input) {
61
+ if (mode === 'validate') {
62
+ const p = parse(input);
63
+ return p.ok ? { text: 'valid' } : { error: p.error };
64
+ }
65
+ const p = parse(input);
66
+ if (!p.ok) return { error: p.error };
67
+ const v = p.value;
68
+ switch (mode) {
69
+ case 'pretty':
70
+ return { text: JSON.stringify(v, null, 2) };
71
+ case 'min':
72
+ return { text: JSON.stringify(v) };
73
+ case 'keys':
74
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) {
75
+ return { error: 'keys mode needs a JSON object' };
76
+ }
77
+ return { text: Object.keys(v).join('\n') };
78
+ case 'csv':
79
+ try {
80
+ return { text: toCsv(v) };
81
+ } catch (e) {
82
+ return { error: e.message };
83
+ }
84
+ default:
85
+ return { error: `unknown mode: ${mode}` };
86
+ }
87
+ }
88
+
89
+ function readStdin() {
90
+ return new Promise((resolve) => {
91
+ let data = '';
92
+ process.stdin.setEncoding('utf8');
93
+ process.stdin.on('data', (c) => (data += c));
94
+ process.stdin.on('end', () => resolve(data));
95
+ if (process.stdin.isTTY) resolve('');
96
+ });
97
+ }
98
+
99
+ const MODES = ['pretty', 'min', 'validate', 'keys', 'csv'];
100
+
101
+ async function main() {
102
+ const mode = process.argv.slice(2).find((a) => !a.startsWith('-'));
103
+ if (!mode || !MODES.includes(mode)) {
104
+ process.stderr.write(`unknown mode: ${mode || '(none)'}\nmodes: ${MODES.join(' | ')}\n`);
105
+ process.exit(2);
106
+ }
107
+ const input = await readStdin();
108
+ const res = run(mode, input);
109
+ if (res.error) {
110
+ process.stderr.write(res.error + '\n');
111
+ process.exit(2);
112
+ }
113
+ if (res.text.length) process.stdout.write(res.text + '\n');
114
+ }
115
+
116
+ if (require.main === module) {
117
+ main();
118
+ }
119
+
120
+ module.exports = { run, toCsv, MODES };
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ // det/pr-description.js — draft a PR description from the branch's diff vs a base.
3
+ // Replaces the "write a PR description" ask: the title comes from the commits,
4
+ // the summary bullets from which areas changed, and the test-plan skeleton from
5
+ // the touched test files — all read off the diff, so it is exact and never
6
+ // invents a rationale or a checklist item that isn't backed by a real change.
7
+ //
8
+ // Usage:
9
+ // node pr-description.js # diff origin/master...HEAD -> markdown
10
+ // node pr-description.js origin/main # different base
11
+ // node pr-description.js origin/main HEAD # explicit base + head
12
+ // node pr-description.js --json # structured {title,summary,testPlan,...}
13
+ //
14
+ // Reads git itself; no stdin. The pure core build({commits, files}) is exported
15
+ // and unit-tested. Reuses parseSubject (changelog) and leadFile (commit-msg) so
16
+ // the library stays coherent.
17
+
18
+ 'use strict';
19
+
20
+ const { execFileSync } = require('child_process');
21
+ const { parseSubject, SECTIONS } = require('./changelog');
22
+ const { leadFile } = require('./commit-msg');
23
+
24
+ // --- pure core (no git, no process) ---------------------------------------
25
+
26
+ function topDir(p) {
27
+ const i = p.indexOf('/');
28
+ return i === -1 ? '.' : p.slice(0, i);
29
+ }
30
+ function isTest(p) {
31
+ // a test/ dir, a foo.test.js, or a file literally named test.js/test.ts
32
+ return /(^|\/)tests?\//.test(p) || /\.test\.[jt]s$/.test(p) || /(^|\/)tests?\.[jt]s$/.test(p);
33
+ }
34
+
35
+ // title: one commit -> its subject; many -> dominant Conventional type + the
36
+ // lead file. Dominant type ties break in SECTIONS order (feat before fix ...).
37
+ function pickTitle(commits, files) {
38
+ if (commits.length === 1) return (commits[0].subject || '').trim();
39
+ const counts = {};
40
+ for (const c of commits) {
41
+ const t = parseSubject(c.subject || '').type;
42
+ counts[t] = (counts[t] || 0) + 1;
43
+ }
44
+ let lead = 'other';
45
+ let best = -1;
46
+ for (const [type] of SECTIONS) {
47
+ if ((counts[type] || 0) > best) {
48
+ best = counts[type] || 0;
49
+ lead = type;
50
+ }
51
+ }
52
+ if (!files.length) return `${lead}: ${commits.length} commits`;
53
+ const f = leadFile(files);
54
+ const name = f.path.split('/').pop();
55
+ return `${lead}: ${name}${files.length > 1 ? ` (+${files.length - 1} more)` : ''}`;
56
+ }
57
+
58
+ // one summary bullet per top-level area (first-seen order), with add/change/
59
+ // remove counts and exact churn — so a reviewer sees the shape at a glance.
60
+ function areaBullets(files) {
61
+ const groups = new Map();
62
+ for (const f of files) {
63
+ const k = topDir(f.path);
64
+ if (!groups.has(k)) groups.set(k, []);
65
+ groups.get(k).push(f);
66
+ }
67
+ const bullets = [];
68
+ for (const [area, fs] of groups) {
69
+ const counts = { A: 0, M: 0, D: 0 };
70
+ let added = 0;
71
+ let deleted = 0;
72
+ for (const f of fs) {
73
+ counts[f.status] = (counts[f.status] || 0) + 1;
74
+ added += f.added || 0;
75
+ deleted += f.deleted || 0;
76
+ }
77
+ const parts = [];
78
+ if (counts.A) parts.push(`${counts.A} added`);
79
+ if (counts.M) parts.push(`${counts.M} changed`);
80
+ if (counts.D) parts.push(`${counts.D} removed`);
81
+ bullets.push(`- **${area}** — ${parts.join(', ')} (+${added}/-${deleted})`);
82
+ }
83
+ return bullets;
84
+ }
85
+
86
+ // test-plan skeleton: list the touched test files to run, then one check per
87
+ // non-test area. Every line is backed by a real change; no invented steps.
88
+ function testPlan(files) {
89
+ const lines = [];
90
+ const tests = files.filter((f) => isTest(f.path) && f.status !== 'D').map((f) => f.path);
91
+ if (tests.length) {
92
+ lines.push('- [ ] Run the touched tests:');
93
+ for (const t of tests) lines.push(` - \`${t}\``);
94
+ }
95
+ const areas = [];
96
+ for (const f of files) {
97
+ if (isTest(f.path)) continue;
98
+ const a = topDir(f.path);
99
+ if (!areas.includes(a)) areas.push(a);
100
+ }
101
+ for (const a of areas) lines.push(`- [ ] Exercise **${a}** and confirm no regression`);
102
+ if (!lines.length) lines.push('- [ ] Manual verification of the changed files');
103
+ return lines;
104
+ }
105
+
106
+ // { commits, files } -> { title, summary, testPlan, total, commits }
107
+ function build(input) {
108
+ const commits = (input && input.commits) || [];
109
+ const files = (input && input.files) || [];
110
+ if (!commits.length && !files.length) {
111
+ return { error: 'no commits or files vs base — is the branch ahead of it?' };
112
+ }
113
+ const totals = files.reduce(
114
+ (a, f) => ({ added: a.added + (f.added || 0), deleted: a.deleted + (f.deleted || 0) }),
115
+ { added: 0, deleted: 0 }
116
+ );
117
+ return {
118
+ title: pickTitle(commits, files),
119
+ summary: areaBullets(files),
120
+ testPlan: testPlan(files),
121
+ totals,
122
+ fileCount: files.length,
123
+ commitCount: commits.length,
124
+ };
125
+ }
126
+
127
+ function render(res) {
128
+ const out = [`# ${res.title}`, ''];
129
+ out.push('## Summary');
130
+ out.push(...(res.summary.length ? res.summary : ['- (no file changes)']));
131
+ out.push(`- ${res.commitCount} commit${res.commitCount === 1 ? '' : 's'}, ${res.fileCount} file${
132
+ res.fileCount === 1 ? '' : 's'
133
+ }, +${res.totals.added}/-${res.totals.deleted}`);
134
+ out.push('', '## Test plan');
135
+ out.push(...res.testPlan);
136
+ return out.join('\n');
137
+ }
138
+
139
+ // --- git plumbing (impure, only in main) ----------------------------------
140
+
141
+ function readCommits(range) {
142
+ const out = execFileSync('git', ['log', '--no-merges', '--pretty=%h%x09%s', range], {
143
+ encoding: 'utf8',
144
+ });
145
+ const commits = [];
146
+ for (const line of out.split('\n')) {
147
+ if (!line.trim()) continue;
148
+ const tab = line.indexOf('\t');
149
+ commits.push({ hash: line.slice(0, tab), subject: line.slice(tab + 1) });
150
+ }
151
+ return commits;
152
+ }
153
+
154
+ // merge-base diff (three-dot) so the PR shows only this branch's changes.
155
+ function readFiles(threeDot) {
156
+ const numstat = execFileSync('git', ['diff', '--numstat', threeDot], { encoding: 'utf8' });
157
+ const names = execFileSync('git', ['diff', '--name-status', threeDot], { encoding: 'utf8' });
158
+ const stat = {};
159
+ for (const line of numstat.split('\n')) {
160
+ if (!line.trim()) continue;
161
+ const [added, deleted, path] = line.split('\t');
162
+ stat[path] = {
163
+ added: added === '-' ? 0 : Number(added),
164
+ deleted: deleted === '-' ? 0 : Number(deleted),
165
+ };
166
+ }
167
+ const files = [];
168
+ for (const line of names.split('\n')) {
169
+ if (!line.trim()) continue;
170
+ const parts = line.split('\t');
171
+ const status = parts[0][0];
172
+ const path = parts[parts.length - 1];
173
+ files.push({
174
+ path,
175
+ status,
176
+ added: (stat[path] || {}).added || 0,
177
+ deleted: (stat[path] || {}).deleted || 0,
178
+ });
179
+ }
180
+ return files;
181
+ }
182
+
183
+ function main() {
184
+ const args = process.argv.slice(2).filter((a) => a !== '--json');
185
+ const wantJson = process.argv.includes('--json');
186
+ const base = args[0] || 'origin/master';
187
+ const head = args[1] || 'HEAD';
188
+ let commits;
189
+ let files;
190
+ try {
191
+ commits = readCommits(`${base}..${head}`);
192
+ files = readFiles(`${base}...${head}`);
193
+ } catch (e) {
194
+ process.stderr.write(`git failed: ${e.message}\n`);
195
+ process.exit(2);
196
+ }
197
+ const res = build({ commits, files });
198
+ if (res.error) {
199
+ process.stderr.write(res.error + '\n');
200
+ process.exit(2);
201
+ }
202
+ if (wantJson) {
203
+ process.stdout.write(JSON.stringify({ base, head, ...res }, null, 2) + '\n');
204
+ } else {
205
+ process.stdout.write(render(res) + '\n');
206
+ }
207
+ }
208
+
209
+ if (require.main === module) {
210
+ main();
211
+ }
212
+
213
+ module.exports = { build, pickTitle, areaBullets, testPlan, render };
@@ -0,0 +1,296 @@
1
+ #!/usr/bin/env node
2
+ // det/test.js — self-test for the deterministic task scripts.
3
+ // Zero deps, exits non-zero on any failure so CI and agents can trust the lib.
4
+ 'use strict';
5
+
6
+ const assert = require('assert');
7
+ const extractModule = require('./extract');
8
+ const { extract } = extractModule;
9
+ const jsonModule = require('./json');
10
+ const { run } = jsonModule;
11
+ const text = require('./text');
12
+ const hash = require('./hash');
13
+ const date = require('./date');
14
+ const commitMsg = require('./commit-msg');
15
+ const changelog = require('./changelog');
16
+ const prDesc = require('./pr-description');
17
+ const { CATALOG, catalogJson, catalogText, GIT_SCRIPTS } = require('./det');
18
+
19
+ let passed = 0;
20
+ function check(name, actual, expected) {
21
+ assert.deepStrictEqual(actual, expected, name);
22
+ passed += 1;
23
+ }
24
+
25
+ // urls: strip trailing punctuation, dedupe, keep order
26
+ check(
27
+ 'urls',
28
+ extract('urls', 'see https://a.com/x. and http://b.io, then https://a.com/x again'),
29
+ ['https://a.com/x', 'http://b.io']
30
+ );
31
+
32
+ check('emails', extract('emails', 'a@b.com and c@d.co and a@b.com'), ['a@b.com', 'c@d.co']);
33
+
34
+ check(
35
+ 'code',
36
+ extract('code', 'text\n```js\nconst x = 1;\n```\nmore\n```\nplain\n```'),
37
+ ['const x = 1;', 'plain']
38
+ );
39
+
40
+ check('numbers', extract('numbers', 'got 1,234 items at 9.5 each, -3 lost'), ['1,234', '9.5', '-3']);
41
+
42
+ check('ipv4', extract('ipv4', 'from 192.168.0.1 not 999.1.1.1'), ['192.168.0.1']);
43
+
44
+ check('hashtags', extract('hashtags', 'ship #atris and #det #atris'), ['#atris', '#det']);
45
+
46
+ // unknown kind -> null
47
+ check('unknown', extract('nope', 'x'), null);
48
+
49
+ // empty input -> empty list
50
+ check('empty', extract('urls', ''), []);
51
+
52
+ // --- json.js ---
53
+ check('json.pretty', run('pretty', '{"a":1}'), { text: '{\n "a": 1\n}' });
54
+ check('json.min', run('min', '{ "a" : 1 }'), { text: '{"a":1}' });
55
+ check('json.validate.ok', run('validate', '[1,2,3]'), { text: 'valid' });
56
+ check('json.validate.bad', run('validate', '{bad}').error !== undefined, true);
57
+ check('json.keys', run('keys', '{"a":1,"b":2}'), { text: 'a\nb' });
58
+ // csv: header from first-seen key order, proper RFC-4180 quoting of commas/quotes
59
+ check(
60
+ 'json.csv',
61
+ run('csv', '[{"name":"a, b","n":1},{"name":"c\\"d","n":2}]'),
62
+ { text: 'name,n\n"a, b",1\n"c""d",2' }
63
+ );
64
+ check('json.csv.notArray', run('csv', '{"a":1}').error !== undefined, true);
65
+ check('json.badMode', run('nope', '{}').error !== undefined, true);
66
+
67
+ // --- text.js ---
68
+ check('text.dedupe', text.run('dedupe', 'a\nb\na\nc'), { text: 'a\nb\nc' });
69
+ check('text.sort', text.run('sort', 'c\na\nb'), { text: 'a\nb\nc' });
70
+ check('text.rsort', text.run('rsort', 'a\nc\nb'), { text: 'c\nb\na' });
71
+ check('text.count', text.run('count', 'a b\nc'), { text: 'lines\t2\nwords\t3\nchars\t5' });
72
+ check('text.slug', text.slugify('Hello, World! 2026'), 'hello-world-2026');
73
+ check('text.slug.accents', text.slugify('Café Déjà Vu'), 'cafe-deja-vu');
74
+ check('text.trim', text.run('trim', 'a \n\n \nb'), { text: 'a\nb' });
75
+ check('text.empty', text.run('dedupe', ''), { text: '' });
76
+ check('text.badMode', text.run('nope', 'x').error !== undefined, true);
77
+
78
+ // --- hash.js ---
79
+ check('hash.b64', hash.run('b64', 'hi'), { text: 'aGk=' });
80
+ check('hash.b64.roundtrip', hash.run('b64d', hash.run('b64', 'hello').text), { text: 'hello' });
81
+ check('hash.sha256', hash.run('sha256', 'hi'), {
82
+ text: '8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4',
83
+ });
84
+ check('hash.md5', hash.run('md5', 'hi'), { text: '49f68a5c8493ec2c0bf489821c21fc3b' });
85
+ check('hash.hex.roundtrip', hash.run('hexdec', hash.run('hexenc', 'yo').text), { text: 'yo' });
86
+ check('hash.newlineStripped', hash.run('b64', 'hi\n'), { text: 'aGk=' }); // echo == printf
87
+ check('hash.hexdec.bad', hash.run('hexdec', 'xyz').error !== undefined, true);
88
+ check('hash.badMode', hash.run('nope', 'x').error !== undefined, true);
89
+
90
+ // --- date.js ---
91
+ check('date.iso.sec', date.run('iso', '1700000000'), { text: '2023-11-14T22:13:20.000Z' });
92
+ check('date.iso.ms', date.run('iso', '1700000000000'), { text: '2023-11-14T22:13:20.000Z' });
93
+ check('date.epoch', date.run('epoch', '2026-07-07'), { text: '1783382400' });
94
+ check('date.epochms', date.run('epochms', '2026-07-07'), { text: '1783382400000' });
95
+ check('date.weekday', date.run('weekday', '2026-07-07'), { text: 'Tuesday' });
96
+ check('date.epoch0', date.run('iso', '0'), { text: '1970-01-01T00:00:00.000Z' });
97
+ check('date.utcPinned', date.run('epoch', '2026-07-07T00:00:00'), { text: '1783382400' }); // no zone -> UTC
98
+ check('date.bad', date.run('iso', 'not-a-date').error !== undefined, true);
99
+ check('date.badMode', date.run('nope', '0').error !== undefined, true);
100
+
101
+ // --- commit-msg.js (git-facing) ---
102
+ // type from paths: all under scripts/ -> chore, scope = deepest common dir
103
+ {
104
+ const d = commitMsg.draft([
105
+ { path: 'scripts/det/date.js', status: 'A', added: 90, deleted: 0 },
106
+ { path: 'scripts/det/test.js', status: 'M', added: 11, deleted: 1 },
107
+ ]);
108
+ // names the lead (added) file, not an anonymous "update 2 files" count
109
+ check('commit.subject', d.subject, 'chore(det): add date.js (+1 more)');
110
+ check('commit.totals', d.totals, { added: 101, deleted: 1 });
111
+ check('commit.body.stat', /2 files changed, \+101\/-1$/.test(d.body), true);
112
+ }
113
+ // lead file = biggest churn when nothing is added; tie broken by path
114
+ check(
115
+ 'commit.lead.churn',
116
+ commitMsg.draft([
117
+ { path: 'lib/a.js', status: 'M', added: 2, deleted: 1 },
118
+ { path: 'lib/b.js', status: 'M', added: 40, deleted: 5 },
119
+ ]).subject,
120
+ 'fix(lib): update b.js (+1 more)'
121
+ );
122
+ // added file wins over a higher-churn modified file
123
+ check(
124
+ 'commit.lead.added',
125
+ commitMsg.leadFile([
126
+ { path: 'lib/big.js', status: 'M', added: 99, deleted: 0 },
127
+ { path: 'lib/new.js', status: 'A', added: 3, deleted: 0 },
128
+ ]).path,
129
+ 'lib/new.js'
130
+ );
131
+ check(
132
+ 'commit.docs',
133
+ commitMsg.draft([{ path: 'README.md', status: 'M', added: 3, deleted: 0 }]).subject,
134
+ 'docs: update README.md'
135
+ );
136
+ check(
137
+ 'commit.test',
138
+ commitMsg.draft([{ path: 'test/foo.test.js', status: 'A', added: 5, deleted: 0 }]).subject,
139
+ 'test: add foo.test.js'
140
+ );
141
+ check(
142
+ 'commit.feat',
143
+ commitMsg.draft([{ path: 'lib/parser.js', status: 'A', added: 40, deleted: 0 }]).subject,
144
+ 'feat(lib): add parser.js'
145
+ );
146
+ check(
147
+ 'commit.fix',
148
+ commitMsg.draft([{ path: 'lib/parser.js', status: 'M', added: 2, deleted: 2 }]).subject,
149
+ 'fix(lib): update parser.js'
150
+ );
151
+ check('commit.scope.root', commitMsg.commonDirScope(['package.json']), '');
152
+ check('commit.empty', commitMsg.draft([]).error !== undefined, true);
153
+
154
+ // --- changelog.js (git-facing) ---
155
+ // header grammar: type(scope)!: subject -> parsed fields, breaking flagged
156
+ check('changelog.parse', changelog.parseSubject('feat(cli): add reel'), {
157
+ type: 'feat',
158
+ scope: 'cli',
159
+ breaking: false,
160
+ subject: 'add reel',
161
+ });
162
+ check('changelog.parse.bang', changelog.parseSubject('feat!: drop v1').breaking, true);
163
+ // unknown type -> "other" bucket, whole line kept (nothing dropped)
164
+ check('changelog.parse.unknown', changelog.parseSubject('wip: poke').type, 'other');
165
+ check('changelog.parse.freeform', changelog.parseSubject('just a note').subject, 'just a note');
166
+ {
167
+ const r = changelog.build([
168
+ { hash: 'a1', subject: 'feat(cli): add reel' },
169
+ { hash: 'b2', subject: 'fix(det): guard empty range' },
170
+ { hash: 'c3', subject: 'feat: add card' },
171
+ { hash: 'd4', subject: 'chore!: bump major' },
172
+ ]);
173
+ // sections come back in SECTIONS order: feat before fix before chore
174
+ check('changelog.order', r.sections.map((s) => s.type), ['feat', 'fix', 'chore']);
175
+ check('changelog.counts', r.counts, { feat: 2, fix: 1, chore: 1 });
176
+ check('changelog.breaking', r.breaking.length, 1);
177
+ check('changelog.total', r.total, 4);
178
+ // rendered markdown groups under human headings, breaking first
179
+ const md = changelog.render(r);
180
+ check('changelog.render.breaking', /^### ⚠ BREAKING CHANGES/.test(md), true);
181
+ check('changelog.render.feat', md.includes('### Features'), true);
182
+ check('changelog.render.item', md.includes('- add reel (cli) [a1]'), true);
183
+ }
184
+ check('changelog.empty', changelog.render(changelog.build([])), 'No changes.');
185
+ check('changelog.badInput', changelog.build('nope').error !== undefined, true);
186
+
187
+ // --- pr-description.js (git-facing) ---
188
+ // single commit -> title is that subject verbatim
189
+ check(
190
+ 'pr.title.one',
191
+ prDesc.pickTitle([{ subject: 'feat(cli): add reel' }], [{ path: 'commands/reel.js', status: 'A' }]),
192
+ 'feat(cli): add reel'
193
+ );
194
+ // many commits -> dominant type + lead file (feat wins the tie by SECTIONS order)
195
+ check(
196
+ 'pr.title.many',
197
+ prDesc.pickTitle(
198
+ [{ subject: 'feat: a' }, { subject: 'fix: b' }, { subject: 'feat: c' }],
199
+ [
200
+ { path: 'lib/new.js', status: 'A', added: 3, deleted: 0 },
201
+ { path: 'lib/old.js', status: 'M', added: 1, deleted: 1 },
202
+ ]
203
+ ),
204
+ 'feat: new.js (+1 more)'
205
+ );
206
+ // summary bullets: one per top-level area, first-seen order, with counts + churn
207
+ check(
208
+ 'pr.summary.areas',
209
+ prDesc.areaBullets([
210
+ { path: 'scripts/det/a.js', status: 'A', added: 10, deleted: 0 },
211
+ { path: 'scripts/det/b.js', status: 'M', added: 2, deleted: 1 },
212
+ { path: 'test/x.test.js', status: 'A', added: 5, deleted: 0 },
213
+ ]),
214
+ ['- **scripts** — 1 added, 1 changed (+12/-1)', '- **test** — 1 added (+5/-0)']
215
+ );
216
+ // test plan lists touched test files, then a check per non-test area
217
+ check(
218
+ 'pr.testplan',
219
+ prDesc.testPlan([
220
+ { path: 'scripts/det/a.js', status: 'M' },
221
+ { path: 'scripts/det/test.js', status: 'M' },
222
+ ]),
223
+ [
224
+ '- [ ] Run the touched tests:',
225
+ ' - `scripts/det/test.js`',
226
+ '- [ ] Exercise **scripts** and confirm no regression',
227
+ ]
228
+ );
229
+ // no test files -> generic fallback line
230
+ check('pr.testplan.none', prDesc.testPlan([{ path: 'README.md', status: 'M' }]), [
231
+ '- [ ] Exercise **.** and confirm no regression',
232
+ ]);
233
+ {
234
+ const r = prDesc.build({
235
+ commits: [{ hash: 'a1', subject: 'feat(det): add pr-description.js' }],
236
+ files: [{ path: 'scripts/det/pr-description.js', status: 'A', added: 100, deleted: 0 }],
237
+ });
238
+ const md = prDesc.render(r);
239
+ check('pr.render.title', /^# feat\(det\): add pr-description\.js/.test(md), true);
240
+ check('pr.render.summary', md.includes('## Summary'), true);
241
+ check('pr.render.testplan', md.includes('## Test plan'), true);
242
+ check('pr.render.stat', md.includes('1 commit, 1 file, +100/-0'), true);
243
+ }
244
+ check('pr.empty', prDesc.build({ commits: [], files: [] }).error !== undefined, true);
245
+
246
+ // --- det.js dispatcher ---
247
+ check('det.catalog', Object.keys(CATALOG).sort(), ['date', 'extract', 'hash', 'json', 'text', 'voice']);
248
+ check('det.voice.pass', CATALOG.voice.run('scan', 'The build is green.').text, 'PASS');
249
+ check('det.voice.fail', CATALOG.voice.run('scan', 'fixed the worktree').text.startsWith('FAIL'), true);
250
+ check('det.date.route', CATALOG.date.run('weekday', '2026-07-07'), { text: 'Tuesday' });
251
+ check('det.date.modes', CATALOG.date.modes, date.MODES);
252
+ check('det.hash.route', CATALOG.hash.run('b64', 'hi'), { text: 'aGk=' });
253
+ check('det.hash.modes', CATALOG.hash.modes, hash.MODES);
254
+ // every catalog entry advertises modes and routes to a working run()
255
+ check('det.extract.route', CATALOG.extract.run('emails', 'x a@b.com'), { text: 'a@b.com' });
256
+ check('det.json.route', CATALOG.json.run('min', '{ "a": 1 }'), { text: '{"a":1}' });
257
+ check('det.text.route', CATALOG.text.run('dedupe', 'a\na'), { text: 'a' });
258
+ check('det.badMode', CATALOG.extract.run('nope', 'x').error !== undefined, true);
259
+ // catalog modes must equal what each script actually exports (no drift)
260
+ check('det.extract.modes', CATALOG.extract.modes, Object.keys(extractModule.EXTRACTORS));
261
+ check('det.json.modes', CATALOG.json.modes, jsonModule.MODES);
262
+ check('det.text.modes', CATALOG.text.modes, text.MODES);
263
+ // git-facing scripts surface at the front door so all 8 tools are discoverable
264
+ check('det.git.names', GIT_SCRIPTS.map((g) => g.name), ['commit-msg', 'changelog', 'pr-description']);
265
+ check('det.git.text', catalogText().includes('git-facing') && catalogText().includes('pr-description'), true);
266
+ check('det.git.json', JSON.parse(catalogJson()).git['commit-msg'].usage.includes('commit-msg.js'), true);
267
+ // stdin catalog stays separate from the git list (routing must not mix them)
268
+ check('det.git.notRoutable', CATALOG['commit-msg'], undefined);
269
+
270
+ console.log(`ok — ${passed} checks passed`);
271
+
272
+ // hunk-filter: keeps only matching hunks, drops non-matching files entirely
273
+ {
274
+ const { filterHunks } = require('./hunk-filter');
275
+ const diff = [
276
+ 'diff --git a/f.md b/f.md',
277
+ 'index 111..222 100644',
278
+ '--- a/f.md',
279
+ '+++ b/f.md',
280
+ '@@ -1,2 +1,2 @@',
281
+ ' keep me',
282
+ '-old horizon line',
283
+ '+new horizon line',
284
+ '@@ -9,2 +9,2 @@',
285
+ ' other',
286
+ '-their churn',
287
+ '+their new churn',
288
+ ''
289
+ ].join('\n');
290
+ const filtered = filterHunks(diff, 'horizon');
291
+ assert.ok(filtered.includes('+new horizon line'), 'hunk-filter keeps matching hunk');
292
+ assert.ok(!filtered.includes('their churn'), 'hunk-filter drops non-matching hunk');
293
+ assert.ok(filtered.includes('diff --git a/f.md'), 'hunk-filter keeps file header');
294
+ assert.deepStrictEqual(filterHunks(diff, 'nomatch-xyz'), '', 'hunk-filter empty when nothing matches');
295
+ passed += 4;
296
+ }