universal-dev-standards 6.14.0-beta.2 → 6.14.0-beta.3
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/bundled/ai/standards/open-work-tracking.ai.yaml +4 -1
- package/bundled/core/open-work-tracking.md +1 -1
- package/bundled/locales/zh-CN/CHANGELOG.md +19 -2
- package/bundled/locales/zh-CN/README.md +1 -1
- package/bundled/locales/zh-CN/SECURITY.md +1 -1
- package/bundled/locales/zh-TW/CHANGELOG.md +19 -2
- package/bundled/locales/zh-TW/README.md +1 -1
- package/bundled/locales/zh-TW/SECURITY.md +1 -1
- package/bundled/locales/zh-TW/core/open-work-tracking.md +3 -3
- package/package.json +1 -1
- package/src/commands/check.js +9 -0
- package/src/commands/init.js +71 -18
- package/src/commands/uninstall.js +144 -30
- package/src/commands/update.js +59 -1
- package/src/core/install-records.js +191 -0
- package/src/i18n/messages.js +39 -6
- package/src/installers/hooks-installer.js +61 -30
- package/src/installers/integration-installer.js +5 -1
- package/src/uninstallers/hook-uninstaller.js +216 -32
- package/src/uninstallers/integration-uninstaller.js +35 -5
- package/src/utils/git-hooks.js +135 -7
- package/src/utils/hasher.js +36 -0
- package/src/utils/integration-generator.js +16 -6
- package/src/utils/legacy-hook-migration.js +112 -0
- package/src/utils/open-work-tracking.mjs +124 -23
- package/standards-registry.json +7 -7
package/src/utils/hasher.js
CHANGED
|
@@ -532,6 +532,42 @@ export function computeIntegrationBlockHash(filePath) {
|
|
|
532
532
|
}
|
|
533
533
|
}
|
|
534
534
|
|
|
535
|
+
/**
|
|
536
|
+
* Hash everything in an integration file that is OUTSIDE the UDS marker block.
|
|
537
|
+
*
|
|
538
|
+
* `computeIntegrationBlockHash` answers "did anyone edit what UDS manages?".
|
|
539
|
+
* This answers the opposite question, which `uds uninstall` needs: "is anything
|
|
540
|
+
* outside the block something other than what UDS itself wrote?". A file UDS
|
|
541
|
+
* created from nothing (AGENTS.md's `# AGENTS.md` / `> Auto-generated by ...`
|
|
542
|
+
* header and its trailing sections, CLAUDE.md's leading guidance) has plenty of
|
|
543
|
+
* text outside the markers, and that text alone cannot tell "generated by UDS"
|
|
544
|
+
* from "typed by the adopter" — only a hash recorded when UDS created the file,
|
|
545
|
+
* compared with the same hash now, can.
|
|
546
|
+
*
|
|
547
|
+
* Line endings are normalized (same reason as every other hash here) and each
|
|
548
|
+
* side is trimmed, so an editor adding a trailing newline is not "a user edit".
|
|
549
|
+
*
|
|
550
|
+
* @param {string} filePath - Absolute file path
|
|
551
|
+
* @returns {{hash: string, size: number}|null} null when the file cannot be read,
|
|
552
|
+
* has no marker block, or has an ambiguous marker pair (no proof is available)
|
|
553
|
+
*/
|
|
554
|
+
export function computeOutsideBlockHash(filePath) {
|
|
555
|
+
try {
|
|
556
|
+
const content = normalizeLineEndings(readFileSync(filePath, 'utf-8'));
|
|
557
|
+
const { before, blockContent, after } = extractBlockContent(content, detectFormat(filePath));
|
|
558
|
+
if (!blockContent) return null;
|
|
559
|
+
const outside = `${before.trim()}\n\u0000\n${after.trim()}`;
|
|
560
|
+
return {
|
|
561
|
+
hash: `sha256:${createHash('sha256').update(outside).digest('hex')}`,
|
|
562
|
+
size: Buffer.byteLength(outside, 'utf-8')
|
|
563
|
+
};
|
|
564
|
+
} catch {
|
|
565
|
+
// Includes AmbiguousMarkerError: with two marker pairs there is no single
|
|
566
|
+
// "outside", so there is nothing to prove anything with.
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
535
571
|
/**
|
|
536
572
|
* Compare integration block hash with stored hash info
|
|
537
573
|
* @param {string} filePath - Absolute file path
|
|
@@ -3202,8 +3202,14 @@ export function writeIntegrationFile(tool, config, projectPath) {
|
|
|
3202
3202
|
try {
|
|
3203
3203
|
let content = generateIntegrationContent({ ...config, tool });
|
|
3204
3204
|
|
|
3205
|
+
// Whether this call CREATES the file (as opposed to merging into one that was
|
|
3206
|
+
// already there). Only a file UDS created from nothing can later be proven
|
|
3207
|
+
// to be wholly UDS's — see core/install-records.js. Taken from the existence
|
|
3208
|
+
// check that already gates the merge below, before anything is written.
|
|
3209
|
+
const created = !existsSync(filePath);
|
|
3210
|
+
|
|
3205
3211
|
// Handle merge if file exists
|
|
3206
|
-
if (
|
|
3212
|
+
if (!created) {
|
|
3207
3213
|
const existingContent = readFileSync(filePath, 'utf-8');
|
|
3208
3214
|
|
|
3209
3215
|
if (config.mergeStrategy) {
|
|
@@ -3242,6 +3248,7 @@ export function writeIntegrationFile(tool, config, projectPath) {
|
|
|
3242
3248
|
success: true,
|
|
3243
3249
|
path: fileName, // Return relative path for consistency
|
|
3244
3250
|
absolutePath: filePath,
|
|
3251
|
+
created,
|
|
3245
3252
|
blockHashInfo // Contains: blockHash, blockSize, fullHash, fullSize
|
|
3246
3253
|
};
|
|
3247
3254
|
} catch (error) {
|
|
@@ -3584,8 +3591,8 @@ export function parseStandardsIndexCount(content) {
|
|
|
3584
3591
|
export function wrapWithMarkers(content, format) {
|
|
3585
3592
|
const markers = UDS_MARKERS[format] || UDS_MARKERS.markdown;
|
|
3586
3593
|
const warning = format === 'plaintext'
|
|
3587
|
-
? '# WARNING: This block is managed by UDS
|
|
3588
|
-
: '<!-- WARNING: This block is managed by UDS
|
|
3594
|
+
? '# WARNING: This block is managed by UDS. DO NOT manually edit. Use \'npx universal-dev-standards init\' or \'npx universal-dev-standards update\' to modify.'
|
|
3595
|
+
: '<!-- WARNING: This block is managed by UDS. DO NOT manually edit. Use \'npx universal-dev-standards init\' or \'npx universal-dev-standards update\' to modify. -->';
|
|
3589
3596
|
// 冪等:warning 位於 markers **內部**,而 extractMarkedContent 取出的內容也含它,
|
|
3590
3597
|
// 於是重新包裝會疊出第二份(dev-platform CLAUDE.md 實測 178/179 兩行完全相同)。
|
|
3591
3598
|
// 這裡先剝掉內容開頭既有的 warning,不論上游哪條路徑造成都能修掉。
|
|
@@ -3949,7 +3956,7 @@ export function generateAgentsMdSummary(config = {}) {
|
|
|
3949
3956
|
blockLines.push(`- \`${dir}/${filename}\` — ${filename.replace(suffix, '')}`);
|
|
3950
3957
|
}
|
|
3951
3958
|
} else {
|
|
3952
|
-
blockLines.push('No standards installed yet. Run `npx
|
|
3959
|
+
blockLines.push('No standards installed yet. Run `npx universal-dev-standards init` to install.');
|
|
3953
3960
|
}
|
|
3954
3961
|
|
|
3955
3962
|
lines.push(wrapWithMarkers(blockLines.join('\n'), 'markdown'));
|
|
@@ -3976,12 +3983,14 @@ export function generateAgentsMdSummary(config = {}) {
|
|
|
3976
3983
|
*/
|
|
3977
3984
|
export function writeAgentsMdSummary(config, projectPath) {
|
|
3978
3985
|
const filePath = join(projectPath, 'AGENTS.md');
|
|
3979
|
-
|
|
3980
3986
|
try {
|
|
3981
3987
|
let content = generateAgentsMdSummary({ ...config, projectPath });
|
|
3982
3988
|
|
|
3989
|
+
// See writeIntegrationFile: true only when this call creates the file.
|
|
3990
|
+
const created = !existsSync(filePath);
|
|
3991
|
+
|
|
3983
3992
|
// Handle merge if file exists (preserve user content outside markers)
|
|
3984
|
-
if (
|
|
3993
|
+
if (!created) {
|
|
3985
3994
|
const existingContent = readFileSync(filePath, 'utf-8');
|
|
3986
3995
|
const newParts = extractMarkedContent(content, 'markdown');
|
|
3987
3996
|
if (newParts.content) {
|
|
@@ -3997,6 +4006,7 @@ export function writeAgentsMdSummary(config, projectPath) {
|
|
|
3997
4006
|
success: true,
|
|
3998
4007
|
path: 'AGENTS.md',
|
|
3999
4008
|
absolutePath: filePath,
|
|
4009
|
+
created,
|
|
4000
4010
|
blockHashInfo
|
|
4001
4011
|
};
|
|
4002
4012
|
} catch (error) {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migrate the pre-commit hook line older UDS versions wrote
|
|
3
|
+
* (`npx uds check`) to the block current UDS writes.
|
|
4
|
+
*
|
|
5
|
+
* Why it exists: every adopter who ran `uds init` before this fix has
|
|
6
|
+
* `.husky/pre-commit` asking `npx` to run the bare name `uds`. `npx` looks in
|
|
7
|
+
* node_modules/.bin and PATH first and otherwise asks the npm registry — where
|
|
8
|
+
* `uds` is an unrelated package that is not ours (see buildPreCommitBlock in
|
|
9
|
+
* git-hooks.js for the measurements). Fixing what `uds init` writes today does
|
|
10
|
+
* nothing for the hooks already on disk, so `uds update` swaps them.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ THIS FILE RECOGNISES the old text; it must never EMIT it. The regression
|
|
13
|
+
* guard (tests/unit/no-bare-uds-npx.test.js) lists this file as the one place
|
|
14
|
+
* a bare-name npx string is allowed, and says why. Nothing here may write one.
|
|
15
|
+
*
|
|
16
|
+
* What counts as "UDS wrote this line" — exact equality, not a pattern:
|
|
17
|
+
* only the two shapes UDS has ever generated, and only directly under the
|
|
18
|
+
* marker comment UDS put above them.
|
|
19
|
+
* 2026-02-04 .. 2026-03-04 npx uds check --standard checkin-standards
|
|
20
|
+
* 2026-03-04 .. this fix npx uds check
|
|
21
|
+
* (from `git log -S` on the writer in cli/src/commands/init.js.) A line an
|
|
22
|
+
* adopter edited, or wrote themselves, is NOT swapped: it cannot be proven to be
|
|
23
|
+
* ours, and a hook that runs the adopter's own arguments is theirs to change.
|
|
24
|
+
* It is reported, with the exact line number and what to write instead.
|
|
25
|
+
*
|
|
26
|
+
* @module utils/legacy-hook-migration
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
30
|
+
import { join } from 'path';
|
|
31
|
+
import { buildPreCommitBlock, UDS_HOOK_MARKER, BARE_UDS_RUNNER_RE, hasBareUdsRunner } from './git-hooks.js';
|
|
32
|
+
import { getFileRecord, proveUnchanged, recordFile, newRecorder, RECORD_KINDS } from '../core/install-records.js';
|
|
33
|
+
|
|
34
|
+
/** The exact command lines UDS itself has written under the marker, and the extra arguments each carried. */
|
|
35
|
+
const UDS_WRITTEN_LEGACY_LINES = new Map([
|
|
36
|
+
['npx uds check', ''],
|
|
37
|
+
['npx uds check --standard checkin-standards', '--standard checkin-standards']
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Swap the legacy hook line UDS wrote in `.husky/pre-commit` for the current block.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} projectPath
|
|
44
|
+
* @param {{plan?: boolean, manifest?: object|null}} [opts]
|
|
45
|
+
* plan — report what would change, write nothing
|
|
46
|
+
* manifest — read only to see whether the file was provably UDS's before the
|
|
47
|
+
* swap (so the install record can follow it); may be null
|
|
48
|
+
* @returns {{
|
|
49
|
+
* state: 'none'|'migrated'|'would-migrate'|'kept'|'error',
|
|
50
|
+
* replaced: number, // legacy UDS-written lines swapped (or that would be)
|
|
51
|
+
* kept: Array<{line: number, text: string}>, // bare-name lines UDS cannot prove it wrote
|
|
52
|
+
* recorder: object|null, // fold into the manifest when non-null
|
|
53
|
+
* error?: string
|
|
54
|
+
* }}
|
|
55
|
+
*/
|
|
56
|
+
export function migrateLegacyHuskyHook(projectPath, { plan = false, manifest = null } = {}) {
|
|
57
|
+
const rel = '.husky/pre-commit';
|
|
58
|
+
const abs = join(projectPath, '.husky', 'pre-commit');
|
|
59
|
+
const none = { state: 'none', replaced: 0, kept: [], recorder: null };
|
|
60
|
+
if (!existsSync(abs)) return none;
|
|
61
|
+
|
|
62
|
+
let content;
|
|
63
|
+
try {
|
|
64
|
+
content = readFileSync(abs, 'utf-8');
|
|
65
|
+
} catch (e) {
|
|
66
|
+
return { ...none, state: 'error', error: e.message };
|
|
67
|
+
}
|
|
68
|
+
if (!hasBareUdsRunner(content)) return none;
|
|
69
|
+
|
|
70
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
71
|
+
const lines = content.split('\n');
|
|
72
|
+
const out = [];
|
|
73
|
+
const kept = [];
|
|
74
|
+
let replaced = 0;
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < lines.length; i++) {
|
|
77
|
+
const text = lines[i].replace(/\r$/, '');
|
|
78
|
+
const next = i + 1 < lines.length ? lines[i + 1].replace(/\r$/, '').trim() : null;
|
|
79
|
+
// Marker line directly followed by a line UDS is known to have written.
|
|
80
|
+
if (text.trim() === UDS_HOOK_MARKER && next !== null && UDS_WRITTEN_LEGACY_LINES.has(next)) {
|
|
81
|
+
const block = buildPreCommitBlock({ args: UDS_WRITTEN_LEGACY_LINES.get(next) });
|
|
82
|
+
// block ends with '\n'; drop that so the surrounding line join supplies it.
|
|
83
|
+
out.push(...block.replace(/\n$/, '').split('\n').map((l) => l + (eol === '\r\n' ? '\r' : '')));
|
|
84
|
+
replaced += 1;
|
|
85
|
+
i += 1; // the legacy command line is consumed
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (BARE_UDS_RUNNER_RE.test(text)) kept.push({ line: i + 1, text: text.trim() });
|
|
89
|
+
out.push(lines[i]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (replaced === 0) return { state: 'kept', replaced: 0, kept, recorder: null };
|
|
93
|
+
|
|
94
|
+
if (plan) return { state: 'would-migrate', replaced, kept, recorder: null };
|
|
95
|
+
|
|
96
|
+
// Was the whole file provably UDS's until now? Then the swap must not cost it
|
|
97
|
+
// that proof: re-record the new content, or uninstall falls back from "delete
|
|
98
|
+
// the file UDS created" to "strip lines".
|
|
99
|
+
let recorder = null;
|
|
100
|
+
const wasProven = !!manifest && !!getFileRecord(manifest, rel) && proveUnchanged(manifest, projectPath, rel).state === 'proven';
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
writeFileSync(abs, out.join('\n'), 'utf-8');
|
|
104
|
+
} catch (e) {
|
|
105
|
+
return { state: 'error', replaced: 0, kept, recorder: null, error: e.message };
|
|
106
|
+
}
|
|
107
|
+
if (wasProven) {
|
|
108
|
+
recorder = newRecorder();
|
|
109
|
+
recordFile(recorder, projectPath, rel, RECORD_KINDS.GIT_HOOK);
|
|
110
|
+
}
|
|
111
|
+
return { state: 'migrated', replaced, kept, recorder };
|
|
112
|
+
}
|
|
@@ -44,6 +44,19 @@
|
|
|
44
44
|
* could not apply (command, test
|
|
45
45
|
* name, identifier)
|
|
46
46
|
* unnamed VIOLATION — the only failure
|
|
47
|
+
* A field is read in three shapes, all through the one
|
|
48
|
+
* VOCAB.nextAction list: a heading section, an inline
|
|
49
|
+
* "Next action: ..." label, and EVERY ROW of a table column whose
|
|
50
|
+
* header is in that list (each report carries the line and the
|
|
51
|
+
* row's first cell, so it can be found).
|
|
52
|
+
* Not evaluated, counted: an empty cell, `-`, `—`, `n/a`, `none`,
|
|
53
|
+
* `done`. OWT-019 is NOT violated by them: it judges a next action
|
|
54
|
+
* that was written; "no next action written" is another failure
|
|
55
|
+
* this check does not decide (a finished item has none either).
|
|
56
|
+
* Undecidable, listed: a table row whose cell count differs from its
|
|
57
|
+
* header (a stray or missing `|`). It is neither judged nor read as
|
|
58
|
+
* empty. A violation elsewhere still exits 1; otherwise the exit is 2,
|
|
59
|
+
* because "no violation found" would then cover only part of the field.
|
|
47
60
|
* revision OWT-018 the acceptance/goal/constraint sections changed
|
|
48
61
|
* between two versions of a carrier; a NEW revision
|
|
49
62
|
* record (change, approver, reason) must exist. No
|
|
@@ -75,8 +88,8 @@
|
|
|
75
88
|
* (--id-pattern) and read the heading vocabulary as a starting point.
|
|
76
89
|
*
|
|
77
90
|
* Exit codes: 0 no violation · 1 violation · 2 cannot decide (no structure
|
|
78
|
-
* found,
|
|
79
|
-
* pass.
|
|
91
|
+
* found, a next-action table row could not be read, git failed, or this
|
|
92
|
+
* script's own self-test arms failed). 2 is NOT a pass.
|
|
80
93
|
*
|
|
81
94
|
* Usage (`uds` from the npm package, or the repo shim; same arguments):
|
|
82
95
|
* uds open-work next-action <file...> [--root DIR] [--id-pattern RE]
|
|
@@ -97,7 +110,16 @@ import { fileURLToPath } from 'node:url';
|
|
|
97
110
|
|
|
98
111
|
export const VOCAB = {
|
|
99
112
|
revision: /revision|change[\s-]?log|amendment|history|修訂|變更紀錄|變更記錄|修改紀錄|修改記錄|異動/i,
|
|
100
|
-
|
|
113
|
+
// ONE list of "next action" words. It is read three ways and there is no second copy:
|
|
114
|
+
// a heading (classifyHeading), a table column header (extractNextActions) and an inline
|
|
115
|
+
// label ("Next action: ...", built from .source below). Keep it free of capture groups:
|
|
116
|
+
// the label regex embeds it and reads its own group 1.
|
|
117
|
+
// 回來要做什麼 ("what to do when you come back") is the header the dev-platform worklog's
|
|
118
|
+
// main table actually uses (DEC-122 H2 baseline carrier); that file describes the column as
|
|
119
|
+
// 下一動 in prose but the header text drifted. Added because a header outside this list is
|
|
120
|
+
// invisible to the check, which is the failure DEC-122 measured. Adopter-specific and
|
|
121
|
+
// UNCALIBRATED (OWT-016); an adopter whose header differs adds its own word here.
|
|
122
|
+
nextAction: /next[\s-]?(?:action|step)s?|下一步|下一動|回來要做什麼/i,
|
|
101
123
|
progress: /\bprogress\b|\bstate\b|\bblockers?\b|\bblocked\b|進度|現況|卡在/i,
|
|
102
124
|
intent: /acceptance|criteria|\brequirements?\b|\bgoals?\b|objectives?|constraints?|驗收|需求|目標|限制/i,
|
|
103
125
|
// revision-table columns / labelled list fields
|
|
@@ -198,14 +220,58 @@ function innermost(stack) {
|
|
|
198
220
|
|
|
199
221
|
const norm = (s) => s.replace(/\s+/g, ' ').trim();
|
|
200
222
|
|
|
223
|
+
/** A table line may sit inside a Markdown blockquote ("> | a | b |"): drop the quote marker. */
|
|
224
|
+
const unquote = (line) => line.replace(/^\s*(?:>\s?)+/, '');
|
|
225
|
+
|
|
226
|
+
/** Index of the closing backtick run of exactly `n` backticks at or after `from`, or -1. */
|
|
227
|
+
function closingRun(t, from, n) {
|
|
228
|
+
let j = from;
|
|
229
|
+
while (j < t.length) {
|
|
230
|
+
if (t[j] !== '`') { j++; continue; }
|
|
231
|
+
let m = 0;
|
|
232
|
+
while (t[j + m] === '`') m++;
|
|
233
|
+
if (m === n) return j;
|
|
234
|
+
j += m;
|
|
235
|
+
}
|
|
236
|
+
return -1;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Split one table row into cells. A `|` does NOT split a cell when it is escaped (`\|`) or
|
|
241
|
+
* sits inside a code span that closes on the same line. The second rule is deliberately
|
|
242
|
+
* looser than GFM (which splits inside code unless escaped): a hand-written note such as
|
|
243
|
+
* `tar -tzf \| grep` or `a|b` in backticks means one cell, and splitting it would shift
|
|
244
|
+
* every later column and misread the next-action cell. An unclosed backtick is literal.
|
|
245
|
+
*/
|
|
201
246
|
function splitRow(line) {
|
|
202
|
-
let t = line.trim();
|
|
247
|
+
let t = unquote(line).trim();
|
|
203
248
|
if (t.startsWith('|')) t = t.slice(1);
|
|
204
|
-
|
|
205
|
-
|
|
249
|
+
const cells = [];
|
|
250
|
+
let cur = '';
|
|
251
|
+
let lastDelim = false;
|
|
252
|
+
let i = 0;
|
|
253
|
+
while (i < t.length) {
|
|
254
|
+
const ch = t[i];
|
|
255
|
+
lastDelim = false;
|
|
256
|
+
if (ch === '\\' && t[i + 1] === '|') { cur += '|'; i += 2; continue; }
|
|
257
|
+
if (ch === '`') {
|
|
258
|
+
let n = 0;
|
|
259
|
+
while (t[i + n] === '`') n++;
|
|
260
|
+
const close = closingRun(t, i + n, n);
|
|
261
|
+
if (close !== -1) { cur += t.slice(i, close + n).replace(/\\\|/g, '|'); i = close + n; continue; }
|
|
262
|
+
cur += '`'.repeat(n);
|
|
263
|
+
i += n;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (ch === '|') { cells.push(cur); cur = ''; lastDelim = true; i++; continue; }
|
|
267
|
+
cur += ch;
|
|
268
|
+
i++;
|
|
269
|
+
}
|
|
270
|
+
if (!lastDelim) cells.push(cur); // a trailing unescaped `|` closes the row; it opens no cell
|
|
271
|
+
return cells.map(norm);
|
|
206
272
|
}
|
|
207
273
|
|
|
208
|
-
const isSepRow = (line) => /^\s*\|?[\s:|-]+\|?\s*$/.test(line) && /-/.test(line);
|
|
274
|
+
const isSepRow = (line) => /^\s*\|?[\s:|-]+\|?\s*$/.test(unquote(line)) && /-/.test(line);
|
|
209
275
|
|
|
210
276
|
/** Tables among annotated lines: [{header, rows:[{cells, idx}]}] */
|
|
211
277
|
function findTables(lines) {
|
|
@@ -213,12 +279,12 @@ function findTables(lines) {
|
|
|
213
279
|
let k = 0;
|
|
214
280
|
while (k < lines.length) {
|
|
215
281
|
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('|')) {
|
|
282
|
+
if (!l.inFence && !l.fence && !l.front && unquote(l.text).trim().startsWith('|') && k + 1 < lines.length
|
|
283
|
+
&& isSepRow(lines[k + 1].text) && unquote(lines[k + 1].text).trim().startsWith('|')) {
|
|
218
284
|
const header = splitRow(l.text);
|
|
219
285
|
const rows = [];
|
|
220
286
|
let j = k + 2;
|
|
221
|
-
while (j < lines.length && lines[j].text.trim().startsWith('|')) {
|
|
287
|
+
while (j < lines.length && unquote(lines[j].text).trim().startsWith('|')) {
|
|
222
288
|
rows.push({ cells: splitRow(lines[j].text), idx: j });
|
|
223
289
|
j++;
|
|
224
290
|
}
|
|
@@ -456,24 +522,45 @@ export function classifyNextAction(text, opts = {}) {
|
|
|
456
522
|
};
|
|
457
523
|
}
|
|
458
524
|
|
|
459
|
-
|
|
525
|
+
const clip = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Find "next action" fields in a carrier. Three shapes are read, all through the ONE
|
|
529
|
+
* vocabulary VOCAB.nextAction: a heading section, an inline label, and a table column whose
|
|
530
|
+
* HEADER is in that vocabulary (every row of that column is one field).
|
|
531
|
+
*
|
|
532
|
+
* A table row whose cell count differs from the header's is returned in `ragged`, not in
|
|
533
|
+
* `items`: after a stray or missing `|` the next-action cell cannot be told from its
|
|
534
|
+
* neighbour, and reading an absent cell as "empty" would turn a broken row into a quiet
|
|
535
|
+
* "no next action written". It is neither judged nor dropped: the caller counts and lists it.
|
|
536
|
+
* @returns {{fields:number, items:{text:string,where:string}[], ragged:{where:string,cells:number,expected:number}[]}}
|
|
537
|
+
*/
|
|
460
538
|
export function extractNextActions(md) {
|
|
461
539
|
const lines = parseDoc(md);
|
|
462
540
|
const items = [];
|
|
541
|
+
const ragged = [];
|
|
463
542
|
let fields = 0;
|
|
464
543
|
const tableIdx = new Set();
|
|
465
544
|
const headerIdx = new Set();
|
|
466
545
|
const tables = findTables(lines);
|
|
467
546
|
|
|
468
547
|
for (const t of tables) {
|
|
469
|
-
|
|
470
|
-
const
|
|
548
|
+
// every header cell in the shared vocabulary is a next-action column (usually exactly one)
|
|
549
|
+
const cols = t.header.map((c, n) => (VOCAB.nextAction.test(c) ? n : -1)).filter((n) => n >= 0); // [mutation-anchor:table-header]
|
|
550
|
+
const hasCol = cols.length > 0;
|
|
471
551
|
tableIdx.add(t.headerIdx); tableIdx.add(t.headerIdx + 1); headerIdx.add(t.headerIdx);
|
|
472
552
|
t.rows.forEach((r) => tableIdx.add(r.idx));
|
|
473
553
|
if (hasCol) {
|
|
474
554
|
for (const r of t.rows) {
|
|
475
|
-
|
|
476
|
-
|
|
555
|
+
const who = `row "${clip(r.cells[0].replace(/\*\*|__/g, ''), 40)}"`;
|
|
556
|
+
if (r.cells.length !== t.header.length) {
|
|
557
|
+
ragged.push({ idx: r.idx, where: `table row (line ${r.idx + 1}, ${who})`, cells: r.cells.length, expected: t.header.length });
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
for (const col of cols) {
|
|
561
|
+
fields++;
|
|
562
|
+
items.push({ text: r.cells[col], where: `table column "${t.header[col]}" (line ${r.idx + 1}, ${who})` });
|
|
563
|
+
}
|
|
477
564
|
}
|
|
478
565
|
}
|
|
479
566
|
// rows inside a nextAction-class section are handled by the section walk below
|
|
@@ -481,12 +568,16 @@ export function extractNextActions(md) {
|
|
|
481
568
|
}
|
|
482
569
|
|
|
483
570
|
// inline labelled "**Next action**: ..." (outside tables that have their own column)
|
|
484
|
-
const labelRe =
|
|
571
|
+
const labelRe = new RegExp(`(?:\\*\\*|__)?(?:${VOCAB.nextAction.source})(?:\\*\\*|__)?\\s*[::]\\s*(.+)$`, 'i');
|
|
485
572
|
const colTableRows = new Set();
|
|
486
|
-
|
|
573
|
+
// Only rows read through the column are exempt from the label scan. A ragged row was not
|
|
574
|
+
// read, so an inline "Next action: ..." inside it must still be seen (else it goes dark).
|
|
575
|
+
const raggedIdx = new Set(ragged.map((g) => g.idx));
|
|
576
|
+
for (const t of tables) if (t.hasNextActionColumn) t.rows.forEach((r) => { if (!raggedIdx.has(r.idx)) colTableRows.add(r.idx); });
|
|
487
577
|
lines.forEach((l, idx) => {
|
|
488
578
|
if (l.front || l.fence || l.inFence || l.heading || l.cls === 'nextAction' || colTableRows.has(idx)) return;
|
|
489
|
-
|
|
579
|
+
// a label inside a table row reads to the end of the row, not into its closing pipe
|
|
580
|
+
const m = labelRe.exec(unquote(l.text).trim().startsWith('|') ? l.text.replace(/\s*\|\s*$/, '') : l.text);
|
|
490
581
|
if (m && !/^\s*$/.test(m[1])) { fields++; items.push({ text: m[1], where: `label (line ${idx + 1})` }); }
|
|
491
582
|
});
|
|
492
583
|
|
|
@@ -522,14 +613,15 @@ export function extractNextActions(md) {
|
|
|
522
613
|
if (sec.length === 0) items.push({ text: '', where: `section "${owner.heading}" (empty)` });
|
|
523
614
|
for (const s of sec) items.push({ text: s, where: `section "${owner.heading}"` });
|
|
524
615
|
}
|
|
525
|
-
return { fields, items };
|
|
616
|
+
return { fields, items, ragged };
|
|
526
617
|
}
|
|
527
618
|
|
|
528
619
|
export function checkNextActions(carriers, opts = {}) {
|
|
529
|
-
const res = { walked: 0, empty: 0, counts: { 'named-resolved': 0, 'named-unresolved': 0, unnamed: 0 }, violations: [], detail: [], noFieldCarriers: [] };
|
|
620
|
+
const res = { walked: 0, empty: 0, counts: { 'named-resolved': 0, 'named-unresolved': 0, unnamed: 0 }, violations: [], detail: [], noFieldCarriers: [], undecidable: [] };
|
|
530
621
|
for (const c of carriers) {
|
|
531
|
-
const { fields, items } = extractNextActions(c.content);
|
|
532
|
-
|
|
622
|
+
const { fields, items, ragged } = extractNextActions(c.content);
|
|
623
|
+
for (const g of ragged) res.undecidable.push({ path: c.path, ...g });
|
|
624
|
+
if (fields === 0 && ragged.length === 0) { res.noFieldCarriers.push(c.path); continue; }
|
|
533
625
|
for (const it of items) {
|
|
534
626
|
const text = it.text.replace(/\*\*|__/g, '').trim();
|
|
535
627
|
if (EMPTY_FIELD.test(text)) { res.empty++; continue; }
|
|
@@ -557,6 +649,11 @@ export function runSelfTest() {
|
|
|
557
649
|
expect('D2 clean: identifier', classifyNextAction('finish XSPEC-436 R1', {}).status !== 'unnamed');
|
|
558
650
|
expect('D2 clean: test name', classifyNextAction('make the test "a next action that names nothing is reported" pass', {}).status !== 'unnamed');
|
|
559
651
|
|
|
652
|
+
// OWT-019, table columns: the header decides which column is read, never the position
|
|
653
|
+
const tbl = extractNextActions('| item | state | Next action |\n|---|---|---|\n| a | open | keep going |\n');
|
|
654
|
+
expect('D2 clean: only the header-named column is a next-action field', tbl.fields === 1 && tbl.items[0].text === 'keep going');
|
|
655
|
+
expect('D2 clean: a table without such a header has no next-action field', extractNextActions('| item | state |\n|---|---|\n| a | open |\n').fields === 0);
|
|
656
|
+
|
|
560
657
|
// OWT-018
|
|
561
658
|
const spec = (ac, rev) => `# S\n\n## Acceptance criteria\n\n${ac}\n\n## Revisions\n\n${rev}\n`;
|
|
562
659
|
const table = (rows) => `| Change | Approver | Reason |\n|---|---|---|\n${rows}`;
|
|
@@ -639,12 +736,16 @@ export function main(argv, io = { log: console.log, err: console.error }) {
|
|
|
639
736
|
if (!files.length) { say('[owt] next-action: no files given'); return finish(2); }
|
|
640
737
|
const res = checkNextActions(files.map(readCarrier), { root, idPattern });
|
|
641
738
|
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}`);
|
|
739
|
+
say(`[owt] named-resolved=${res.counts['named-resolved']} named-unresolved=${res.counts['named-unresolved']} unnamed=${res.counts.unnamed} undecidable-table-rows=${res.undecidable.length}`);
|
|
643
740
|
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
741
|
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)}"`);
|
|
742
|
+
for (const u of res.undecidable) say(`[owt] UNDECIDABLE: ${u.path} ${u.where} has ${u.cells} cell(s) but its header has ${u.expected}; the next-action cell cannot be located, so the row is neither judged nor counted as empty`);
|
|
645
743
|
say(`[owt] ${COVERAGE_NOTE}`);
|
|
646
744
|
say(`[owt] ${UNCALIBRATED_NOTE}`);
|
|
647
745
|
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); }
|
|
746
|
+
// A violation is a definite answer whatever else is unreadable. Absent one, a row we could
|
|
747
|
+
// not read means "no violation found" is not "no violation": exit 2, never a green 0.
|
|
748
|
+
if (!res.violations.length && res.undecidable.length) { say(`[owt] CANNOT DECIDE: ${res.undecidable.length} table row(s) could not be read (see UNDECIDABLE above), so a clean result would cover only part of the field. Exit 2 is not a pass.`); return finish(2); }
|
|
648
749
|
return finish(res.violations.length ? 1 : 0);
|
|
649
750
|
}
|
|
650
751
|
if (cmd === 'revision') {
|
package/standards-registry.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
-
"version": "6.14.0-beta.
|
|
3
|
+
"version": "6.14.0-beta.3",
|
|
4
4
|
"lastUpdated": "2026-05-13",
|
|
5
5
|
"description": "Standards registry for universal-dev-standards with integrated skills and AI-optimized formats",
|
|
6
6
|
"formats": {
|
|
@@ -58,14 +58,14 @@
|
|
|
58
58
|
"standards": {
|
|
59
59
|
"name": "universal-dev-standards",
|
|
60
60
|
"url": "https://github.com/AsiaOstrich/universal-dev-standards",
|
|
61
|
-
"version": "6.14.0-beta.
|
|
61
|
+
"version": "6.14.0-beta.3"
|
|
62
62
|
},
|
|
63
63
|
"skills": {
|
|
64
64
|
"name": "universal-dev-standards",
|
|
65
65
|
"url": "https://github.com/AsiaOstrich/universal-dev-standards",
|
|
66
66
|
"localPath": "skills",
|
|
67
67
|
"rawUrl": "https://raw.githubusercontent.com/AsiaOstrich/universal-dev-standards/main/skills",
|
|
68
|
-
"version": "6.14.0-beta.
|
|
68
|
+
"version": "6.14.0-beta.3",
|
|
69
69
|
"note": "Skills are now included in the main repository under skills/"
|
|
70
70
|
}
|
|
71
71
|
},
|
|
@@ -2295,7 +2295,7 @@
|
|
|
2295
2295
|
"id": "license-compliance",
|
|
2296
2296
|
"name": "License Compliance Standards",
|
|
2297
2297
|
"nameZh": "授權合規標準",
|
|
2298
|
-
"version": "6.14.0-beta.
|
|
2298
|
+
"version": "6.14.0-beta.3",
|
|
2299
2299
|
"source": {
|
|
2300
2300
|
"human": "core/license-compliance.md",
|
|
2301
2301
|
"ai": "ai/standards/license-compliance.ai.yaml"
|
|
@@ -2307,7 +2307,7 @@
|
|
|
2307
2307
|
"id": "verification-oracle",
|
|
2308
2308
|
"name": "Verification Oracle Standards",
|
|
2309
2309
|
"nameZh": "驗證 Oracle 標準",
|
|
2310
|
-
"version": "6.14.0-beta.
|
|
2310
|
+
"version": "6.14.0-beta.3",
|
|
2311
2311
|
"source": {
|
|
2312
2312
|
"human": "core/verification-oracle.md",
|
|
2313
2313
|
"ai": "ai/standards/verification-oracle.ai.yaml"
|
|
@@ -2319,7 +2319,7 @@
|
|
|
2319
2319
|
"id": "model-provenance",
|
|
2320
2320
|
"name": "Model Provenance Policy Standards",
|
|
2321
2321
|
"nameZh": "模型來源政策標準",
|
|
2322
|
-
"version": "6.14.0-beta.
|
|
2322
|
+
"version": "6.14.0-beta.3",
|
|
2323
2323
|
"source": {
|
|
2324
2324
|
"human": "core/model-provenance.md",
|
|
2325
2325
|
"ai": "ai/standards/model-provenance.ai.yaml"
|
|
@@ -2331,7 +2331,7 @@
|
|
|
2331
2331
|
"id": "resource-cost-boundary",
|
|
2332
2332
|
"name": "Resource / Cost Boundary Declaration Standards",
|
|
2333
2333
|
"nameZh": "資源/成本邊界宣告標準",
|
|
2334
|
-
"version": "6.14.0-beta.
|
|
2334
|
+
"version": "6.14.0-beta.3",
|
|
2335
2335
|
"source": {
|
|
2336
2336
|
"human": "core/resource-cost-boundary.md",
|
|
2337
2337
|
"ai": "ai/standards/resource-cost-boundary.ai.yaml"
|