@assuredloop/cli 0.0.0-stage → 0.1.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/src/tests.js ADDED
@@ -0,0 +1,267 @@
1
+ // Tests and their results ([LNK-3]), observed and never judged: test files
2
+ // by common path patterns or the `tests:` paths of .assuredloop; assertion
3
+ // counts for JS/TS, Python and Go; the result files `results:` names, each
4
+ // classed by the revision it names; the tests linked to the changed code,
5
+ // each with its reason; and [HNT-2]'s four test notes.
6
+ import { basename } from 'node:path';
7
+ import { git, hasCommits, isShallow } from './git.js';
8
+ import { configured } from './spec.js';
9
+ import { line } from './commands.js';
10
+ import { fileLinks } from './views.js';
11
+ import { adrFolders } from './adrs.js';
12
+ import { changedWith, idNear, idsOn, paths } from './links.js';
13
+
14
+ const DIR = /(^|\/)(test|tests|__tests__|spec)\//;
15
+ const NAME = /^test_|\.(test|spec)\.|_test\.|Tests?\.[^.]+$/;
16
+ const MARK = /^test_|\.(test|spec)(?=\.)|_test(?=\.)|Tests?(?=\.[^.]+$)/g;
17
+ const JS = /\.[cm]?[jt]sx?$/;
18
+ const ASSERT = /(?<![\w$.])(?:t\.)?assert(?:\.\w+)*\s*\(|(?<![\w$.])expect\s*\(/g;
19
+ // Python and Go: a line that starts with an assertion, no tokenizer (#130).
20
+ const LINE_ASSERT = [
21
+ [/^test_.*\.py$|_test\.py$/, /^(?:assert |self\.assert\w*\(|(?:with )?pytest\.raises\()/],
22
+ [/_test\.go$/, /^(?:t\.(?:Errorf?|Fatalf?)|(?:assert|require)\.\w+)\(/],
23
+ ];
24
+ const REVISION = /\brevision:[ \t]*([0-9a-f]{7,40})\b/i;
25
+ const stem = (p) => basename(p).replace(MARK, '').replace(/\..*$/, '');
26
+ const COMMENT = /\/\/[^\n]*|\/\*[\s\S]*?\*\//y;
27
+ const STRING = /'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"/y;
28
+ const TEXT = /(?:\\[\s\S]|[^`\\$]|\$(?!\{))*(?:`|\$\{)?/y;
29
+ const REGEX = /\/(?![*/])(?:\\.|\[(?:\\.|[^\]\\\n])*\]|[^/\\\n[])+\/[a-z]*/y;
30
+ // A `/` starts a regex after these, not after a value (where it divides).
31
+ const OPERAND = /(?:^|[(,=:[!&|?{};+\-*%<>~^]|\b(?:return|typeof|case|do|else|in|of|new|delete|void|throw|yield|await)) ?$/;
32
+
33
+ // The code of JS/TS text: comments, string and regex contents and template
34
+ // text blanked, lines kept, so only code holds calls; a template's `${…}` stays code.
35
+ function codeOf(t) {
36
+ const blank = (s) => s.replace(/[^\n]/g, ' ');
37
+ const open = []; // one per `{`: a plain brace, or a template's `${`
38
+ let [out, i, prev] = ['', 0, '']; // prev: the last code characters, spaces squeezed, a value as x
39
+ const take = (re) => { re.lastIndex = i; return re.exec(t)?.[0] || null; };
40
+ const skip = (m, value) => { out += blank(m); i += m.length; if (value) prev = `${prev}x`.slice(-8); };
41
+ // Template text up to its closing backtick, or up to a `${`, whose code follows.
42
+ const text = () => { const m = take(TEXT) ?? ''; skip(m, m.endsWith('`')); if (m.endsWith('${')) open.push('template'); };
43
+ while (i < t.length) {
44
+ const m = take(COMMENT);
45
+ if (m) { skip(m); continue; }
46
+ const v = take(STRING) ?? (t[i] === '/' && OPERAND.test(prev) ? take(REGEX) : null);
47
+ if (v) { skip(v, true); continue; }
48
+ const c = t[i++];
49
+ if (c === '`') { out += ' '; text(); continue; }
50
+ if (c === '}' && open.pop() === 'template') { out += ' '; text(); continue; }
51
+ if (c === '{') open.push('brace');
52
+ out += c;
53
+ if (!/\s/.test(c) || !prev.endsWith(' ')) prev = `${prev}${/\s/.test(c) ? ' ' : c}`.slice(-8);
54
+ }
55
+ return out;
56
+ }
57
+
58
+ // Whether a path is a test file; never one under the baseline root or requests/.
59
+ export const testMatcher = (root, named) => (p) => !p.startsWith(`${root}/`) && !p.startsWith('requests/') &&
60
+ (DIR.test(p) || NAME.test(basename(p)) || named.some((n) => p === n || p.startsWith(`${n}/`)));
61
+
62
+ // A test file's assertions from `before` to `after` (null when absent): the
63
+ // counts, and the assertion lines added and removed; other syntax is skipped.
64
+ function observe(path, before, after) {
65
+ const starts = LINE_ASSERT.find(([name]) => name.test(basename(path)))?.[1];
66
+ if (!JS.test(path) && !starts) return { path, skipped: 'not JS/TS, Python or Go, the syntax al counts' };
67
+ const lines = starts ? (t) => (t ?? '').split('\n').map((l) => l.trim()).filter((l) => starts.test(l))
68
+ : (t) => { const src = (t ?? '').split('\n'); return codeOf(t ?? '').split('\n').flatMap((l, i) => (l.match(ASSERT) ? [src[i].trim()] : [])); };
69
+ const minus = (x, y) => { const left = [...y]; return x.filter((l) => { const i = left.indexOf(l); return i < 0 || !left.splice(i, 1); }); };
70
+ const count = starts ? (t) => lines(t).length : (t) => codeOf(t ?? '').match(ASSERT)?.length ?? 0;
71
+ const [a, b] = [lines(before), lines(after)];
72
+ return { path, was: count(before), now: count(after), added: minus(b, a).length, removed: minus(a, b).length };
73
+ }
74
+ const moved = (o) => !o.skipped && (o.added || o.removed);
75
+ const observation = (o) => (o.skipped ? `${o.path} skipped (${o.skipped})` : `${o.path} ${o.was} → ${o.now} assertions (+${o.added} -${o.removed} lines)`);
76
+
77
+ // A result file's counts and its passing and failing test names: JUnit XML's
78
+ // testcases, or TAP's points (a SKIP or TODO is neither), each named under
79
+ // its `# Subtest:` parents, and whether the TAP is incomplete or invalid: it
80
+ // bails out, or a stream (the root, or a subtest's by its indent) has no plan
81
+ // or a count of points other than its plan; null when it is neither.
82
+ function parse(text) {
83
+ const r = { passed: 0, failed: 0, failing: new Set(), passing: new Set() };
84
+ const note = (name, failed) => (failed ? (r.failed++, r.failing.add(name)) : (r.passed++, r.passing.add(name)));
85
+ if (/<testcase\b/.test(text)) {
86
+ for (const [, attrs, inner = ''] of text.matchAll(/<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g)) {
87
+ if (!/<skipped\b/.test(inner)) note(attrs.match(/\bname="([^"]*)"/)?.[1] ?? '', /<(failure|error)\b/.test(inner));
88
+ }
89
+ return r;
90
+ }
91
+ const [parents, streams] = [[], []];
92
+ let [points, bail, bad] = [0, false, null];
93
+ // The streams deeper than `at` end: each needs its plan met.
94
+ const close = (at) => {
95
+ while (streams.at(-1)?.at > at) {
96
+ const { plan, n } = streams.pop();
97
+ bad ??= plan === null ? 'incomplete: no plan' : n < plan ? `incomplete: ${n} of a plan of ${plan}` : n > plan ? `invalid: ${n} points for a plan of ${plan}` : null;
98
+ }
99
+ };
100
+ const stream = (at) => {
101
+ close(at);
102
+ if (streams.at(-1)?.at !== at) streams.push({ at, plan: null, n: 0 });
103
+ return streams.at(-1);
104
+ };
105
+ for (const l of text.split('\n')) {
106
+ const at = l.match(/^[ \t]*/)[0].length;
107
+ const sub = l.match(/^[ \t]*# Subtest: (.*)$/);
108
+ const point = l.match(/^[ \t]*(not )?ok\b[ \t]*\d*[ \t]*(?:-[ \t]*)?(.*)$/);
109
+ if (sub) {
110
+ while (parents.at(-1)?.at >= at) parents.pop();
111
+ parents.push({ at, name: sub[1].trim() });
112
+ } else if (/^[ \t]*Bail out!/.test(l)) bail = true;
113
+ else if (/^[ \t]*1\.\.\d+/.test(l)) stream(at).plan = Number(l.match(/1\.\.(\d+)/)[1]);
114
+ else if (point) {
115
+ points++;
116
+ stream(at).n++;
117
+ const name = [...parents.filter((p) => p.at < at).map((p) => p.name), point[2].replace(/\s+#\s*(skip|todo)\b.*$/i, '').trim()].join(' > ');
118
+ if (!/(^|\s)#\s*(skip|todo)\b/i.test(point[2])) note(name, Boolean(point[1]));
119
+ }
120
+ }
121
+ close(-1);
122
+ r.incomplete = bail ? 'incomplete: bail out' : bad;
123
+ return points ? r : null;
124
+ }
125
+
126
+ // The files `results:` names, read from `tree` (the working tree, or the
127
+ // commit's under --at), each classed by its revision line against `head` and
128
+ // `base`: head, base, older or unknown.
129
+ function results(top, tree, at, head, base) {
130
+ const out = [];
131
+ for (const p of configured(top, tree, at, 'results')) {
132
+ const files = tree.read(p) !== null ? [p] : tree.walk(p);
133
+ if (!files.length) out.push({ path: p, missing: true });
134
+ for (const path of files) {
135
+ const text = tree.read(path).toString('utf8');
136
+ const sha = text.match(REVISION)?.[1].toLowerCase();
137
+ const rev = sha && git(top, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`], { allowFail: true });
138
+ const cls = !rev ? 'unknown' : rev === head ? 'head' : rev === base ? 'base'
139
+ : git(top, ['merge-base', '--is-ancestor', rev, head], { allowFail: true }) !== null ? 'older' : 'unknown';
140
+ out.push({ path, rev: rev || sha, cls, tests: parse(text) });
141
+ }
142
+ }
143
+ return out;
144
+ }
145
+
146
+ // What check and the views read of branch `b` (readBranch's), once: the changed tests and
147
+ // their assertions, the tests linked to each changed code file, the sections
148
+ // near the changed code, and the named results.
149
+ export function testFacts(top, b) {
150
+ if (b.testFacts) return b.testFacts;
151
+ // [HNT-3]: a repo with no commits yet has no tests or results to read against.
152
+ if (!b.at && !hasCommits(top)) return (b.testFacts = { head: null, tests: [], names: new Map(), observed: [], links: new Map(), near: new Map(), results: [] });
153
+ const head = git(top, ['rev-parse', b.at ?? 'HEAD']);
154
+ const isTest = testMatcher(b.root, configured(top, b.tree, b.at, 'tests'));
155
+ // --diff reads the working tree's results unless --at names a commit ([VW-8]).
156
+ const r = b.readResults ?? b;
157
+ const found = results(top, r.tree, r.at, head, b.base);
158
+ const adr = adrFolders(top, b.tree, b.at);
159
+ const text = (p) => b.tree.read(p)?.toString('utf8') ?? null;
160
+ const tracked = new Set(paths(b.at ? git(top, ['ls-tree', '-r', '-z', '--name-only', head]) : git(top, ['ls-files', '-z'], { worktree: true })));
161
+ const tests = [...new Set([...tracked, ...b.changed])].filter((p) => isTest(p) && text(p) !== null);
162
+ const names = new Map(tests.map((t) => [t, new Set(idsOn(text(t)))]));
163
+ const observed = b.changed.filter(isTest).map((p) => observe(p, b.base && git(top, ['show', `${b.base}:${p}`], { allowFail: true }), text(p)));
164
+ const isAdr = (p) => adr.some((d) => p.startsWith(`${d}/`));
165
+ const code = b.changed.filter((p) => !isTest(p) && !p.startsWith('requests/') && !p.startsWith(`${b.root}/`)
166
+ && !isAdr(p) && !found.some((r) => r.path === p));
167
+ const ctx = { root: b.root, requests: b.requests, seen: b.seen, shallow: isShallow(top), headings: new Map() };
168
+ // The tests linked to changed file `c`, each with its reason and, as `how`,
169
+ // the reason without its commit, by which the note groups them.
170
+ const linked = (c) => {
171
+ // check reads the working tree; an untracked file has no history, only its own [ID] lines.
172
+ const lines = (text(c) ?? '').split('\n');
173
+ const reached = tracked.has(c) ? fileLinks(top, b.base, b.at ? head : null, c, ctx).ids
174
+ : new Map(idNear(lines, 1, lines.map((_, i) => i + 1)).map((x) => [x.id, `${c} ${x.reason}`]));
175
+ const together = tracked.has(c) ? changedWith(top, head, c).commits : [];
176
+ const ls = tests.flatMap((t) => {
177
+ const id = [...reached.keys()].find((i) => names.get(t).has(i));
178
+ const commit = together.find((x) => x.files.includes(t));
179
+ const [reason, how] = id ? [`names [${id}], near ${c}`, `naming [${id}] (${reached.get(id).replace(/ {2,}/, ': ')})`]
180
+ : commit ? [`changed together with ${c} (${commit.sha.slice(0, 7)})`, `changed together with ${c}`]
181
+ : stem(t) === stem(c) ? [`the same stem as ${c}`, `with the same stem as ${c}`] : [];
182
+ return reason ? [{ test: t, reason, how }] : [];
183
+ });
184
+ return { reached: [...reached.keys()], ls };
185
+ };
186
+ const links = new Map();
187
+ const near = new Map();
188
+ for (const c of b.base ? code : []) {
189
+ const { reached, ls } = linked(c);
190
+ near.set(c, reached);
191
+ links.set(c, ls);
192
+ }
193
+ // A changed ADR counts as a linked change for a test's assertions, as code does.
194
+ const adrLinks = new Map((b.base ? b.changed.filter((p) => isAdr(p) && !isTest(p)) : []).map((p) => [p, linked(p).ls]));
195
+ b.testFacts = { head, tests, names, observed, links, adrLinks, near, results: found };
196
+ return b.testFacts;
197
+ }
198
+
199
+ // Linked tests grouped by how they are linked, with `cap` paths named in all
200
+ // and the rest counted ([HNT-1]): `8 naming [X] (<how c reaches X>): a, b, c; and 5 more; …`.
201
+ function grouped(ls, cap) {
202
+ const groups = new Map();
203
+ for (const l of ls) groups.set(l.how, [...(groups.get(l.how) ?? []), l.test]);
204
+ let left = cap;
205
+ const out = [...groups].map(([how, ts]) => {
206
+ const named = ts.slice(0, left);
207
+ left -= named.length;
208
+ return `${ts.length} ${how}${named.length ? `: ${named.join(', ')}` : ''}`;
209
+ });
210
+ return `${out.join('; ')}${ls.length > cap ? `; and ${ls.length - cap} more; al check --all lists them` : ''}`;
211
+ }
212
+
213
+ // [HNT-2]'s test notes for branch `b`.
214
+ export function testHints(top, b) {
215
+ const f = testFacts(top, b);
216
+ const out = [];
217
+ const add = (rank, text, command) => out.push({ kind: 'note', rank, owners: [], text, command });
218
+ const diff = `al context --diff ${b.range}`;
219
+ for (const [c, ls] of f.links) {
220
+ if (ls.length && !ls.some((l) => b.changed.includes(l.test))) add(15, `${c} changed, but its linked tests did not: ${grouped(ls, b.all ? ls.length : 3)}`, diff);
221
+ }
222
+ const nearIds = new Set([...f.near.values()].flat());
223
+ for (const o of f.observed.filter(moved)) {
224
+ const code = [...f.links].filter(([, ls]) => ls.some((l) => l.test === o.path)).map(([c]) => c);
225
+ const adrs = [...f.adrLinks].filter(([, ls]) => ls.some((l) => l.test === o.path));
226
+ const named = [...(f.names.get(o.path) ?? [])];
227
+ if (!code.length && !adrs.length && !named.some((id) => b.changedIds.includes(id))) add(16, `${o.path} changed its assertions (${o.was} → ${o.now}) with no linked code or spec change`, diff);
228
+ const ids = [...new Set([...named.filter((id) => nearIds.has(id)), ...code.flatMap((c) => f.near.get(c))])];
229
+ if (/^0\b/.test(b.tier ?? '') && ids.length) add(21, `the claim is tier 0, but ${o.path}, a test of ${ids.map((i) => `[${i}]`).join(', ')} near the changed code, changed its assertions`, `${diff} --for review`);
230
+ }
231
+ const head = f.results.some((r) => r.cls === 'head');
232
+ for (const r of f.results.filter((x) => !x.missing && (['older', 'unknown'].includes(x.cls) || (x.cls === 'base' && !head)))) {
233
+ add(20, `${r.path} is at ${r.rev ? `${r.cls} revision ${r.rev.slice(0, 7)}` : 'an unknown revision'}: not evidence for this change`,
234
+ `run the tests at ${f.head.slice(0, 7)} with a "revision: <sha>" line in ${r.path}, then al check`);
235
+ }
236
+ return out;
237
+ }
238
+
239
+ // The Tests lines: each changed test's assertions; with `linked`, each test
240
+ // linked to the changed code or naming a changed section, with its reason.
241
+ export function testLines(top, b, linked) {
242
+ const f = testFacts(top, b);
243
+ const out = f.observed.map(observation);
244
+ if (linked) {
245
+ for (const [c, ls] of f.links) out.push(...ls.map((l) => `${l.test} linked to ${c}: ${l.reason}`));
246
+ for (const t of f.tests) out.push(...[...f.names.get(t)].filter((id) => b.changedIds.includes(id)).map((id) => `${t} names [${id}], changed on this branch`));
247
+ }
248
+ return (out.length ? out : ['no test file changed']).map((t, i) => line(i ? '' : 'Tests', t));
249
+ }
250
+
251
+ // The Results lines: each named file with its revision, class and counts;
252
+ // then, with both a head and a base result, the failures compared by name.
253
+ export function resultLines(top, b) {
254
+ const rs = testFacts(top, b).results;
255
+ const out = rs.map((r) => (r.missing ? `${r.path} not found` : !r.tests ? `${r.path} not a format al reads (TAP, JUnit XML): skipped`
256
+ : `${r.path} at ${r.rev ? r.rev.slice(0, 7) : 'no revision'} (${r.cls}${['older', 'unknown'].includes(r.cls) ? ', not evidence for this change' : ''}): ${r.tests.passed} passed, ${r.tests.failed} failed${r.tests.incomplete ? `; ${r.tests.incomplete}` : ''}`));
257
+ const names = (cls, kind) => (rs.some((r) => r.cls === cls && r.tests) ? new Set(rs.filter((r) => r.cls === cls && r.tests).flatMap((r) => [...r.tests[kind]])) : null);
258
+ const [h, was, passing] = [names('head', 'failing'), names('base', 'failing'), names('head', 'passing')];
259
+ const list = (xs) => xs.join(', ') || 'none';
260
+ if (h && was) out.push(`new failures: ${list([...h].filter((n) => !was.has(n)))} · already failing at base: ${list([...h].filter((n) => was.has(n)))} · fixed: ${list([...was].filter((n) => passing.has(n) && !h.has(n)))}`);
261
+ return (out.length ? out : ['no result files named (add results: <path> to .assuredloop)']).map((t, i) => line(i ? '' : 'Results', t));
262
+ }
263
+
264
+ // Not known, when a head result is shown.
265
+ export const headNote = (top, b) => (testFacts(top, b).results.some((r) => r.cls === 'head') ? ['whether the head results covered uncommitted changes'] : []);
266
+ export const assertionsChanged = (top, b) => testFacts(top, b).observed.filter(moved).map((o) => o.path);
267
+ export const nearIds = (top, b) => [...new Set([...testFacts(top, b).near.values()].flat())];
package/src/tree.js ADDED
@@ -0,0 +1,113 @@
1
+ // Every read goes through a tree: the working tree, or a commit's tree read
2
+ // through git ([VW-8]). Paths are relative to the repo's top level.
3
+ import { lstatSync, readFileSync, readdirSync } from 'node:fs';
4
+ import { join, relative } from 'node:path';
5
+ import { git, resolveCommit } from './git.js';
6
+
7
+ export function openTree(top, at) {
8
+ if (!at) {
9
+ return {
10
+ label: 'working tree',
11
+ // No read goes through a symlink anywhere on its path, and a symlink is
12
+ // never listed, so no read leaves the repo through one ([SPC-1]).
13
+ read(path) {
14
+ if (!noSymlinkOn(top, path)) return null;
15
+ try { return readFileSync(join(top, path)); } catch { return null; }
16
+ },
17
+ list(dir) {
18
+ if (!noSymlinkOn(top, dir)) return null;
19
+ try { return readdirSync(join(top, dir), { withFileTypes: true }).filter((d) => !d.isSymbolicLink()).map((d) => d.name).sort(); } catch { return null; }
20
+ },
21
+ // Every file under `dir`, as repo-relative paths in path order, going
22
+ // down real folders only.
23
+ walk(dir) {
24
+ const out = [];
25
+ if (!noSymlinkOn(top, dir)) return out;
26
+ const down = (d) => {
27
+ let entries;
28
+ try { entries = readdirSync(join(top, d), { withFileTypes: true }); } catch { return; }
29
+ for (const e of entries) {
30
+ if (e.isDirectory()) down(`${d}/${e.name}`);
31
+ else if (e.isFile()) out.push(relative(top, join(top, d, e.name)));
32
+ }
33
+ };
34
+ down(dir);
35
+ return out.sort();
36
+ },
37
+ };
38
+ }
39
+ const sha = resolveCommit(top, at);
40
+ return {
41
+ label: `commit ${sha.slice(0, 7)}`,
42
+ read(path) {
43
+ return git(top, ['cat-file', 'blob', `${sha}:${path}`], { text: false, allowFail: true });
44
+ },
45
+ list(dir) {
46
+ // -z: names as stored, never quoted (a non-ASCII or quoted name included).
47
+ const out = git(top, ['ls-tree', '-z', '--name-only', sha, '--', `${dir}/`], { allowFail: true });
48
+ if (!out) return null;
49
+ return out.split('\0').filter(Boolean).map((p) => p.slice(dir.length + 1)).sort();
50
+ },
51
+ walk(dir) {
52
+ const out = git(top, ['ls-tree', '-z', '-r', '--name-only', sha, '--', `${dir}/`], { allowFail: true });
53
+ return out ? out.split('\0').filter(Boolean).sort() : [];
54
+ },
55
+ // Whether any part of `path` is a symlink (mode 120000) in this commit.
56
+ linkOn(path) {
57
+ const parts = path.split('/');
58
+ return parts.some((_, i) => git(top, ['ls-tree', sha, '--', parts.slice(0, i + 1).join('/')], { allowFail: true })?.startsWith('120000'));
59
+ },
60
+ };
61
+ }
62
+
63
+ // A request's name, its permanent ID ([REC-1]): lowercase letters, digits and
64
+ // hyphens. It is never a path, so no alias reaches a request by another way.
65
+ export const NAME = /^[a-z0-9][a-z0-9-]*$/;
66
+ export const isName = (name) => typeof name === 'string' && NAME.test(name) && name !== 'archive';
67
+
68
+ // Every request folder in `tree` whose name is a request name, as { name,
69
+ // dir, open }; a name both open and archived is read open, as findRequest
70
+ // reads it. A symlink is never a request folder (list leaves it out).
71
+ export function requestDirs(tree) {
72
+ const dirs = [];
73
+ for (const [at, open] of [['requests', true], ['requests/archive', false]]) {
74
+ for (const name of (tree.list(at) ?? []).filter(isName)) {
75
+ if (!dirs.some((d) => d.name === name) && tree.read(`${at}/${name}/request.md`) !== null) dirs.push({ name, dir: `${at}/${name}`, open });
76
+ }
77
+ }
78
+ return dirs;
79
+ }
80
+
81
+ // The folder under requests/ (or requests/archive/) that a path is in, whatever its name.
82
+ export const folderName = (path) => path.match(/^requests\/archive\/([^/]+)\//)?.[1] ?? path.match(/^requests\/(?!archive\/)([^/]+)\//)?.[1];
83
+
84
+ // The request a path under requests/ belongs to, when its folder is a request name.
85
+ export const folderOf = (path) => {
86
+ const name = folderName(path);
87
+ return isName(name) ? name : undefined;
88
+ };
89
+
90
+ // Where a request lives in this tree: requests/<name> or requests/archive/<name>.
91
+ export function findRequest(tree, name) {
92
+ if (!isName(name)) return null;
93
+ for (const at of ['requests', 'requests/archive']) {
94
+ if ((tree.list(at) ?? []).includes(name) && tree.read(`${at}/${name}/request.md`) !== null) return `${at}/${name}`;
95
+ }
96
+ return null;
97
+ }
98
+
99
+ // Whether the tool may write `path` (repo-relative): it stays inside the repo
100
+ // (no `..`), and no part of it below the repo top, from the first
101
+ // folder down to the file, is a symlink, wherever the link points. Parts that
102
+ // do not exist yet are created as real folders.
103
+ export function noSymlinkOn(top, path) {
104
+ if (path.split('/').includes('..')) return false;
105
+ let at = top;
106
+ for (const part of path.split('/')) {
107
+ at = join(at, part);
108
+ try {
109
+ if (lstatSync(at).isSymbolicLink()) return false;
110
+ } catch { return true; }
111
+ }
112
+ return true;
113
+ }