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.
- package/bin/uds.js +37 -0
- package/bundled/ai/standards/open-work-tracking.ai.yaml +3 -1
- package/bundled/core/open-work-tracking.md +1 -1
- package/bundled/hooks/turn-completion/locales/en.mjs +54 -5
- package/bundled/hooks/turn-completion/locales/zh-TW.mjs +37 -6
- package/bundled/locales/zh-CN/CHANGELOG.md +21 -3
- package/bundled/locales/zh-CN/README.md +2 -2
- package/bundled/locales/zh-CN/SECURITY.md +1 -1
- package/bundled/locales/zh-CN/docs/CHEATSHEET.md +2 -0
- package/bundled/locales/zh-CN/docs/CLI-INIT-OPTIONS.md +24 -0
- package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +11 -4
- package/bundled/locales/zh-TW/CHANGELOG.md +21 -3
- package/bundled/locales/zh-TW/README.md +2 -2
- package/bundled/locales/zh-TW/SECURITY.md +1 -1
- package/bundled/locales/zh-TW/core/open-work-tracking.md +2 -2
- package/bundled/locales/zh-TW/docs/CHEATSHEET.md +2 -0
- package/bundled/locales/zh-TW/docs/CLI-INIT-OPTIONS.md +24 -0
- package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +11 -4
- package/package.json +1 -1
- package/src/commands/init.js +2 -0
- package/src/commands/open-work.js +60 -0
- package/src/commands/update.js +91 -0
- package/src/i18n/messages.js +3 -0
- package/src/installers/hooks-installer.js +189 -11
- package/src/utils/detector.js +46 -1
- package/src/utils/open-work-tracking.mjs +693 -0
- package/standards-registry.json +7 -7
|
@@ -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
|
+
}
|