@sdd-method/author-cli 0.9.0 → 0.10.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/dist/author-program.js +1 -0
- package/dist/lib/intents/format.js +77 -0
- package/dist/lib/intents/migrate.js +644 -0
- package/dist/lib/validate/dispatch.js +7 -0
- package/dist/lib/validate/intents.js +76 -0
- package/dist/maintain-version.json +2 -2
- package/dist/verbs/migrate.js +44 -0
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
package/dist/author-program.js
CHANGED
|
@@ -47,6 +47,7 @@ export const AUTHOR_ONLY_SURFACE = [
|
|
|
47
47
|
"context pull",
|
|
48
48
|
"catalogue check",
|
|
49
49
|
"migrate vocabulary",
|
|
50
|
+
"migrate intents",
|
|
50
51
|
];
|
|
51
52
|
/** The full surface: verify's, plus the author's own. */
|
|
52
53
|
export const AUTHOR_SURFACE = [...VERIFY_SURFACE, ...AUTHOR_ONLY_SURFACE];
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The intent artefact's shape (M-ADR 0227 §4.1), shared by the migration that writes intents and the check
|
|
2
|
+
// that reads them. The section names and header lines are the shipped template's
|
|
3
|
+
// (docs/method/methods/templates/intent-template.md), so a change there is a change here.
|
|
4
|
+
export const INTENT_DIRS = ["content/method/intents", "content/product/intents"];
|
|
5
|
+
export const INTENT_SECTIONS = [
|
|
6
|
+
"Problem",
|
|
7
|
+
"Proposed outcome",
|
|
8
|
+
"Affected users and systems",
|
|
9
|
+
"Constraints",
|
|
10
|
+
"Open questions",
|
|
11
|
+
"Decision to resolve in exploration",
|
|
12
|
+
"ADR requirement",
|
|
13
|
+
"Where it went",
|
|
14
|
+
];
|
|
15
|
+
/** Header fields of the retired Idea and the idea map: planning state, which belongs in the work item (§4.3). */
|
|
16
|
+
export const PLANNING_FIELDS = [
|
|
17
|
+
"Status",
|
|
18
|
+
"Priority",
|
|
19
|
+
"Blocked by",
|
|
20
|
+
"Enables",
|
|
21
|
+
"Parent Idea",
|
|
22
|
+
"Child Ideas",
|
|
23
|
+
"Idea ID",
|
|
24
|
+
"Epic ID",
|
|
25
|
+
"Theme",
|
|
26
|
+
];
|
|
27
|
+
/** A "Where it went" line naming a path: "- `<path>` (<sdd-id>; <source>)". */
|
|
28
|
+
export const WHERE_PATH_LINE = /^-\s+`([^`]+)`\s+\(([^;)]+);\s*([^)]+)\)\s*$/;
|
|
29
|
+
/** "## 3. Why Explore This (Revised)" -> "why explore this". */
|
|
30
|
+
export function normaliseHeading(h) {
|
|
31
|
+
return h
|
|
32
|
+
.replace(/^\s*\d+[A-Za-z]?(\.\d+)*\.?\s*/, "")
|
|
33
|
+
.replace(/\((revised|updated)\)\s*$/i, "")
|
|
34
|
+
.trim()
|
|
35
|
+
.toLowerCase();
|
|
36
|
+
}
|
|
37
|
+
export function parseMarkdown(text) {
|
|
38
|
+
const lines = text.split("\n");
|
|
39
|
+
let title = "";
|
|
40
|
+
const fields = new Map();
|
|
41
|
+
const sections = new Map();
|
|
42
|
+
const headings = [];
|
|
43
|
+
let current = null;
|
|
44
|
+
let buf = [];
|
|
45
|
+
const flush = () => {
|
|
46
|
+
if (current !== null) {
|
|
47
|
+
const prev = sections.get(current);
|
|
48
|
+
const body = buf.join("\n").trim();
|
|
49
|
+
sections.set(current, prev ? `${prev}\n\n${body}` : body);
|
|
50
|
+
}
|
|
51
|
+
buf = [];
|
|
52
|
+
};
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
const h1 = /^#\s+(.+?)\s*$/.exec(line);
|
|
55
|
+
if (h1 && !title && current === null) {
|
|
56
|
+
title = h1[1];
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const h2 = /^##\s+(.+?)\s*$/.exec(line);
|
|
60
|
+
if (h2) {
|
|
61
|
+
flush();
|
|
62
|
+
headings.push(h2[1]);
|
|
63
|
+
current = normaliseHeading(h2[1]);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (current === null) {
|
|
67
|
+
const f = /^\*\*([^*:]+):\*\*\s*(.*?)\s*$/.exec(line);
|
|
68
|
+
if (f)
|
|
69
|
+
fields.set(f[1].trim(), f[2]);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
buf.push(line);
|
|
73
|
+
}
|
|
74
|
+
flush();
|
|
75
|
+
return { title, fields, sections, headings };
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=format.js.map
|
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
// `migrate intents` (M-ADR 0227 §5.2): turn an adopter's Ideas and Epic Briefs into intents, one per request id.
|
|
2
|
+
//
|
|
3
|
+
// An Idea and its Epic Brief are one request at two stages, so they make one intent. The intent takes the
|
|
4
|
+
// originator's words from the Idea where it has them and the Brief's otherwise (a request with no Idea takes
|
|
5
|
+
// its outcome from the Brief's Scope); the Brief's other elaboration (evidence, handoff, exit criteria) is not
|
|
6
|
+
// copied, because the artefacts it produced hold it. The method shipped no Brief template, so Brief headings
|
|
7
|
+
// are matched by name, and the report lists every heading the converter did not recognise. Its
|
|
8
|
+
// "Where it went" is rebuilt from evidence, each line saying which source supports it:
|
|
9
|
+
// cited a path the Idea or Brief names that is a file the SDD owns in its working tree (a repository-
|
|
10
|
+
// relative path, or a blob URL whose repository is one of the SDDs);
|
|
11
|
+
// mentioned a tracked file in an SDD that names the request id, unless the file names many request ids
|
|
12
|
+
// (an index lists requests; it is not where one went);
|
|
13
|
+
// commit a commit in an SDD whose message names the id, unless its subject moves, renames, archives or
|
|
14
|
+
// marks the Ideas and Briefs themselves. When it names at most a few ids (a plan
|
|
15
|
+
// bundle delivers several), the files it changed that the SDD still owns are evidence too
|
|
16
|
+
// (committed, naming the other ids it shares with), unless it touched so many that it was a sweep;
|
|
17
|
+
// plan a plan file (from --plans) that names the id. A plan says what was planned, not what shipped,
|
|
18
|
+
// so an id whose only evidence is a plan is flagged for a check that the work landed. A plan
|
|
19
|
+
// naming at most a few ids also contributes the paths it cites that an SDD owns now (planned).
|
|
20
|
+
// A path that moved since it was cited or committed is followed through the SDD's git renames to where it is
|
|
21
|
+
// now. A reviewer's decisions are given back as --home ID=<sdd-id> and --status ID=open|closed.
|
|
22
|
+
// Planning state (status, priority, relations) goes to a tool-neutral work-item import file, never into the
|
|
23
|
+
// intent (M-ADR 0227 §4.3, M-ADR 0035). A dry run writes the report, the import file, the citation table and
|
|
24
|
+
// the intents, all under --out; --write writes the intents into the SDDs instead, never over an existing file.
|
|
25
|
+
import { execFile } from "node:child_process";
|
|
26
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
27
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
28
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
29
|
+
import { promisify } from "node:util";
|
|
30
|
+
import { parseDocument } from "yaml";
|
|
31
|
+
import { normaliseHeading, parseMarkdown } from "./format.js";
|
|
32
|
+
const run = promisify(execFile);
|
|
33
|
+
const ID_TOKEN = /\b[A-Z][A-Z0-9]*-\d+\b/g;
|
|
34
|
+
const CLOSED = new Set(["completed", "complete", "done", "delivered", "shipped", "closed"]);
|
|
35
|
+
const DECLINED = new Set(["dropped", "rejected", "declined", "obsolete", "withdrawn"]);
|
|
36
|
+
const IDEA_MAP_IDS = /\b[A-Z][A-Z0-9]*-\d+\b/g;
|
|
37
|
+
const NOT_OWN = /^(docs\/method\/|catalogues|\.sdd\/|openspec\/schemas\/|content\/(method|product)\/intents\/)/;
|
|
38
|
+
/** A commit changing more owned files than this was a sweep (a rename, a reformat), not a delivery. */
|
|
39
|
+
const SWEEP = 25;
|
|
40
|
+
/** A commit or plan naming more request ids than this is not evidence for any one of them. */
|
|
41
|
+
const SHARED = 3;
|
|
42
|
+
const HOUSEKEEPING = /\b(move[sd]?|moving|rename[sd]?|renaming|archive[sd]?|mark(s|ed)?|retire[sd]?|close[sd]?|complete[sd]?)\b.*\b(idea|ideas|idea-map|epic|epics|brief|briefs)\b|\b(idea|ideas|idea-map|epic|epics|brief|briefs)\b.*\b(moved|renamed|archived|marked|retired|closed|completed)\b/i;
|
|
43
|
+
async function walk(dir, accept) {
|
|
44
|
+
const out = [];
|
|
45
|
+
const go = async (d) => {
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = await readdir(d, { withFileTypes: true });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
for (const e of entries) {
|
|
54
|
+
if (e.name === ".git" || e.name === "node_modules")
|
|
55
|
+
continue;
|
|
56
|
+
const p = join(d, e.name);
|
|
57
|
+
if (e.isDirectory())
|
|
58
|
+
await go(p);
|
|
59
|
+
else if (e.isFile() && accept(p))
|
|
60
|
+
out.push(p);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
await go(dir);
|
|
64
|
+
return out.sort();
|
|
65
|
+
}
|
|
66
|
+
async function gitTop(dir) {
|
|
67
|
+
try {
|
|
68
|
+
return (await run("git", ["-C", dir, "rev-parse", "--show-toplevel"])).stdout.trim();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return dir;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function gitHead(dir) {
|
|
75
|
+
try {
|
|
76
|
+
return (await run("git", ["-C", dir, "rev-parse", "--short", "HEAD"])).stdout.trim();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return "uncommitted";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function loadSdd(dir) {
|
|
83
|
+
let id = basename(dir);
|
|
84
|
+
try {
|
|
85
|
+
const m = parseDocument(await readFile(join(dir, ".sdd/sdd-manifest.yaml"), "utf8")).toJS();
|
|
86
|
+
if (typeof m?.sdd_id === "string" && m.sdd_id)
|
|
87
|
+
id = m.sdd_id;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// no manifest: the directory name
|
|
91
|
+
}
|
|
92
|
+
const repoNames = new Set([basename(dir)]);
|
|
93
|
+
try {
|
|
94
|
+
const url = (await run("git", ["-C", dir, "remote", "get-url", "origin"])).stdout.trim();
|
|
95
|
+
repoNames.add(basename(url).replace(/\.git$/, ""));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// no remote
|
|
99
|
+
}
|
|
100
|
+
const renames = new Map();
|
|
101
|
+
try {
|
|
102
|
+
const { stdout } = await run("git", ["-C", dir, "log", "--format=", "--name-status", "-M", "--diff-filter=R"], { maxBuffer: 256 * 1024 * 1024 });
|
|
103
|
+
for (const line of stdout.split("\n")) {
|
|
104
|
+
const [st, from, to] = line.split("\t");
|
|
105
|
+
if (st?.startsWith("R") && from && to && !renames.has(from))
|
|
106
|
+
renames.set(from, to);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// not a git repository
|
|
111
|
+
}
|
|
112
|
+
return { dir, id, repoNames, managed: await managedPaths(dir), renames };
|
|
113
|
+
}
|
|
114
|
+
function readSource(path, text) {
|
|
115
|
+
const doc = parseMarkdown(text);
|
|
116
|
+
// The title decides first, then the id field, then the folder: a Brief often repeats its Idea's id field.
|
|
117
|
+
const t = doc.title.toLowerCase();
|
|
118
|
+
const kind = t.startsWith("epic brief")
|
|
119
|
+
? "brief"
|
|
120
|
+
: t.startsWith("idea")
|
|
121
|
+
? "idea"
|
|
122
|
+
: doc.fields.has("Epic ID")
|
|
123
|
+
? "brief"
|
|
124
|
+
: /(^|\/)epics\//.test(path)
|
|
125
|
+
? "brief"
|
|
126
|
+
: doc.fields.has("Idea ID") || /(^|\/)ideas\//.test(path)
|
|
127
|
+
? "idea"
|
|
128
|
+
: null;
|
|
129
|
+
if (!kind)
|
|
130
|
+
return null;
|
|
131
|
+
const id = doc.fields.get("Idea ID") ?? doc.fields.get("Epic ID") ?? /^([A-Z][A-Z0-9]*-\d+)/.exec(basename(path))?.[1];
|
|
132
|
+
if (!id)
|
|
133
|
+
return null;
|
|
134
|
+
return { kind, path, id: id.replace(/`/g, "").trim(), doc };
|
|
135
|
+
}
|
|
136
|
+
function titleOf(f) {
|
|
137
|
+
return f.doc.title
|
|
138
|
+
.replace(/^(idea|epic brief)\s*:\s*/i, "")
|
|
139
|
+
.replace(new RegExp(`^${f.id}\\s*[—–:-]\\s*`), "")
|
|
140
|
+
.trim();
|
|
141
|
+
}
|
|
142
|
+
function section(f, ...names) {
|
|
143
|
+
if (!f)
|
|
144
|
+
return "";
|
|
145
|
+
for (const n of names) {
|
|
146
|
+
for (const [k, v] of f.doc.sections)
|
|
147
|
+
if ((k === n || k.startsWith(`${n} `)) && v)
|
|
148
|
+
return v;
|
|
149
|
+
}
|
|
150
|
+
return "";
|
|
151
|
+
}
|
|
152
|
+
/** Every section whose normalised heading passes the test, in order. */
|
|
153
|
+
function matching(f, test) {
|
|
154
|
+
if (!f)
|
|
155
|
+
return [];
|
|
156
|
+
return [...f.doc.sections.entries()].filter(([h, v]) => test(h) && v).map(([, v]) => v);
|
|
157
|
+
}
|
|
158
|
+
const isGuardrails = (h) => h.startsWith("guardrails");
|
|
159
|
+
function idsIn(s) {
|
|
160
|
+
return s ? [...new Set(s.match(IDEA_MAP_IDS) ?? [])] : [];
|
|
161
|
+
}
|
|
162
|
+
/** Required | Optional | None, keeping the source's own words after it. */
|
|
163
|
+
function adrRequirement(raw) {
|
|
164
|
+
const v = raw.trim();
|
|
165
|
+
if (!v)
|
|
166
|
+
return "None (not recorded in the source).";
|
|
167
|
+
const w = v.split(/[\s—–(-]/)[0].toLowerCase();
|
|
168
|
+
const norm = w === "required" || w === "likely" ? "Required" : w === "optional" ? "Optional" : w === "none" || w === "unlikely" ? "None" : null;
|
|
169
|
+
return norm ? `${norm}. Source: ${v}` : `${v} (not one of Required, Optional or None: review)`;
|
|
170
|
+
}
|
|
171
|
+
const KNOWN_BRIEF = [
|
|
172
|
+
"problem statement",
|
|
173
|
+
"decision to resolve",
|
|
174
|
+
"scope",
|
|
175
|
+
"resolved and open questions",
|
|
176
|
+
"resolved vs open",
|
|
177
|
+
"resolved vs open questions",
|
|
178
|
+
"dependencies and constraints",
|
|
179
|
+
"dependencies",
|
|
180
|
+
"evidence and draft artifacts",
|
|
181
|
+
"planning handoff targets",
|
|
182
|
+
"planning handoff",
|
|
183
|
+
"exit criteria for planning readiness",
|
|
184
|
+
"exit criteria",
|
|
185
|
+
];
|
|
186
|
+
const KNOWN_IDEA = [
|
|
187
|
+
"problem or opportunity",
|
|
188
|
+
"idea summary",
|
|
189
|
+
"why explore this",
|
|
190
|
+
"open questions",
|
|
191
|
+
"early impact notes",
|
|
192
|
+
"explore next",
|
|
193
|
+
"related ideas",
|
|
194
|
+
"exit criteria to become an epic brief",
|
|
195
|
+
];
|
|
196
|
+
function readIdeaMap(text) {
|
|
197
|
+
const rows = new Map();
|
|
198
|
+
let cols = null;
|
|
199
|
+
for (const line of text.split("\n")) {
|
|
200
|
+
if (!line.trim().startsWith("|")) {
|
|
201
|
+
cols = null;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const cells = line.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
|
|
205
|
+
if (cells.every((c) => /^:?-+:?$/.test(c)))
|
|
206
|
+
continue;
|
|
207
|
+
if (!cols) {
|
|
208
|
+
cols = cells.map((c) => c.toLowerCase());
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const at = (name) => {
|
|
212
|
+
const i = cols.indexOf(name);
|
|
213
|
+
return i >= 0 ? cells[i] : undefined;
|
|
214
|
+
};
|
|
215
|
+
const id = at("id");
|
|
216
|
+
if (!id || !/^[A-Z][A-Z0-9]*-\d+$/.test(id))
|
|
217
|
+
continue;
|
|
218
|
+
rows.set(id, { status: at("status"), priority: at("priority"), depends: idsIn(at("depends on")) });
|
|
219
|
+
}
|
|
220
|
+
return rows;
|
|
221
|
+
}
|
|
222
|
+
const PATHISH = /`([^`\s][^`]*\/[^`]*)`/g;
|
|
223
|
+
const LINK = /\]\(([^)\s]+)\)/g;
|
|
224
|
+
const BLOB = /^https?:\/\/[^/]+\/[^/]+\/([^/]+)\/(?:blob|tree)\/[^/]+\/(.+)$/;
|
|
225
|
+
/** A file the SDD owns: not a directory, not a method path, not a synced copy, not generated or migrated output. */
|
|
226
|
+
function owned(s, p) {
|
|
227
|
+
if (s.managed.has(p) || NOT_OWN.test(p))
|
|
228
|
+
return false;
|
|
229
|
+
try {
|
|
230
|
+
return statSync(join(s.dir, p)).isFile();
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** Where a path is now: itself if the SDD owns it, else followed through the SDD's renames; null if neither. */
|
|
237
|
+
function current(s, p) {
|
|
238
|
+
const seen = new Set();
|
|
239
|
+
let q = p;
|
|
240
|
+
while (!owned(s, q)) {
|
|
241
|
+
const next = s.renames.get(q);
|
|
242
|
+
if (!next || seen.has(q))
|
|
243
|
+
return null;
|
|
244
|
+
seen.add(q);
|
|
245
|
+
q = next;
|
|
246
|
+
}
|
|
247
|
+
return q;
|
|
248
|
+
}
|
|
249
|
+
function citedPaths(text, sdds) {
|
|
250
|
+
const out = [];
|
|
251
|
+
const consider = (raw) => {
|
|
252
|
+
let t = raw.trim();
|
|
253
|
+
try {
|
|
254
|
+
t = decodeURIComponent(t);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// a literal % that is not an escape: keep the text as written
|
|
258
|
+
}
|
|
259
|
+
t = t.replace(/[#?].*$/, "").replace(/:\d+(-\d+)?$/, "");
|
|
260
|
+
const blob = BLOB.exec(t);
|
|
261
|
+
if (blob) {
|
|
262
|
+
const s = sdds.find((x) => x.repoNames.has(blob[1]));
|
|
263
|
+
const now = s ? current(s, blob[2]) : null;
|
|
264
|
+
if (s && now)
|
|
265
|
+
out.push({ sdd: s.id, path: now });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (/^[a-z]+:/i.test(t) || t.startsWith("/") || t.startsWith(".."))
|
|
269
|
+
return;
|
|
270
|
+
for (const s of sdds) {
|
|
271
|
+
const now = current(s, t);
|
|
272
|
+
if (now)
|
|
273
|
+
out.push({ sdd: s.id, path: now });
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
for (const m of text.matchAll(PATHISH))
|
|
277
|
+
consider(m[1]);
|
|
278
|
+
for (const m of text.matchAll(LINK))
|
|
279
|
+
consider(m[1]);
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
/** Paths every bundle in the SDD's lock manages: synced copies (method files, context-pack ADRs), not its own content. */
|
|
283
|
+
async function managedPaths(dir) {
|
|
284
|
+
try {
|
|
285
|
+
const text = await readFile(join(dir, "method-baseline.lock"), "utf8");
|
|
286
|
+
return new Set([...text.matchAll(/^\s+-\s+[0-9a-f]{64}\s{2}(.+?)\s*$/gm)].map((m) => m[1]));
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return new Set();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
async function trackedText(dir, managed) {
|
|
293
|
+
try {
|
|
294
|
+
const { stdout } = await run("git", ["-C", dir, "ls-files", "-z", "--", "*.md", "*.yaml", "*.yml", "*.feature"], {
|
|
295
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
296
|
+
});
|
|
297
|
+
return stdout
|
|
298
|
+
.split("\0")
|
|
299
|
+
.filter((p) => p && !managed.has(p) && !NOT_OWN.test(p));
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return [];
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
export async function migrateIntents(opts) {
|
|
306
|
+
const sdds = await Promise.all(opts.sdds.map((d) => loadSdd(resolve(d))));
|
|
307
|
+
if (sdds.length === 0)
|
|
308
|
+
throw new Error("name at least one SDD (--sdd)");
|
|
309
|
+
// 1. Sources, grouped by request id.
|
|
310
|
+
const byId = new Map();
|
|
311
|
+
const unrecognised = [];
|
|
312
|
+
const sourceRepos = new Map();
|
|
313
|
+
for (const dir of opts.sources) {
|
|
314
|
+
const top = await gitTop(resolve(dir));
|
|
315
|
+
sourceRepos.set(resolve(dir), { top, head: await gitHead(top) });
|
|
316
|
+
for (const p of await walk(resolve(dir), (x) => x.endsWith(".md") && basename(x).toLowerCase() !== "readme.md")) {
|
|
317
|
+
if (opts.ideaMap && resolve(p) === resolve(opts.ideaMap))
|
|
318
|
+
continue;
|
|
319
|
+
const f = readSource(p, await readFile(p, "utf8"));
|
|
320
|
+
if (!f) {
|
|
321
|
+
unrecognised.push(p);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const e = byId.get(f.id) ?? {};
|
|
325
|
+
if (e[f.kind])
|
|
326
|
+
unrecognised.push(`${p} (a second ${f.kind} for ${f.id}; the first is kept)`);
|
|
327
|
+
else
|
|
328
|
+
e[f.kind] = f;
|
|
329
|
+
byId.set(f.id, e);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const ids = new Set(byId.keys());
|
|
333
|
+
const map = opts.ideaMap ? readIdeaMap(await readFile(opts.ideaMap, "utf8")) : new Map();
|
|
334
|
+
// 2. Evidence.
|
|
335
|
+
const ev = new Map([...ids].map((i) => [i, { cited: [], mentioned: [], committed: [], planned: [], commits: [], plans: [] }]));
|
|
336
|
+
const indexFiles = [];
|
|
337
|
+
for (const [id, e] of byId) {
|
|
338
|
+
const text = [e.idea, e.brief].filter(Boolean).map((f) => readFileSyncSafe(f.path)).join("\n");
|
|
339
|
+
const seen = new Set();
|
|
340
|
+
for (const c of citedPaths(text, sdds)) {
|
|
341
|
+
const k = `${c.sdd}\0${c.path}`;
|
|
342
|
+
if (seen.has(k))
|
|
343
|
+
continue;
|
|
344
|
+
seen.add(k);
|
|
345
|
+
ev.get(id).cited.push(c);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
for (const s of sdds) {
|
|
349
|
+
for (const rel of await trackedText(s.dir, s.managed)) {
|
|
350
|
+
let text;
|
|
351
|
+
try {
|
|
352
|
+
text = await readFile(join(s.dir, rel), "utf8");
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const hits = [...new Set((text.match(ID_TOKEN) ?? []).filter((t) => ids.has(t)))];
|
|
358
|
+
if (hits.length === 0)
|
|
359
|
+
continue;
|
|
360
|
+
if (hits.length > opts.indexThreshold) {
|
|
361
|
+
indexFiles.push(`${s.id}: ${rel} (${hits.length} ids)`);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
for (const id of hits) {
|
|
365
|
+
const e = ev.get(id);
|
|
366
|
+
if (!e.cited.some((c) => c.sdd === s.id && c.path === rel))
|
|
367
|
+
e.mentioned.push({ sdd: s.id, path: rel });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
const { stdout } = await run("git", ["-C", s.dir, "log", "--name-only", "--format=%x1e%h%x09%s%x09%b%x1f"], { maxBuffer: 256 * 1024 * 1024 });
|
|
372
|
+
for (const rec of stdout.split("\x1e")) {
|
|
373
|
+
const [head, names = ""] = rec.split("\x1f");
|
|
374
|
+
const [sha, subject = "", ...rest] = (head ?? "").trim().split("\t");
|
|
375
|
+
if (!sha)
|
|
376
|
+
continue;
|
|
377
|
+
// A commit that only kept the Ideas and Briefs themselves (moved, renamed, marked done) is not where one went.
|
|
378
|
+
if (HOUSEKEEPING.test(subject))
|
|
379
|
+
continue;
|
|
380
|
+
const named = [...new Set(`${subject} ${rest.join(" ")}`.match(ID_TOKEN) ?? [])].filter((i) => ids.has(i));
|
|
381
|
+
for (const id of named)
|
|
382
|
+
ev.get(id).commits.push({ sdd: s.id, sha, subject });
|
|
383
|
+
// Its files are evidence when it names at most a few requests, and was not a sweep.
|
|
384
|
+
if (named.length === 0 || named.length > SHARED)
|
|
385
|
+
continue;
|
|
386
|
+
const files = [...new Set(names.split("\n").map((n) => n.trim()).filter(Boolean).map((n) => current(s, n)).filter((n) => n !== null))];
|
|
387
|
+
if (files.length === 0 || files.length > SWEEP)
|
|
388
|
+
continue;
|
|
389
|
+
for (const id of named) {
|
|
390
|
+
const e = ev.get(id);
|
|
391
|
+
for (const f of files) {
|
|
392
|
+
if (!e.committed.some((c) => c.sdd === s.id && c.path === f))
|
|
393
|
+
e.committed.push({ sdd: s.id, path: f, sha, with: named.filter((n) => n !== id) });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
// not a git repository
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
for (const dir of opts.plans) {
|
|
403
|
+
const top = await gitTop(resolve(dir));
|
|
404
|
+
for (const p of await walk(resolve(dir), (x) => x.endsWith(".md"))) {
|
|
405
|
+
const text = readFileSyncSafe(p);
|
|
406
|
+
const named = [...new Set((text.match(ID_TOKEN) ?? []).filter((t) => ids.has(t)))];
|
|
407
|
+
const where = relative(top, p);
|
|
408
|
+
const paths = named.length > 0 && named.length <= SHARED ? citedPaths(text, sdds) : [];
|
|
409
|
+
for (const id of named) {
|
|
410
|
+
const e = ev.get(id);
|
|
411
|
+
e.plans.push({ repo: basename(top), path: where });
|
|
412
|
+
for (const c of paths) {
|
|
413
|
+
if (!e.planned.some((x) => x.sdd === c.sdd && x.path === c.path))
|
|
414
|
+
e.planned.push({ ...c, plan: where, with: named.filter((n) => n !== id) });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// 3. Intents, work items and citations.
|
|
420
|
+
const flags = new Map();
|
|
421
|
+
const notCarried = new Map();
|
|
422
|
+
const flag = (id, why) => flags.set(id, [...(flags.get(id) ?? []), why]);
|
|
423
|
+
const items = [];
|
|
424
|
+
const citations = ["id\tkind\tsource_repository\tsource_path\ttarget_sdd\ttarget_path"];
|
|
425
|
+
const written = [];
|
|
426
|
+
const skippedExisting = [];
|
|
427
|
+
const intents = [];
|
|
428
|
+
for (const id of [...ids].sort(compareIds)) {
|
|
429
|
+
const { idea, brief } = byId.get(id);
|
|
430
|
+
const e = ev.get(id);
|
|
431
|
+
const primary = (idea ?? brief);
|
|
432
|
+
const fields = (k) => idea?.doc.fields.get(k) ?? brief?.doc.fields.get(k) ?? "";
|
|
433
|
+
for (const f of [idea, brief]) {
|
|
434
|
+
if (!f)
|
|
435
|
+
continue;
|
|
436
|
+
const known = f.kind === "idea" ? KNOWN_IDEA : KNOWN_BRIEF;
|
|
437
|
+
const extra = f.doc.headings
|
|
438
|
+
.map(normaliseHeading)
|
|
439
|
+
.filter((h) => !known.some((k) => h === k || h.startsWith(`${k} `)) && !h.includes("decision questions") && h !== "impact" && !isGuardrails(h));
|
|
440
|
+
for (const h of extra)
|
|
441
|
+
notCarried.set(`${f.kind}: ${h}`, (notCarried.get(`${f.kind}: ${h}`) ?? 0) + 1);
|
|
442
|
+
}
|
|
443
|
+
// Mentioned and committed paths already cited are listed once, as cited.
|
|
444
|
+
const has = (l, x) => l.some((c) => c.sdd === x.sdd && c.path === x.path);
|
|
445
|
+
e.mentioned = e.mentioned.filter((x) => !has(e.cited, x));
|
|
446
|
+
e.committed = e.committed.filter((x) => !has(e.cited, x) && !has(e.mentioned, x));
|
|
447
|
+
e.planned = e.planned.filter((x) => !has(e.cited, x) && !has(e.mentioned, x) && !has(e.committed, x));
|
|
448
|
+
// Home: the SDD most of the evidence is in, unless a reviewer set it.
|
|
449
|
+
const count = new Map();
|
|
450
|
+
for (const x of [...e.cited, ...e.mentioned, ...e.committed, ...e.planned])
|
|
451
|
+
count.set(x.sdd, (count.get(x.sdd) ?? 0) + 1);
|
|
452
|
+
const ranked = [...count.entries()].sort((a, b) => b[1] - a[1]);
|
|
453
|
+
let home = sdds[0];
|
|
454
|
+
const set = opts.homes?.get(id);
|
|
455
|
+
if (set) {
|
|
456
|
+
const s = sdds.find((x) => x.id === set);
|
|
457
|
+
if (s)
|
|
458
|
+
home = s;
|
|
459
|
+
else
|
|
460
|
+
flag(id, `--home names ${set}, which is not one of the SDDs`);
|
|
461
|
+
}
|
|
462
|
+
else if (ranked.length === 0) {
|
|
463
|
+
flag(id, e.plans.length || e.commits.length ? (e.commits.length ? "no artefact found; commits only" : "plan only: check that the work landed") : "no evidence found");
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
if (ranked.length > 1 && ranked[0][1] === ranked[1][1])
|
|
467
|
+
flag(id, `home ambiguous: ${ranked.map(([s, n]) => `${s} ${n}`).join(", ")}`);
|
|
468
|
+
home = sdds.find((s) => s.id === ranked[0][0]);
|
|
469
|
+
if (e.cited.length + e.mentioned.length + e.committed.length === 0)
|
|
470
|
+
flag(id, "planned paths only: check that the work landed");
|
|
471
|
+
}
|
|
472
|
+
const classification = (fields("Classification") || "").toLowerCase();
|
|
473
|
+
const isMethod = classification.startsWith("method") || /(^|\/)method\//.test(relative(sourceRoot(primary.path, sourceRepos), primary.path));
|
|
474
|
+
const dir = isMethod ? "content/method/intents" : "content/product/intents";
|
|
475
|
+
const path = `${dir}/${slug(primary.path, id)}`;
|
|
476
|
+
const where = [
|
|
477
|
+
...e.cited.map((c) => `- \`${c.path}\` (${c.sdd}; cited)`),
|
|
478
|
+
...e.mentioned.map((c) => `- \`${c.path}\` (${c.sdd}; mentioned)`),
|
|
479
|
+
...e.committed.map((c) => `- \`${c.path}\` (${c.sdd}; committed in ${c.sha}${c.with.length ? ` with ${c.with.join(", ")}` : ""})`),
|
|
480
|
+
...e.planned.map((c) => `- \`${c.path}\` (${c.sdd}; planned in ${c.plan}${c.with.length ? ` with ${c.with.join(", ")}` : ""})`),
|
|
481
|
+
...e.commits.slice(0, 8).map((c) => `- commit \`${c.sha}\` in ${c.sdd}: ${c.subject}`),
|
|
482
|
+
...e.plans.map((p) => `- plan \`${p.path}\` in ${p.repo}`),
|
|
483
|
+
];
|
|
484
|
+
if (e.commits.length > 8)
|
|
485
|
+
where.push(`- ${e.commits.length - 8} more commit(s) name ${id}`);
|
|
486
|
+
const srcs = [idea, brief].filter(Boolean).map((f) => {
|
|
487
|
+
const r = sourceRepos.get(sourceDirOf(f.path, sourceRepos));
|
|
488
|
+
return { f: f, repo: basename(r.top), rel: relative(r.top, f.path), head: r.head };
|
|
489
|
+
});
|
|
490
|
+
const originator = `${fields("Owner") || "not recorded"}. Migrated from ${srcs.map((s) => `the ${s.f.kind === "idea" ? "Idea" : "Epic Brief"} \`${s.rel}\``).join(" and ")} in ${srcs[0].repo} at ${srcs[0].head}.`;
|
|
491
|
+
const outcome = [section(idea, "idea summary"), section(idea, "why explore this")].filter(Boolean).join("\n\n") || section(brief, "scope");
|
|
492
|
+
const open = [section(idea, "open questions"), ...matching(idea, (h) => h.includes("decision questions"))].filter(Boolean).join("\n\n") ||
|
|
493
|
+
section(brief, "resolved and open questions", "resolved vs open questions", "resolved vs open");
|
|
494
|
+
const affected = [section(idea, "early impact notes"), ...matching(idea, (h) => h === "impact")].filter(Boolean).join("\n\n");
|
|
495
|
+
const constraints = [section(brief, "dependencies and constraints", "dependencies"), ...matching(idea, isGuardrails), ...matching(brief, isGuardrails)]
|
|
496
|
+
.filter(Boolean)
|
|
497
|
+
.join("\n\n");
|
|
498
|
+
const decision = fields("Decision to Resolve in Exploration") || section(brief, "decision to resolve");
|
|
499
|
+
const body = [
|
|
500
|
+
`# Intent: ${titleOf(primary)}`,
|
|
501
|
+
"",
|
|
502
|
+
`**Work item:** ${id}`,
|
|
503
|
+
`**Captured:** ${fields("Created") || "not recorded"}`,
|
|
504
|
+
`**Originator:** ${originator}`,
|
|
505
|
+
"",
|
|
506
|
+
"## Problem",
|
|
507
|
+
"",
|
|
508
|
+
section(idea, "problem or opportunity") || section(brief, "problem statement") || "Not recorded in the source.",
|
|
509
|
+
"",
|
|
510
|
+
"## Proposed outcome",
|
|
511
|
+
"",
|
|
512
|
+
outcome || "Not recorded in the source.",
|
|
513
|
+
"",
|
|
514
|
+
"## Affected users and systems",
|
|
515
|
+
"",
|
|
516
|
+
affected || "Not recorded in the source.",
|
|
517
|
+
"",
|
|
518
|
+
"## Constraints",
|
|
519
|
+
"",
|
|
520
|
+
constraints || "Not recorded in the source.",
|
|
521
|
+
"",
|
|
522
|
+
"## Open questions",
|
|
523
|
+
"",
|
|
524
|
+
open || "None recorded.",
|
|
525
|
+
"",
|
|
526
|
+
"## Decision to resolve in exploration",
|
|
527
|
+
"",
|
|
528
|
+
decision || "None.",
|
|
529
|
+
"",
|
|
530
|
+
"## ADR requirement",
|
|
531
|
+
"",
|
|
532
|
+
adrRequirement(fields("ADR Requirement")),
|
|
533
|
+
"",
|
|
534
|
+
"## Where it went",
|
|
535
|
+
"",
|
|
536
|
+
...(where.length ? where : ["- Nothing found: see the migration report."]),
|
|
537
|
+
"",
|
|
538
|
+
].join("\n");
|
|
539
|
+
intents.push({ sdd: home, path, body });
|
|
540
|
+
for (const s of srcs)
|
|
541
|
+
citations.push([id, s.f.kind, s.repo, s.rel, home.id, path].join("\t"));
|
|
542
|
+
const row = map.get(id);
|
|
543
|
+
const statusRaw = row?.status || brief?.doc.fields.get("Status") || idea?.doc.fields.get("Status") || "";
|
|
544
|
+
const st = statusRaw.toLowerCase().replace(/[`*]/g, "").trim();
|
|
545
|
+
const reviewed = opts.statuses?.get(id);
|
|
546
|
+
const closed = reviewed ? reviewed === "closed" : CLOSED.has(st) || DECLINED.has(st);
|
|
547
|
+
items.push({
|
|
548
|
+
alias: id,
|
|
549
|
+
title: titleOf(primary),
|
|
550
|
+
status: closed ? "closed" : "open",
|
|
551
|
+
...(closed ? { closed_reason: !reviewed && DECLINED.has(st) ? "declined" : "completed" } : {}),
|
|
552
|
+
...(row?.priority ? { priority: row.priority } : {}),
|
|
553
|
+
intent: { sdd: home.id, path },
|
|
554
|
+
...(idsIn(fields("Parent Idea"))[0] ? { parent: idsIn(fields("Parent Idea"))[0] } : {}),
|
|
555
|
+
children: idsIn(fields("Child Ideas")),
|
|
556
|
+
depends_on: [...new Set([...(row?.depends ?? []), ...idsIn(fields("Blocked by"))])].filter((d) => d !== id),
|
|
557
|
+
labels: (fields("Theme") || "").split("|").map((s) => s.trim()).filter(Boolean),
|
|
558
|
+
...(statusRaw ? { source_status: statusRaw } : {}),
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
// 4. Write.
|
|
562
|
+
await mkdir(opts.out, { recursive: true });
|
|
563
|
+
await writeFile(join(opts.out, "work-items.json"), `${JSON.stringify(items, null, 2)}\n`);
|
|
564
|
+
await writeFile(join(opts.out, "citations.tsv"), `${citations.join("\n")}\n`);
|
|
565
|
+
if (opts.write) {
|
|
566
|
+
for (const i of intents) {
|
|
567
|
+
const full = join(i.sdd.dir, i.path);
|
|
568
|
+
if (existsSync(full)) {
|
|
569
|
+
skippedExisting.push(`${i.sdd.id}: ${i.path}`);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
await mkdir(dirname(full), { recursive: true });
|
|
573
|
+
await writeFile(full, i.body);
|
|
574
|
+
written.push(`${i.sdd.id}: ${i.path}`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
for (const i of intents) {
|
|
579
|
+
const full = join(opts.out, "intents", i.sdd.id, i.path);
|
|
580
|
+
await mkdir(dirname(full), { recursive: true });
|
|
581
|
+
await writeFile(full, i.body);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const homes = new Map();
|
|
585
|
+
for (const i of intents)
|
|
586
|
+
homes.set(i.sdd.id, (homes.get(i.sdd.id) ?? 0) + 1);
|
|
587
|
+
const pairs = [...byId.values()];
|
|
588
|
+
const supported = (k) => [...ev.values()].filter((e) => e[k].length > 0).length;
|
|
589
|
+
const report = [
|
|
590
|
+
"# Intent migration report",
|
|
591
|
+
"",
|
|
592
|
+
`Mode: ${opts.write ? "write" : "dry run (intents under intents/ here, not in the SDDs)"}.`,
|
|
593
|
+
`Request ids: ${ids.size} (${pairs.filter((p) => p.idea && p.brief).length} with an Idea and an Epic Brief, ${pairs.filter((p) => p.idea && !p.brief).length} with an Idea only, ${pairs.filter((p) => !p.idea && p.brief).length} with a Brief only).`,
|
|
594
|
+
`Work items: ${items.filter((i) => i.status === "closed").length} closed, ${items.filter((i) => i.status === "open").length} open.`,
|
|
595
|
+
`Homes: ${[...homes.entries()].map(([s, n]) => `${s} ${n}`).join(", ")}.`,
|
|
596
|
+
`Evidence, ids supported by each source: cited ${supported("cited")}, mentioned ${supported("mentioned")}, committed ${supported("committed")}, planned ${supported("planned")}, commit ${supported("commits")}, plan ${supported("plans")}.`,
|
|
597
|
+
"",
|
|
598
|
+
`## Flagged (${flags.size})`,
|
|
599
|
+
"",
|
|
600
|
+
...([...flags.entries()].sort((a, b) => compareIds(a[0], b[0])).map(([id, w]) => `- ${id}: ${w.join("; ")}`)),
|
|
601
|
+
"",
|
|
602
|
+
`## Source headings not recognised (${notCarried.size} distinct)`,
|
|
603
|
+
"",
|
|
604
|
+
"Not carried into intents. Review the list for a heading that should map to an intent section.",
|
|
605
|
+
"",
|
|
606
|
+
...[...notCarried.entries()].sort((a, b) => b[1] - a[1]).map(([h, n]) => `- ${n} × ${h}`),
|
|
607
|
+
"",
|
|
608
|
+
`## Index files, not counted as evidence (${indexFiles.length}; threshold ${opts.indexThreshold} ids)`,
|
|
609
|
+
"",
|
|
610
|
+
...indexFiles.map((f) => `- ${f}`),
|
|
611
|
+
"",
|
|
612
|
+
`## Not read as an Idea or Epic Brief (${unrecognised.length})`,
|
|
613
|
+
"",
|
|
614
|
+
...unrecognised.map((f) => `- ${f}`),
|
|
615
|
+
"",
|
|
616
|
+
...(opts.write ? [`## Written (${written.length})`, "", ...written.map((w) => `- ${w}`), "", `## Not written: a file exists (${skippedExisting.length})`, "", ...skippedExisting.map((w) => `- ${w}`), ""] : []),
|
|
617
|
+
].join("\n");
|
|
618
|
+
await writeFile(join(opts.out, "report.md"), report);
|
|
619
|
+
return { ids: ids.size, written, skippedExisting, flags, unrecognised };
|
|
620
|
+
}
|
|
621
|
+
function readFileSyncSafe(p) {
|
|
622
|
+
try {
|
|
623
|
+
return readFileSync(p, "utf8");
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
return "";
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function compareIds(a, b) {
|
|
630
|
+
const na = Number(/\d+$/.exec(a)?.[0] ?? 0);
|
|
631
|
+
const nb = Number(/\d+$/.exec(b)?.[0] ?? 0);
|
|
632
|
+
return a.replace(/\d+$/, "").localeCompare(b.replace(/\d+$/, "")) || na - nb;
|
|
633
|
+
}
|
|
634
|
+
function slug(path, id) {
|
|
635
|
+
const b = basename(path, ".md").toLowerCase();
|
|
636
|
+
return b.startsWith(id.toLowerCase()) ? `${b}.md` : `${id.toLowerCase()}-${b}.md`;
|
|
637
|
+
}
|
|
638
|
+
function sourceDirOf(p, repos) {
|
|
639
|
+
return [...repos.keys()].filter((d) => p.startsWith(`${d}/`)).sort((a, b) => b.length - a.length)[0] ?? dirname(p);
|
|
640
|
+
}
|
|
641
|
+
function sourceRoot(p, repos) {
|
|
642
|
+
return repos.get(sourceDirOf(p, repos))?.top ?? dirname(p);
|
|
643
|
+
}
|
|
644
|
+
//# sourceMappingURL=migrate.js.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { checkArchetypeCitations } from "./archetype-citations.js";
|
|
2
2
|
import { checkBehaviourStore, checkBehaviourStoreOpenspec } from "./behaviour-store.js";
|
|
3
3
|
import { checkBundleCoherence } from "./bundle-coherence.js";
|
|
4
|
+
import { checkIntents } from "./intents.js";
|
|
4
5
|
import { checkYamlParse } from "./yaml-parse.js";
|
|
5
6
|
// The behaviour store (M-ADR 0221-0223) can sit in an SDD of any profile, so every profile carries
|
|
6
7
|
// its two checks; without a store both report [N/A], an empty population, not a pass.
|
|
@@ -21,6 +22,12 @@ const BEHAVIOUR_STORE_VALIDATORS = [
|
|
|
21
22
|
source: { kind: "native", run: async (ctx) => checkArchetypeCitations(ctx.targetRepo) },
|
|
22
23
|
build: () => ({ status: "run", args: [] }),
|
|
23
24
|
},
|
|
25
|
+
// Intents (M-ADR 0227) can sit in an SDD of any profile; without any the check reports [N/A].
|
|
26
|
+
{
|
|
27
|
+
name: "intents",
|
|
28
|
+
source: { kind: "native", run: async (ctx) => checkIntents(ctx.targetRepo) },
|
|
29
|
+
build: () => ({ status: "run", args: [] }),
|
|
30
|
+
},
|
|
24
31
|
];
|
|
25
32
|
// Validators that accept --app-root / --repo-root flags work fine from the
|
|
26
33
|
// bundled location because they override their internal ROOT_DIR via the
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// intents: every intent file (M-ADR 0227 §4.1) has the template's shape, carries no planning state, names its
|
|
2
|
+
// work item, and every path its "Where it went" names in this SDD exists.
|
|
3
|
+
//
|
|
4
|
+
// Read from content/method/intents/ and content/product/intents/. A path line in "Where it went" is
|
|
5
|
+
// "- `<path>` (<sdd-id>; <source>)"; only paths labelled with this SDD's id are checked here, since another
|
|
6
|
+
// SDD's paths resolve in that SDD. Commit and plan lines are records, not paths, and are not checked. An
|
|
7
|
+
// intent with nothing yet in "Where it went" is captured but not explored: counted, not failed. With no
|
|
8
|
+
// intents the check reports [N/A], an empty population, not a pass.
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
11
|
+
import { basename, join } from "node:path";
|
|
12
|
+
import { parseDocument } from "yaml";
|
|
13
|
+
import { INTENT_DIRS, INTENT_SECTIONS, PLANNING_FIELDS, WHERE_PATH_LINE, parseMarkdown } from "../intents/format.js";
|
|
14
|
+
async function sddId(repo) {
|
|
15
|
+
try {
|
|
16
|
+
const m = parseDocument(await readFile(join(repo, ".sdd/sdd-manifest.yaml"), "utf8")).toJS();
|
|
17
|
+
if (typeof m?.sdd_id === "string" && m.sdd_id)
|
|
18
|
+
return m.sdd_id;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// fall through
|
|
22
|
+
}
|
|
23
|
+
return basename(repo);
|
|
24
|
+
}
|
|
25
|
+
export async function checkIntents(repo) {
|
|
26
|
+
const files = [];
|
|
27
|
+
for (const d of INTENT_DIRS) {
|
|
28
|
+
try {
|
|
29
|
+
for (const e of await readdir(join(repo, d), { withFileTypes: true })) {
|
|
30
|
+
if (e.isFile() && e.name.endsWith(".md") && e.name.toLowerCase() !== "readme.md")
|
|
31
|
+
files.push(`${d}/${e.name}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// no such directory
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (files.length === 0)
|
|
39
|
+
return { exitCode: 0, stdout: "[N/A] no intents (content/{method,product}/intents/*.md) in this repository\n" };
|
|
40
|
+
const self = await sddId(repo);
|
|
41
|
+
const problems = [];
|
|
42
|
+
let unexplored = 0;
|
|
43
|
+
let pathsChecked = 0;
|
|
44
|
+
for (const rel of files.sort()) {
|
|
45
|
+
const text = await readFile(join(repo, rel), "utf8");
|
|
46
|
+
const doc = parseMarkdown(text);
|
|
47
|
+
if (!/^Intent:\s*\S/.test(doc.title))
|
|
48
|
+
problems.push(`${rel}: no "# Intent: <title>" line`);
|
|
49
|
+
for (const s of INTENT_SECTIONS)
|
|
50
|
+
if (!doc.sections.has(s.toLowerCase()))
|
|
51
|
+
problems.push(`${rel}: no "## ${s}" section`);
|
|
52
|
+
const wi = doc.fields.get("Work item")?.trim() ?? "";
|
|
53
|
+
if (!wi || /^<.*>$/.test(wi))
|
|
54
|
+
problems.push(`${rel}: no work item named (**Work item:**)`);
|
|
55
|
+
for (const f of PLANNING_FIELDS)
|
|
56
|
+
if (doc.fields.has(f))
|
|
57
|
+
problems.push(`${rel}: planning field **${f}:** belongs in the work item (M-ADR 0227 §4.3)`);
|
|
58
|
+
const where = (doc.sections.get("where it went") ?? "").split("\n").map((l) => l.trim()).filter((l) => l.startsWith("- "));
|
|
59
|
+
if (where.length === 0)
|
|
60
|
+
unexplored++;
|
|
61
|
+
for (const line of where) {
|
|
62
|
+
const m = WHERE_PATH_LINE.exec(line);
|
|
63
|
+
if (!m || m[2].trim() !== self)
|
|
64
|
+
continue;
|
|
65
|
+
pathsChecked++;
|
|
66
|
+
const p = m[1].replace(/[#?].*$/, "");
|
|
67
|
+
if (!existsSync(join(repo, p)))
|
|
68
|
+
problems.push(`${rel}: "Where it went" names ${p}, which does not exist`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const head = `intents: ${files.length} intent(s); ${pathsChecked} "Where it went" path(s) in this SDD checked; ${unexplored} not yet explored\n`;
|
|
72
|
+
if (problems.length === 0)
|
|
73
|
+
return { exitCode: 0, stdout: `${head}[PASS] intents: ok\n` };
|
|
74
|
+
return { exitCode: 1, stdout: `${head}${problems.map((p) => `[FAIL] ${p}\n`).join("")}[FAIL] intents: ${problems.length} problem(s)\n` };
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=intents.js.map
|
package/dist/verbs/migrate.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
|
+
import { migrateIntents } from "../lib/intents/migrate.js";
|
|
2
3
|
import { migrateVocabulary } from "../lib/vocabulary/migrate.js";
|
|
3
4
|
/**
|
|
4
5
|
* `migrate vocabulary` (M-ADR 0222 §4.1): rewrite the SDD's own files from the former tool-read values to the
|
|
@@ -28,5 +29,48 @@ export function registerMigrate(program) {
|
|
|
28
29
|
if (r.rewrites.length > 0)
|
|
29
30
|
process.exit(1);
|
|
30
31
|
});
|
|
32
|
+
const many = (v, prev) => [...prev, v];
|
|
33
|
+
migrate
|
|
34
|
+
.command("intents")
|
|
35
|
+
.description("Turn Ideas and Epic Briefs into intents (M-ADR 0227), one per request id: an Idea and its Epic Brief are one " +
|
|
36
|
+
"request at two stages. Rebuilds each intent's \"Where it went\" from evidence (paths the sources cite, SDD files " +
|
|
37
|
+
"and commits naming the id, plan files naming it) and writes the planning state to a tool-neutral work-item " +
|
|
38
|
+
"import file, with a table of old paths to intents for re-pointing citations. Dry run unless --write; never " +
|
|
39
|
+
"overwrites an existing intent")
|
|
40
|
+
.requiredOption("--source <dir>", "a directory of Idea and Epic Brief files, searched recursively (repeatable)", many, [])
|
|
41
|
+
.option("--sdd <dir>", "an SDD searched for evidence and offered as a home; the first is the default home (repeatable; default: the current directory)", many, [])
|
|
42
|
+
.option("--plans <dir>", "a directory of plan files: evidence only, never converted (repeatable)", many, [])
|
|
43
|
+
.option("--idea-map <file>", "the idea map, for status, priority and dependency edges")
|
|
44
|
+
.option("--out <dir>", "where the report, work-items.json and citations.tsv go", "intent-migration")
|
|
45
|
+
.option("--index-threshold <n>", "a file naming more request ids than this is an index, not evidence", "5")
|
|
46
|
+
.option("--home <id=sdd-id>", "a reviewer's home for a request id (repeatable)", many, [])
|
|
47
|
+
.option("--status <id=open|closed>", "a reviewer's status for a request id (repeatable)", many, [])
|
|
48
|
+
.option("--write", "write each intent into the SDD it is placed in, under every --sdd named (default: a dry run that writes them under --out)")
|
|
49
|
+
.action(async (o) => {
|
|
50
|
+
const pairs = (list, name) => new Map(list.map((x) => {
|
|
51
|
+
const [k, v] = x.split("=");
|
|
52
|
+
if (!k || !v)
|
|
53
|
+
throw new Error(`--${name} takes <id>=<value>, not "${x}"`);
|
|
54
|
+
return [k.trim(), v.trim()];
|
|
55
|
+
}));
|
|
56
|
+
const statuses = pairs(o.status, "status");
|
|
57
|
+
for (const [k, v] of statuses)
|
|
58
|
+
if (v !== "open" && v !== "closed")
|
|
59
|
+
throw new Error(`--status ${k}=${v}: the status is open or closed`);
|
|
60
|
+
const r = await migrateIntents({
|
|
61
|
+
sources: o.source,
|
|
62
|
+
sdds: o.sdd.length ? o.sdd : [process.cwd()],
|
|
63
|
+
plans: o.plans,
|
|
64
|
+
...(o.ideaMap ? { ideaMap: o.ideaMap } : {}),
|
|
65
|
+
out: resolve(o.out),
|
|
66
|
+
write: o.write === true,
|
|
67
|
+
indexThreshold: Number(o.indexThreshold),
|
|
68
|
+
homes: pairs(o.home, "home"),
|
|
69
|
+
statuses: statuses,
|
|
70
|
+
});
|
|
71
|
+
process.stdout.write(`[INFO] migrate intents: ${r.ids} request id(s); ${r.flags.size} flagged; ${r.unrecognised.length} file(s) not read as an Idea or Epic Brief\n` +
|
|
72
|
+
(o.write ? `[INFO] migrate intents: wrote ${r.written.length} intent(s); ${r.skippedExisting.length} not written (a file exists)\n` : "") +
|
|
73
|
+
`[INFO] migrate intents: report, work-items.json and citations.tsv in ${resolve(o.out)}\n`);
|
|
74
|
+
});
|
|
31
75
|
}
|
|
32
76
|
//# sourceMappingURL=migrate.js.map
|
package/dist/verify-version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdd-method/author-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"sddCliVersion": "2.
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"sddCliVersion": "2.5.0",
|
|
5
5
|
"description": "Re-author one SDD's content — scaffold, plan, contracts, conformance, api-surface, snapshots, briefings, catalogue check — plus every read-only verify verb. Ships only the code those verbs reach; it cannot sync the baseline or rebuild the catalogues. Maintenance is in @sdd-method/maintain-cli, administration in @sdd-method/sdd-cli.",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"type": "module",
|