universal-dev-standards 6.14.0-beta.4 → 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 (41) hide show
  1. package/bin/uds.js +7 -1
  2. package/bundled/ai/standards/full-coverage-testing.ai.yaml +46 -5
  3. package/bundled/core/full-coverage-testing.md +57 -3
  4. package/bundled/locales/zh-CN/CHANGELOG.md +29 -2
  5. package/bundled/locales/zh-CN/README.md +1 -1
  6. package/bundled/locales/zh-CN/SECURITY.md +1 -1
  7. package/bundled/locales/zh-CN/core/full-coverage-testing.md +61 -7
  8. package/bundled/locales/zh-CN/docs/CHEATSHEET.md +3 -1
  9. package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +8 -5
  10. package/bundled/locales/zh-TW/CHANGELOG.md +29 -2
  11. package/bundled/locales/zh-TW/README.md +1 -1
  12. package/bundled/locales/zh-TW/SECURITY.md +1 -1
  13. package/bundled/locales/zh-TW/core/full-coverage-testing.md +61 -7
  14. package/bundled/locales/zh-TW/docs/CHEATSHEET.md +3 -1
  15. package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +8 -5
  16. package/bundled/templates/gates/check-anti-fake-tests.mjs +991 -0
  17. package/bundled/templates/gates/check-stubs.mjs +644 -0
  18. package/package.json +2 -2
  19. package/src/commands/audit.js +11 -0
  20. package/src/commands/check.js +124 -24
  21. package/src/commands/init.js +16 -0
  22. package/src/commands/update.js +180 -19
  23. package/src/core/install-records.js +2 -1
  24. package/src/i18n/messages.js +50 -9
  25. package/src/reconciler/backup-manager.js +418 -82
  26. package/src/reconciler/index.js +27 -5
  27. package/src/reconciler/install-roots.js +90 -0
  28. package/src/reconciler/plan-executor.js +23 -2
  29. package/src/uninstallers/hook-uninstaller.js +2 -1
  30. package/src/utils/command-hash-ownership.js +103 -0
  31. package/src/utils/copier.js +21 -1
  32. package/src/utils/gate-scripts.js +141 -0
  33. package/src/utils/health-scorer.js +10 -7
  34. package/src/utils/skill-hash-ownership.js +64 -0
  35. package/src/utils/skills-installer.js +12 -1
  36. package/src/utils/test-change-check.js +160 -0
  37. package/src/utils/test-policy.js +214 -0
  38. package/src/utils/update-summary.js +29 -0
  39. package/standards-registry.json +7 -7
  40. package/bundled/extensions/languages/php/fat-free-patterns.md +0 -915
  41. package/bundled/extensions/languages/php/php-style.md +0 -693
@@ -28,6 +28,8 @@ import { scanActualState, legacyDiscovery } from './actual-state-scanner.js';
28
28
  import { computeDiff, createEmptyPlan } from './diff-engine.js';
29
29
  import { executePlan } from './plan-executor.js';
30
30
  import { rollback } from './backup-manager.js';
31
+ import { pruneForeignSkillHashes } from '../utils/skill-hash-ownership.js';
32
+ import { pruneForeignCommandHashes } from '../utils/command-hash-ownership.js';
31
33
 
32
34
  /**
33
35
  * Full reconciliation pipeline.
@@ -72,11 +74,23 @@ function reconcileFileHashes(manifest, verifiedPristine) {
72
74
  (k) => k.startsWith('.claude/skills/') || k.startsWith('.claude/commands/')
73
75
  );
74
76
 
75
- if (!verifiedPristine?.length && stale.length === 0) return null;
77
+ // XSPEC-454 R2 step 1: forget skillHashes records that were never UDS's (see
78
+ // utils/skill-hash-ownership.js). They are not harmless: once `uds check` counts a missing
79
+ // skill file against the verdict, a record for a folder an older update already removed
80
+ // would fail the project for a file nobody can restore. This runs on every apply, plan empty
81
+ // or not, so an existing project is corrected by its next `uds update --apply` with nothing
82
+ // for the adopter to do.
83
+ const skillProbe = { skillHashes: { ...(manifest.skillHashes || {}) } };
84
+ const foreignSkillKeys = pruneForeignSkillHashes(skillProbe);
85
+ // ...and the same for command records that name a command UDS does not ship.
86
+ const commandProbe = { commandHashes: { ...(manifest.commandHashes || {}) } };
87
+ const foreignCommandKeys = pruneForeignCommandHashes(commandProbe);
88
+
89
+ if (!verifiedPristine?.length && stale.length === 0 && foreignSkillKeys.length === 0 && foreignCommandKeys.length === 0) return null;
76
90
 
77
91
  const kept = Object.fromEntries(Object.entries(current).filter(([k]) => !stale.includes(k)));
78
92
  const corrected = {};
79
- let count = stale.length;
93
+ let count = stale.length + foreignSkillKeys.length + foreignCommandKeys.length;
80
94
 
81
95
  for (const entry of verifiedPristine || []) {
82
96
  const key = entry.path.replace(/\\/g, '/');
@@ -92,7 +106,12 @@ function reconcileFileHashes(manifest, verifiedPristine) {
92
106
 
93
107
  if (count === 0) return null;
94
108
  return {
95
- manifest: { ...manifest, fileHashes: { ...kept, ...corrected } },
109
+ manifest: {
110
+ ...manifest,
111
+ fileHashes: { ...kept, ...corrected },
112
+ ...(foreignSkillKeys.length > 0 ? { skillHashes: skillProbe.skillHashes } : {}),
113
+ ...(foreignCommandKeys.length > 0 ? { commandHashes: commandProbe.commandHashes } : {})
114
+ },
96
115
  count
97
116
  };
98
117
  }
@@ -133,7 +152,10 @@ export async function reconcile(projectPath, options = {}) {
133
152
  // own binding rather than reassigning it.
134
153
  const hashFix = reconcileFileHashes(manifest, reconciliationPlan.verifiedPristine);
135
154
  const effectiveManifest = hashFix ? hashFix.manifest : manifest;
136
- if (hashFix) writeManifest(effectiveManifest, projectPath);
155
+ // With a non-empty plan the executor writes `effectiveManifest` itself, AFTER it has taken its backup.
156
+ // Writing it here first would put the hash correction into the backup's "before" copy, so a rollback
157
+ // could not give back the manifest byte for byte (XSPEC-454 R1).
158
+ if (hashFix && reconciliationPlan.actions.length === 0) writeManifest(effectiveManifest, projectPath);
137
159
 
138
160
  // Step 5: Execute plan
139
161
  if (reconciliationPlan.actions.length === 0) {
@@ -242,5 +264,5 @@ async function getManifest(projectPath) {
242
264
 
243
265
  // Re-export for convenience
244
266
  export { formatPlan } from './diff-engine.js';
245
- export { listBackups } from './backup-manager.js';
267
+ export { listBackups, createStepBackup, finalizeBackup, cleanupBackups } from './backup-manager.js';
246
268
  export { migrateAndBackfill } from './manifest-migrator.js';
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Where `uds update` writes Skills and Commands, in the terms a backup needs (XSPEC-454 R1):
3
+ * project-relative directories it can copy, and targets outside the project it cannot.
4
+ *
5
+ * A user-level target (`~/.claude/skills`) is shared by every project on the machine. Copying it into
6
+ * one project's backup, and writing it back on that project's rollback, would undo other projects'
7
+ * updates — so it is listed as "not backed up" and the rollback says so, rather than touching it.
8
+ *
9
+ * @module reconciler/install-roots
10
+ */
11
+
12
+ import { relative, isAbsolute } from 'path';
13
+ import { getSkillsDirForAgent, getCommandsDirForAgent } from '../config/ai-agent-paths.js';
14
+ import { getAvailableSkillNames, getAvailableCommandNames } from '../utils/skills-installer.js';
15
+
16
+ const normalizeInstallation = (inst) =>
17
+ typeof inst === 'string' ? { agent: inst, level: 'project' } : { agent: inst.agent, level: inst.level || 'project' };
18
+
19
+ /** Project-relative posix path when `abs` is inside the project, else null. */
20
+ function insideProject(projectPath, abs) {
21
+ if (!abs) return null;
22
+ const rel = relative(projectPath, abs).replace(/\\/g, '/');
23
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return null;
24
+ return rel;
25
+ }
26
+
27
+ /**
28
+ * @param {string} projectPath
29
+ * @param {Array<string|{agent: string, level?: string}>} installations
30
+ * @param {'skills'|'commands'} kind
31
+ * @returns {{ dirs: string[], outside: Array<{path: string, reason: string}> }}
32
+ */
33
+ export function installRoots(projectPath, installations, kind) {
34
+ const dirs = [];
35
+ const outside = [];
36
+ for (const raw of installations || []) {
37
+ const { agent, level } = normalizeInstallation(raw);
38
+ if (level === 'marketplace') continue; // nothing on disk
39
+ const abs = kind === 'skills'
40
+ ? getSkillsDirForAgent(agent, level, projectPath)
41
+ : getCommandsDirForAgent(agent, level, projectPath);
42
+ if (!abs) continue;
43
+ const rel = insideProject(projectPath, abs);
44
+ if (rel) {
45
+ dirs.push(rel);
46
+ } else {
47
+ outside.push({
48
+ path: abs,
49
+ reason: `${kind} installed at user level for ${agent}; shared by every project on this machine, so it is not backed up and not rolled back`
50
+ });
51
+ }
52
+ }
53
+ return { dirs: [...new Set(dirs)], outside };
54
+ }
55
+
56
+ /**
57
+ * The bookkeeping file every Skills/Commands install rewrites, for each project-level target.
58
+ * A reconciler plan names skill and command files, never these, yet they change on every run.
59
+ */
60
+ export function bookkeepingFiles(projectPath, manifest, { skills, commands }) {
61
+ const out = [];
62
+ if (skills) out.push(...installRoots(projectPath, manifest?.skills?.installations, 'skills').dirs.map((d) => `${d}/.manifest.json`));
63
+ if (commands) out.push(...installRoots(projectPath, manifest?.commands?.installations, 'commands').dirs.map((d) => `${d}/.manifest.json`));
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * What a `--skills` / `--commands` step is going to write, as paths a backup can copy: the skill folders
69
+ * (or command files) UDS ships, and the bookkeeping file, inside each project-level target.
70
+ *
71
+ * Deliberately not the whole target folder. That folder also holds the adopter's own skills and commands;
72
+ * a backup that copied them would let a rollback write them back — undoing an edit the adopter made
73
+ * after the update, to a file UDS never touched. A shipped name that is not on disk yet is still listed:
74
+ * the backup records it as absent, which is what lets a rollback remove it again.
75
+ *
76
+ * @param {string} projectPath
77
+ * @param {Array<string|{agent: string, level?: string}>} installations
78
+ * @param {'skills'|'commands'} kind
79
+ * @returns {{ paths: string[], outside: Array<{path: string, reason: string}> }}
80
+ */
81
+ export function stepWritePaths(projectPath, installations, kind) {
82
+ const { dirs, outside } = installRoots(projectPath, installations, kind);
83
+ const names = kind === 'skills' ? getAvailableSkillNames() : getAvailableCommandNames();
84
+ const paths = [];
85
+ for (const dir of dirs) {
86
+ paths.push(`${dir}/.manifest.json`);
87
+ for (const name of names) paths.push(kind === 'skills' ? `${dir}/${name}` : `${dir}/${name}.md`);
88
+ }
89
+ return { paths, outside };
90
+ }
@@ -28,7 +28,8 @@ import { writeManifest } from '../core/manifest.js';
28
28
  import { getRepositoryInfo } from '../utils/registry.js';
29
29
  import { displayLanguageToLocale } from '../utils/locale.js';
30
30
  import { computeFileHash, pruneIntegrationFileHashes } from '../utils/hasher.js';
31
- import { createBackup, cleanupBackups } from './backup-manager.js';
31
+ import { createBackup, cleanupBackups, finalizeBackup } from './backup-manager.js';
32
+ import { bookkeepingFiles } from './install-roots.js';
32
33
 
33
34
  /**
34
35
  * @typedef {Object} ExecutionResult
@@ -75,7 +76,13 @@ export async function executePlan(projectPath, plan, manifest, options = {}) {
75
76
 
76
77
  // Create backup
77
78
  if (backup && !dryRun) {
78
- const backupResult = createBackup(projectPath, plan);
79
+ // XSPEC-454 R1: the Skills and Commands installers also rewrite `.manifest.json` in each target
80
+ // folder. No plan action names it, so it has to be watched explicitly or a rollback leaves the new one.
81
+ const bookkeeping = bookkeepingFiles(projectPath, manifest, {
82
+ skills: plan.actions.some((a) => a.category === 'skill'),
83
+ commands: plan.actions.some((a) => a.category === 'command')
84
+ });
85
+ const backupResult = createBackup(projectPath, plan, { alsoWatch: bookkeeping });
79
86
  // Abort if ANY planned path could not be backed up — not only if every one
80
87
  // failed.
81
88
  //
@@ -197,6 +204,10 @@ export async function executePlan(projectPath, plan, manifest, options = {}) {
197
204
  error: `Failed to write manifest: ${err.message}`
198
205
  });
199
206
  }
207
+ // XSPEC-454 R1: now that the step is over — whether or not that last write worked — record what it
208
+ // created and what the manifest became. This has to stay after the manifest write: the chain between
209
+ // consecutive backups is "the manifest this step left == the manifest the next step started from".
210
+ finalizeBackup(projectPath, backupId);
200
211
  }
201
212
 
202
213
  const summary = {
@@ -422,6 +433,16 @@ async function executeSkillBatch(projectPath, skillActions, manifest) {
422
433
  if (existsSync(targetPath)) {
423
434
  rmSync(targetPath, { recursive: true, force: true });
424
435
  }
436
+ // XSPEC-454 R2: a deleted skill folder must also lose its hash records. Commands already
437
+ // do this (see executeCommandBatch); skills did not, so each folder removed here stayed in
438
+ // `skillHashes` and `uds check` later called every file in it missing — 26 of them for one project.
439
+ const meta = action.details?.metadata;
440
+ if (meta?.agent && meta?.level && meta?.skillName && manifest.skillHashes) {
441
+ const prefix = `${meta.agent}/${meta.level}/${meta.skillName}/`;
442
+ for (const key of Object.keys(manifest.skillHashes)) {
443
+ if (key.startsWith(prefix)) delete manifest.skillHashes[key];
444
+ }
445
+ }
425
446
  results.push({ action, success: true });
426
447
  } catch (err) {
427
448
  results.push({ action, success: false, error: err.message });
@@ -303,7 +303,8 @@ export function uninstallHookScripts(projectPath, manifest, { dryRun = false, bl
303
303
  const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
304
304
  const files = manifest?.[RECORDS_KEY]?.files || {};
305
305
  const recorded = Object.entries(files)
306
- .filter(([, rec]) => rec && rec.kind === RECORD_KINDS.HOOK_SCRIPT)
306
+ // gate-script: the scanners `uds init` writes to scripts/ (XSPEC-444 R5) — same proof rule as a hook script
307
+ .filter(([, rec]) => rec && (rec.kind === RECORD_KINDS.HOOK_SCRIPT || rec.kind === RECORD_KINDS.GATE_SCRIPT))
307
308
  .map(([rel]) => rel)
308
309
  .sort();
309
310
 
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Which `manifest.commandHashes` records `uds check` may hold this project to (XSPEC-454 R2, the
3
+ * Commands half).
4
+ *
5
+ * A record is `<agent>/<file>` (`opencode/commit.md`, `gemini-cli/commit.toml`) with no level in it.
6
+ * Two kinds of record do not describe a file this project's check can vouch for:
7
+ *
8
+ * - FOREIGN: the name is not a command UDS ships (any more). A retired command whose file is gone is
9
+ * a "missing" nobody can restore. These are dropped for good by `uds update`.
10
+ * - ELSEWHERE: the command was installed at user level (`~/.config/opencode/command`), shared by every
11
+ * project on the machine. The check looks inside the project, so every one of them would read
12
+ * "missing" — and another project's `uds update` can rewrite them at any time, so "modified" would
13
+ * be noise too. They are ignored by the check and left in the manifest (user-level update still
14
+ * refreshes them).
15
+ *
16
+ * Both exist for the same reason the skills ones do: once a missing or changed command file counts toward
17
+ * the verdict, a record that points at nothing this project owns would fail the project for a file
18
+ * nobody can fix.
19
+ *
20
+ * @module utils/command-hash-ownership
21
+ */
22
+
23
+ import { getAvailableCommandNames } from './skills-installer.js';
24
+ import { getCommandFileExtension } from '../config/ai-agent-paths.js';
25
+
26
+ /** Below this many shipped commands the source tree did not load; never treat that as "UDS ships none". */
27
+ const MIN_SHIPPED_COMMANDS = 10;
28
+
29
+ /**
30
+ * @param {string} key - A commandHashes key
31
+ * @param {Set<string>} shipped - Names of the commands UDS ships (no extension)
32
+ * @returns {boolean}
33
+ */
34
+ export function isUdsCommandHashKey(key, shipped) {
35
+ const parts = String(key).split('/');
36
+ if (parts.length !== 2 || !parts[0] || !parts[1]) return false;
37
+ const [agent, file] = parts;
38
+ const ext = getCommandFileExtension(agent);
39
+ return file.endsWith(ext) && shipped.has(file.slice(0, -ext.length));
40
+ }
41
+
42
+ /**
43
+ * Drop the commandHashes records for commands UDS does not ship. Mutates `manifest`.
44
+ * @param {Object} manifest
45
+ * @param {Set<string>} [shipped] - Injection point for tests
46
+ * @returns {string[]} The keys dropped, sorted
47
+ */
48
+ export function pruneForeignCommandHashes(manifest, shipped = new Set(getAvailableCommandNames())) {
49
+ const hashes = manifest?.commandHashes;
50
+ if (!hashes || shipped.size < MIN_SHIPPED_COMMANDS) return [];
51
+ const dropped = [];
52
+ for (const key of Object.keys(hashes)) {
53
+ if (isUdsCommandHashKey(key, shipped)) continue;
54
+ delete hashes[key];
55
+ dropped.push(key);
56
+ }
57
+ return dropped.sort();
58
+ }
59
+
60
+ /**
61
+ * Agents whose commands are installed, but only outside this project (user level or marketplace).
62
+ * An agent with no recorded installation at all is a legacy manifest and counts as project-level.
63
+ * @param {Object} manifest
64
+ * @returns {Set<string>}
65
+ */
66
+ export function agentsWithCommandsOutsideProject(manifest) {
67
+ const levels = new Map(); // agent -> Set of levels
68
+ for (const entry of manifest?.commands?.installations || []) {
69
+ const agent = typeof entry === 'string' ? entry : entry?.agent;
70
+ const level = typeof entry === 'string' ? 'project' : (entry?.level || 'project');
71
+ if (!agent) continue;
72
+ if (!levels.has(agent)) levels.set(agent, new Set());
73
+ levels.get(agent).add(level);
74
+ }
75
+ return new Set([...levels].filter(([, set]) => !set.has('project')).map(([agent]) => agent));
76
+ }
77
+
78
+ /**
79
+ * The part of `manifest.commandHashes` this project's check is responsible for.
80
+ * @param {Object} manifest
81
+ * @returns {{ hashes: Object, ignored: string[] }} `ignored` are the keys set aside as ELSEWHERE
82
+ */
83
+ export function projectCommandHashes(manifest) {
84
+ const all = manifest?.commandHashes || {};
85
+ const outside = agentsWithCommandsOutsideProject(manifest);
86
+ const hashes = {};
87
+ const ignored = [];
88
+ for (const [key, info] of Object.entries(all)) {
89
+ if (outside.has(key.split('/')[0])) ignored.push(key);
90
+ else hashes[key] = info;
91
+ }
92
+ return { hashes, ignored };
93
+ }
94
+
95
+ /**
96
+ * The command files `uds check` has to report: missing and modified, as one list. Its result is what the
97
+ * verdict is built from — it used to be discarded, like the Skills one.
98
+ * @param {{ missing?: string[], modified?: string[] }} status
99
+ * @returns {string[]}
100
+ */
101
+ export function commandIssuesOf(status) {
102
+ return [...(status?.missing || []), ...(status?.modified || [])];
103
+ }
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, copyFileSync } from 'fs';
1
+ import { existsSync, mkdirSync, copyFileSync, readFileSync } from 'fs';
2
2
  import { dirname, join, basename } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { downloadStandard, downloadIntegration } from './github.js';
@@ -78,6 +78,26 @@ export async function copyExtension(sourcePath, targetDir, projectPath) {
78
78
  }
79
79
  }
80
80
 
81
+ /**
82
+ * Read the original of a source path from the installed package — never from the network.
83
+ *
84
+ * This is what `uds check --diff` compares an adopter's file against (XSPEC-453 R1). The original
85
+ * used to be downloaded from GitHub `main`: offline it failed, and whatever UDS had changed on `main`
86
+ * since the adopter installed was listed as a difference the adopter had made. The package's own copy
87
+ * is the version that was installed, so a difference against it is the adopter's.
88
+ *
89
+ * Returns `null` when the installed package has no such file; the caller says so by name. There is
90
+ * deliberately no fallback to a download here.
91
+ *
92
+ * @param {string} sourcePath - Relative path from repo root (e.g., 'ai/standards/x.ai.yaml', 'extensions/locales/zh-tw.md')
93
+ * @returns {string|null} File content, or null if the installed package does not contain it
94
+ */
95
+ export function readPackagedSource(sourcePath) {
96
+ const source = getSourcePath(sourcePath);
97
+ if (!source) return null;
98
+ return readFileSync(source, 'utf-8');
99
+ }
100
+
81
101
  /**
82
102
  * Copy a standard file to target project
83
103
  * Falls back to downloading from GitHub if local file not found
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The two scanners UDS ships into an adopter's project, and the code that runs them.
3
+ * // implements XSPEC-444 R5
4
+ *
5
+ * `full-coverage-testing` has always told adopters to "add scripts/check-stubs.sh and
6
+ * scripts/check-anti-fake-tests.sh", and its validator checked that those files EXIST —
7
+ * but UDS shipped neither, so the instruction could not be followed and the check could
8
+ * only ever say "missing". This is the missing half:
9
+ *
10
+ * installGateScripts() `uds init` writes the two scanners (templates/gates/*.mjs) to
11
+ * `<project>/scripts/`, records them for `uds uninstall`, and never
12
+ * overwrites a file that is already there.
13
+ * runTestQualityGates() `uds check` (what the pre-commit hook runs) executes the ones that
14
+ * are installed and prints what they found as a WARNING. It does not
15
+ * change the exit code unless the adopter set `"mode": "block"`.
16
+ *
17
+ * Why the scanners are written into the project instead of run from the CLI: they are the
18
+ * adopter's to read, edit and wire into CI (`node scripts/check-stubs.mjs` is a hard gate on
19
+ * its own), and a standard that says "this file must exist" is only honest if the file does.
20
+ */
21
+
22
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
23
+ import { dirname, join } from 'path';
24
+ import { spawnSync } from 'child_process';
25
+ import chalk from 'chalk';
26
+ import { getRepoRoot } from './copier.js';
27
+ import { newRecorder, mkdirTracked, recordFile, RECORD_KINDS } from '../core/install-records.js';
28
+ import { loadPolicy } from './test-policy.js';
29
+ import { readStagedChanges } from './test-change-check.js';
30
+
31
+ /** The standard that asks for these scripts; they are only written when it is installed. */
32
+ export const GATE_STANDARD_FILE = 'full-coverage-testing.ai.yaml';
33
+
34
+ export const GATE_SCRIPTS = Object.freeze([
35
+ { file: 'check-anti-fake-tests.mjs', label: 'anti-fake-test', what: 'tests that cannot fail' },
36
+ { file: 'check-stubs.mjs', label: 'stub', what: 'placeholders and empty shells' }
37
+ ]);
38
+
39
+ const templatePath = (file) => join(getRepoRoot(), 'templates', 'gates', file);
40
+ const destRel = (file) => `scripts/${file}`;
41
+
42
+ export function gateStandardInstalled(projectPath) {
43
+ return existsSync(join(projectPath, '.standards', GATE_STANDARD_FILE));
44
+ }
45
+
46
+ /**
47
+ * Write the scanners into `<project>/scripts/`. Never overwrites: a file that exists is the
48
+ * adopter's (they may have edited it), and is reported as kept.
49
+ * @param {string} projectPath
50
+ * @param {object} recorder an install-records recorder (see core/install-records.js)
51
+ * @returns {{ written: string[], kept: string[], missingTemplate: string[], skipped?: string }}
52
+ */
53
+ export function installGateScripts(projectPath, recorder = newRecorder()) {
54
+ const result = { written: [], kept: [], missingTemplate: [] };
55
+ if (!gateStandardInstalled(projectPath)) {
56
+ result.skipped = `${GATE_STANDARD_FILE} is not installed in this project`;
57
+ return result;
58
+ }
59
+ for (const { file } of GATE_SCRIPTS) {
60
+ const rel = destRel(file);
61
+ const dest = join(projectPath, rel);
62
+ if (existsSync(dest)) { result.kept.push(rel); continue; }
63
+ const src = templatePath(file);
64
+ if (!existsSync(src)) { result.missingTemplate.push(file); continue; }
65
+ mkdirTracked(recorder, projectPath, dirname(dest));
66
+ writeFileSync(dest, readFileSync(src, 'utf-8'), 'utf-8');
67
+ recordFile(recorder, projectPath, rel, RECORD_KINDS.GATE_SCRIPT);
68
+ result.written.push(rel);
69
+ }
70
+ return result;
71
+ }
72
+
73
+ const MAX_SHOWN = 12;
74
+
75
+ /**
76
+ * Run the installed scanners and print the outcome. Never throws.
77
+ * @param {string} projectPath
78
+ * @param {{ staged?: boolean, mode?: 'warn'|'block', log?: Function }} [opts]
79
+ * staged — scan only the files staged for commit (what a pre-commit run wants);
80
+ * otherwise the whole project (a CI or manual run). Default: staged when
81
+ * something is staged, whole project when nothing is.
82
+ * @returns {{ ran: string[], findings: Record<string, number>, couldNotJudge: string[], blocked: boolean, missing: string[] }}
83
+ */
84
+ export function runTestQualityGates(projectPath, { staged, mode, log = console.log } = {}) {
85
+ const result = { ran: [], findings: {}, couldNotJudge: [], blocked: false, missing: [] };
86
+ const effectiveMode = mode || loadPolicy(projectPath).policy.mode;
87
+ const blocking = effectiveMode === 'block';
88
+
89
+ if (!gateStandardInstalled(projectPath)) return result;
90
+ if (staged === undefined) {
91
+ const s = readStagedChanges(projectPath);
92
+ staged = Array.isArray(s.changes) && s.changes.length > 0;
93
+ }
94
+
95
+ for (const { file, label } of GATE_SCRIPTS) {
96
+ const rel = destRel(file);
97
+ const abs = join(projectPath, rel);
98
+ if (!existsSync(abs)) { result.missing.push(file); continue; }
99
+
100
+ const args = [abs, ...(staged ? ['--staged'] : [])];
101
+ const r = spawnSync(process.execPath, args, { cwd: projectPath, encoding: 'utf-8', timeout: 120000, maxBuffer: 32 * 1024 * 1024 });
102
+ result.ran.push(file);
103
+
104
+ if (r.status === 0) {
105
+ log(chalk.gray(` ✓ [${label}] ${rel}: nothing found${staged ? ' in the staged files' : ''}`));
106
+ // "Nothing found" must not hide what was NOT looked at: the script lists files it could not
107
+ // judge (a language it has no rule for, an unparsable call) on lines that start with `·`.
108
+ for (const l of (r.stdout || '').split('\n').filter((x) => /^\s*·/.test(x))) log((/NOT scanned|could not be parsed/.test(l) ? chalk.yellow : chalk.gray)(` ${l.trim()}`));
109
+ continue;
110
+ }
111
+ if (r.status === 1) {
112
+ const lines = (r.stdout || '').split('\n').filter((l) => l.trim());
113
+ const count = lines.filter((l) => /^\s*✗/.test(l)).length;
114
+ result.findings[label] = count;
115
+ const head = blocking
116
+ ? ` ✗ [${label}] BLOCKED: ${rel} found ${count} finding(s) (mode "block").`
117
+ : ` ⚠ [${label}] ${rel} found ${count} finding(s) — warning only, the commit is not blocked.`;
118
+ log(blocking ? chalk.red(head) : chalk.yellow(head));
119
+ const shown = lines.slice(0, MAX_SHOWN + 4);
120
+ for (const l of shown) log(chalk.gray(` ${l.trim()}`));
121
+ if (lines.length > shown.length) log(chalk.gray(` ... (run \`node ${rel}\` to see everything)`));
122
+ if (blocking) result.blocked = true;
123
+ continue;
124
+ }
125
+ // exit 2, a crash, a timeout, a missing node: the scanner could not judge. Never a pass.
126
+ const why = r.error ? r.error.message : (r.stderr || r.stdout || `exit ${r.status}`).split('\n').find((l) => l.trim()) || `exit ${r.status}`;
127
+ result.couldNotJudge.push(label);
128
+ const head = blocking
129
+ ? ` ✗ [${label}] BLOCKED: ${rel} could not judge (mode "block" treats that as a failure): ${why.trim()}`
130
+ : ` ⚠ [${label}] ${rel} could not judge — this is NOT a pass: ${why.trim()}`;
131
+ log(blocking ? chalk.red(head) : chalk.yellow(head));
132
+ if (blocking) result.blocked = true;
133
+ }
134
+
135
+ if (result.missing.length > 0) {
136
+ log(chalk.gray(` ℹ [test-gates] ${result.missing.map(destRel).join(', ')} not installed (the full-coverage-testing standard asks for them). \`uds update\` offers to install them.`));
137
+ }
138
+ if (result.blocked) process.exitCode = 1;
139
+ if (result.ran.length > 0 || result.missing.length > 0) log();
140
+ return result;
141
+ }
@@ -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
  };
@@ -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;