universal-dev-standards 6.14.0-beta.3 → 6.14.0-beta.5

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.
Files changed (63) hide show
  1. package/bin/uds.js +7 -1
  2. package/bundled/ai/standards/ai-response-navigation.ai.yaml +43 -3
  3. package/bundled/ai/standards/checkin-standards.ai.yaml +25 -6
  4. package/bundled/ai/standards/full-coverage-testing.ai.yaml +46 -5
  5. package/bundled/ai/standards/pipeline-security-gates.ai.yaml +5 -1
  6. package/bundled/core/ai-response-navigation.md +128 -12
  7. package/bundled/core/full-coverage-testing.md +57 -3
  8. package/bundled/extensions/frameworks/fat-free-patterns.md +937 -0
  9. package/bundled/extensions/languages/csharp-style.md +464 -0
  10. package/bundled/extensions/languages/php-style.md +700 -0
  11. package/bundled/extensions/locales/zh-cn.md +717 -0
  12. package/bundled/extensions/locales/zh-tw.md +717 -0
  13. package/bundled/locales/COVERAGE.md +5 -4
  14. package/bundled/locales/zh-CN/CHANGELOG.md +54 -3
  15. package/bundled/locales/zh-CN/README.md +2 -2
  16. package/bundled/locales/zh-CN/SECURITY.md +1 -1
  17. package/bundled/locales/zh-CN/core/ai-response-navigation.md +110 -12
  18. package/bundled/locales/zh-CN/core/full-coverage-testing.md +61 -7
  19. package/bundled/locales/zh-CN/docs/CHEATSHEET.md +3 -1
  20. package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +8 -5
  21. package/bundled/locales/zh-CN/skills/README.md +1 -0
  22. package/bundled/locales/zh-CN/skills/comprehension-ladder/SKILL.md +289 -0
  23. package/bundled/locales/zh-CN/skills/comprehension-ladder/eval-cases.md +261 -0
  24. package/bundled/locales/zh-TW/CHANGELOG.md +54 -3
  25. package/bundled/locales/zh-TW/README.md +2 -2
  26. package/bundled/locales/zh-TW/SECURITY.md +1 -1
  27. package/bundled/locales/zh-TW/core/ai-response-navigation.md +110 -12
  28. package/bundled/locales/zh-TW/core/full-coverage-testing.md +61 -7
  29. package/bundled/locales/zh-TW/docs/CHEATSHEET.md +3 -1
  30. package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +8 -5
  31. package/bundled/locales/zh-TW/skills/README.md +1 -0
  32. package/bundled/locales/zh-TW/skills/comprehension-ladder/SKILL.md +289 -0
  33. package/bundled/locales/zh-TW/skills/comprehension-ladder/eval-cases.md +261 -0
  34. package/bundled/skills/README.md +1 -0
  35. package/bundled/skills/comprehension-ladder/SKILL.md +283 -0
  36. package/bundled/skills/comprehension-ladder/eval-cases.md +255 -0
  37. package/bundled/templates/gates/check-anti-fake-tests.mjs +991 -0
  38. package/bundled/templates/gates/check-stubs.mjs +644 -0
  39. package/package.json +3 -3
  40. package/src/commands/audit.js +11 -0
  41. package/src/commands/check.js +124 -24
  42. package/src/commands/init.js +45 -9
  43. package/src/commands/update.js +183 -21
  44. package/src/core/install-records.js +2 -1
  45. package/src/i18n/messages.js +50 -9
  46. package/src/installers/standards-installer.js +16 -23
  47. package/src/reconciler/backup-manager.js +418 -82
  48. package/src/reconciler/index.js +27 -5
  49. package/src/reconciler/install-roots.js +90 -0
  50. package/src/reconciler/plan-executor.js +33 -13
  51. package/src/uninstallers/hook-uninstaller.js +7 -4
  52. package/src/utils/command-hash-ownership.js +103 -0
  53. package/src/utils/copier.js +78 -1
  54. package/src/utils/gate-scripts.js +141 -0
  55. package/src/utils/git-hooks.js +8 -4
  56. package/src/utils/health-scorer.js +10 -7
  57. package/src/utils/locale.js +19 -0
  58. package/src/utils/skill-hash-ownership.js +64 -0
  59. package/src/utils/skills-installer.js +12 -1
  60. package/src/utils/test-change-check.js +160 -0
  61. package/src/utils/test-policy.js +214 -0
  62. package/src/utils/update-summary.js +29 -0
  63. package/standards-registry.json +21 -7
@@ -177,20 +177,26 @@ export function calculateConsistency(projectPath) {
177
177
  }
178
178
 
179
179
  /**
180
- * Calculate coverage score
180
+ * Calculate coverage score — how many of the two check scripts each installed standard can
181
+ * have (`scripts/check-<id>.sh`, `scripts/check-<id>-sync.sh`) are present.
182
+ *
183
+ * XSPEC-444 R5: this used to report `details.has_tests` and add it into the sum, but
184
+ * `hasTests` was declared as 0 and never incremented anywhere — a number that was always 0 and
185
+ * looked like a measurement of whether the standards have tests. It is removed rather than
186
+ * faked. The SCORE is unchanged: the denominator was already two per standard, which is the
187
+ * two script kinds above, so no project's coverage score moves.
181
188
  * @param {string} projectPath
182
189
  * @param {string[]} standardIds
183
190
  * @returns {{ score: number, details: object }}
184
191
  */
185
192
  export function calculateCoverage(projectPath, standardIds) {
186
193
  if (!standardIds || standardIds.length === 0) {
187
- return { score: 0, details: { has_check_script: 0, has_tests: 0, total: 0 } };
194
+ return { score: 0, details: { has_check_script: 0, total: 0 } };
188
195
  }
189
196
 
190
197
  // Check for check scripts matching standard names
191
198
  const scriptsDir = join(projectPath, 'scripts');
192
199
  let hasCheckScript = 0;
193
- let hasTests = 0;
194
200
 
195
201
  for (const id of standardIds) {
196
202
  const scriptName = `check-${id}.sh`;
@@ -204,16 +210,13 @@ export function calculateCoverage(projectPath, standardIds) {
204
210
  }
205
211
  }
206
212
 
207
- const ratio = standardIds.length > 0
208
- ? (hasCheckScript + hasTests) / (standardIds.length * 2)
209
- : 0;
213
+ const ratio = hasCheckScript / (standardIds.length * 2);
210
214
  const score = Math.round(Math.min(ratio * 100, 100));
211
215
 
212
216
  return {
213
217
  score,
214
218
  details: {
215
219
  has_check_script: hasCheckScript,
216
- has_tests: hasTests,
217
220
  total: standardIds.length
218
221
  }
219
222
  };
@@ -32,6 +32,24 @@ export function displayLanguageToLocale(displayLanguage) {
32
32
  return LOCALE_MAP[displayLanguage.toLowerCase()] || 'en';
33
33
  }
34
34
 
35
+ /**
36
+ * Normalise a requested display language and say whether it is supported.
37
+ *
38
+ * `uds init --locale zh-CN` used to install English and exit 0: the value was compared case-sensitively
39
+ * further down, so the upper-case spelling silently missed every Chinese branch. An unsupported value such
40
+ * as `fr` did the same — English, exit 0, nothing said. The caller still falls back to English for an
41
+ * unsupported value, but now it can say so.
42
+ * @param {string|undefined|null} requested - what the user, install.yaml or the environment asked for
43
+ * @returns {{ displayLanguage: string|undefined|null, supported: boolean }}
44
+ */
45
+ export function normalizeRequestedLocale(requested) {
46
+ if (typeof requested !== 'string' || requested.trim() === '') {
47
+ return { displayLanguage: requested, supported: true };
48
+ }
49
+ const displayLanguage = requested.trim().toLowerCase();
50
+ return { displayLanguage, supported: Object.prototype.hasOwnProperty.call(LOCALE_MAP, displayLanguage) };
51
+ }
52
+
35
53
  /**
36
54
  * Check if a locale requires localized skill installation.
37
55
  * English locale uses the default source, no localization needed.
@@ -67,6 +85,7 @@ export function detectLocaleFromStandards(projectPath) {
67
85
 
68
86
  export default {
69
87
  displayLanguageToLocale,
88
+ normalizeRequestedLocale,
70
89
  isLocalizedLocale,
71
90
  detectLocaleFromStandards,
72
91
  LOCALE_MAP
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Which `manifest.skillHashes` records really describe files UDS installed.
3
+ *
4
+ * A record is `<agent>/<level>/<skill>/<file>`. It is UDS's only if `<skill>` is a skill UDS ships.
5
+ * Anything else got there because an older installer hashed the whole skills folder (XSPEC-454 R2):
6
+ * the adopter's own skills, the `agents/` / `workflows/` / `_shared/` folders an old CLI copied in by
7
+ * mistake (which a later `uds update` deletes — leaving the record behind), and UDS's own
8
+ * `.manifest.json` bookkeeping file. None of those are skills UDS can vouch for, so none of them may
9
+ * decide whether `uds check` says the project is compliant.
10
+ *
11
+ * @module utils/skill-hash-ownership
12
+ */
13
+
14
+ import { getAvailableSkillNames } from './skills-installer.js';
15
+
16
+ /**
17
+ * Below this many shipped skills the source tree did not load; it does not mean UDS stopped shipping
18
+ * skills. Without this guard a broken install would classify every record as foreign and empty the
19
+ * manifest (same guard as pruneRetiredHashes' registry-size check).
20
+ */
21
+ const MIN_SHIPPED_SKILLS = 10;
22
+
23
+ /**
24
+ * @param {string} key - A skillHashes key
25
+ * @param {Set<string>} shipped - Names of the skills UDS ships
26
+ * @returns {boolean}
27
+ */
28
+ export function isUdsSkillHashKey(key, shipped) {
29
+ const parts = String(key).split('/');
30
+ // agent / level / skill / at least one file path segment
31
+ return parts.length >= 4 && shipped.has(parts[2]);
32
+ }
33
+
34
+ /**
35
+ * Drop the skillHashes records that do not describe a file UDS installed. Mutates `manifest`.
36
+ *
37
+ * @param {Object} manifest - Project manifest
38
+ * @param {Set<string>} [shipped] - Injection point for tests; defaults to the skills this CLI ships
39
+ * @returns {string[]} The keys that were dropped, sorted
40
+ */
41
+ export function pruneForeignSkillHashes(manifest, shipped = new Set(getAvailableSkillNames())) {
42
+ const hashes = manifest?.skillHashes;
43
+ if (!hashes || shipped.size < MIN_SHIPPED_SKILLS) return [];
44
+ const dropped = [];
45
+ for (const key of Object.keys(hashes)) {
46
+ if (isUdsSkillHashKey(key, shipped)) continue;
47
+ delete hashes[key];
48
+ dropped.push(key);
49
+ }
50
+ return dropped.sort();
51
+ }
52
+
53
+ /**
54
+ * Which skill files `uds check` has to report: the missing and the changed ones, as one list.
55
+ *
56
+ * Its result is what the verdict is built from. `uds check` used to throw the integrity result away, so a
57
+ * deleted skill file printed a red ✗ and the run still ended "compliant", exit 0 (XSPEC-454 R2).
58
+ *
59
+ * @param {{ missing?: string[], modified?: string[] }} status - Result of the Skills integrity check
60
+ * @returns {string[]}
61
+ */
62
+ export function skillIssuesOf(status) {
63
+ return [...(status?.missing || []), ...(status?.modified || [])];
64
+ }
@@ -248,8 +248,19 @@ export async function installSkillsForAgent(agent, level, skillNames = null, pro
248
248
 
249
249
  // Compute file hashes for tracking
250
250
  // Key format: agent/level/skillName/filename (e.g., "opencode/project/commit-standards/SKILL.md")
251
+ //
252
+ // XSPEC-454 R2: only the skills THIS run installed are recorded. This used to hash the whole
253
+ // target directory, so everything that happened to sit in `.claude/skills/` — the adopter's own
254
+ // skills, and the `agents/`, `workflows/`, `_shared/` folders an old CLI copied in by mistake —
255
+ // became "files UDS installed". `uds update` later (rightly) deleted the old strays but kept their
256
+ // records, and `uds check` listed 26 of them as missing. A record has to mean "UDS wrote this".
257
+ // `.manifest.json` is deliberately not recorded either: it is UDS's own bookkeeping, rewritten by
258
+ // every install, and nothing a user edits — a hash of it could only ever raise a false alarm.
251
259
  const baseKey = `${agent}/${level}`;
252
- results.fileHashes = computeDirectoryHashes(targetDir, baseKey);
260
+ results.fileHashes = {};
261
+ for (const name of results.installed) {
262
+ Object.assign(results.fileHashes, computeDirectoryHashes(join(targetDir, name), `${baseKey}/${name}`));
263
+ }
253
264
  }
254
265
 
255
266
  return results;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * "This commit changes code and touches no test" — the pre-commit check.
3
+ * // implements XSPEC-444 R2
4
+ *
5
+ * Runs inside `uds check`, which is what the pre-commit hook UDS writes calls. It reads
6
+ * what is staged (`git diff --cached`); with nothing staged (a CI run, a manual
7
+ * `uds check`) it says nothing, because there is no commit to judge.
8
+ *
9
+ * What it decides, per staged file (see test-policy.js for the table):
10
+ * source + no test in the same change → warn, listing every code file
11
+ * a file of a type the table has never seen → warn, listing it and saying how to
12
+ * classify it. Never read as "fine".
13
+ * pure rename (git's R100) → exempt; recorded in the output with its reason
14
+ * deletion → not a code change that needs a test
15
+ * `exempt` entry in the policy → exempt; recorded with the adopter's reason
16
+ *
17
+ * Severity: "warn" by default. `"mode": "block"` in `.standards/test-policy.json` makes a
18
+ * code change without a test fail the check. An unclassified file never blocks — it is
19
+ * UDS's own gap, not the committer's.
20
+ *
21
+ * NOT done here, on purpose (reported to the maintainer instead of guessed):
22
+ * - the ratchet ("count of unpaired changes may only go down"): `uds check` is read-only
23
+ * by design and the spec does not say where the baseline lives or what a "change" is
24
+ * counted in.
25
+ * - per-commit exemption with a reason: a pre-commit hook cannot read the commit message
26
+ * (that is the commit-msg hook), so a per-commit reason has nowhere to be read from.
27
+ * Standing exemptions with reasons are `exempt` entries in the policy file.
28
+ */
29
+
30
+ import { execFileSync } from 'child_process';
31
+ import chalk from 'chalk';
32
+ import { loadPolicy, classifyPath, POLICY_FILE } from './test-policy.js';
33
+
34
+ /**
35
+ * Parse `git diff --name-status -z` output.
36
+ * Records: `A\0path\0`, `M\0path\0`, `D\0path\0`, `R100\0old\0new\0`, `C75\0old\0new\0`.
37
+ * @returns {{ status: string, path: string, oldPath?: string, similarity?: number }[]}
38
+ */
39
+ export function parseNameStatusZ(output) {
40
+ const parts = output.split('\0');
41
+ if (parts[parts.length - 1] === '') parts.pop();
42
+ const out = [];
43
+ for (let i = 0; i < parts.length;) {
44
+ const code = parts[i++];
45
+ if (!code) continue;
46
+ const letter = code[0];
47
+ if (letter === 'R' || letter === 'C') {
48
+ out.push({ status: letter, oldPath: parts[i++], path: parts[i++], similarity: parseInt(code.slice(1), 10) || 0 });
49
+ } else {
50
+ out.push({ status: letter, path: parts[i++] });
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /** The staged changes of the repository at `projectPath`, or `{ error }` when git cannot say. */
57
+ export function readStagedChanges(projectPath) {
58
+ try {
59
+ const out = execFileSync('git', ['-c', 'core.quotepath=off', 'diff', '--cached', '--name-status', '-M', '-z'], {
60
+ cwd: projectPath,
61
+ encoding: 'utf-8',
62
+ stdio: ['ignore', 'pipe', 'pipe'],
63
+ maxBuffer: 64 * 1024 * 1024
64
+ });
65
+ return { changes: parseNameStatusZ(out) };
66
+ } catch (e) {
67
+ const stderr = String(e.stderr || e.message || '').trim().split('\n')[0];
68
+ // "not a git repository" is not a failure of this check: there is no commit to judge.
69
+ if (/not a git repository/i.test(stderr) || e.code === 'ENOENT') return { notRepo: true, error: stderr || 'git not found' };
70
+ return { error: stderr || 'git diff failed' };
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Judge a list of staged changes against a policy. Pure: no I/O.
76
+ * @returns {{
77
+ * staged: number, source: string[], tests: string[], unclassified: string[],
78
+ * exempt: {path:string, reason:string}[], verdict: 'none'|'ok'|'warn'
79
+ * }}
80
+ */
81
+ export function judgeChanges(changes, policy) {
82
+ const result = { staged: changes.length, source: [], tests: [], unclassified: [], exempt: [], verdict: 'none' };
83
+ for (const ch of changes) {
84
+ if (ch.status === 'D') continue;
85
+ const cls = classifyPath(ch.path, policy);
86
+ if (cls.kind === 'test') { result.tests.push(ch.path); continue; }
87
+ if (cls.kind === 'unclassified') { result.unclassified.push(ch.path); continue; }
88
+ if (cls.kind !== 'source') continue; // ignored / noncode
89
+ if (ch.status === 'R' && ch.similarity === 100) {
90
+ result.exempt.push({ path: ch.path, reason: `pure rename of ${ch.oldPath} (content unchanged)` });
91
+ continue;
92
+ }
93
+ if (cls.exempt) {
94
+ result.exempt.push({ path: ch.path, reason: `policy exemption "${cls.exempt.pattern}": ${cls.exempt.reason}` });
95
+ continue;
96
+ }
97
+ result.source.push(ch.path);
98
+ }
99
+ if (result.staged === 0) result.verdict = 'none';
100
+ else if (result.source.length > 0 && result.tests.length === 0) result.verdict = 'warn';
101
+ else result.verdict = 'ok';
102
+ return result;
103
+ }
104
+
105
+ const show = (list, max = 12) => {
106
+ const lines = list.slice(0, max).map((f) => ` - ${f}`);
107
+ if (list.length > max) lines.push(` ... and ${list.length - max} more`);
108
+ return lines;
109
+ };
110
+
111
+ /**
112
+ * Run the check and print its report. Never throws; never changes the exit code in
113
+ * "warn" mode. In "block" mode a code change with no test sets `process.exitCode = 1`.
114
+ * @returns {{ ran: boolean, stagedCount: number, verdict: string, mode: string, blocked: boolean, policyProblems: string[] }}
115
+ */
116
+ export function runTestChangeCheck(projectPath, { log = console.log } = {}) {
117
+ const { policy, problems } = loadPolicy(projectPath);
118
+ const staged = readStagedChanges(projectPath);
119
+ const base = { ran: false, stagedCount: 0, verdict: 'none', mode: policy.mode, blocked: false, policyProblems: problems };
120
+
121
+ for (const p of problems) log(chalk.yellow(` ⚠ [test-change] ${p}`));
122
+
123
+ if (staged.notRepo) return base;
124
+ if (staged.error) {
125
+ log(chalk.yellow(` ⚠ [test-change] could not read the staged changes (${staged.error}); the code-without-test check did not run.`));
126
+ return base;
127
+ }
128
+ const judged = judgeChanges(staged.changes, policy);
129
+ const result = { ...base, ran: true, stagedCount: judged.staged, verdict: judged.verdict };
130
+ if (judged.staged === 0) return result;
131
+
132
+ const blocking = judged.verdict === 'warn' && policy.mode === 'block';
133
+
134
+ if (judged.verdict === 'warn') {
135
+ const head = blocking
136
+ ? ` ✗ [test-change] BLOCKED: this commit changes ${judged.source.length} code file(s) and touches no test file (XSPEC-444 R2, mode "block").`
137
+ : ` ⚠ [test-change] This commit changes ${judged.source.length} code file(s) and touches no test file (XSPEC-444 R2). Warning only — the commit is not blocked.`;
138
+ log(blocking ? chalk.red(head) : chalk.yellow(head));
139
+ log(chalk.gray(' code files changed without a test change:'));
140
+ for (const l of show(judged.source)) log(chalk.gray(l));
141
+ log(chalk.gray(' A change to behavior needs a test that fails without it. Not a behavior change (generated code, a rename with edits)?'));
142
+ log(chalk.gray(` Record the exemption with its reason in ${POLICY_FILE}: "exempt": [{ "pattern": "<glob>", "reason": "<why>" }]`));
143
+ if (!blocking) log(chalk.gray(` To make this block the commit instead: set "mode": "block" in ${POLICY_FILE}.`));
144
+ }
145
+ if (judged.unclassified.length > 0) {
146
+ log(chalk.yellow(` ⚠ [test-change] ${judged.unclassified.length} changed file(s) are of a type UDS does not recognize, so it cannot tell whether they are code (never assumed fine, never blocks):`));
147
+ for (const l of show(judged.unclassified)) log(chalk.gray(l));
148
+ log(chalk.gray(` Say what they are in ${POLICY_FILE}: "sourceExtensions": ["<ext>"] (code) or "nonCodeExtensions": ["<ext>"] (not code); test files: "testPatterns".`));
149
+ }
150
+ if (judged.exempt.length > 0) {
151
+ log(chalk.gray(' ℹ [test-change] exempt from the test requirement, with the reason on record:'));
152
+ for (const e of judged.exempt.slice(0, 12)) log(chalk.gray(` - ${e.path} — ${e.reason}`));
153
+ }
154
+ if (blocking) {
155
+ process.exitCode = 1;
156
+ result.blocked = true;
157
+ }
158
+ if (judged.verdict === 'warn' || judged.unclassified.length > 0 || judged.exempt.length > 0) log();
159
+ return result;
160
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Which files are code, which are tests, and which UDS cannot tell — one answer,
3
+ * configurable per project. // implements XSPEC-444 R2 (and the classification R5's scripts share)
4
+ *
5
+ * Why this is data and not a branch per language: UDS is a general standard, and the
6
+ * check built on this must work in every language an adopter writes. So "what is a test
7
+ * file" is a list of path patterns with defaults for the common ecosystems, and a project
8
+ * adds its own in `.standards/test-policy.json`. No test framework is named anywhere.
9
+ *
10
+ * Three answers beyond "code" and "test", and the third is the point:
11
+ * - `noncode` docs, data, config, images — known not to need a test
12
+ * - `ignored` generated/vendored/tooling paths (node_modules, dist, .github, ...)
13
+ * - `unclassified` an extension this table has never heard of. It is NOT assumed to be
14
+ * code and it is NOT assumed to be safe: the caller must say so out
15
+ * loud. A language UDS does not know must not read as "passed".
16
+ *
17
+ * The same defaults are copied into the two gate scripts UDS ships to adopters
18
+ * (templates/gates/check-anti-fake-tests.mjs, check-stubs.mjs) because those run in a
19
+ * project with no UDS code beside them. A test (test-policy-drift) compares all three.
20
+ *
21
+ * Policy file `.standards/test-policy.json` (optional; every list ADDS to the defaults):
22
+ * {
23
+ * "mode": "warn" | "block", // default "warn"
24
+ * "testDirs": ["integration"], // directory names that hold tests
25
+ * "testPatterns": ["*.itest.*"], // file-name globs of test files
26
+ * "sourceExtensions": ["zig"], // extensions that are code
27
+ * "nonCodeExtensions": ["gradle"], // extensions that are not
28
+ * "ignore": ["generated/**"], // path globs to leave alone
29
+ * "exempt": [{ "pattern": "src/gen/**", "reason": "generated by protoc" }]
30
+ * }
31
+ * An `exempt` entry without a `reason` is not honored (and is reported): an exemption that
32
+ * leaves no explanation is just a switch.
33
+ */
34
+
35
+ import { existsSync, readFileSync } from 'fs';
36
+ import { join } from 'path';
37
+
38
+ // ---- BEGIN SHARED POLICY (identical in templates/gates/check-anti-fake-tests.mjs and check-stubs.mjs; test-policy-drift.test.js compares them) ----
39
+ /** Where an adopter's policy lives (inside `.standards/`, beside release-config.yaml). */
40
+ export const POLICY_FILE = '.standards/test-policy.json';
41
+
42
+ export const DEFAULT_POLICY = Object.freeze({
43
+ mode: 'warn',
44
+ testDirs: Object.freeze(['test', 'tests', '__tests__', 'spec', 'e2e']),
45
+ testPatterns: Object.freeze([
46
+ '*.test.*', '*.spec.*', '*_test.*', '*_spec.*', 'test_*.*', 'conftest.py',
47
+ '*Test.*', '*Tests.*', '*Spec.scala', '*Spec.kt', '*Spec.groovy'
48
+ ]),
49
+ sourceExtensions: Object.freeze([
50
+ 'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'mts', 'cts', 'vue', 'svelte', 'astro',
51
+ 'py', 'pyi', 'java', 'kt', 'kts', 'scala', 'groovy', 'go', 'rs', 'rb', 'php', 'cs', 'fs', 'vb',
52
+ 'swift', 'm', 'mm', 'dart', 'c', 'h', 'cc', 'cpp', 'cxx', 'hpp', 'hh',
53
+ 'ex', 'exs', 'erl', 'hrl', 'lua', 'pl', 'pm', 'r', 'jl', 'clj', 'cljs', 'hs', 'ml', 'mli',
54
+ 'sh', 'bash', 'zsh', 'ps1'
55
+ ]),
56
+ nonCodeExtensions: Object.freeze([
57
+ 'md', 'mdx', 'txt', 'rst', 'adoc', 'json', 'jsonc', 'json5', 'yaml', 'yml', 'toml', 'ini', 'cfg',
58
+ 'conf', 'properties', 'env', 'lock', 'xml', 'csv', 'tsv', 'svg', 'png', 'jpg', 'jpeg', 'gif', 'ico',
59
+ 'webp', 'avif', 'pdf', 'woff', 'woff2', 'ttf', 'otf', 'eot', 'map', 'html', 'htm', 'css', 'scss',
60
+ 'sass', 'less', 'sql', 'gitignore', 'gitattributes', 'editorconfig', 'npmrc', 'nvmrc', 'license',
61
+ 'log', 'snap'
62
+ ]),
63
+ nonCodeNames: Object.freeze([
64
+ 'LICENSE', 'README', 'CHANGELOG', 'NOTICE', 'AUTHORS', 'CODEOWNERS', 'Dockerfile', 'Makefile', 'Procfile'
65
+ ]),
66
+ ignoreDirs: Object.freeze([
67
+ 'node_modules', 'vendor', 'dist', 'build', 'coverage', '.git', '.standards', '.claude', '.husky',
68
+ '.github', 'docs', 'target', '__pycache__', '.venv', 'venv', '.next', '.nuxt', '.idea', '.vscode',
69
+ '.ruff_cache', '.pytest_cache', '.mypy_cache', '.tox', '.gradle', '.cache', '.turbo', '.svelte-kit',
70
+ '.parcel-cache', '.terraform', '.dart_tool', '.eggs'
71
+ ]),
72
+ ignore: Object.freeze(['**/*.min.js', '**/*.min.css', '**/*.d.ts']),
73
+ exempt: Object.freeze([])
74
+ });
75
+
76
+ const norm = (p) => String(p).replace(/\\/g, '/').replace(/^\.\//, '');
77
+
78
+ /** Glob → RegExp. `*` stays inside a path segment, `**` crosses them, `?` is one non-slash character. */
79
+ export function globToRegex(glob) {
80
+ const g = norm(glob);
81
+ let re = '';
82
+ for (let i = 0; i < g.length; i++) {
83
+ const c = g[i];
84
+ if (c === '*') {
85
+ if (g[i + 1] === '*') {
86
+ if (g[i + 2] === '/') { re += '(?:.*/)?'; i += 2; } else { re += '.*'; i += 1; }
87
+ } else {
88
+ re += '[^/]*';
89
+ }
90
+ } else if (c === '?') {
91
+ re += '[^/]';
92
+ } else if ('\\^$+.()|{}[]'.includes(c)) {
93
+ re += '\\' + c;
94
+ } else {
95
+ re += c;
96
+ }
97
+ }
98
+ return new RegExp(`^${re}$`);
99
+ }
100
+
101
+ /** A glob with no slash is a file-name glob (matches the basename anywhere); otherwise it matches the whole path. */
102
+ function matchesGlob(relPath, glob) {
103
+ const p = norm(relPath);
104
+ const g = norm(glob);
105
+ const target = g.includes('/') ? p : p.split('/').pop();
106
+ return globToRegex(g).test(target);
107
+ }
108
+
109
+ const extOf = (relPath) => {
110
+ const base = norm(relPath).split('/').pop();
111
+ const i = base.lastIndexOf('.');
112
+ return i > 0 ? base.slice(i + 1).toLowerCase() : '';
113
+ };
114
+
115
+ /**
116
+ * Read `.standards/test-policy.json` and merge it over the defaults.
117
+ * @returns {{ policy: object, problems: string[], source: string|null }}
118
+ * `problems` is never swallowed: an unreadable file or a bad value is reported, and the
119
+ * defaults (mode "warn") are what runs.
120
+ */
121
+ export function loadPolicy(projectPath) {
122
+ const problems = [];
123
+ const policy = {
124
+ mode: DEFAULT_POLICY.mode,
125
+ testDirs: [...DEFAULT_POLICY.testDirs],
126
+ testPatterns: [...DEFAULT_POLICY.testPatterns],
127
+ sourceExtensions: [...DEFAULT_POLICY.sourceExtensions],
128
+ nonCodeExtensions: [...DEFAULT_POLICY.nonCodeExtensions],
129
+ nonCodeNames: [...DEFAULT_POLICY.nonCodeNames],
130
+ ignoreDirs: [...DEFAULT_POLICY.ignoreDirs],
131
+ ignore: [...DEFAULT_POLICY.ignore],
132
+ exempt: []
133
+ };
134
+ const file = join(projectPath, POLICY_FILE);
135
+ if (!existsSync(file)) return { policy, problems, source: null };
136
+
137
+ let raw;
138
+ try {
139
+ raw = JSON.parse(readFileSync(file, 'utf-8').replace(/^\uFEFF/, ''));
140
+ } catch (e) {
141
+ problems.push(`${POLICY_FILE} cannot be read (${e.message}); defaults are in effect`);
142
+ return { policy, problems, source: POLICY_FILE };
143
+ }
144
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
145
+ problems.push(`${POLICY_FILE} must be a JSON object; defaults are in effect`);
146
+ return { policy, problems, source: POLICY_FILE };
147
+ }
148
+
149
+ if (raw.mode !== undefined) {
150
+ if (raw.mode === 'warn' || raw.mode === 'block') policy.mode = raw.mode;
151
+ else problems.push(`${POLICY_FILE}: "mode" must be "warn" or "block", got ${JSON.stringify(raw.mode)}; "warn" is in effect`);
152
+ }
153
+ for (const key of ['testDirs', 'testPatterns', 'sourceExtensions', 'nonCodeExtensions', 'ignore']) {
154
+ if (raw[key] === undefined) continue;
155
+ if (!Array.isArray(raw[key]) || raw[key].some((v) => typeof v !== 'string' || !v)) {
156
+ problems.push(`${POLICY_FILE}: "${key}" must be an array of non-empty strings; it is ignored`);
157
+ continue;
158
+ }
159
+ const add = key === 'sourceExtensions' || key === 'nonCodeExtensions'
160
+ ? raw[key].map((v) => v.replace(/^\./, '').toLowerCase())
161
+ : raw[key];
162
+ policy[key] = [...policy[key], ...add];
163
+ }
164
+ if (raw.exempt !== undefined) {
165
+ if (!Array.isArray(raw.exempt)) {
166
+ problems.push(`${POLICY_FILE}: "exempt" must be an array; it is ignored`);
167
+ } else {
168
+ raw.exempt.forEach((e, i) => {
169
+ const okPattern = e && typeof e.pattern === 'string' && e.pattern.trim();
170
+ const okReason = e && typeof e.reason === 'string' && e.reason.trim();
171
+ if (!okPattern) problems.push(`${POLICY_FILE}: exempt[${i}] has no "pattern"; it is ignored`);
172
+ else if (!okReason) problems.push(`${POLICY_FILE}: exempt[${i}] ("${e.pattern}") has no "reason" and is NOT honored — an exemption must say why`);
173
+ else policy.exempt.push({ pattern: e.pattern, reason: e.reason.trim() });
174
+ });
175
+ }
176
+ }
177
+ return { policy, problems, source: POLICY_FILE };
178
+ }
179
+
180
+ /**
181
+ * Classify one project-relative path.
182
+ * @returns {{ kind: 'ignored'|'noncode'|'test'|'source'|'unclassified', ext: string, exempt?: {pattern:string, reason:string} }}
183
+ */
184
+ export function classifyPath(relPath, policy = DEFAULT_POLICY) {
185
+ const p = norm(relPath);
186
+ const segments = p.split('/');
187
+ const base = segments[segments.length - 1];
188
+ const dirs = segments.slice(0, -1);
189
+ const ext = extOf(p);
190
+ const sets = {
191
+ source: new Set(policy.sourceExtensions || DEFAULT_POLICY.sourceExtensions),
192
+ noncode: new Set(policy.nonCodeExtensions || DEFAULT_POLICY.nonCodeExtensions),
193
+ names: new Set(policy.nonCodeNames || DEFAULT_POLICY.nonCodeNames),
194
+ ignoreDirs: new Set(policy.ignoreDirs || DEFAULT_POLICY.ignoreDirs)
195
+ };
196
+
197
+ if (dirs.some((d) => sets.ignoreDirs.has(d))) return { kind: 'ignored', ext };
198
+ if ((policy.ignore || DEFAULT_POLICY.ignore).some((g) => matchesGlob(p, g))) return { kind: 'ignored', ext };
199
+
200
+ const docsLike = sets.noncode.has(ext) || sets.names.has(base) || sets.names.has(base.replace(/\.[^.]*$/, ''));
201
+ const inTestDir = dirs.some((d) => (policy.testDirs || DEFAULT_POLICY.testDirs).includes(d));
202
+ const testNamed = (policy.testPatterns || DEFAULT_POLICY.testPatterns).some((g) => matchesGlob(p, g));
203
+
204
+ // A test is a test even when its data is JSON; but a README inside tests/ is documentation.
205
+ if ((inTestDir || testNamed) && !['md', 'mdx', 'txt', 'rst', 'adoc'].includes(ext)) return { kind: 'test', ext };
206
+ // Dot-files (.prettierrc, .eslintrc.js, .env.local ...) are tool configuration, not product code.
207
+ if (docsLike || base.startsWith('.')) return { kind: 'noncode', ext };
208
+ if (sets.source.has(ext)) {
209
+ const exempt = (policy.exempt || []).find((e) => matchesGlob(p, e.pattern));
210
+ return exempt ? { kind: 'source', ext, exempt } : { kind: 'source', ext };
211
+ }
212
+ return { kind: 'unclassified', ext };
213
+ }
214
+ // ---- END SHARED POLICY ----
@@ -0,0 +1,29 @@
1
+ /**
2
+ * One-line summaries `uds update` prints, kept apart from the command so what they say can be tested
3
+ * without running an update.
4
+ *
5
+ * @module utils/update-summary
6
+ */
7
+
8
+ const agentOf = (inst) => (typeof inst === 'string' ? inst : inst.agent);
9
+
10
+ /**
11
+ * "Updated N commands for M AI tools: <where>" (XSPEC-454 R4).
12
+ *
13
+ * The number of tools and the number of commands are different numbers and are filled into different
14
+ * slots. The summary used to reuse `commandsUpdated`, a message worded "N AI tools" in every language,
15
+ * and feed it the command-file count: one OpenCode install printed "Updated Commands for 51 AI tools".
16
+ *
17
+ * @param {Object} msg - `t().commands.update`
18
+ * @param {Array<string|{agent: string}>} installations - The targets the commands went to
19
+ * @param {{ totalInstalled: number }} result - The installer's result
20
+ * @param {string} locations - Already-formatted list of where they went
21
+ * @returns {string}
22
+ */
23
+ export function commandsUpdatedMessage(msg, installations, result, locations) {
24
+ const tools = new Set((installations || []).map(agentOf)).size;
25
+ return (msg.commandsUpdatedCounts || 'Updated {commands} commands for {tools} AI tool(s): {locations}')
26
+ .replace('{tools}', tools)
27
+ .replace('{commands}', result.totalInstalled)
28
+ .replace('{locations}', locations);
29
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "version": "6.14.0-beta.3",
3
+ "version": "6.14.0-beta.5",
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.3"
61
+ "version": "6.14.0-beta.5"
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.3",
68
+ "version": "6.14.0-beta.5",
69
69
  "note": "Skills are now included in the main repository under skills/"
70
70
  }
71
71
  },
@@ -382,6 +382,10 @@
382
382
  "skills/brainstorm-assistant/SKILL.md",
383
383
  "skills/brainstorm-assistant/guide.md"
384
384
  ],
385
+ "comprehension-ladder": [
386
+ "skills/comprehension-ladder/SKILL.md",
387
+ "skills/comprehension-ladder/eval-cases.md"
388
+ ],
385
389
  "bdd-assistant": [
386
390
  "skills/bdd-assistant/SKILL.md",
387
391
  "skills/bdd-assistant/guide.md",
@@ -1536,6 +1540,16 @@
1536
1540
  "description": "Traditional Chinese (Taiwan) localization for all standards",
1537
1541
  "applicability": "Traditional Chinese speaking teams"
1538
1542
  },
1543
+ {
1544
+ "id": "zh-cn-locale",
1545
+ "name": "Simplified Chinese Locale",
1546
+ "nameZh": "简体中文本地化",
1547
+ "source": "extensions/locales/zh-cn.md",
1548
+ "category": "extension",
1549
+ "skillName": null,
1550
+ "description": "Simplified Chinese (Mainland China) localization for all standards",
1551
+ "applicability": "Simplified Chinese speaking teams"
1552
+ },
1539
1553
  {
1540
1554
  "id": "copilot-integration",
1541
1555
  "name": "GitHub Copilot Instructions",
@@ -2295,7 +2309,7 @@
2295
2309
  "id": "license-compliance",
2296
2310
  "name": "License Compliance Standards",
2297
2311
  "nameZh": "授權合規標準",
2298
- "version": "6.14.0-beta.3",
2312
+ "version": "6.14.0-beta.5",
2299
2313
  "source": {
2300
2314
  "human": "core/license-compliance.md",
2301
2315
  "ai": "ai/standards/license-compliance.ai.yaml"
@@ -2307,7 +2321,7 @@
2307
2321
  "id": "verification-oracle",
2308
2322
  "name": "Verification Oracle Standards",
2309
2323
  "nameZh": "驗證 Oracle 標準",
2310
- "version": "6.14.0-beta.3",
2324
+ "version": "6.14.0-beta.5",
2311
2325
  "source": {
2312
2326
  "human": "core/verification-oracle.md",
2313
2327
  "ai": "ai/standards/verification-oracle.ai.yaml"
@@ -2319,7 +2333,7 @@
2319
2333
  "id": "model-provenance",
2320
2334
  "name": "Model Provenance Policy Standards",
2321
2335
  "nameZh": "模型來源政策標準",
2322
- "version": "6.14.0-beta.3",
2336
+ "version": "6.14.0-beta.5",
2323
2337
  "source": {
2324
2338
  "human": "core/model-provenance.md",
2325
2339
  "ai": "ai/standards/model-provenance.ai.yaml"
@@ -2331,7 +2345,7 @@
2331
2345
  "id": "resource-cost-boundary",
2332
2346
  "name": "Resource / Cost Boundary Declaration Standards",
2333
2347
  "nameZh": "資源/成本邊界宣告標準",
2334
- "version": "6.14.0-beta.3",
2348
+ "version": "6.14.0-beta.5",
2335
2349
  "source": {
2336
2350
  "human": "core/resource-cost-boundary.md",
2337
2351
  "ai": "ai/standards/resource-cost-boundary.ai.yaml"