atris 3.47.0 → 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.
- package/atris/skills/youtube/SKILL.md +5 -3
- package/bin/atris.js +3 -1
- package/commands/mission.js +1 -22
- package/commands/youtube.js +31 -1
- package/lib/engine-ask.js +15 -1
- package/lib/engine-registry.js +22 -0
- package/lib/fleet.js +15 -0
- package/package.json +2 -1
- package/scripts/det/README.md +162 -0
- package/scripts/det/ax-lane-eval.js +116 -0
- package/scripts/det/changelog.js +148 -0
- package/scripts/det/codex-watchdog.js +217 -0
- package/scripts/det/commit-msg.js +153 -0
- package/scripts/det/data/ax-lane-gold.jsonl +81 -0
- package/scripts/det/data/ax-lane-holdout.jsonl +20 -0
- package/scripts/det/date.js +91 -0
- package/scripts/det/det.js +149 -0
- package/scripts/det/extract.js +93 -0
- package/scripts/det/hash.js +79 -0
- package/scripts/det/hunk-filter.js +73 -0
- package/scripts/det/json.js +120 -0
- package/scripts/det/pr-description.js +213 -0
- package/scripts/det/test.js +296 -0
- package/scripts/det/text.js +102 -0
- package/scripts/det/voice.js +76 -0
- package/scripts/det/ytnotes +196 -0
- package/scripts/det/ytquote-repair.js +181 -0
- package/scripts/det/ytrail-eval.js +124 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// det/text.js — deterministic line-and-word chores an LLM gets asked to eyeball
|
|
3
|
+
// (and miscounts). Reads text on stdin, writes stdout.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// cat list.txt | node text.js dedupe # drop duplicate lines, keep first order
|
|
7
|
+
// node text.js sort < list.txt # sort lines (byte order)
|
|
8
|
+
// node text.js rsort < list.txt # reverse sort
|
|
9
|
+
// node text.js count < list.txt # lines / words / chars, one metric per line
|
|
10
|
+
// node text.js slug < title.txt # each line -> url slug
|
|
11
|
+
// node text.js trim < messy.txt # strip trailing ws, drop blank lines
|
|
12
|
+
//
|
|
13
|
+
// Modes: dedupe | sort | rsort | count | slug | trim
|
|
14
|
+
// Exit 0 on success, 2 on bad mode.
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
function splitLines(input) {
|
|
19
|
+
// Normalize CRLF, drop a single trailing newline so "a\nb\n" is 2 lines not 3.
|
|
20
|
+
const t = input.replace(/\r\n/g, '\n').replace(/\n$/, '');
|
|
21
|
+
return t === '' ? [] : t.split('\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function slugify(s) {
|
|
25
|
+
return s
|
|
26
|
+
.normalize('NFKD')
|
|
27
|
+
.replace(/[̀-ͯ]/g, '') // strip accents
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[^a-z0-9]+/g, '-') // non-alphanumeric -> hyphen
|
|
30
|
+
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Pure core: returns { text } or { error }. Unit-testable without process I/O.
|
|
34
|
+
function run(mode, input) {
|
|
35
|
+
const lines = splitLines(input);
|
|
36
|
+
switch (mode) {
|
|
37
|
+
case 'dedupe': {
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const l of lines) {
|
|
41
|
+
if (!seen.has(l)) {
|
|
42
|
+
seen.add(l);
|
|
43
|
+
out.push(l);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { text: out.join('\n') };
|
|
47
|
+
}
|
|
48
|
+
case 'sort':
|
|
49
|
+
return { text: [...lines].sort().join('\n') };
|
|
50
|
+
case 'rsort':
|
|
51
|
+
return { text: [...lines].sort().reverse().join('\n') };
|
|
52
|
+
case 'count': {
|
|
53
|
+
const words = (input.match(/\S+/g) || []).length;
|
|
54
|
+
const chars = input.replace(/\n$/, '').length;
|
|
55
|
+
return { text: `lines\t${lines.length}\nwords\t${words}\nchars\t${chars}` };
|
|
56
|
+
}
|
|
57
|
+
case 'slug':
|
|
58
|
+
return { text: lines.map(slugify).join('\n') };
|
|
59
|
+
case 'trim':
|
|
60
|
+
return {
|
|
61
|
+
text: lines
|
|
62
|
+
.map((l) => l.replace(/\s+$/, ''))
|
|
63
|
+
.filter((l) => l.trim() !== '')
|
|
64
|
+
.join('\n'),
|
|
65
|
+
};
|
|
66
|
+
default:
|
|
67
|
+
return { error: `unknown mode: ${mode}` };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readStdin() {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
let data = '';
|
|
74
|
+
process.stdin.setEncoding('utf8');
|
|
75
|
+
process.stdin.on('data', (c) => (data += c));
|
|
76
|
+
process.stdin.on('end', () => resolve(data));
|
|
77
|
+
if (process.stdin.isTTY) resolve('');
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const MODES = ['dedupe', 'sort', 'rsort', 'count', 'slug', 'trim'];
|
|
82
|
+
|
|
83
|
+
async function main() {
|
|
84
|
+
const mode = process.argv.slice(2).find((a) => !a.startsWith('-'));
|
|
85
|
+
if (!mode || !MODES.includes(mode)) {
|
|
86
|
+
process.stderr.write(`unknown mode: ${mode || '(none)'}\nmodes: ${MODES.join(' | ')}\n`);
|
|
87
|
+
process.exit(2);
|
|
88
|
+
}
|
|
89
|
+
const input = await readStdin();
|
|
90
|
+
const res = run(mode, input);
|
|
91
|
+
if (res.error) {
|
|
92
|
+
process.stderr.write(res.error + '\n');
|
|
93
|
+
process.exit(2);
|
|
94
|
+
}
|
|
95
|
+
if (res.text.length) process.stdout.write(res.text + '\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (require.main === module) {
|
|
99
|
+
main();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { run, slugify, MODES };
|