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.
- package/bin/uds.js +7 -1
- package/bundled/ai/standards/full-coverage-testing.ai.yaml +46 -5
- package/bundled/core/full-coverage-testing.md +57 -3
- package/bundled/locales/zh-CN/CHANGELOG.md +29 -2
- package/bundled/locales/zh-CN/README.md +1 -1
- package/bundled/locales/zh-CN/SECURITY.md +1 -1
- package/bundled/locales/zh-CN/core/full-coverage-testing.md +61 -7
- package/bundled/locales/zh-CN/docs/CHEATSHEET.md +3 -1
- package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +8 -5
- package/bundled/locales/zh-TW/CHANGELOG.md +29 -2
- package/bundled/locales/zh-TW/README.md +1 -1
- package/bundled/locales/zh-TW/SECURITY.md +1 -1
- package/bundled/locales/zh-TW/core/full-coverage-testing.md +61 -7
- package/bundled/locales/zh-TW/docs/CHEATSHEET.md +3 -1
- package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +8 -5
- package/bundled/templates/gates/check-anti-fake-tests.mjs +991 -0
- package/bundled/templates/gates/check-stubs.mjs +644 -0
- package/package.json +2 -2
- package/src/commands/audit.js +11 -0
- package/src/commands/check.js +124 -24
- package/src/commands/init.js +16 -0
- package/src/commands/update.js +180 -19
- package/src/core/install-records.js +2 -1
- package/src/i18n/messages.js +50 -9
- package/src/reconciler/backup-manager.js +418 -82
- package/src/reconciler/index.js +27 -5
- package/src/reconciler/install-roots.js +90 -0
- package/src/reconciler/plan-executor.js +23 -2
- package/src/uninstallers/hook-uninstaller.js +2 -1
- package/src/utils/command-hash-ownership.js +103 -0
- package/src/utils/copier.js +21 -1
- package/src/utils/gate-scripts.js +141 -0
- package/src/utils/health-scorer.js +10 -7
- package/src/utils/skill-hash-ownership.js +64 -0
- package/src/utils/skills-installer.js +12 -1
- package/src/utils/test-change-check.js +160 -0
- package/src/utils/test-policy.js +214 -0
- package/src/utils/update-summary.js +29 -0
- package/standards-registry.json +7 -7
- package/bundled/extensions/languages/php/fat-free-patterns.md +0 -915
- package/bundled/extensions/languages/php/php-style.md +0 -693
package/src/reconciler/index.js
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/utils/copier.js
CHANGED
|
@@ -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,
|
|
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
|
|
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 =
|
|
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;
|