universal-dev-standards 6.14.0-beta.1 → 6.14.0-beta.2

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,693 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Open-work-tracking reference checks for OWT-017 / OWT-018 / OWT-019.
4
+ * open-work-tracking 1.1.0 參考判定程序(OWT-017/018/019)。
5
+ * // implements DEC-122-L1
6
+ *
7
+ * ── Where this lives, and why there is exactly one copy ─────────────────────
8
+ * This file is the ONE body of the rules. Two front doors call it and neither
9
+ * holds a copy:
10
+ * uds open-work <next-action|revision|separation|self-test> ...
11
+ * cli/src/commands/open-work.js, shipped in the npm package (`cli/src`
12
+ * is in package.json `files`), so an adopter can run it without a clone.
13
+ * node scripts/check-open-work-tracking.mjs ...
14
+ * a three-line shim in the UDS repository, kept so the documented repo
15
+ * invocation keeps working.
16
+ * It used to live only in `scripts/`, which the npm package does not contain,
17
+ * and 6.14.0-beta.1 therefore shipped a reference check no adopter could run.
18
+ * A second copy under `cli/` would have fixed that and started a second set of
19
+ * rules that drift from the first; a bundling step (prepack copies) would have
20
+ * left the source tree unable to run it. Living under `cli/src/` costs neither.
21
+ *
22
+ * ── What this is, and what it is not ────────────────────────────────────────
23
+ * core/open-work-tracking.md says UDS ships no gate. This script is NOT a gate
24
+ * and is not wired into pre-release-check.sh: UDS has no open-work carrier of
25
+ * its own for it to check. It is the reference decision procedure the standard
26
+ * offers as OWT-015 evidence — an adopter can run it against their own
27
+ * carriers, or reimplement it. It has been observed to fail against violating
28
+ * samples (see cli/tests/unit/scripts/open-work-tracking-intent-and-next-
29
+ * action.test.js, which mutates this file's source to prove it).
30
+ *
31
+ * Self-contained on purpose (node builtins only): the mutation tests copy this
32
+ * file to a temp directory and edit its text, which only works if it imports
33
+ * nothing relative. The `.mjs` extension is part of that: a copy in a bare temp
34
+ * directory has no package.json, and only `.mjs` is read as an ES module there.
35
+ *
36
+ * ── Three checks ────────────────────────────────────────────────────────────
37
+ * next-action OWT-019 every "next action" field names a file path, test
38
+ * name, command or requirement identifier.
39
+ * Three outcomes, never one green:
40
+ * named-resolved an object was found (a path exists)
41
+ * named-unresolved an object is named but not found
42
+ * (legitimate when the next action
43
+ * is to create it), or resolution
44
+ * could not apply (command, test
45
+ * name, identifier)
46
+ * unnamed VIOLATION — the only failure
47
+ * revision OWT-018 the acceptance/goal/constraint sections changed
48
+ * between two versions of a carrier; a NEW revision
49
+ * record (change, approver, reason) must exist. No
50
+ * record, or an incomplete one -> violation. A record
51
+ * whose approver is empty, and an edit with no record
52
+ * at all, are LISTED for the hand-back (OWT-007). The
53
+ * listing never changes the exit code (OWT-008).
54
+ * separation OWT-017 no single carrier holds both an intent section and
55
+ * a progress / next-action section.
56
+ *
57
+ * ── What none of them can decide (stated, not implied) ──────────────────────
58
+ * - Whether a revision record honestly describes the diff (OWT-014: that is
59
+ * a claim about meaning, not a relation over artefacts). The check decides
60
+ * that the intent changed, that a new record exists, that it is complete,
61
+ * and whether its approver is filled in — nothing more.
62
+ * - Whether a named object is the RIGHT object.
63
+ * - Recognising "this string is a path / command / test name / identifier"
64
+ * is pattern matching, so its coverage is UNKNOWN (OWT-011). An unrecognised
65
+ * format is reported as unnamed, and a clean pass never means "every next
66
+ * action is specific".
67
+ * - Prose revision notes ("2026-09-29 amended R1") are not structural records.
68
+ * Only table rows and list items inside a section whose heading is a
69
+ * revision heading are read.
70
+ *
71
+ * ── UNCALIBRATED (OWT-016) ──────────────────────────────────────────────────
72
+ * Everything in VOCAB, COMMANDS, EXTENSIONS, DEFAULT_ID_PATTERN and
73
+ * ID_PREFIX_DENYLIST is an initial judgment. None of it was measured against
74
+ * real usage. Adopters should pass their own identifier pattern
75
+ * (--id-pattern) and read the heading vocabulary as a starting point.
76
+ *
77
+ * Exit codes: 0 no violation · 1 violation · 2 cannot decide (no structure
78
+ * found, git failed, or this script's own self-test arms failed). 2 is NOT a
79
+ * pass.
80
+ *
81
+ * Usage (`uds` from the npm package, or the repo shim; same arguments):
82
+ * uds open-work next-action <file...> [--root DIR] [--id-pattern RE]
83
+ * uds open-work revision --file PATH --base GIT_REV
84
+ * uds open-work revision --before FILE --after FILE
85
+ * uds open-work separation <file...>
86
+ * uds open-work self-test
87
+ * node scripts/check-open-work-tracking.mjs next-action <file...> ... (repo clone)
88
+ * node scripts/check-open-work-tracking.mjs --self-test
89
+ */
90
+
91
+ import { readFileSync, existsSync, realpathSync } from 'node:fs';
92
+ import { execFileSync } from 'node:child_process';
93
+ import { resolve, dirname, join, isAbsolute, basename } from 'node:path';
94
+ import { fileURLToPath } from 'node:url';
95
+
96
+ // ── Vocabulary (UNCALIBRATED, OWT-016) ───────────────────────────────────────
97
+
98
+ export const VOCAB = {
99
+ revision: /revision|change[\s-]?log|amendment|history|修訂|變更紀錄|變更記錄|修改紀錄|修改記錄|異動/i,
100
+ nextAction: /next[\s-]?(action|step)s?|下一步|下一動/i,
101
+ progress: /\bprogress\b|\bstate\b|\bblockers?\b|\bblocked\b|進度|現況|卡在/i,
102
+ intent: /acceptance|criteria|\brequirements?\b|\bgoals?\b|objectives?|constraints?|驗收|需求|目標|限制/i,
103
+ // revision-table columns / labelled list fields
104
+ colChange: /what|change|改了什麼|修改|變更|內容/i,
105
+ colApprover: /approv|核可|核准|批准|同意|確認者/i,
106
+ colReason: /reason|why|理由|原因|為什麼/i,
107
+ };
108
+
109
+ const LABELS = [
110
+ ['change', /(?:what(?:\s+changed)?|changed?|改了什麼|修改|變更)\s*[::]/gi],
111
+ ['approver', /(?:approved\s+by|approver|approval|核可者|核可|核准|批准)\s*[::]/gi],
112
+ ['reason', /(?:reason|why|理由|原因|為什麼)\s*[::]/gi],
113
+ ];
114
+
115
+ const NO_APPROVER = /^(?:|-+|—|–|n\/?a|none|tbd|pending|unknown|\?+|無|未核可|待定|待核可|尚未)$/i;
116
+ const EMPTY_FIELD = /^(?:|-+|—|–|n\/?a|none|無|done|完成|已完成|✅)$/i;
117
+
118
+ // Words that make `word arg` read as a command in prose. Deliberately excludes
119
+ // common English words (go, make, sh): "go through the list" is not a command.
120
+ export const COMMANDS = [
121
+ 'npm', 'npx', 'pnpm', 'yarn', 'node', 'tsx', 'git', 'gh', 'bash', 'python', 'python3',
122
+ 'pytest', 'vitest', 'docker', 'cargo', 'uds', 'egr',
123
+ ];
124
+
125
+ export const EXTENSIONS = [
126
+ 'md', 'mdx', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'py', 'sh', 'bash', 'zsh', 'yaml', 'yml',
127
+ 'json', 'jsonc', 'tsv', 'csv', 'toml', 'sql', 'css', 'scss', 'html', 'txt', 'rb', 'go', 'rs',
128
+ 'java', 'kt', 'swift', 'lock', 'env', 'conf', 'ini', 'xml', 'svg', 'plist',
129
+ ];
130
+
131
+ export const DEFAULT_ID_PATTERN = '\\b[A-Z][A-Z0-9]{1,9}-\\d{1,6}\\b|\\b(?:R|OQ|AC)\\d{1,3}\\b';
132
+ export const ID_PREFIX_DENYLIST = ['UTF', 'SHA', 'ISO', 'GPT', 'RFC', 'CRC', 'AES', 'RSA', 'TLS', 'MD', 'HTTP'];
133
+
134
+ // ── Markdown structure walk ──────────────────────────────────────────────────
135
+
136
+ /** @returns {string|null} */
137
+ function classifyHeading(h) {
138
+ if (VOCAB.revision.test(h)) return 'revision';
139
+ if (VOCAB.nextAction.test(h)) return 'nextAction';
140
+ if (VOCAB.progress.test(h)) return 'progress';
141
+ if (VOCAB.intent.test(h)) return 'intent';
142
+ return null;
143
+ }
144
+
145
+ /**
146
+ * Annotate every line with the class of its innermost classified ancestor
147
+ * heading. Front matter and fenced blocks keep their text but are flagged.
148
+ */
149
+ export function parseDoc(md) {
150
+ const raw = md.split(/\r?\n/);
151
+ const lines = [];
152
+ const stack = [];
153
+ let inFence = false;
154
+ let fenceMark = '';
155
+ let i = 0;
156
+ let inFront = false;
157
+ if (raw[0] && raw[0].trim() === '---') {
158
+ inFront = true;
159
+ i = 1;
160
+ lines.push({ text: raw[0], cls: null, owner: null, front: true });
161
+ }
162
+ for (; i < raw.length; i++) {
163
+ const text = raw[i];
164
+ if (inFront) {
165
+ lines.push({ text, cls: null, owner: null, front: true });
166
+ if (text.trim() === '---') inFront = false;
167
+ continue;
168
+ }
169
+ const fence = /^\s*(```|~~~)/.exec(text);
170
+ if (fence) {
171
+ if (!inFence) { inFence = true; fenceMark = fence[1]; } else if (fence[1] === fenceMark) { inFence = false; }
172
+ const own = innermost(stack);
173
+ lines.push({ text, cls: own ? own.cls : null, owner: own, fence: true });
174
+ continue;
175
+ }
176
+ if (!inFence) {
177
+ const h = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(text);
178
+ if (h) {
179
+ const level = h[1].length;
180
+ while (stack.length && stack[stack.length - 1].level >= level) stack.pop();
181
+ const entry = { level, heading: h[2], cls: classifyHeading(h[2]) };
182
+ stack.push(entry);
183
+ const own = innermost(stack);
184
+ lines.push({ text, cls: own ? own.cls : null, owner: own, heading: entry });
185
+ continue;
186
+ }
187
+ }
188
+ const own = innermost(stack);
189
+ lines.push({ text, cls: own ? own.cls : null, owner: own, inFence });
190
+ }
191
+ return lines;
192
+ }
193
+
194
+ function innermost(stack) {
195
+ for (let k = stack.length - 1; k >= 0; k--) if (stack[k].cls) return stack[k];
196
+ return null;
197
+ }
198
+
199
+ const norm = (s) => s.replace(/\s+/g, ' ').trim();
200
+
201
+ function splitRow(line) {
202
+ let t = line.trim();
203
+ if (t.startsWith('|')) t = t.slice(1);
204
+ if (t.endsWith('|')) t = t.slice(0, -1);
205
+ return t.split(/(?<!\\)\|/).map((c) => norm(c.replace(/\\\|/g, '|')));
206
+ }
207
+
208
+ const isSepRow = (line) => /^\s*\|?[\s:|-]+\|?\s*$/.test(line) && /-/.test(line);
209
+
210
+ /** Tables among annotated lines: [{header, rows:[{cells, idx}]}] */
211
+ function findTables(lines) {
212
+ const tables = [];
213
+ let k = 0;
214
+ while (k < lines.length) {
215
+ const l = lines[k];
216
+ if (!l.inFence && !l.fence && !l.front && l.text.trim().startsWith('|') && k + 1 < lines.length
217
+ && isSepRow(lines[k + 1].text) && lines[k + 1].text.trim().startsWith('|')) {
218
+ const header = splitRow(l.text);
219
+ const rows = [];
220
+ let j = k + 2;
221
+ while (j < lines.length && lines[j].text.trim().startsWith('|')) {
222
+ rows.push({ cells: splitRow(lines[j].text), idx: j });
223
+ j++;
224
+ }
225
+ tables.push({ header, rows, headerIdx: k });
226
+ k = j;
227
+ } else {
228
+ k++;
229
+ }
230
+ }
231
+ return tables;
232
+ }
233
+
234
+ // ── OWT-018: intent sections and revision records ────────────────────────────
235
+
236
+ /** Map "<heading>#<n>" -> normalised body, for sections classed as intent. */
237
+ export function intentSections(md) {
238
+ const map = new Map();
239
+ const seen = new Map();
240
+ const bodies = new Map();
241
+ for (const l of parseDoc(md)) {
242
+ if (l.cls !== 'intent' || !l.owner || l.heading === l.owner) continue;
243
+ if (!bodies.has(l.owner)) {
244
+ const n = (seen.get(l.owner.heading) || 0) + 1;
245
+ seen.set(l.owner.heading, n);
246
+ bodies.set(l.owner, `${l.owner.heading}#${n}`);
247
+ map.set(bodies.get(l.owner), []);
248
+ }
249
+ const t = norm(l.text);
250
+ if (t) map.get(bodies.get(l.owner)).push(t);
251
+ }
252
+ // An intent heading with no body lines still exists as a (empty) section.
253
+ for (const l of parseDoc(md)) {
254
+ if (l.heading && l.heading.cls === 'intent' && !bodies.has(l.heading)) {
255
+ const n = (seen.get(l.heading.heading) || 0) + 1;
256
+ seen.set(l.heading.heading, n);
257
+ bodies.set(l.heading, `${l.heading.heading}#${n}`);
258
+ map.set(bodies.get(l.heading), []);
259
+ }
260
+ }
261
+ const out = new Map();
262
+ for (const [k, v] of map) out.set(k, v.join('\n'));
263
+ return out;
264
+ }
265
+
266
+ function parseLabelled(text) {
267
+ const t = text.replace(/\*\*|__/g, '');
268
+ const hits = [];
269
+ for (const [kind, re] of LABELS) {
270
+ re.lastIndex = 0;
271
+ let m;
272
+ while ((m = re.exec(t)) !== null) hits.push({ kind, start: m.index, end: m.index + m[0].length });
273
+ }
274
+ hits.sort((a, b) => a.start - b.start);
275
+ const out = { change: '', approver: '', reason: '' };
276
+ if (!hits.length) { out.change = norm(t); return out; }
277
+ for (let h = 0; h < hits.length; h++) {
278
+ const next = hits[h + 1] ? hits[h + 1].start : t.length;
279
+ const val = norm(t.slice(hits[h].end, next)).replace(/[;;,,|]+$/, '').trim();
280
+ if (!out[hits[h].kind]) out[hits[h].kind] = val;
281
+ }
282
+ return out;
283
+ }
284
+
285
+ /** Revision records: table rows and list items inside revision-class sections. */
286
+ export function revisionEntries(md) {
287
+ const lines = parseDoc(md);
288
+ const entries = [];
289
+ const revLines = lines.map((l) => l.cls === 'revision' && !l.heading && !l.front);
290
+ // tables
291
+ const tableLineIdx = new Set();
292
+ for (const t of findTables(lines)) {
293
+ if (!revLines[t.headerIdx]) continue;
294
+ const ci = t.header.findIndex((c) => VOCAB.colChange.test(c));
295
+ const ai = t.header.findIndex((c) => VOCAB.colApprover.test(c));
296
+ const ri = t.header.findIndex((c) => VOCAB.colReason.test(c));
297
+ tableLineIdx.add(t.headerIdx); tableLineIdx.add(t.headerIdx + 1);
298
+ for (const r of t.rows) {
299
+ tableLineIdx.add(r.idx);
300
+ const others = r.cells.filter((_, n) => n !== ai && n !== ri);
301
+ entries.push({
302
+ key: norm(r.cells.join('|')).toLowerCase(),
303
+ change: ci >= 0 ? (r.cells[ci] || '') : norm(others.join(' ')),
304
+ approver: ai >= 0 ? (r.cells[ai] || '') : '',
305
+ reason: ri >= 0 ? (r.cells[ri] || '') : '',
306
+ });
307
+ }
308
+ }
309
+ // list items
310
+ let cur = null;
311
+ const flush = () => {
312
+ if (cur !== null) {
313
+ const f = parseLabelled(cur);
314
+ entries.push({ key: norm(cur).toLowerCase(), ...f });
315
+ }
316
+ cur = null;
317
+ };
318
+ lines.forEach((l, idx) => {
319
+ if (!revLines[idx] || l.inFence || l.fence || tableLineIdx.has(idx)) { if (!revLines[idx]) flush(); return; }
320
+ const li = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(l.text);
321
+ if (li) { flush(); cur = li[1]; } else if (cur !== null && /^\s+\S/.test(l.text)) { cur += ' ' + l.text.trim(); } else { flush(); }
322
+ });
323
+ flush();
324
+ return entries;
325
+ }
326
+
327
+ /**
328
+ * OWT-018. before === null means the carrier did not exist in the baseline.
329
+ * @returns {{status:string, changedSections:string[], newEntries:object[], violations:object[], handBack:object[]}}
330
+ */
331
+ export function checkIntentRevision({ before, after, path = '(carrier)' }) {
332
+ const res = { status: '', path, changedSections: [], newEntries: [], violations: [], handBack: [] };
333
+ const a = intentSections(after);
334
+ if (before === null) { res.status = 'no-baseline'; return res; }
335
+ const b = intentSections(before);
336
+ if (a.size === 0 && b.size === 0) { res.status = 'undecidable'; return res; }
337
+ const keys = new Set([...a.keys(), ...b.keys()]);
338
+ for (const k of keys) if (a.get(k) !== b.get(k)) res.changedSections.push(k.replace(/#\d+$/, ''));
339
+ res.changedSections = [...new Set(res.changedSections)];
340
+ const intentChanged = res.changedSections.length > 0; // [mutation-anchor:intent-changed]
341
+ if (!intentChanged) { res.status = 'unchanged'; return res; }
342
+ const beforeKeys = new Set(revisionEntries(before).map((e) => e.key));
343
+ res.newEntries = revisionEntries(after).filter((e) => !beforeKeys.has(e.key));
344
+ if (res.newEntries.length === 0) {
345
+ res.status = 'changed-no-record';
346
+ res.violations.push({ rule: 'OWT-018', why: 'intent changed and no new revision record exists', sections: res.changedSections });
347
+ res.handBack.push({ rule: 'OWT-018', why: 'no approver: there is no revision record at all', sections: res.changedSections });
348
+ return res;
349
+ }
350
+ res.status = 'changed-recorded';
351
+ for (const e of res.newEntries) {
352
+ if (!e.change || !e.reason) {
353
+ res.violations.push({ rule: 'OWT-018', why: 'revision record is incomplete (needs what changed and why)', entry: e.key });
354
+ }
355
+ const hasApprover = !NO_APPROVER.test(e.approver.trim()); // [mutation-anchor:has-approver]
356
+ if (!hasApprover) {
357
+ res.handBack.push({ rule: 'OWT-018', why: 'revision record has no approver', sections: res.changedSections, entry: e.key });
358
+ }
359
+ }
360
+ return res;
361
+ }
362
+
363
+ // ── OWT-017: carrier separation ──────────────────────────────────────────────
364
+
365
+ export function checkSeparation(carriers) {
366
+ const res = { walked: carriers.length, recognised: 0, violations: [] };
367
+ for (const c of carriers) {
368
+ const lines = parseDoc(c.content);
369
+ const intent = new Set();
370
+ const progress = new Set();
371
+ for (const l of lines) {
372
+ if (!l.heading) continue;
373
+ if (l.heading.cls === 'intent') intent.add(l.heading.heading);
374
+ if (l.heading.cls === 'progress' || l.heading.cls === 'nextAction') progress.add(l.heading.heading);
375
+ }
376
+ if (intent.size || progress.size) res.recognised++;
377
+ const both = intent.size > 0 && progress.size > 0; // [mutation-anchor:separation-both]
378
+ if (both) {
379
+ res.violations.push({ rule: 'OWT-017', path: c.path, intent: [...intent], progress: [...progress] });
380
+ }
381
+ }
382
+ return res;
383
+ }
384
+
385
+ // ── OWT-019: next-action naming ──────────────────────────────────────────────
386
+
387
+ const stripPunct = (s) => s.replace(/^[("'「『“]+|[)"'」』”.,;:!?、。,;:)]+$/g, '');
388
+
389
+ function pathLike(tok, inCode) {
390
+ const t = stripPunct(tok).replace(/:\d+(?::\d+)?$/, '');
391
+ if (!t || /\s/.test(t) || /^[a-z][a-z0-9+.-]*:\/\//i.test(t)) return null;
392
+ const segs = t.split('/');
393
+ const last = segs[segs.length - 1];
394
+ const ext = /\.([A-Za-z0-9]+)$/.exec(last);
395
+ const hasExt = !!ext && EXTENSIONS.includes(ext[1].toLowerCase()) && last.length > ext[0].length;
396
+ const slashes = segs.length - 1;
397
+ const leading = /^(?:\.{1,2}\/|~\/|\/)./.test(t);
398
+ if (inCode) return (slashes >= 1 || hasExt) && /^[\w@./~-]+$/.test(t) ? t : null;
399
+ if (/^[\w@./~-]+$/.test(t) && (hasExt || leading || slashes >= 2)) return t;
400
+ return null;
401
+ }
402
+
403
+ /**
404
+ * @returns {{status:string, kinds:{kind:string,value:string}[], resolution:string}}
405
+ */
406
+ export function classifyNextAction(text, opts = {}) {
407
+ const idRe = new RegExp(opts.idPattern || DEFAULT_ID_PATTERN, 'g');
408
+ const commands = [...COMMANDS, ...(opts.extraCommands || [])];
409
+ const kinds = [];
410
+ const push = (kind, value) => { if (!kinds.some((k) => k.kind === kind && k.value === value)) kinds.push({ kind, value }); };
411
+ const idOk = (v) => !ID_PREFIX_DENYLIST.includes(v.split('-')[0]);
412
+
413
+ // 1) inline code spans: the author marked these as literal names
414
+ const prose = text.replace(/`([^`]+)`/g, (_, c) => {
415
+ const code = c.trim();
416
+ const first = code.split(/\s+/)[0].replace(/^\$\s*/, '');
417
+ if (pathLike(code, true)) push('path', pathLike(code, true)); // [mutation-anchor:kind-path]
418
+ else if (commands.includes(first) || /^\.{1,2}\//.test(first)) push('command', code); // [mutation-anchor:kind-command]
419
+ else if (/\.(?:test|spec)\.[cm]?[jt]sx?|(?:^|[\s/])test_\w+|::/.test(code)) push('test', code); // [mutation-anchor:kind-test]
420
+ else {
421
+ idRe.lastIndex = 0;
422
+ const m = idRe.exec(code);
423
+ if (m && idOk(m[0])) push('id', m[0]);
424
+ }
425
+ return ' ';
426
+ });
427
+
428
+ // 2) prose
429
+ for (const tok of prose.split(/\s+/)) {
430
+ const p = pathLike(tok, false);
431
+ if (p) push('path', p);
432
+ }
433
+ const cmdRe = new RegExp(`(?:^|[^\\w-])(${commands.join('|')})\\s+([-@\\w./:]+)`, 'g');
434
+ let m;
435
+ while ((m = cmdRe.exec(prose)) !== null) push('command', `${m[1]} ${m[2]}`);
436
+ const testRe = /(?:test|測試)\s*[「"“']([^」"”']{6,})[」"”']/gi;
437
+ while ((m = testRe.exec(prose)) !== null) push('test', m[1]);
438
+ idRe.lastIndex = 0;
439
+ while ((m = idRe.exec(prose)) !== null) if (idOk(m[0])) push('id', m[0]); // [mutation-anchor:kind-id]
440
+
441
+ const named = kinds.length > 0; // [mutation-anchor:named]
442
+ if (!named) return { status: 'unnamed', kinds, resolution: 'n/a' };
443
+ const root = opts.root;
444
+ let resolved = false;
445
+ if (root) {
446
+ for (const k of kinds) {
447
+ if (k.kind !== 'path') continue;
448
+ const rel = k.value.replace(/^\.\//, '');
449
+ if (existsSync(isAbsolute(rel) ? rel : join(root, rel))) resolved = true;
450
+ }
451
+ }
452
+ return {
453
+ status: resolved ? 'named-resolved' : 'named-unresolved',
454
+ kinds,
455
+ resolution: root ? 'attempted (paths only)' : 'not attempted (no --root)',
456
+ };
457
+ }
458
+
459
+ /** Find "next action" fields in a carrier. @returns {{fields:number, items:{text:string,where:string}[]}} */
460
+ export function extractNextActions(md) {
461
+ const lines = parseDoc(md);
462
+ const items = [];
463
+ let fields = 0;
464
+ const tableIdx = new Set();
465
+ const headerIdx = new Set();
466
+ const tables = findTables(lines);
467
+
468
+ for (const t of tables) {
469
+ const col = t.header.findIndex((c) => VOCAB.nextAction.test(c));
470
+ const hasCol = col >= 0;
471
+ tableIdx.add(t.headerIdx); tableIdx.add(t.headerIdx + 1); headerIdx.add(t.headerIdx);
472
+ t.rows.forEach((r) => tableIdx.add(r.idx));
473
+ if (hasCol) {
474
+ for (const r of t.rows) {
475
+ fields++;
476
+ items.push({ text: r.cells[col] || '', where: `table column "${t.header[col]}" (line ${r.idx + 1})` });
477
+ }
478
+ }
479
+ // rows inside a nextAction-class section are handled by the section walk below
480
+ t.hasNextActionColumn = hasCol;
481
+ }
482
+
483
+ // inline labelled "**Next action**: ..." (outside tables that have their own column)
484
+ const labelRe = /(?:\*\*|__)?(?:next[\s-]?(?:action|step)s?|下一步|下一動)(?:\*\*|__)?\s*[::]\s*(.+)$/i;
485
+ const colTableRows = new Set();
486
+ for (const t of tables) if (t.hasNextActionColumn) t.rows.forEach((r) => colTableRows.add(r.idx));
487
+ lines.forEach((l, idx) => {
488
+ if (l.front || l.fence || l.inFence || l.heading || l.cls === 'nextAction' || colTableRows.has(idx)) return;
489
+ const m = labelRe.exec(l.text);
490
+ if (m && !/^\s*$/.test(m[1])) { fields++; items.push({ text: m[1], where: `label (line ${idx + 1})` }); }
491
+ });
492
+
493
+ // heading sections classed nextAction
494
+ const byOwner = new Map();
495
+ lines.forEach((l, idx) => {
496
+ if (l.front || l.cls !== 'nextAction' || !l.owner || l.owner.cls !== 'nextAction') return;
497
+ if (l.heading) { if (!byOwner.has(l.owner)) byOwner.set(l.owner, []); return; }
498
+ if (!byOwner.has(l.owner)) byOwner.set(l.owner, []);
499
+ byOwner.get(l.owner).push({ l, idx });
500
+ });
501
+ for (const [owner, body] of byOwner) {
502
+ const sec = [];
503
+ let cur = null;
504
+ let fenceBuf = false;
505
+ const flush = () => { if (cur !== null && norm(cur)) sec.push(norm(cur)); cur = null; };
506
+ for (const { l, idx } of body) {
507
+ if (l.fence) { fenceBuf = !fenceBuf; continue; }
508
+ if (l.inFence) { if (norm(l.text)) cur = (cur ?? '') + ` \`${norm(l.text)}\``; continue; }
509
+ if (tableIdx.has(idx)) {
510
+ if (isSepRow(l.text) || headerIdx.has(idx) || colTableRows.has(idx)) continue;
511
+ sec.push(norm(splitRow(l.text).join(' ')));
512
+ continue;
513
+ }
514
+ const li = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(l.text);
515
+ if (li) { flush(); cur = li[1]; } else if (!norm(l.text)) { flush(); } else { cur = (cur === null ? '' : cur + ' ') + l.text.trim(); }
516
+ }
517
+ flush();
518
+ // a section whose only content is a table that has its own next-action column
519
+ // was already counted through that column; it is not an empty field
520
+ if (sec.length === 0 && body.some(({ idx }) => colTableRows.has(idx))) continue;
521
+ fields++;
522
+ if (sec.length === 0) items.push({ text: '', where: `section "${owner.heading}" (empty)` });
523
+ for (const s of sec) items.push({ text: s, where: `section "${owner.heading}"` });
524
+ }
525
+ return { fields, items };
526
+ }
527
+
528
+ export function checkNextActions(carriers, opts = {}) {
529
+ const res = { walked: 0, empty: 0, counts: { 'named-resolved': 0, 'named-unresolved': 0, unnamed: 0 }, violations: [], detail: [], noFieldCarriers: [] };
530
+ for (const c of carriers) {
531
+ const { fields, items } = extractNextActions(c.content);
532
+ if (fields === 0) { res.noFieldCarriers.push(c.path); continue; }
533
+ for (const it of items) {
534
+ const text = it.text.replace(/\*\*|__/g, '').trim();
535
+ if (EMPTY_FIELD.test(text)) { res.empty++; continue; }
536
+ res.walked++;
537
+ const r = classifyNextAction(text, opts);
538
+ res.counts[r.status]++;
539
+ res.detail.push({ path: c.path, where: it.where, text, ...r });
540
+ if (r.status === 'unnamed') res.violations.push({ rule: 'OWT-019', path: c.path, where: it.where, text });
541
+ }
542
+ }
543
+ return res;
544
+ }
545
+
546
+ // ── Self-test arms (the main path runs these first; a broken checker is exit 2) ─
547
+
548
+ export function runSelfTest() {
549
+ const failures = [];
550
+ const expect = (name, cond) => { if (!cond) failures.push(name); };
551
+
552
+ // OWT-019
553
+ expect('D2 violating: verb-only is unnamed', classifyNextAction('Continue implementation', {}).status === 'unnamed');
554
+ expect('D2 violating: vague sentence is unnamed', classifyNextAction('handle the rest of the items and clean up', {}).status === 'unnamed');
555
+ expect('D2 clean: path', classifyNextAction('edit scripts/foo.mjs', {}).status !== 'unnamed');
556
+ expect('D2 clean: command', classifyNextAction('run `npm test` again', {}).status !== 'unnamed');
557
+ expect('D2 clean: identifier', classifyNextAction('finish XSPEC-436 R1', {}).status !== 'unnamed');
558
+ expect('D2 clean: test name', classifyNextAction('make the test "a next action that names nothing is reported" pass', {}).status !== 'unnamed');
559
+
560
+ // OWT-018
561
+ const spec = (ac, rev) => `# S\n\n## Acceptance criteria\n\n${ac}\n\n## Revisions\n\n${rev}\n`;
562
+ const table = (rows) => `| Change | Approver | Reason |\n|---|---|---|\n${rows}`;
563
+ const r0 = checkIntentRevision({ before: spec('- A', table('')), after: spec('- A\n- B', table('')) });
564
+ expect('D1 violating: changed, no record -> violation', r0.violations.length === 1 && r0.handBack.length === 1);
565
+ const r1 = checkIntentRevision({ before: spec('- A', table('')), after: spec('- A\n- B', table('| added B | | scope grew |\n')) });
566
+ expect('D1 violating: record without approver -> hand-back only', r1.violations.length === 0 && r1.handBack.length === 1);
567
+ const r2 = checkIntentRevision({ before: spec('- A', table('')), after: spec('- A\n- B', table('| added B | albert | scope grew |\n')) });
568
+ expect('D1 clean: recorded and approved', r2.violations.length === 0 && r2.handBack.length === 0 && r2.status === 'changed-recorded');
569
+ const r3 = checkIntentRevision({ before: spec('- A', table('')), after: spec('- A', table('')) });
570
+ expect('D1 clean: unchanged', r3.status === 'unchanged');
571
+
572
+ // OWT-017
573
+ const both = checkSeparation([{ path: 'x.md', content: '# X\n## Acceptance criteria\n- a\n## Next action\n- b\n' }]);
574
+ expect('D1 violating: one carrier holds both', both.violations.length === 1);
575
+ const apart = checkSeparation([
576
+ { path: 'spec.md', content: '# X\n## Acceptance criteria\n- a\n' },
577
+ { path: 'log.md', content: '# L\n## Next action\n- b\n' },
578
+ ]);
579
+ expect('D1 clean: carriers apart', apart.violations.length === 0 && apart.recognised === 2);
580
+
581
+ return { ok: failures.length === 0, failures };
582
+ }
583
+
584
+ // ── CLI ──────────────────────────────────────────────────────────────────────
585
+
586
+ const COVERAGE_NOTE = 'COVERAGE UNKNOWN (OWT-011): recognising a path/command/test name/identifier is pattern matching; an unrecognised format is reported as unnamed. A clean pass does not mean every next action is specific.';
587
+ const UNCALIBRATED_NOTE = 'UNCALIBRATED (OWT-016): heading vocabulary, command list, extension list and identifier pattern are initial judgments.';
588
+
589
+ function readCarrier(p) {
590
+ return { path: p, content: readFileSync(p, 'utf8') };
591
+ }
592
+
593
+ function gitShowBase(file, rev) {
594
+ const abs = resolve(file);
595
+ const git = (args, cwd) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
596
+ const top = git(['rev-parse', '--show-toplevel'], dirname(abs)).trim();
597
+ git(['rev-parse', '--verify', `${rev}^{commit}`], top);
598
+ // Ask git for the path, never compute it from two filesystem paths: on Windows the
599
+ // temp directory can carry an 8.3 short name (RUNNER~1) that realpathSync does not
600
+ // expand while git reports the long name, so relative() walks out of the repo and
601
+ // `git show` fails, which this tool reports as "undecidable" (exit 2) rather than a
602
+ // verdict. git computes the prefix from the same cwd it will read from.
603
+ const prefix = git(['rev-parse', '--show-prefix'], dirname(abs)).trim();
604
+ const rel = `${prefix}${basename(abs)}`;
605
+ const listed = git(['ls-tree', rev, '--', rel], top).trim();
606
+ if (!listed) return null; // absent at that revision: a new file, not an error
607
+ return git(['show', `${rev}:${rel}`], top);
608
+ }
609
+
610
+ export function main(argv, io = { log: console.log, err: console.error }) {
611
+ const out = [];
612
+ const say = (s) => out.push(s);
613
+ const args = [...argv];
614
+ const flag = (name) => {
615
+ const i = args.indexOf(name);
616
+ if (i === -1) return undefined;
617
+ const v = args[i + 1];
618
+ args.splice(i, 2);
619
+ return v;
620
+ };
621
+ const finish = (code) => { for (const l of out) (code === 2 ? io.err : io.log)(l); return code; };
622
+
623
+ const selfTest = runSelfTest();
624
+ if (args[0] === '--self-test') {
625
+ say(selfTest.ok ? '[owt] self-test: OK' : `[owt] self-test FAILED: ${selfTest.failures.join('; ')}`);
626
+ return finish(selfTest.ok ? 0 : 2);
627
+ }
628
+ if (!selfTest.ok) {
629
+ say(`[owt] CANNOT DECIDE: this checker's own self-test arms failed (${selfTest.failures.join('; ')}). A checker that fails its own arms is not evidence of anything. Exit 2 is not a pass.`);
630
+ return finish(2);
631
+ }
632
+
633
+ const cmd = args.shift();
634
+ try {
635
+ if (cmd === 'next-action') {
636
+ const root = flag('--root') ?? process.cwd();
637
+ const idPattern = flag('--id-pattern');
638
+ const files = args;
639
+ if (!files.length) { say('[owt] next-action: no files given'); return finish(2); }
640
+ const res = checkNextActions(files.map(readCarrier), { root, idPattern });
641
+ say(`[owt] OWT-019 walked ${res.walked} next-action field(s) in ${files.length} carrier(s); ${res.empty} empty/done field(s) not evaluated; ${res.noFieldCarriers.length} carrier(s) had no next-action field`);
642
+ say(`[owt] named-resolved=${res.counts['named-resolved']} named-unresolved=${res.counts['named-unresolved']} unnamed=${res.counts.unnamed}`);
643
+ for (const d of res.detail) say(`[owt] ${d.status.padEnd(16)} ${d.path} ${d.where}: ${d.text.slice(0, 80)}${d.kinds.length ? ' <- ' + d.kinds.map((k) => `${k.kind}:${k.value}`).join(', ') : ''}`);
644
+ for (const v of res.violations) say(`[owt] VIOLATION OWT-019: ${v.path} ${v.where} names no file path, test name, command or requirement identifier: "${v.text.slice(0, 80)}"`);
645
+ say(`[owt] ${COVERAGE_NOTE}`);
646
+ say(`[owt] ${UNCALIBRATED_NOTE}`);
647
+ if (res.walked + res.empty === 0) { say('[owt] CANNOT DECIDE: no next-action field found in any carrier (walked 0). Exit 2 is not a pass.'); return finish(2); }
648
+ return finish(res.violations.length ? 1 : 0);
649
+ }
650
+ if (cmd === 'revision') {
651
+ const file = flag('--file');
652
+ const base = flag('--base');
653
+ const beforeF = flag('--before');
654
+ const afterF = flag('--after');
655
+ let before; let after; let path;
656
+ if (file && base) { path = file; after = readFileSync(file, 'utf8'); before = gitShowBase(file, base); } else if (beforeF && afterF) { path = afterF; before = readFileSync(beforeF, 'utf8'); after = readFileSync(afterF, 'utf8'); } else { say('[owt] revision: give --file PATH --base REV, or --before FILE --after FILE'); return finish(2); }
657
+ const r = checkIntentRevision({ before, after, path });
658
+ say(`[owt] OWT-018 ${path}: ${r.status}${r.changedSections.length ? ` (changed: ${r.changedSections.join(', ')})` : ''}`);
659
+ if (r.status === 'no-baseline') say('[owt] the carrier did not exist in the baseline; there is nothing to compare');
660
+ if (r.status === 'undecidable') { say('[owt] CANNOT DECIDE: no acceptance/goal/constraint section found in either version, so the check cannot see the intent. Exit 2 is not a pass.'); say(`[owt] ${UNCALIBRATED_NOTE}`); return finish(2); }
661
+ for (const v of r.violations) say(`[owt] VIOLATION ${v.rule}: ${v.why}${v.sections ? ` [${v.sections.join(', ')}]` : ''}${v.entry ? ` <${v.entry.slice(0, 60)}>` : ''}`);
662
+ if (r.handBack.length) {
663
+ say('[owt] HAND-BACK (OWT-018 -> OWT-007) — list these when control returns to a human; this listing never blocks (OWT-008):');
664
+ for (const h of r.handBack) say(`[owt] edit to ${h.sections ? h.sections.join(', ') : 'intent'} in ${path}: ${h.why}`);
665
+ }
666
+ say('[owt] LIMIT: the check decides that intent changed, that a NEW record exists, that it is complete and whether an approver is filled in. It cannot decide that the record describes the change honestly (OWT-014).');
667
+ say(`[owt] ${UNCALIBRATED_NOTE}`);
668
+ return finish(r.violations.length ? 1 : 0);
669
+ }
670
+ if (cmd === 'separation') {
671
+ const files = args;
672
+ if (!files.length) { say('[owt] separation: no files given'); return finish(2); }
673
+ const r = checkSeparation(files.map(readCarrier));
674
+ say(`[owt] OWT-017 walked ${r.walked} carrier(s); ${r.recognised} had a recognised intent or progress section`);
675
+ for (const v of r.violations) say(`[owt] VIOLATION OWT-017: ${v.path} holds intent (${v.intent.join(', ')}) and progress (${v.progress.join(', ')}) in one carrier`);
676
+ say(`[owt] ${UNCALIBRATED_NOTE}`);
677
+ if (r.recognised === 0) { say('[owt] CANNOT DECIDE: no carrier had a recognised intent or progress heading. Exit 2 is not a pass.'); return finish(2); }
678
+ return finish(r.violations.length ? 1 : 0);
679
+ }
680
+ // The repo shim (its own `--self-test` flag) is named FIRST on purpose: cli/scripts/check-command-existence.mjs
681
+ // reads a `uds <...>` string up to the closing quote, so a `--self-test` written AFTER `uds open-work`
682
+ // is judged as a flag of the `uds open-work` command, which has none (it is the subcommand `self-test`).
683
+ say('[owt] usage: node scripts/check-open-work-tracking.mjs ... | --self-test (or from the npm package: uds open-work next-action|revision|separation|self-test ...)');
684
+ return finish(2);
685
+ } catch (e) {
686
+ say(`[owt] CANNOT DECIDE: ${e.message.split('\n')[0]}`);
687
+ return finish(2);
688
+ }
689
+ }
690
+
691
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
692
+ process.exitCode = main(process.argv.slice(2));
693
+ }