universal-dev-standards 6.7.5 → 6.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/bin/uds.js +7 -0
  2. package/bundled/ai/standards/ai-instruction-standards.ai.yaml +6 -6
  3. package/bundled/core/ai-instruction-standards.md +9 -7
  4. package/bundled/locales/zh-CN/CHANGELOG.md +21 -3
  5. package/bundled/locales/zh-CN/README.md +6 -6
  6. package/bundled/locales/zh-CN/SECURITY.md +1 -1
  7. package/bundled/locales/zh-CN/core/ai-instruction-standards.md +10 -8
  8. package/bundled/locales/zh-CN/docs/CHEATSHEET.md +20 -6
  9. package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +149 -11
  10. package/bundled/locales/zh-CN/skills/agents/README.md +1 -1
  11. package/bundled/locales/zh-CN/skills/reverse-engineer/tdd-analysis.md +13 -23
  12. package/bundled/locales/zh-CN/skills/workflows/README.md +2 -11
  13. package/bundled/locales/zh-TW/CHANGELOG.md +21 -3
  14. package/bundled/locales/zh-TW/README.md +6 -6
  15. package/bundled/locales/zh-TW/SECURITY.md +1 -1
  16. package/bundled/locales/zh-TW/core/ai-instruction-standards.md +10 -8
  17. package/bundled/locales/zh-TW/docs/CHEATSHEET.md +20 -6
  18. package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +149 -11
  19. package/bundled/locales/zh-TW/skills/agents/README.md +1 -1
  20. package/bundled/locales/zh-TW/skills/reverse-engineer/tdd-analysis.md +13 -23
  21. package/bundled/locales/zh-TW/skills/workflows/README.md +2 -11
  22. package/bundled/skills/agents/README.md +1 -1
  23. package/bundled/skills/reverse-engineer/tdd-analysis.md +16 -23
  24. package/bundled/skills/workflows/README.md +2 -11
  25. package/package.json +4 -2
  26. package/src/commands/lint.js +96 -0
  27. package/src/commands/quickstart.js +16 -13
  28. package/src/i18n/messages.js +3 -3
  29. package/src/reconciler/actual-state-scanner.js +14 -3
  30. package/src/utils/hasher.js +63 -8
  31. package/src/utils/integration-generator.js +21 -3
  32. package/src/utils/skills-installer.js +17 -3
  33. package/src/utils/spec-linter.js +35 -76
  34. package/standards-registry.json +7 -7
  35. package/src/commands/sync.js +0 -133
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `uds lint` — dependency validity + size checks against installed specs
3
+ * (specs/*.md).
4
+ *
5
+ * XSPEC-383 R5 (Option E). `cli/src/utils/spec-linter.js` and this command's
6
+ * JSON shape existed since 2026-04-07, but no `uds lint` command was ever
7
+ * registered — VibeOps's `lint-executor.ts` has been calling
8
+ * `npx uds lint --json` since the same day and getting `command not found`
9
+ * every time, four and a half months, without anyone noticing (see
10
+ * `cli/scripts/check-module-reachability.mjs` for the full incident).
11
+ *
12
+ * This registers the command with exactly the two checks that survived
13
+ * `lintAll()`'s AC-coverage removal (see spec-linter.js for why AC coverage
14
+ * was dropped rather than patched). The `--json` shape is deliberately NOT a
15
+ * new design — it matches the shape VibeOps's `lint-executor.ts` has already
16
+ * been parsing (`result.summary.fail`, `result.results[].specId/.status/.message`)
17
+ * since it was written, so wiring this up does not also require a change on
18
+ * the VibeOps side.
19
+ *
20
+ * @module commands/lint
21
+ */
22
+
23
+ import chalk from 'chalk';
24
+ import { lintAll } from '../utils/spec-linter.js';
25
+
26
+ /**
27
+ * Render a one-line human message for a single spec's lint result.
28
+ * Exported for tests; also used to build the `message` field of `--json`
29
+ * output.
30
+ */
31
+ export function buildMessage(result) {
32
+ const parts = [];
33
+ if (result.deps.broken.length > 0) {
34
+ const targets = result.deps.broken.map((b) => b.target).join(', ');
35
+ parts.push(
36
+ `${result.deps.broken.length} broken dependenc${result.deps.broken.length === 1 ? 'y' : 'ies'}: ${targets}`
37
+ );
38
+ }
39
+ parts.push(`${result.size.effectiveLines} effective lines (${result.size.status})`);
40
+ return parts.join('; ');
41
+ }
42
+
43
+ export async function lintCommand(options = {}) {
44
+ const projectPath = process.cwd();
45
+ const result = lintAll(projectPath);
46
+
47
+ if (options.json) {
48
+ const payload = {
49
+ summary: result.summary,
50
+ results: result.results.map((r) => ({
51
+ specId: r.spec,
52
+ status: r.status,
53
+ message: buildMessage(r),
54
+ })),
55
+ };
56
+ console.log(JSON.stringify(payload, null, 2));
57
+ if (result.summary.fail > 0) process.exitCode = 1;
58
+ return;
59
+ }
60
+
61
+ console.log();
62
+ console.log(chalk.bold('Spec Lint'));
63
+ console.log(chalk.gray('─'.repeat(50)));
64
+
65
+ if (!result.specsDirExists) {
66
+ // 查無 spec 目錄 must say so explicitly — an empty { pass: 0, warn: 0,
67
+ // fail: 0 } summary is indistinguishable from "checked, all clean" unless
68
+ // the command says out loud that nothing was scanned.
69
+ console.log(chalk.yellow(` 查無 spec 目錄(./${result.specsDir}/ 不存在)`));
70
+ console.log(chalk.gray(' 沒有東西被掃描——這不代表沒有問題,代表沒有檢查。'));
71
+ console.log();
72
+ return;
73
+ }
74
+
75
+ console.log(chalk.gray(` 掃描 ${result.results.length} 份 spec(./${result.specsDir}/)`));
76
+ console.log();
77
+
78
+ for (const r of result.results) {
79
+ const msg = buildMessage(r);
80
+ if (r.status === 'fail') {
81
+ console.log(chalk.red(` ✗ ${r.spec}: ${msg}`));
82
+ } else if (r.status === 'warn') {
83
+ console.log(chalk.yellow(` ⚠ ${r.spec}: ${msg}`));
84
+ } else {
85
+ console.log(chalk.green(` ✓ ${r.spec}: ${msg}`));
86
+ }
87
+ }
88
+
89
+ console.log();
90
+ console.log(
91
+ chalk.gray(` ${result.summary.pass} pass, ${result.summary.warn} warn, ${result.summary.fail} fail`)
92
+ );
93
+ console.log();
94
+
95
+ if (result.summary.fail > 0) process.exitCode = 1;
96
+ }
@@ -3,6 +3,19 @@
3
3
  *
4
4
  * Reduces cognitive load by guiding users to common workflow paths.
5
5
  *
6
+ * ⚠️ Every `cmd` that starts with `uds ` MUST be a command this CLI actually
7
+ * registers, with flags it actually accepts. This is the one place whose entire
8
+ * job is "find the right commands quickly", so a wrong entry here does the exact
9
+ * opposite of what the command exists for.
10
+ *
11
+ * 2026-08-19: four entries pointed at things that do not exist — `uds lint`
12
+ * (twice), `uds sync` (twice, one of them the sole content of a whole
13
+ * workflow), `uds check --spec-size`, and `uds spec create --boost`. Verify with
14
+ * `uds <cmd> --definitely-not-a-real-flag` and look for "unknown command";
15
+ * `uds <cmd> --help` is NOT a valid check — commander prints the general help
16
+ * for an unknown command instead of erroring, so --help reports every name as
17
+ * valid.
18
+ *
6
19
  * @module commands/quickstart
7
20
  */
8
21
 
@@ -24,11 +37,10 @@ export const WORKFLOWS = [
24
37
  name: 'Full SDD Spec Flow (Boost)',
25
38
  description: 'Complete spec-driven development for complex features',
26
39
  steps: [
27
- { cmd: 'uds spec create "your feature" --boost', desc: 'Create full SDD spec with design sections' },
28
- { cmd: 'uds lint', desc: 'Validate spec quality and cross-references' },
40
+ { cmd: '# Use the /sdd skill', desc: 'Full spec lifecycle with review (see `uds spec --help`)' },
41
+ { cmd: 'uds spec create "your feature" --scope fullstack', desc: 'Create the spec, scoped' },
29
42
  { cmd: 'uds spec confirm SPEC-XXX', desc: 'Confirm after review' },
30
43
  { cmd: '# Implement with /derive → /tdd', desc: 'Use forward derivation and TDD' },
31
- { cmd: 'uds sync', desc: 'Export context for session resume' },
32
44
  ],
33
45
  },
34
46
  {
@@ -46,19 +58,10 @@ export const WORKFLOWS = [
46
58
  description: 'Audit standards compliance and spec quality',
47
59
  steps: [
48
60
  { cmd: 'uds check', desc: 'Check standards file integrity' },
49
- { cmd: 'uds check --spec-size', desc: 'Check spec sizes against limits' },
50
- { cmd: 'uds lint', desc: 'Lint specs for AC coverage and dependency validity' },
61
+ { cmd: 'uds check --i18n', desc: 'Run i18n lint rules across canonical + locale variants' },
51
62
  { cmd: 'uds audit', desc: 'Deep health diagnosis' },
52
63
  ],
53
64
  },
54
- {
55
- name: 'Resume Previous Work',
56
- description: 'Restore context from a previous session',
57
- steps: [
58
- { cmd: 'uds sync', desc: 'Generate context.md from git diff + workflow state' },
59
- { cmd: 'cat .workflow-state/context.md', desc: 'Read context in new session' },
60
- ],
61
- },
62
65
  ];
63
66
 
64
67
  /**
@@ -757,7 +757,7 @@ export const messages = {
757
757
  actionsAvailable: 'Actions available:',
758
758
  restoreOption: '• Run `uds check --restore` to restore all modified/missing files',
759
759
  diffOption: '• Run `uds check --diff` to view changes',
760
- interactiveOption: '• Run `uds check --interactive` for file-by-file decisions',
760
+ interactiveOption: '• Run `uds check` for file-by-file decisions (interactive by default)',
761
761
  // Interactive mode
762
762
  interactiveMode: 'Interactive Mode:',
763
763
  filesNeedAttention: '{count} file(s) need attention.',
@@ -1991,7 +1991,7 @@ export const messages = {
1991
1991
  actionsAvailable: '可用操作:',
1992
1992
  restoreOption: '• 執行 `uds check --restore` 還原所有已修改/遺失的檔案',
1993
1993
  diffOption: '• 執行 `uds check --diff` 檢視變更',
1994
- interactiveOption: '• 執行 `uds check --interactive` 逐一處理檔案',
1994
+ interactiveOption: '• 執行 `uds check` 逐一處理檔案(預設為互動模式)',
1995
1995
  // Interactive mode
1996
1996
  interactiveMode: '互動模式:',
1997
1997
  filesNeedAttention: '{count} 個檔案需要注意。',
@@ -3240,7 +3240,7 @@ export const messages = {
3240
3240
  actionsAvailable: '可用操作:',
3241
3241
  restoreOption: '• 执行 `uds check --restore` 恢复所有已修改/缺失文件',
3242
3242
  diffOption: '• 执行 `uds check --diff` 查看更改',
3243
- interactiveOption: '• 执行 `uds check --interactive` 逐文件决策',
3243
+ interactiveOption: '• 执行 `uds check` 逐文件决策(默认为交互模式)',
3244
3244
  // Interactive mode
3245
3245
  interactiveMode: '交互模式:',
3246
3246
  filesNeedAttention: '{count} 个文件需要关注。',
@@ -11,7 +11,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
11
11
  import { createHash } from 'crypto';
12
12
  import { join, relative } from 'path';
13
13
  import { readManifest } from '../core/manifest.js';
14
- import { computeFileHash, computeIntegrationBlockHash } from '../utils/hasher.js';
14
+ import { computeFileHash, computeIntegrationBlockHash, normalizeLineEndings } from '../utils/hasher.js';
15
15
  import { SUPPORTED_AI_TOOLS, UDS_MARKERS } from '../core/constants.js';
16
16
  import { getSkillsDirForAgent, getCommandsDirForAgent, getCommandFileExtension } from '../config/ai-agent-paths.js';
17
17
  import { getSkillsSourceEntryNames, getAvailableCommandNames } from '../utils/skills-installer.js';
@@ -322,6 +322,13 @@ function shippedCommandNames() {
322
322
  * materialising a second array first. The format is the contract; if it changes
323
323
  * in one place and not the other every skill reports as changed, which the
324
324
  * paired tests in `skill-content-hash.test.js` exist to catch.
325
+ *
326
+ * Content is line-ending normalized before hashing (GitHub issue #155): these
327
+ * are files installed into the adopter's own project, which `git checkout`
328
+ * may have rewritten to CRLF under `core.autocrlf=true` on Windows even
329
+ * though the desired side (`computeSkillContentHash`, reading UDS's own
330
+ * package source) never sees a `\r`. Without matching normalization here,
331
+ * every skill would report as changed on every Windows `uds update`.
325
332
  */
326
333
  function hashInstalledSkillDir(dirPath) {
327
334
  let entries;
@@ -346,7 +353,7 @@ function hashInstalledSkillDir(dirPath) {
346
353
  }
347
354
  h.update(name);
348
355
  h.update('\0');
349
- h.update(content);
356
+ h.update(normalizeLineEndings(content));
350
357
  h.update('\0');
351
358
  }
352
359
  return `sha256:${h.digest('hex')}`;
@@ -437,12 +444,16 @@ function scanCommands(state, projectPath, manifest) {
437
444
  // Content hash of the installed file, for UDS-managed commands only —
438
445
  // same reasoning as skills: an adopter's own command has no desired
439
446
  // counterpart to compare against. (XSPEC-382 R7)
447
+ //
448
+ // Line-ending normalized before hashing (GitHub issue #155), matching
449
+ // `computeCommandContentHash` on the desired side — the same CRLF
450
+ // checkout risk `hashInstalledSkillDir` above documents applies here.
440
451
  const cmdUdsManaged = shippedCommandNames().has(cmdName);
441
452
  let cmdHash = null;
442
453
  if (cmdUdsManaged) {
443
454
  try {
444
455
  cmdHash = `sha256:${createHash('sha256')
445
- .update(readFileSync(join(cmdsDir, entry.name), 'utf-8'))
456
+ .update(normalizeLineEndings(readFileSync(join(cmdsDir, entry.name), 'utf-8')))
446
457
  .digest('hex')}`;
447
458
  } catch {
448
459
  // Unreadable → no hash. A partial answer would claim a match that
@@ -1,22 +1,75 @@
1
1
  import { createHash } from 'crypto';
2
- import { readFileSync, statSync, existsSync, readdirSync } from 'fs';
2
+ import { readFileSync, existsSync, readdirSync } from 'fs';
3
3
  import { join, relative } from 'path';
4
4
  import { UDS_MARKERS } from '../core/constants.js';
5
5
  import { resolveIntegrationFile } from '../core/constants.js';
6
6
 
7
+ // GitHub issue #155. `git config core.autocrlf true` (the common
8
+ // Windows default) rewrites LF to CRLF on checkout. The manifest's stored
9
+ // hashes are computed from the LF bytes git carries in the blob (that is what
10
+ // every non-Windows install reads), so a Windows working tree — content
11
+ // byte-for-byte what `git status` calls clean — hashed to something else
12
+ // entirely. Every `.standards/*` file, every skill/command file, and every
13
+ // CLAUDE.md/AGENTS.md UDS block came back "modified" although nothing had
14
+ // changed. Normalizing line endings before hashing (both when the manifest is
15
+ // written and when it is compared) makes the hash line-ending agnostic, so
16
+ // LF and CRLF checkouts of the same content agree.
17
+ //
18
+ // This intentionally makes "someone converted this file's line endings, text
19
+ // otherwise identical" invisible to `uds check`/`uds update`. That is the
20
+ // point, not a gap: line-ending convention is a checkout artifact, not
21
+ // content a standards library should track as a modification — it is the
22
+ // same normalization `git diff`/`git status` already apply before deciding a
23
+ // tracked file is dirty.
24
+
25
+ /**
26
+ * Heuristic binary-content detector, matching the approach git itself uses:
27
+ * a NUL byte anywhere in the first 8000 bytes marks the buffer as binary.
28
+ * Binary content must never be line-ending-normalized — flipping `\r\n` bytes
29
+ * inside an image or font would silently corrupt the hash rather than make it
30
+ * platform-agnostic, and could hide (or manufacture) a real difference.
31
+ * Every file `uds` currently manages under `.standards/`, skills, and
32
+ * commands is text (`.md`/`.yaml`/`.json`), but this function is the general
33
+ * per-file entry point (also used for arbitrary skill directory contents),
34
+ * so the check stays in place for whatever gets added later.
35
+ * @param {Buffer} buffer
36
+ * @returns {boolean} True if the buffer looks binary
37
+ */
38
+ export function isBinaryContent(buffer) {
39
+ const sampleSize = Math.min(buffer.length, 8000);
40
+ for (let i = 0; i < sampleSize; i++) {
41
+ if (buffer[i] === 0) return true;
42
+ }
43
+ return false;
44
+ }
45
+
46
+ /**
47
+ * Normalize CRLF and lone-CR line endings to LF.
48
+ * @param {string} text
49
+ * @returns {string} Normalized text
50
+ */
51
+ export function normalizeLineEndings(text) {
52
+ return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
53
+ }
54
+
7
55
  /**
8
- * Compute SHA-256 hash for a file
56
+ * Compute SHA-256 hash for a file, normalizing line endings first (unless the
57
+ * file is binary — see `isBinaryContent`). `size` is the byte length of the
58
+ * normalized content, not the raw on-disk size, so the two stay consistent
59
+ * with each other for the quick-reject check in `compareFileHash`.
9
60
  * @param {string} filePath - Absolute file path
10
61
  * @returns {Object|null} { hash, size } or null if file doesn't exist
11
62
  */
12
63
  export function computeFileHash(filePath) {
13
64
  try {
14
- const content = readFileSync(filePath);
65
+ const raw = readFileSync(filePath);
66
+ const content = isBinaryContent(raw)
67
+ ? raw
68
+ : Buffer.from(normalizeLineEndings(raw.toString('utf-8')), 'utf-8');
15
69
  const hash = createHash('sha256').update(content).digest('hex');
16
- const stats = statSync(filePath);
17
70
  return {
18
71
  hash: `sha256:${hash}`,
19
- size: stats.size
72
+ size: content.length
20
73
  };
21
74
  } catch {
22
75
  return null;
@@ -276,7 +329,10 @@ function extractBlockContent(content, format) {
276
329
  */
277
330
  export function computeIntegrationBlockHash(filePath) {
278
331
  try {
279
- const content = readFileSync(filePath, 'utf-8');
332
+ // CLAUDE.md / AGENTS.md are always text — no binary check needed here,
333
+ // unlike `computeFileHash`.
334
+ const rawContent = readFileSync(filePath, 'utf-8');
335
+ const content = normalizeLineEndings(rawContent);
280
336
  const format = detectFormat(filePath);
281
337
  const { blockContent } = extractBlockContent(content, format);
282
338
 
@@ -287,13 +343,12 @@ export function computeIntegrationBlockHash(filePath) {
287
343
 
288
344
  const blockHash = createHash('sha256').update(blockContent).digest('hex');
289
345
  const fullHash = createHash('sha256').update(content).digest('hex');
290
- const stats = statSync(filePath);
291
346
 
292
347
  return {
293
348
  blockHash: `sha256:${blockHash}`,
294
349
  blockSize: Buffer.byteLength(blockContent, 'utf-8'),
295
350
  fullHash: `sha256:${fullHash}`,
296
- fullSize: stats.size
351
+ fullSize: Buffer.byteLength(content, 'utf-8')
297
352
  };
298
353
  } catch {
299
354
  return null;
@@ -3181,8 +3181,8 @@ export function parseStandardsIndexCount(content) {
3181
3181
  export function wrapWithMarkers(content, format) {
3182
3182
  const markers = UDS_MARKERS[format] || UDS_MARKERS.markdown;
3183
3183
  const warning = format === 'plaintext'
3184
- ? '# WARNING: This block is managed by UDS (universal-dev-standards). DO NOT manually edit. Use \'npx uds install\' or \'npx uds update\' to modify.'
3185
- : '<!-- WARNING: This block is managed by UDS (universal-dev-standards). DO NOT manually edit. Use \'npx uds install\' or \'npx uds update\' to modify. -->';
3184
+ ? '# WARNING: This block is managed by UDS (universal-dev-standards). DO NOT manually edit. Use \'npx uds init\' or \'npx uds update\' to modify.'
3185
+ : '<!-- WARNING: This block is managed by UDS (universal-dev-standards). DO NOT manually edit. Use \'npx uds init\' or \'npx uds update\' to modify. -->';
3186
3186
  // 冪等:warning 位於 markers **內部**,而 extractMarkedContent 取出的內容也含它,
3187
3187
  // 於是重新包裝會疊出第二份(dev-platform CLAUDE.md 實測 178/179 兩行完全相同)。
3188
3188
  // 這裡先剝掉內容開頭既有的 warning,不論上游哪條路徑造成都能修掉。
@@ -3362,7 +3362,25 @@ export function generateAgentsMdSummary(config = {}) {
3362
3362
  lines.push('# AGENTS.md');
3363
3363
  lines.push('');
3364
3364
  lines.push('> Auto-generated by [Universal Dev Standards (UDS)](https://github.com/AsiaOstrich/universal-dev-standards).');
3365
- lines.push('> Full standards available in the `.standards/` directory.');
3365
+ lines.push('>');
3366
+ // Say it as an instruction, and say what this file is NOT.
3367
+ //
3368
+ // The previous line — "Full standards available in the `.standards/` directory" — was a
3369
+ // description, and a description asks for nothing. Measured 2026-07-23: Codex read this
3370
+ // file, listed the 65 standards it indexes, and opened none of them, so the rules had the
3371
+ // same effect as not installing UDS at all. Measured again 2026-08-18 on a fresh
3372
+ // `uds init -y`: the generated file is 5,667 bytes containing 69 filename references and
3373
+ // **zero rule statements**. That is not a bug in the generator — 143 `.ai.yaml` files come
3374
+ // to roughly 248k tokens, so inlining them is not possible — which is exactly why the file
3375
+ // has to be explicit that it is an index and that the rules are elsewhere.
3376
+ //
3377
+ // This does not prove the rules get read; only XSPEC-357's P7 probe can measure that, and
3378
+ // it is not built yet. It removes the one thing that was certainly wrong: a file that read
3379
+ // as though it carried the standards when it carried their filenames. (XSPEC-357 R7)
3380
+ lines.push('> **This file is an index, not the standards.** The rules are NOT reproduced here.');
3381
+ lines.push('> Before acting on anything below, open the relevant file under `.standards/`');
3382
+ lines.push('> and follow its contents. Working from this summary alone means working');
3383
+ lines.push('> without the standards.');
3366
3384
  lines.push('');
3367
3385
 
3368
3386
  // Build & Test Commands (auto-detect project type)
@@ -19,7 +19,7 @@ import {
19
19
  getCommandsSupportedAgents,
20
20
  getCommandFileExtension
21
21
  } from '../config/ai-agent-paths.js';
22
- import { computeDirectoryHashes, computeFileHash } from './hasher.js';
22
+ import { computeDirectoryHashes, computeFileHash, normalizeLineEndings } from './hasher.js';
23
23
  import { isLocalizedLocale } from './locale.js';
24
24
  import { getSkillsSourceDir } from './skills-source.js';
25
25
 
@@ -504,6 +504,16 @@ export function resolveSkillFiles(skillName, locale = 'en') {
504
504
  * are different installs, and a hash over contents alone would call a rename
505
505
  * "unchanged".
506
506
  *
507
+ * Content is line-ending normalized before hashing (GitHub issue #155): the
508
+ * counterpart on the actual-state side, `hashInstalledSkillDir` in
509
+ * `reconciler/actual-state-scanner.js`, hashes files that were checked out
510
+ * into the adopter's own repo and so may have been rewritten to CRLF by
511
+ * `core.autocrlf=true` on Windows. Both sides must apply the same
512
+ * normalization or every skill would report as changed on Windows, same as
513
+ * the `.standards/*` hashes this issue was filed about — the two functions
514
+ * only stay comparable by construction if they agree on this too, which is
515
+ * why the paired tests in `skill-content-hash.test.js` exist.
516
+ *
507
517
  * @param {Array<{name: string, content: string}>} files
508
518
  * @returns {string|null} `sha256:<hex>`, or null for an empty resolution
509
519
  */
@@ -513,7 +523,7 @@ export function computeSkillContentHash(files) {
513
523
  for (const f of files) {
514
524
  h.update(f.name);
515
525
  h.update('\0');
516
- h.update(f.content);
526
+ h.update(normalizeLineEndings(f.content));
517
527
  h.update('\0');
518
528
  }
519
529
  return `sha256:${h.digest('hex')}`;
@@ -779,13 +789,17 @@ export function resolveCommandContent(cmdName, agent, locale = 'en') {
779
789
  *
780
790
  * Same `sha256:` shape as `computeSkillContentHash` so the two sides of the
781
791
  * diff can be compared without caring which category an entry came from.
792
+ * Line-ending normalized before hashing for the same reason as
793
+ * `computeSkillContentHash` (GitHub issue #155) — its actual-state
794
+ * counterpart in `reconciler/actual-state-scanner.js` hashes an installed
795
+ * command file that may have been checked out as CRLF on Windows.
782
796
  *
783
797
  * @param {string|null} content
784
798
  * @returns {string|null}
785
799
  */
786
800
  export function computeCommandContentHash(content) {
787
801
  if (content === null || content === undefined) return null;
788
- return `sha256:${createHash('sha256').update(content).digest('hex')}`;
802
+ return `sha256:${createHash('sha256').update(normalizeLineEndings(content)).digest('hex')}`;
789
803
  }
790
804
 
791
805
  function installSingleCommand(cmdName, targetDir, agent, locale = 'en') {
@@ -2,6 +2,22 @@
2
2
  * Spec Linter — Stateless analysis functions for spec quality checks
3
3
  * @module utils/spec-linter
4
4
  * @see specs/superspec-borrowing-phase1-2-spec.md (AC-11, AC-12, AC-13)
5
+ *
6
+ * XSPEC-383 R5 (Option E): `checkACCoverage` / `collectTestFiles` / `scanDir`
7
+ * were removed here. They shipped 2026-04-07, had passing unit tests, and were
8
+ * never wired to any CLI command — `uds lint` did not exist. 2026-08-19,
9
+ * registering `uds lint` and running it against VibeOps's 93 real specs
10
+ * surfaced that the removed check would have reported 0/98 ACs covered on
11
+ * every one of them, for two independent reasons: it derived AC identifiers
12
+ * positionally (`AC-1`, `AC-2`, …) instead of reading the ones a spec
13
+ * declares, and it hardcoded the `@AC-N` tag convention from this repo's own
14
+ * `skills/ac-coverage`, while VibeOps's tests tag coverage as `AC-045-001`
15
+ * without an `@` prefix. Neither project is "wrong" — they never agreed on a
16
+ * convention — but a linter that always reports zero regardless of actual
17
+ * coverage is worse than no linter: it looks like a working gate. Redoing AC
18
+ * coverage requires deciding how identifiers are read and how conventions are
19
+ * negotiated across adopters; that is a new design, not a patch, and is out
20
+ * of scope for R5.
5
21
  */
6
22
 
7
23
  import { readFileSync, readdirSync, existsSync } from 'fs';
@@ -9,35 +25,6 @@ import { join, basename } from 'path';
9
25
  import { StandardValidator } from './standard-validator.js';
10
26
  import { MicroSpec } from '../vibe/micro-spec.js';
11
27
 
12
- /**
13
- * Check if a spec's ACs are referenced in test files
14
- * @param {string} specId - e.g. "SPEC-001"
15
- * @param {string[]} acIds - e.g. ["AC-1", "AC-2", "AC-3"]
16
- * @param {string} projectPath - Project root directory
17
- * @returns {{ covered: string[], orphans: string[], coverage: number }}
18
- */
19
- export function checkACCoverage(specId, acIds, projectPath) {
20
- const covered = [];
21
- const orphans = [];
22
-
23
- // Collect all test file contents
24
- const testContent = collectTestFiles(projectPath);
25
-
26
- for (const acId of acIds) {
27
- // Search for @AC-N pattern in test files
28
- const pattern = new RegExp(`@${acId}\\b`);
29
- if (testContent.some(content => pattern.test(content))) {
30
- covered.push(acId);
31
- } else {
32
- orphans.push(acId);
33
- }
34
- }
35
-
36
- const coverage = acIds.length > 0 ? covered.length / acIds.length : 0;
37
-
38
- return { covered, orphans, coverage };
39
- }
40
-
41
28
  /**
42
29
  * Validate depends_on references exist
43
30
  * @param {Object[]} specs - Array of specs with { id, dependsOn }
@@ -75,14 +62,22 @@ export function checkSpecSize(specFilePath, options = {}) {
75
62
  }
76
63
 
77
64
  /**
78
- * Run all lint checks on specs in a project
65
+ * Run all lint checks (dependency validity + size) on specs in a project.
66
+ *
67
+ * `specsDirExists` is a three-state signal, not decoration: "no specs
68
+ * directory" and "specs directory with zero problems" must not collapse into
69
+ * the same `{ pass: 0, warn: 0, fail: 0 }` shape, or a caller cannot tell
70
+ * "nothing was checked" from "everything is fine" (XSPEC-383 R4/R5's own
71
+ * standing rule for this repo's gates — see check-module-reachability.mjs and
72
+ * check-command-existence.mjs).
73
+ *
79
74
  * @param {string} projectPath - Project root directory
80
- * @returns {{ results: Object[], summary: { pass: number, warn: number, fail: number } }}
75
+ * @returns {{ results: Object[], summary: { pass: number, warn: number, fail: number }, specsDir: string, specsDirExists: boolean }}
81
76
  */
82
77
  export function lintAll(projectPath) {
83
78
  const specsDir = join(projectPath, 'specs');
84
79
  if (!existsSync(specsDir)) {
85
- return { results: [], summary: { pass: 0, warn: 0, fail: 0 } };
80
+ return { results: [], summary: { pass: 0, warn: 0, fail: 0 }, specsDir: 'specs', specsDirExists: false };
86
81
  }
87
82
 
88
83
  // Load all specs
@@ -102,10 +97,6 @@ export function lintAll(projectPath) {
102
97
  const summary = { pass: 0, warn: 0, fail: 0 };
103
98
 
104
99
  for (const spec of allSpecs) {
105
- // AC coverage
106
- const acIds = (spec.acceptance || []).map((_, i) => `AC-${i + 1}`);
107
- const acCoverage = checkACCoverage(spec.id, acIds, projectPath);
108
-
109
100
  // Dependencies for this spec
110
101
  const specBroken = depResults.broken.filter(b => b.spec === spec.id);
111
102
  const specValid = depResults.valid.filter(v => v.spec === spec.id);
@@ -116,22 +107,18 @@ export function lintAll(projectPath) {
116
107
  const size = checkSpecSize(specPath);
117
108
 
118
109
  // Determine worst status
119
- let worstStatus = 'pass';
120
- if (specBroken.length > 0 || size.status === 'fail' || acCoverage.coverage === 0 && acIds.length > 0) {
121
- worstStatus = 'fail';
122
- } else if (size.status === 'warn' || acCoverage.coverage < 1) {
123
- worstStatus = 'warn';
110
+ let status = 'pass';
111
+ if (specBroken.length > 0 || size.status === 'fail') {
112
+ status = 'fail';
113
+ } else if (size.status === 'warn') {
114
+ status = 'warn';
124
115
  }
125
116
 
126
- summary[worstStatus]++;
117
+ summary[status]++;
127
118
 
128
119
  results.push({
129
120
  spec: spec.id,
130
- acCoverage: {
131
- covered: acCoverage.covered,
132
- orphans: acCoverage.orphans,
133
- coverage: acCoverage.coverage,
134
- },
121
+ status,
135
122
  deps,
136
123
  size: {
137
124
  effectiveLines: size.effectiveLines,
@@ -140,33 +127,5 @@ export function lintAll(projectPath) {
140
127
  });
141
128
  }
142
129
 
143
- return { results, summary };
144
- }
145
-
146
- // ─── Internal helpers ───
147
-
148
- function collectTestFiles(projectPath) {
149
- const contents = [];
150
- const testDirs = ['tests', 'test', '__tests__', 'cli/tests'];
151
-
152
- for (const dir of testDirs) {
153
- const fullDir = join(projectPath, dir);
154
- if (existsSync(fullDir)) {
155
- scanDir(fullDir, contents);
156
- }
157
- }
158
-
159
- return contents;
160
- }
161
-
162
- function scanDir(dir, contents) {
163
- const entries = readdirSync(dir, { withFileTypes: true });
164
- for (const entry of entries) {
165
- const fullPath = join(dir, entry.name);
166
- if (entry.isDirectory()) {
167
- scanDir(fullPath, contents);
168
- } else if (entry.name.match(/\.(test|spec)\.(js|ts|mjs|cjs)$/)) {
169
- contents.push(readFileSync(fullPath, 'utf-8'));
170
- }
171
- }
130
+ return { results, summary, specsDir: 'specs', specsDirExists: true };
172
131
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "version": "6.7.5",
3
+ "version": "6.8.0",
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.7.5"
61
+ "version": "6.8.0"
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.7.5",
68
+ "version": "6.8.0",
69
69
  "note": "Skills are now included in the main repository under skills/"
70
70
  }
71
71
  },
@@ -2260,7 +2260,7 @@
2260
2260
  "id": "license-compliance",
2261
2261
  "name": "License Compliance Standards",
2262
2262
  "nameZh": "授權合規標準",
2263
- "version": "6.7.5",
2263
+ "version": "6.8.0",
2264
2264
  "source": {
2265
2265
  "human": "core/license-compliance.md",
2266
2266
  "ai": "ai/standards/license-compliance.ai.yaml"
@@ -2272,7 +2272,7 @@
2272
2272
  "id": "verification-oracle",
2273
2273
  "name": "Verification Oracle Standards",
2274
2274
  "nameZh": "驗證 Oracle 標準",
2275
- "version": "6.7.5",
2275
+ "version": "6.8.0",
2276
2276
  "source": {
2277
2277
  "human": "core/verification-oracle.md",
2278
2278
  "ai": "ai/standards/verification-oracle.ai.yaml"
@@ -2284,7 +2284,7 @@
2284
2284
  "id": "model-provenance",
2285
2285
  "name": "Model Provenance Policy Standards",
2286
2286
  "nameZh": "模型來源政策標準",
2287
- "version": "6.7.5",
2287
+ "version": "6.8.0",
2288
2288
  "source": {
2289
2289
  "human": "core/model-provenance.md",
2290
2290
  "ai": "ai/standards/model-provenance.ai.yaml"
@@ -2296,7 +2296,7 @@
2296
2296
  "id": "resource-cost-boundary",
2297
2297
  "name": "Resource / Cost Boundary Declaration Standards",
2298
2298
  "nameZh": "資源/成本邊界宣告標準",
2299
- "version": "6.7.5",
2299
+ "version": "6.8.0",
2300
2300
  "source": {
2301
2301
  "human": "core/resource-cost-boundary.md",
2302
2302
  "ai": "ai/standards/resource-cost-boundary.ai.yaml"