universal-dev-standards 6.14.0-beta.3 → 6.14.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/uds.js +7 -1
- package/bundled/ai/standards/ai-response-navigation.ai.yaml +43 -3
- package/bundled/ai/standards/checkin-standards.ai.yaml +25 -6
- package/bundled/ai/standards/full-coverage-testing.ai.yaml +46 -5
- package/bundled/ai/standards/pipeline-security-gates.ai.yaml +5 -1
- package/bundled/core/ai-response-navigation.md +128 -12
- package/bundled/core/full-coverage-testing.md +57 -3
- package/bundled/extensions/frameworks/fat-free-patterns.md +937 -0
- package/bundled/extensions/languages/csharp-style.md +464 -0
- package/bundled/extensions/languages/php-style.md +700 -0
- package/bundled/extensions/locales/zh-cn.md +717 -0
- package/bundled/extensions/locales/zh-tw.md +717 -0
- package/bundled/locales/COVERAGE.md +5 -4
- package/bundled/locales/zh-CN/CHANGELOG.md +54 -3
- package/bundled/locales/zh-CN/README.md +2 -2
- package/bundled/locales/zh-CN/SECURITY.md +1 -1
- package/bundled/locales/zh-CN/core/ai-response-navigation.md +110 -12
- 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-CN/skills/README.md +1 -0
- package/bundled/locales/zh-CN/skills/comprehension-ladder/SKILL.md +289 -0
- package/bundled/locales/zh-CN/skills/comprehension-ladder/eval-cases.md +261 -0
- package/bundled/locales/zh-TW/CHANGELOG.md +54 -3
- package/bundled/locales/zh-TW/README.md +2 -2
- package/bundled/locales/zh-TW/SECURITY.md +1 -1
- package/bundled/locales/zh-TW/core/ai-response-navigation.md +110 -12
- 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/locales/zh-TW/skills/README.md +1 -0
- package/bundled/locales/zh-TW/skills/comprehension-ladder/SKILL.md +289 -0
- package/bundled/locales/zh-TW/skills/comprehension-ladder/eval-cases.md +261 -0
- package/bundled/skills/README.md +1 -0
- package/bundled/skills/comprehension-ladder/SKILL.md +283 -0
- package/bundled/skills/comprehension-ladder/eval-cases.md +255 -0
- package/bundled/templates/gates/check-anti-fake-tests.mjs +991 -0
- package/bundled/templates/gates/check-stubs.mjs +644 -0
- package/package.json +3 -3
- package/src/commands/audit.js +11 -0
- package/src/commands/check.js +124 -24
- package/src/commands/init.js +45 -9
- package/src/commands/update.js +183 -21
- package/src/core/install-records.js +2 -1
- package/src/i18n/messages.js +50 -9
- package/src/installers/standards-installer.js +16 -23
- 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 +33 -13
- package/src/uninstallers/hook-uninstaller.js +7 -4
- package/src/utils/command-hash-ownership.js +103 -0
- package/src/utils/copier.js +78 -1
- package/src/utils/gate-scripts.js +141 -0
- package/src/utils/git-hooks.js +8 -4
- package/src/utils/health-scorer.js +10 -7
- package/src/utils/locale.js +19 -0
- 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 +21 -7
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 = {
|
|
@@ -249,18 +260,17 @@ async function executeCreateOrUpdate(projectPath, action, manifest) {
|
|
|
249
260
|
let targetDir = '.standards';
|
|
250
261
|
|
|
251
262
|
if (metadata?.extensionSource) {
|
|
252
|
-
// An entry from `manifest.extensions`.
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
// source checkout, and this function answered "No source path available"
|
|
257
|
-
// for a file it was perfectly able to fetch.
|
|
263
|
+
// An entry from `manifest.extensions`. It is resolved from the installed
|
|
264
|
+
// package (`bundled/extensions/`, shipped since XSPEC-452) — never from GitHub.
|
|
265
|
+
// `copyStandard` hands any `extensions/` path to `copyExtension`, which fails
|
|
266
|
+
// by name when the package lacks the file, instead of downloading it.
|
|
258
267
|
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
268
|
+
// History: before XSPEC-452 the published package had no `extensions/`, so
|
|
269
|
+
// `sourcePath` was null for every adopter who did not install from a source
|
|
270
|
+
// checkout, and this function answered "No source path available" for a file it
|
|
271
|
+
// could fetch from GitHub. The legacy update path (`update.js`, "Update
|
|
272
|
+
// extensions") was the one that worked — only because it fell back to a
|
|
273
|
+
// download. (XSPEC-343)
|
|
264
274
|
sourceStr = metadata.extensionSource;
|
|
265
275
|
} else if (metadata?.registryEntry) {
|
|
266
276
|
const source = metadata.registryEntry.source;
|
|
@@ -423,6 +433,16 @@ async function executeSkillBatch(projectPath, skillActions, manifest) {
|
|
|
423
433
|
if (existsSync(targetPath)) {
|
|
424
434
|
rmSync(targetPath, { recursive: true, force: true });
|
|
425
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
|
+
}
|
|
426
446
|
results.push({ action, success: true });
|
|
427
447
|
} catch (err) {
|
|
428
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
|
|
|
@@ -426,11 +427,13 @@ export function uninstallHook(projectPath, options = {}) {
|
|
|
426
427
|
if (!dryRun) unlinkSync(nativeHookPath);
|
|
427
428
|
result.removed.push(`.git/hooks/pre-commit (UDS native hook, file removed — ${proof.why})`);
|
|
428
429
|
result.deletedPaths.push('.git/hooks/pre-commit');
|
|
429
|
-
} else if (!NATIVE_UDS_LINE.test(content)) {
|
|
430
|
+
} else if (!NATIVE_UDS_LINE.test(content) && !stripUdsHookBlock(content).removed) {
|
|
430
431
|
result.skipped.push('.git/hooks/pre-commit (no UDS lines found)');
|
|
431
432
|
} else {
|
|
432
|
-
|
|
433
|
-
|
|
433
|
+
// The block the native hook carries is removed whole, by its markers — its
|
|
434
|
+
// inner lines are not individually matchable (same as the husky path above).
|
|
435
|
+
const filtered = stripUdsHookBlock(content).content.split('\n')
|
|
436
|
+
.filter(line => !NATIVE_UDS_LINE.test(line));
|
|
434
437
|
if (nothingButShebang(filtered)) {
|
|
435
438
|
if (!dryRun) unlinkSync(nativeHookPath);
|
|
436
439
|
result.removed.push('.git/hooks/pre-commit (UDS native hook, file removed)');
|
|
@@ -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';
|
|
@@ -26,15 +26,92 @@ function getSourcePath(sourcePath) {
|
|
|
26
26
|
return PathResolver.getStandardSource(sourcePath);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Is this source path one of the add-on files under `extensions/`?
|
|
31
|
+
* @param {string} sourcePath - Relative path from repo root
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function isExtensionPath(sourcePath) {
|
|
35
|
+
return typeof sourcePath === 'string' && sourcePath.replace(/\\/g, '/').startsWith('extensions/');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Copy an extension file (language style guide, framework pattern, locale pack)
|
|
40
|
+
* into the target project — from the installed package ONLY.
|
|
41
|
+
*
|
|
42
|
+
* 🔴 There is deliberately no download fallback here (XSPEC-452 R2). `extensions/`
|
|
43
|
+
* used to be missing from the npm package, so the copy below fell through to
|
|
44
|
+
* `raw.githubusercontent.com/.../main`: offline installs failed, an install pulled
|
|
45
|
+
* whatever `main` held that day instead of the version the adopter had installed,
|
|
46
|
+
* and a file that did not exist anywhere (`extensions/locales/zh-cn.md`, XSPEC-451)
|
|
47
|
+
* hid behind that fallback until someone ran the locale. A declared extension that
|
|
48
|
+
* is not in the package is a packaging defect, so it fails — by name.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} sourcePath - Relative path from repo root (e.g., 'extensions/locales/zh-tw.md')
|
|
51
|
+
* @param {string} targetDir - Target directory (usually '.standards')
|
|
52
|
+
* @param {string} projectPath - Project root path
|
|
53
|
+
* @returns {Promise<Object>} Result with success status and copied path
|
|
54
|
+
*/
|
|
55
|
+
export async function copyExtension(sourcePath, targetDir, projectPath) {
|
|
56
|
+
try {
|
|
57
|
+
const source = getSourcePath(sourcePath);
|
|
58
|
+
if (!source) {
|
|
59
|
+
return failure(
|
|
60
|
+
`Extension file not found in the installed package: ${sourcePath} (extension files are never downloaded; reinstall or upgrade universal-dev-standards)`,
|
|
61
|
+
ERROR_CODES.FILE_NOT_FOUND,
|
|
62
|
+
{ sourcePath, targetDir }
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const targetFolder = join(projectPath, targetDir);
|
|
66
|
+
const targetFile = join(targetFolder, basename(sourcePath));
|
|
67
|
+
if (!existsSync(targetFolder)) {
|
|
68
|
+
mkdirSync(targetFolder, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
copyFileSync(source, targetFile);
|
|
71
|
+
return success(targetFile, { source: 'local', sourcePath, targetFile });
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return failure(
|
|
74
|
+
error.message,
|
|
75
|
+
ERROR_CODES.FILE_COPY_FAILED,
|
|
76
|
+
{ sourcePath, targetDir, projectPath }
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
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
|
+
|
|
29
101
|
/**
|
|
30
102
|
* Copy a standard file to target project
|
|
31
103
|
* Falls back to downloading from GitHub if local file not found
|
|
104
|
+
* (except for `extensions/` files, which are package-only — see copyExtension)
|
|
32
105
|
* @param {string} sourcePath - Relative path from repo root (e.g., 'core/anti-hallucination.md')
|
|
33
106
|
* @param {string} targetDir - Target directory (usually '.standards')
|
|
34
107
|
* @param {string} projectPath - Project root path
|
|
35
108
|
* @returns {Promise<Object>} Result with success status and copied path
|
|
36
109
|
*/
|
|
37
110
|
export async function copyStandard(sourcePath, targetDir, projectPath) {
|
|
111
|
+
// Extensions never take the GitHub fallback below, whichever caller got here (XSPEC-452 R2).
|
|
112
|
+
if (isExtensionPath(sourcePath)) {
|
|
113
|
+
return copyExtension(sourcePath, targetDir, projectPath);
|
|
114
|
+
}
|
|
38
115
|
try {
|
|
39
116
|
const targetFolder = join(projectPath, targetDir);
|
|
40
117
|
const targetFile = join(targetFolder, basename(sourcePath));
|
|
@@ -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
|
+
}
|
package/src/utils/git-hooks.js
CHANGED
|
@@ -189,11 +189,15 @@ export function hookRunsUdsCheck(content) {
|
|
|
189
189
|
* - Not installed: say what is missing and how to fix it, exit non-zero. It
|
|
190
190
|
* neither skips the check nor downloads anything.
|
|
191
191
|
*
|
|
192
|
-
* @param {{args?: string}} [opts]
|
|
193
|
-
*
|
|
192
|
+
* @param {{args?: string, hookFile?: string}} [opts]
|
|
193
|
+
* args — extra arguments to keep on the check command
|
|
194
|
+
* (e.g. `--standard checkin-standards`, which older UDS versions wrote).
|
|
195
|
+
* hookFile — the file the block is written into, named in the "blocked" message so
|
|
196
|
+
* it points at the file the adopter actually has (default `.husky/pre-commit`;
|
|
197
|
+
* the native hook passes `.git/hooks/pre-commit`).
|
|
194
198
|
* @returns {string} the block, LF line endings, ending with a newline
|
|
195
199
|
*/
|
|
196
|
-
export function buildPreCommitBlock({ args = '' } = {}) {
|
|
200
|
+
export function buildPreCommitBlock({ args = '', hookFile = '.husky/pre-commit' } = {}) {
|
|
197
201
|
const cmd = args ? `${UDS_BIN_NAME} check ${args}` : `${UDS_BIN_NAME} check`;
|
|
198
202
|
return [
|
|
199
203
|
UDS_HOOK_MARKER,
|
|
@@ -208,7 +212,7 @@ export function buildPreCommitBlock({ args = '' } = {}) {
|
|
|
208
212
|
' fi',
|
|
209
213
|
` echo "[UDS] Pre-commit check cannot run: the UDS CLI (${UDS_BIN_NAME}) is not installed." >&2`,
|
|
210
214
|
` echo "[UDS] Install it: npm install --save-dev ${UDS_BIN_NAME} (or: npm install -g ${UDS_BIN_NAME})" >&2`,
|
|
211
|
-
|
|
215
|
+
` echo "[UDS] This commit is blocked until it is installed, or until you remove this block from ${hookFile}." >&2`,
|
|
212
216
|
' exit 1',
|
|
213
217
|
') || exit $?',
|
|
214
218
|
UDS_HOOK_END_MARKER,
|