@assuredloop/cli 0.0.1 → 0.2.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,208 @@
1
+ // One state of a v4 project, the working tree or a commit, as the checks and
2
+ // views read it (T10, T11): the spec paragraphs, each request with its
3
+ // request.md, record and change spec, and the ADRs. Nothing is written.
4
+ import { readdirSync } from 'node:fs';
5
+ import { join, posix } from 'node:path';
6
+ import { parse } from 'yaml';
7
+ import { read, recordPath } from './base.js';
8
+ import { loadConfig, loadSchema } from './config.js';
9
+ import { fileAt, filesAt, git } from './git.js';
10
+ import { KINDS, groupOf, parseMarkdown } from './markers.js';
11
+ import { requirements } from './request-md.js';
12
+ import { SPEC, docsInScope, parseScopes, symlinkOn } from './scope.js';
13
+
14
+ const ADR_FILE = /^(\d{4})-[^/]*\.md$/;
15
+
16
+ // The names in folder `dir` of the state: its folders, or its files.
17
+ function names(top, rev, dir, folders) {
18
+ if (rev) {
19
+ const out = git(top, ['ls-tree', rev, `${dir}/`], { allowFail: true }) ?? '';
20
+ return out.split('\n').filter(Boolean).map((l) => l.split('\t'))
21
+ .filter(([meta]) => (folders ? meta.includes(' tree ') : meta.includes(' blob ')))
22
+ .map(([, path]) => path.slice(dir.length + 1)).sort();
23
+ }
24
+ if (symlinkOn(top, dir)) return [];
25
+ try {
26
+ return readdirSync(join(top, dir), { withFileTypes: true }).filter((e) => (folders ? e.isDirectory() : e.isFile())).map((e) => e.name).sort();
27
+ } catch {
28
+ return [];
29
+ }
30
+ }
31
+
32
+ // The text after an ADR's Status line: what must not change once it is accepted.
33
+ const adrBody = (text) => text.replace(/\r\n?/g, '\n').replace(/^\s*Status:[^\n]*\n?/, '').replace(/^\n+/, '');
34
+
35
+ // The settings of a commit: its own config.yaml and schema.yaml, with the
36
+ // defaults of config.js when a file is absent or not a mapping.
37
+ export function settingsAt(top, rev) {
38
+ const files = filesAt(top, rev, ['.assuredloop/config.yaml', '.assuredloop/schema.yaml']);
39
+ const yamlAt = (name) => {
40
+ try {
41
+ const v = parse(files.get(`.assuredloop/${name}`) ?? 'null');
42
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
43
+ } catch {
44
+ return {};
45
+ }
46
+ };
47
+ const c = yamlAt('config.yaml');
48
+ const s = yamlAt('schema.yaml');
49
+ return { config: { ...c, root: c.root ?? 'specs', docs: Array.isArray(c.docs) ? c.docs : [] }, kinds: s.kinds ?? KINDS };
50
+ }
51
+
52
+ export function loadState(top, rev = null) {
53
+ const { config, kinds } = rev ? settingsAt(top, rev) : { config: loadConfig(top), kinds: loadSchema(top).kinds };
54
+ const root = posix.normalize(String(config.root)).replace(/\/+$/, '');
55
+ const openNames = names(top, rev, 'requests', true).filter((n) => n !== 'archive');
56
+ const archivedNames = names(top, rev, 'requests/archive', true);
57
+ const adrFiles = names(top, rev, `${root}/adr`, false);
58
+ // At a commit, the requests' files and the ADRs are read in one batch.
59
+ const atRev = rev ? filesAt(top, rev, [
60
+ ...openNames.flatMap((n) => [`requests/${n}/request.md`, recordPath(n)]),
61
+ ...archivedNames.flatMap((n) => [`requests/archive/${n}/request.md`, recordPath(n)]),
62
+ ...adrFiles.map((f) => `${root}/adr/${f}`),
63
+ ]) : null;
64
+ const get = rev ? (p) => (atRev.has(p) ? atRev.get(p) : fileAt(top, rev, p)) : (p) => read(top, p);
65
+ const { scopes, lints } = parseScopes(docsInScope(top, config, rev), kinds);
66
+ const spec = new Map((scopes.get(SPEC) ?? []).map((p) => [p.id, p]));
67
+
68
+ const requests = new Map();
69
+ const add = (name, open) => {
70
+ const dir = open ? `requests/${name}` : `requests/archive/${name}`;
71
+ const md = get(`${dir}/request.md`);
72
+ if (md === null || md === undefined) return;
73
+ // The record is read now and parsed on first use (design.md 11): a check
74
+ // that does not need an archived record never parses it.
75
+ let text = null;
76
+ try {
77
+ text = get(recordPath(name));
78
+ } catch {
79
+ text = null;
80
+ }
81
+ let data;
82
+ const dataOf = () => {
83
+ if (data !== undefined) return data;
84
+ try {
85
+ data = parse(text ?? 'null');
86
+ } catch {
87
+ data = null;
88
+ }
89
+ if (!data || typeof data !== 'object' || Array.isArray(data)) data = null;
90
+ return data;
91
+ };
92
+ const specFile = `${dir}/spec.md`;
93
+ const paras = (scopes.get(`request:${name}`) ?? []).filter((p) => p.file === specFile);
94
+ const tierOf = () => String(dataOf()?.tier ?? /^Tier:\s*(\S+)/m.exec(md)?.[1] ?? '');
95
+ requests.set(name, {
96
+ name, open, dir, md, specFile,
97
+ get data() { return dataOf(); },
98
+ get tier() { return tierOf(); },
99
+ get spike() { return tierOf() === 'S' || /^##\s+Organized question\s*$/m.test(md); },
100
+ // Whether the record's text names `x`, so that a reader parses only the
101
+ // records that can hold what it looks for; mayAdopt for the adoption
102
+ // entries. YAML writes a word another way only through an escape in a
103
+ // double-quoted string, so a record with a backslash always counts.
104
+ mentions: (x) => typeof text === 'string' && (text.includes(x) || text.includes('\\')),
105
+ mayAdopt: typeof text === 'string' && (text.includes('adoption') || text.includes('\\')),
106
+ reqs: requirements(md), paras: new Map(paras.map((p) => [p.id, p])),
107
+ });
108
+ };
109
+ for (const n of openNames) add(n, true);
110
+ for (const n of archivedNames) if (!requests.has(n)) add(n, false);
111
+
112
+ const adrs = new Map();
113
+ for (const f of adrFiles) {
114
+ const m = ADR_FILE.exec(f);
115
+ if (!m) continue;
116
+ const file = `${root}/adr/${f}`;
117
+ const text = get(file);
118
+ if (text === null || text === undefined) continue;
119
+ const paragraphs = parseMarkdown(text, file, { kinds }).paragraphs;
120
+ const id = `ADR-${Number(m[1])}`;
121
+ const status = /^\s*Status:\s*([a-z]+)/i.exec(text)?.[1].toLowerCase() ?? null;
122
+ adrs.set(id, { id, file, status, body: adrBody(text), paragraphs, head: paragraphs.find((p) => p.id === id) ?? null });
123
+ }
124
+ return { top, rev, config, kinds, root, get, scopes, lints, spec, requests, adrs };
125
+ }
126
+
127
+ // The kind groups a check asks about.
128
+ export const isPromise = (state, kind) => !!kind && groupOf(kind, state.kinds) === 'promise';
129
+ // A kind whose change paragraph goes into specs/, so it needs a disposition
130
+ // (design.md 5, item 3): promise, the lasting design kinds and the informative ones.
131
+ export const hasBaselineEffect = (state, kind) => !!kind && kind !== 'approach'
132
+ && ['promise', 'design', 'informative'].includes(groupOf(kind, state.kinds));
133
+
134
+ // A link target as written, made exact: `name:ID` is another repo's (T12);
135
+ // `<request>/<ID>` a request's; in a request's own text, R, Q, D and S numbers,
136
+ // a snapshot file and an ID of its own change spec are its own; else a spec ID.
137
+ export function qualify(ref, request, own = new Set()) {
138
+ if (ref.includes(':')) return { key: ref, cross: true };
139
+ const slash = ref.indexOf('/');
140
+ if (slash > 0) return { key: ref, req: ref.slice(0, slash), id: ref.slice(slash + 1) };
141
+ if (request && (/^[RQDS]\d+$/.test(ref) || /\.md$/.test(ref) || own.has(ref))) return { key: `${request}/${ref}`, req: request, id: ref };
142
+ return { key: ref, req: null, id: ref };
143
+ }
144
+
145
+ // The paragraph or record entry a qualified target names in `state`, as
146
+ // { sha, version? }, or null when it does not resolve.
147
+ export function resolve(state, t) {
148
+ if (t.cross) return null;
149
+ if (!t.req) {
150
+ const p = state.spec.get(t.id) ?? adrParagraph(state, t.id);
151
+ return p ? { sha: p.sha256, p } : null;
152
+ }
153
+ const r = state.requests.get(t.req);
154
+ if (!r) return null;
155
+ // A requirement in request.md needs no record, so the record is not parsed for it.
156
+ if (/^[RQ]\d+$/.test(t.id)) {
157
+ const q = r.reqs.find((x) => x.id === t.id);
158
+ if (q) return { sha: q.sha256, req: q };
159
+ const v = latestVersion(r.data ?? {}, t.id);
160
+ return v ? { sha: v.sha256 } : null;
161
+ }
162
+ if (!/^[DST]\d+$/.test(t.id) && !/\.md$/.test(t.id)) {
163
+ const p = r.paras.get(t.id);
164
+ return p ? { sha: p.sha256, p } : null;
165
+ }
166
+ const data = r.data ?? {};
167
+ const entry = (key) => (Array.isArray(data[key]) ? data[key] : []).find((e) => e?.id === t.id);
168
+ if (/^D\d+$/.test(t.id)) return entry('decisions') ? { sha: entry('decisions').sha256 ?? null, entry: entry('decisions') } : null;
169
+ if (/^S\d+$/.test(t.id)) return entry('signoff') ? { sha: entry('signoff').sha256 ?? null, entry: entry('signoff') } : null;
170
+ if (/^T\d+$/.test(t.id)) return entry('tasks') ? { sha: null, entry: entry('tasks') } : null;
171
+ if (/\.md$/.test(t.id)) {
172
+ const s = (Array.isArray(data.sources) ? data.sources : []).find((e) => e?.file === t.id);
173
+ return s ? { sha: s.sha256 ?? null } : null;
174
+ }
175
+ return null;
176
+ }
177
+
178
+ export function adrParagraph(state, id) {
179
+ const m = /^(ADR-\d+)(?:-\d+)?$/.exec(id);
180
+ return m ? state.adrs.get(m[1])?.paragraphs.find((p) => p.id === id) ?? null : null;
181
+ }
182
+
183
+ export const latestVersion = (data, id) => (Array.isArray(data?.requirements) ? data.requirements : []).filter((v) => v?.id === id)
184
+ .reduce((a, v) => (Number(v.version) > Number(a?.version ?? 0) ? v : a), null);
185
+
186
+ // Whether requirement (or question) `id` of request `name` is signed at its
187
+ // current text: a sign-off covers that id with the current text's hash.
188
+ export function signed(state, name, id) {
189
+ const r = state.requests.get(name);
190
+ const q = r?.reqs.find((x) => x.id === id);
191
+ if (!q) return false;
192
+ return (Array.isArray(r.data?.signoff) ? r.data.signoff : [])
193
+ .some((s) => (Array.isArray(s?.covers) ? s.covers : []).some((c) => c?.id === id && c.sha256 === q.sha256));
194
+ }
195
+
196
+ // Whether paragraph `p` (of request `request`, or of specs/ when null) serves a
197
+ // requirement that is signed at its current text.
198
+ export function servesSigned(state, p, request) {
199
+ return (p.links?.serves ?? []).some((ref) => {
200
+ const t = qualify(ref, request);
201
+ return !t.cross && t.req && /^[RQ]\d+$/.test(t.id) && signed(state, t.req, t.id);
202
+ });
203
+ }
204
+
205
+ // The request a scope belongs to, or null for specs/.
206
+ export const requestOf = (scope) => (scope.startsWith('request:') ? scope.slice('request:'.length) : null);
207
+ // How a paragraph is named in findings: a change spec's IDs are its request's own.
208
+ export const named = (request, id) => (request ? `${request}/${id}` : id);