universal-dev-standards 6.14.0-beta.2 → 6.14.0-beta.4
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/bundled/ai/standards/ai-response-navigation.ai.yaml +43 -3
- package/bundled/ai/standards/checkin-standards.ai.yaml +25 -6
- package/bundled/ai/standards/open-work-tracking.ai.yaml +4 -1
- package/bundled/ai/standards/pipeline-security-gates.ai.yaml +5 -1
- package/bundled/core/ai-response-navigation.md +128 -12
- package/bundled/core/open-work-tracking.md +1 -1
- package/bundled/extensions/frameworks/fat-free-patterns.md +937 -0
- package/bundled/extensions/languages/csharp-style.md +464 -0
- package/bundled/extensions/languages/php/fat-free-patterns.md +915 -0
- package/bundled/extensions/languages/php/php-style.md +693 -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 +44 -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/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 +44 -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/open-work-tracking.md +3 -3
- 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/package.json +2 -2
- package/src/commands/check.js +9 -0
- package/src/commands/init.js +100 -27
- package/src/commands/uninstall.js +144 -30
- package/src/commands/update.js +62 -3
- package/src/core/install-records.js +191 -0
- package/src/i18n/messages.js +39 -6
- package/src/installers/hooks-installer.js +61 -30
- package/src/installers/integration-installer.js +5 -1
- package/src/installers/standards-installer.js +16 -23
- package/src/reconciler/plan-executor.js +10 -11
- package/src/uninstallers/hook-uninstaller.js +219 -33
- package/src/uninstallers/integration-uninstaller.js +35 -5
- package/src/utils/copier.js +57 -0
- package/src/utils/git-hooks.js +139 -7
- package/src/utils/hasher.js +36 -0
- package/src/utils/integration-generator.js +16 -6
- package/src/utils/legacy-hook-migration.js +112 -0
- package/src/utils/locale.js +19 -0
- package/src/utils/open-work-tracking.mjs +124 -23
- package/standards-registry.json +21 -7
|
@@ -141,6 +141,10 @@ export async function installIntegrations(config, projectPath) {
|
|
|
141
141
|
if (result.success) {
|
|
142
142
|
results.integrations.push(result.path);
|
|
143
143
|
generatedFiles.add(targetFile);
|
|
144
|
+
// Files this run created from nothing (not merged into an existing file);
|
|
145
|
+
// init records them so uninstall can prove they are wholly UDS's. Added only
|
|
146
|
+
// when there is one, so an empty run keeps its original result shape.
|
|
147
|
+
if (result.created) (results.createdFiles ||= []).push(result.path);
|
|
144
148
|
|
|
145
149
|
// Capture integration block hash for tracking UDS content
|
|
146
150
|
if (result.blockHashInfo) {
|
|
@@ -310,7 +314,7 @@ export async function generateUniversalAgentsMd(config, integrationResults, proj
|
|
|
310
314
|
|
|
311
315
|
if (result.success) {
|
|
312
316
|
spinner.succeed(msg.generatedAgentsMd || 'Generated AGENTS.md (universal summary)');
|
|
313
|
-
return { path: result.path, error: null, blockHashInfo: result.blockHashInfo };
|
|
317
|
+
return { path: result.path, error: null, blockHashInfo: result.blockHashInfo, created: result.created };
|
|
314
318
|
} else {
|
|
315
319
|
spinner.warn(msg.couldNotGenerateAgentsMd || 'Could not generate AGENTS.md');
|
|
316
320
|
return { path: null, error: result.error };
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
getOptionSource,
|
|
8
8
|
findOption
|
|
9
9
|
} from '../utils/registry.js';
|
|
10
|
-
import { copyStandard } from '../utils/copier.js';
|
|
10
|
+
import { copyStandard, copyExtension } from '../utils/copier.js';
|
|
11
11
|
import { t } from '../i18n/messages.js';
|
|
12
12
|
import { computeFileHash } from '../utils/hasher.js';
|
|
13
13
|
import { MANIFEST_OPTION_BINDINGS } from '../core/constants.js';
|
|
@@ -115,36 +115,29 @@ export async function installStandards(config, projectPath) {
|
|
|
115
115
|
if (config.languages.length > 0 || config.frameworks.length > 0 || localeExtension) {
|
|
116
116
|
const extSpinner = createSpinner(msg.copyingExtensions).start();
|
|
117
117
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
118
|
+
// One helper, one copy call. Extension files come from the installed package only:
|
|
119
|
+
// a declared extension that is missing from it is an error naming that file, never a
|
|
120
|
+
// download and never a silent skip (XSPEC-452 R2).
|
|
121
|
+
const installExtension = async (sourcePath) => {
|
|
122
|
+
const result = await copyExtension(sourcePath, '.standards', projectPath);
|
|
123
|
+
if (result.success) {
|
|
124
|
+
results.extensions.push(sourcePath);
|
|
125
|
+
} else {
|
|
126
|
+
results.errors.push(`${sourcePath}: ${result.error}`);
|
|
126
127
|
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
for (const lang of config.languages) {
|
|
131
|
+
if (EXTENSION_MAPPINGS[lang]) await installExtension(EXTENSION_MAPPINGS[lang]);
|
|
127
132
|
}
|
|
128
133
|
|
|
129
134
|
for (const fw of config.frameworks) {
|
|
130
|
-
if (EXTENSION_MAPPINGS[fw])
|
|
131
|
-
const result = await copyStandard(EXTENSION_MAPPINGS[fw], '.standards', projectPath);
|
|
132
|
-
if (result.success) {
|
|
133
|
-
results.extensions.push(EXTENSION_MAPPINGS[fw]);
|
|
134
|
-
} else {
|
|
135
|
-
results.errors.push(`${EXTENSION_MAPPINGS[fw]}: ${result.error}`);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
135
|
+
if (EXTENSION_MAPPINGS[fw]) await installExtension(EXTENSION_MAPPINGS[fw]);
|
|
138
136
|
}
|
|
139
137
|
|
|
140
138
|
// Auto-install locale extension based on display language
|
|
141
139
|
if (localeExtension && EXTENSION_MAPPINGS[localeExtension]) {
|
|
142
|
-
|
|
143
|
-
if (result.success) {
|
|
144
|
-
results.extensions.push(EXTENSION_MAPPINGS[localeExtension]);
|
|
145
|
-
} else {
|
|
146
|
-
results.errors.push(`${EXTENSION_MAPPINGS[localeExtension]}: ${result.error}`);
|
|
147
|
-
}
|
|
140
|
+
await installExtension(EXTENSION_MAPPINGS[localeExtension]);
|
|
148
141
|
}
|
|
149
142
|
|
|
150
143
|
extSpinner.succeed(msg.copiedExtensions.replace('{count}', results.extensions.length));
|
|
@@ -249,18 +249,17 @@ async function executeCreateOrUpdate(projectPath, action, manifest) {
|
|
|
249
249
|
let targetDir = '.standards';
|
|
250
250
|
|
|
251
251
|
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.
|
|
252
|
+
// An entry from `manifest.extensions`. It is resolved from the installed
|
|
253
|
+
// package (`bundled/extensions/`, shipped since XSPEC-452) — never from GitHub.
|
|
254
|
+
// `copyStandard` hands any `extensions/` path to `copyExtension`, which fails
|
|
255
|
+
// by name when the package lacks the file, instead of downloading it.
|
|
258
256
|
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
257
|
+
// History: before XSPEC-452 the published package had no `extensions/`, so
|
|
258
|
+
// `sourcePath` was null for every adopter who did not install from a source
|
|
259
|
+
// checkout, and this function answered "No source path available" for a file it
|
|
260
|
+
// could fetch from GitHub. The legacy update path (`update.js`, "Update
|
|
261
|
+
// extensions") was the one that worked — only because it fell back to a
|
|
262
|
+
// download. (XSPEC-343)
|
|
264
263
|
sourceStr = metadata.extensionSource;
|
|
265
264
|
} else if (metadata?.registryEntry) {
|
|
266
265
|
const source = metadata.registryEntry.source;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, rmdirSync } from 'fs';
|
|
2
2
|
import { join, basename } from 'path';
|
|
3
|
+
import { proveUnchanged, RECORD_KINDS, RECORDS_KEY, isRealDirectory } from '../core/install-records.js';
|
|
4
|
+
import { stripUdsHookBlock } from '../utils/git-hooks.js';
|
|
3
5
|
import {
|
|
4
6
|
collectHookConfigs, standardsSourceDir, hooksSourceDir,
|
|
5
7
|
CODEX_HOOK_SCRIPT, GEMINI_HOOK_SCRIPT, AGY_HOOK_SCRIPT,
|
|
@@ -88,7 +90,7 @@ function applyHooksMap(config, nextMap) {
|
|
|
88
90
|
* difference is which file and which key they look at.
|
|
89
91
|
*/
|
|
90
92
|
function uninstallHookConfigFile({ configPath, label, dryRun, deleteEmptyFile }) {
|
|
91
|
-
const result = { removed: [], skipped: [], errors: [] };
|
|
93
|
+
const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
|
|
92
94
|
if (!existsSync(configPath)) return result; // nothing installed here — nothing to report
|
|
93
95
|
|
|
94
96
|
let config;
|
|
@@ -106,18 +108,21 @@ function uninstallHookConfigFile({ configPath, label, dryRun, deleteEmptyFile })
|
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
const entryLabel = `${label} (${removedCount} UDS hook ${removedCount === 1 ? 'entry' : 'entries'})`;
|
|
111
|
+
const updatedConfig = applyHooksMap(config, nextMap);
|
|
112
|
+
const willDeleteFile = deleteEmptyFile && Object.keys(updatedConfig).length === 0;
|
|
113
|
+
// Decided BEFORE the dry-run return: the preview must say the file goes away,
|
|
114
|
+
// and the folder-cleanup step needs to know the folder will be empty.
|
|
109
115
|
if (dryRun) {
|
|
110
|
-
result.removed.push(entryLabel);
|
|
116
|
+
result.removed.push(willDeleteFile ? `${entryLabel}, file removed — created by UDS, now empty` : entryLabel);
|
|
117
|
+
if (willDeleteFile) result.deletedPaths.push(label);
|
|
111
118
|
return result;
|
|
112
119
|
}
|
|
113
120
|
|
|
114
|
-
const updatedConfig = applyHooksMap(config, nextMap);
|
|
115
|
-
const isNowEmpty = Object.keys(updatedConfig).length === 0;
|
|
116
|
-
|
|
117
121
|
try {
|
|
118
|
-
if (
|
|
122
|
+
if (willDeleteFile) {
|
|
119
123
|
unlinkSync(configPath);
|
|
120
124
|
result.removed.push(`${entryLabel}, file removed — created by UDS, now empty`);
|
|
125
|
+
result.deletedPaths.push(label);
|
|
121
126
|
} else {
|
|
122
127
|
writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2) + '\n');
|
|
123
128
|
result.removed.push(entryLabel);
|
|
@@ -186,7 +191,7 @@ export function uninstallAgyHooks(projectPath, options = {}) {
|
|
|
186
191
|
const configPath = join(projectPath, '.agents', 'hooks.json');
|
|
187
192
|
const label = '.agents/hooks.json';
|
|
188
193
|
const dryRun = options.dryRun || false;
|
|
189
|
-
const result = { removed: [], skipped: [], errors: [] };
|
|
194
|
+
const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
|
|
190
195
|
if (!existsSync(configPath)) return result;
|
|
191
196
|
|
|
192
197
|
let config;
|
|
@@ -242,14 +247,17 @@ export function uninstallAgyHooks(projectPath, options = {}) {
|
|
|
242
247
|
}
|
|
243
248
|
|
|
244
249
|
const entryLabel = `${label} (${removedCount} UDS hook ${removedCount === 1 ? 'entry' : 'entries'})`;
|
|
250
|
+
const willDeleteFile = Object.keys(next).length === 0;
|
|
245
251
|
if (dryRun) {
|
|
246
|
-
result.removed.push(entryLabel);
|
|
252
|
+
result.removed.push(willDeleteFile ? `${entryLabel}, file removed — created by UDS, now empty` : entryLabel);
|
|
253
|
+
if (willDeleteFile) result.deletedPaths.push(label);
|
|
247
254
|
return result;
|
|
248
255
|
}
|
|
249
256
|
try {
|
|
250
|
-
if (
|
|
257
|
+
if (willDeleteFile) {
|
|
251
258
|
unlinkSync(configPath);
|
|
252
259
|
result.removed.push(`${entryLabel}, file removed — created by UDS, now empty`);
|
|
260
|
+
result.deletedPaths.push(label);
|
|
253
261
|
} else {
|
|
254
262
|
writeFileSync(configPath, JSON.stringify(next, null, 2) + '\n');
|
|
255
263
|
result.removed.push(entryLabel);
|
|
@@ -260,18 +268,113 @@ export function uninstallAgyHooks(projectPath, options = {}) {
|
|
|
260
268
|
return result;
|
|
261
269
|
}
|
|
262
270
|
|
|
271
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
272
|
+
// Proof of authorship — "only delete what UDS can prove it wrote, unchanged".
|
|
273
|
+
//
|
|
274
|
+
// A record in `manifest.installedArtifacts` (core/install-records.js) says UDS
|
|
275
|
+
// wrote a file; a matching hash says nobody has changed it since. Both are
|
|
276
|
+
// required to delete a whole file. Anything else is KEPT, and the reason is
|
|
277
|
+
// printed, because "not provably ours" is the honest state of a file from an
|
|
278
|
+
// older UDS (no record) or one an adopter has edited (hash differs) — and
|
|
279
|
+
// guessing "probably ours" is how an adopter's own hook script gets deleted.
|
|
280
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
/** Relative paths (forward slashes) of every file under `dir`, or [] if it is not there. */
|
|
283
|
+
function listFiles(dir, rel = '') {
|
|
284
|
+
if (!dir || !existsSync(dir)) return [];
|
|
285
|
+
const out = [];
|
|
286
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
287
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
288
|
+
if (e.isDirectory()) out.push(...listFiles(join(dir, e.name), r));
|
|
289
|
+
else out.push(r);
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Remove the hook scripts UDS copied into `scripts/hooks/`.
|
|
296
|
+
*
|
|
297
|
+
* `scripts/hooks/` is a directory UDS scaffolds inside the adopter's project, so
|
|
298
|
+
* an adopter's own scripts can sit beside ours (a test in hook-uninstaller.test.js
|
|
299
|
+
* places one there and it must survive). Only files that have an install record
|
|
300
|
+
* AND still match it are deleted.
|
|
301
|
+
*/
|
|
302
|
+
export function uninstallHookScripts(projectPath, manifest, { dryRun = false, blockedBy = null } = {}) {
|
|
303
|
+
const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
|
|
304
|
+
const files = manifest?.[RECORDS_KEY]?.files || {};
|
|
305
|
+
const recorded = Object.entries(files)
|
|
306
|
+
.filter(([, rec]) => rec && rec.kind === RECORD_KINDS.HOOK_SCRIPT)
|
|
307
|
+
.map(([rel]) => rel)
|
|
308
|
+
.sort();
|
|
309
|
+
|
|
310
|
+
for (const rel of recorded) {
|
|
311
|
+
const abs = join(projectPath, rel);
|
|
312
|
+
if (!existsSync(abs)) continue; // already gone — nothing to remove, nothing to report
|
|
313
|
+
if (blockedBy) {
|
|
314
|
+
result.skipped.push(`${rel} (kept: ${blockedBy})`);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const proof = proveUnchanged(manifest, projectPath, rel);
|
|
318
|
+
if (proof.state !== 'proven') {
|
|
319
|
+
result.skipped.push(`${rel} (kept: ${proof.why})`);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
if (!dryRun) unlinkSync(abs);
|
|
324
|
+
result.removed.push(`${rel} (deleted — installed by UDS, ${proof.why})`);
|
|
325
|
+
result.deletedPaths.push(rel);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
result.errors.push(`${rel} — ${error.message}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Files that carry the name of a script UDS ships but have no record. Say so once,
|
|
332
|
+
// so a leftover `scripts/hooks/` is an explained decision and not a silent gap.
|
|
333
|
+
const recordedSet = new Set(recorded);
|
|
334
|
+
const shipped = listFiles(hooksSourceDir());
|
|
335
|
+
const unrecorded = shipped
|
|
336
|
+
.map((f) => `scripts/hooks/${f}`)
|
|
337
|
+
.filter((rel) => existsSync(join(projectPath, rel)) && !recordedSet.has(rel));
|
|
338
|
+
if (unrecorded.length > 0) {
|
|
339
|
+
result.skipped.push(
|
|
340
|
+
`scripts/hooks/ (kept: ${unrecorded.length} file${unrecorded.length === 1 ? '' : 's'} carry the name of a UDS hook script, ` +
|
|
341
|
+
'but the manifest has no record that UDS wrote them — installed by an older UDS, or by hand. ' +
|
|
342
|
+
'Delete them yourself if you no longer want them)'
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
return result;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Line-level fallback, for the one-line form older UDS wrote and for a block whose
|
|
349
|
+
// end marker an adopter removed. The current block is removed whole, by its markers
|
|
350
|
+
// (stripUdsHookBlock) — its inner lines are not individually matchable.
|
|
351
|
+
const UDS_PRECOMMIT_LINE = /uds\s+check|universal-dev-standards\s+check|checkin-standards|^#\s*UDS Standard Check\s*$/;
|
|
352
|
+
const NATIVE_UDS_LINE = /uds\s+check|checkin-standards|UDS pre-commit hook/;
|
|
353
|
+
|
|
354
|
+
/** True when nothing is left but a shebang and blank lines. */
|
|
355
|
+
function nothingButShebang(lines) {
|
|
356
|
+
return lines.every((l) => !l.trim() || /^#!/.test(l.trim()));
|
|
357
|
+
}
|
|
358
|
+
|
|
263
359
|
/**
|
|
264
360
|
* Remove UDS-related lines from .husky/pre-commit, the native
|
|
265
361
|
* .git/hooks/pre-commit fallback, and the enforcement-hook entries UDS wrote
|
|
266
362
|
* into .claude/settings.json, .codex/hooks.json, .gemini/settings.json and
|
|
267
|
-
* .agents/hooks.json
|
|
363
|
+
* .agents/hooks.json; and the hook scripts copied into scripts/hooks/.
|
|
364
|
+
*
|
|
365
|
+
* Whole-file deletion (a hook file, a script) needs proof — see proveUnchanged.
|
|
366
|
+
* A hook file that is not provably wholly UDS's still has its UDS lines removed
|
|
367
|
+
* (each is a line UDS wrote, and the rest of the file is untouched) and the
|
|
368
|
+
* remainder is reported as kept, with the reason.
|
|
369
|
+
*
|
|
268
370
|
* @param {string} projectPath - Project root path
|
|
269
|
-
* @param {Object} options - { dryRun: boolean }
|
|
270
|
-
*
|
|
371
|
+
* @param {Object} options - { dryRun: boolean, manifest: object|null }
|
|
372
|
+
* `manifest` must be read BEFORE `.standards/` is removed; it carries the install records.
|
|
373
|
+
* @returns {Object} { removed: string[], skipped: string[], errors: string[], deletedPaths: string[] }
|
|
271
374
|
*/
|
|
272
375
|
export function uninstallHook(projectPath, options = {}) {
|
|
273
|
-
const { dryRun = false } = options;
|
|
274
|
-
const result = { removed: [], skipped: [], errors: [] };
|
|
376
|
+
const { dryRun = false, manifest = null } = options;
|
|
377
|
+
const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
|
|
275
378
|
const hookPath = join(projectPath, '.husky', 'pre-commit');
|
|
276
379
|
|
|
277
380
|
if (!existsSync(hookPath)) {
|
|
@@ -279,12 +382,23 @@ export function uninstallHook(projectPath, options = {}) {
|
|
|
279
382
|
} else {
|
|
280
383
|
try {
|
|
281
384
|
const content = readFileSync(hookPath, 'utf-8');
|
|
385
|
+
const proof = proveUnchanged(manifest, projectPath, '.husky/pre-commit');
|
|
282
386
|
const lines = content.split('\n');
|
|
283
|
-
const
|
|
284
|
-
|
|
387
|
+
const filteredLines = stripUdsHookBlock(content).content.split('\n')
|
|
388
|
+
.filter(line => !UDS_PRECOMMIT_LINE.test(line));
|
|
285
389
|
|
|
286
|
-
if (
|
|
390
|
+
if (proof.state === 'proven') {
|
|
391
|
+
// UDS created this file and nobody has touched it: nothing of the adopter's is in it.
|
|
392
|
+
if (!dryRun) unlinkSync(hookPath);
|
|
393
|
+
result.removed.push(`.husky/pre-commit (deleted — created by UDS, ${proof.why})`);
|
|
394
|
+
result.deletedPaths.push('.husky/pre-commit');
|
|
395
|
+
} else if (filteredLines.length === lines.length) {
|
|
287
396
|
result.skipped.push('.husky/pre-commit (no UDS lines found)');
|
|
397
|
+
} else if (nothingButShebang(filteredLines)) {
|
|
398
|
+
// Only UDS's lines (and a shebang) were in it — removing them leaves nothing worth keeping.
|
|
399
|
+
if (!dryRun) unlinkSync(hookPath);
|
|
400
|
+
result.removed.push('.husky/pre-commit (UDS check lines; file removed — nothing else was in it)');
|
|
401
|
+
result.deletedPaths.push('.husky/pre-commit');
|
|
288
402
|
} else if (dryRun) {
|
|
289
403
|
result.removed.push('.husky/pre-commit (UDS check lines)');
|
|
290
404
|
} else {
|
|
@@ -301,25 +415,32 @@ export function uninstallHook(projectPath, options = {}) {
|
|
|
301
415
|
if (existsSync(nativeHookPath)) {
|
|
302
416
|
try {
|
|
303
417
|
const content = readFileSync(nativeHookPath, 'utf-8');
|
|
304
|
-
const
|
|
418
|
+
const proof = proveUnchanged(manifest, projectPath, '.git/hooks/pre-commit');
|
|
305
419
|
|
|
306
|
-
if (
|
|
420
|
+
if (proof.state === 'proven') {
|
|
421
|
+
// The whole script is what `uds init` wrote — including the parts no line
|
|
422
|
+
// pattern would ever match (its "Auto-generated by uds init" header, the
|
|
423
|
+
// linter fallbacks, the closing echo). Deleting only the matching lines
|
|
424
|
+
// used to leave that body behind, still executable, still claiming
|
|
425
|
+
// "Pre-commit checks passed".
|
|
426
|
+
if (!dryRun) unlinkSync(nativeHookPath);
|
|
427
|
+
result.removed.push(`.git/hooks/pre-commit (UDS native hook, file removed — ${proof.why})`);
|
|
428
|
+
result.deletedPaths.push('.git/hooks/pre-commit');
|
|
429
|
+
} else if (!NATIVE_UDS_LINE.test(content) && !stripUdsHookBlock(content).removed) {
|
|
307
430
|
result.skipped.push('.git/hooks/pre-commit (no UDS lines found)');
|
|
308
|
-
} else if (dryRun) {
|
|
309
|
-
result.removed.push('.git/hooks/pre-commit (UDS native hook)');
|
|
310
431
|
} else {
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
const filtered =
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if (nonEmpty.length === 0) {
|
|
318
|
-
unlinkSync(nativeHookPath);
|
|
432
|
+
// The block the native hook carries is removed whole, by its markers — its
|
|
433
|
+
// inner lines are not individually matchable (same as the husky path above).
|
|
434
|
+
const filtered = stripUdsHookBlock(content).content.split('\n')
|
|
435
|
+
.filter(line => !NATIVE_UDS_LINE.test(line));
|
|
436
|
+
if (nothingButShebang(filtered)) {
|
|
437
|
+
if (!dryRun) unlinkSync(nativeHookPath);
|
|
319
438
|
result.removed.push('.git/hooks/pre-commit (UDS native hook, file removed)');
|
|
439
|
+
result.deletedPaths.push('.git/hooks/pre-commit');
|
|
320
440
|
} else {
|
|
321
|
-
writeFileSync(nativeHookPath, filtered.join('\n'), 'utf-8');
|
|
441
|
+
if (!dryRun) writeFileSync(nativeHookPath, filtered.join('\n'), 'utf-8');
|
|
322
442
|
result.removed.push('.git/hooks/pre-commit (UDS lines removed)');
|
|
443
|
+
result.skipped.push(`.git/hooks/pre-commit (kept: the rest of the script — ${proof.why})`);
|
|
323
444
|
}
|
|
324
445
|
}
|
|
325
446
|
} catch (error) {
|
|
@@ -329,16 +450,81 @@ export function uninstallHook(projectPath, options = {}) {
|
|
|
329
450
|
|
|
330
451
|
// Enforcement hooks written by installHooks()/installCodexHooks()/installGeminiHooks()
|
|
331
452
|
// — the gap this function used to have entirely (see module doc comment above).
|
|
332
|
-
|
|
453
|
+
const configResults = [
|
|
333
454
|
uninstallClaudeCodeHooks(projectPath, { dryRun }),
|
|
334
455
|
uninstallCodexHooks(projectPath, { dryRun }),
|
|
335
456
|
uninstallGeminiHooks(projectPath, { dryRun }),
|
|
336
457
|
uninstallAgyHooks(projectPath, { dryRun }),
|
|
337
|
-
]
|
|
458
|
+
];
|
|
459
|
+
for (const sub of configResults) {
|
|
338
460
|
result.removed.push(...sub.removed);
|
|
339
461
|
result.skipped.push(...sub.skipped);
|
|
340
462
|
result.errors.push(...sub.errors);
|
|
463
|
+
result.deletedPaths.push(...sub.deletedPaths);
|
|
341
464
|
}
|
|
342
465
|
|
|
466
|
+
// The scripts those entries ran. If a config file could not be cleaned (it still
|
|
467
|
+
// names a script), deleting the script would turn a working hook into a failing one.
|
|
468
|
+
const configFailed = configResults.some((r) => r.errors.length > 0);
|
|
469
|
+
const scripts = uninstallHookScripts(projectPath, manifest, {
|
|
470
|
+
dryRun,
|
|
471
|
+
blockedBy: configFailed ? 'a hook config file could not be cleaned (see the error above) and still runs this script' : null
|
|
472
|
+
});
|
|
473
|
+
result.removed.push(...scripts.removed);
|
|
474
|
+
result.skipped.push(...scripts.skipped);
|
|
475
|
+
result.errors.push(...scripts.errors);
|
|
476
|
+
result.deletedPaths.push(...scripts.deletedPaths);
|
|
477
|
+
|
|
478
|
+
return result;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Remove the folders UDS had to create for its files (`.codex/`, `scripts/hooks/`, ...),
|
|
483
|
+
* once they are empty.
|
|
484
|
+
*
|
|
485
|
+
* A folder is removed only if (a) the manifest recorded that UDS created it and
|
|
486
|
+
* (b) nothing is left in it. An empty `.codex/` that the adopter made themselves is
|
|
487
|
+
* indistinguishable by looking; the record is the only thing that tells them apart.
|
|
488
|
+
* A folder that still holds anything — `.agents/rules/style.md` — is kept and
|
|
489
|
+
* reported, so the adopter sees why it stayed.
|
|
490
|
+
*
|
|
491
|
+
* In a dry run nothing has been deleted yet, so `plannedDeletions` (relative paths
|
|
492
|
+
* the run WOULD delete) stands in for the missing deletions when judging "empty".
|
|
493
|
+
*
|
|
494
|
+
* @param {string} projectPath
|
|
495
|
+
* @param {Object|null} manifest - read before `.standards/` was removed
|
|
496
|
+
* @param {{ dryRun?: boolean, plannedDeletions?: string[] }} [options]
|
|
497
|
+
* @returns {{ removed: string[], skipped: string[], errors: string[], deletedPaths: string[] }}
|
|
498
|
+
*/
|
|
499
|
+
export function pruneCreatedDirs(projectPath, manifest, { dryRun = false, plannedDeletions = [] } = {}) {
|
|
500
|
+
const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
|
|
501
|
+
const dirs = manifest?.[RECORDS_KEY]?.createdDirs || [];
|
|
502
|
+
const gone = new Set(plannedDeletions.map((p) => p.replace(/\\/g, '/')));
|
|
503
|
+
// Deepest first: `scripts/hooks` must go before `scripts` can be seen as empty.
|
|
504
|
+
const ordered = [...new Set(dirs)]
|
|
505
|
+
.filter((d) => d && !d.startsWith('..') && !d.startsWith('/'))
|
|
506
|
+
.sort((a, b) => b.split('/').length - a.split('/').length);
|
|
507
|
+
|
|
508
|
+
for (const rel of ordered) {
|
|
509
|
+
const abs = join(projectPath, rel);
|
|
510
|
+
if (!existsSync(abs)) continue;
|
|
511
|
+
if (!isRealDirectory(abs)) {
|
|
512
|
+
result.skipped.push(`${rel}/ (kept: not a plain directory)`);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
try {
|
|
516
|
+
const left = readdirSync(abs).filter((name) => !gone.has(`${rel}/${name}`));
|
|
517
|
+
if (left.length > 0) {
|
|
518
|
+
result.skipped.push(`${rel}/ (kept: created by UDS but not empty — still holds ${left.slice(0, 3).join(', ')}${left.length > 3 ? ', ...' : ''}, which UDS did not write)`);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (!dryRun) rmdirSync(abs);
|
|
522
|
+
gone.add(rel);
|
|
523
|
+
result.removed.push(`${rel}/ (empty folder created by UDS, removed)`);
|
|
524
|
+
result.deletedPaths.push(rel);
|
|
525
|
+
} catch (error) {
|
|
526
|
+
result.errors.push(`${rel}/ — ${error.message}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
343
529
|
return result;
|
|
344
530
|
}
|
|
@@ -3,6 +3,7 @@ import { join } from 'path';
|
|
|
3
3
|
import { extractMarkedContent } from '../utils/integration-generator.js';
|
|
4
4
|
import { SUPPORTED_AI_TOOLS } from '../core/constants.js';
|
|
5
5
|
import { resolveIntegrationFile } from '../core/constants.js';
|
|
6
|
+
import { proveUnchanged } from '../core/install-records.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Get the format for a given integration file name
|
|
@@ -29,7 +30,7 @@ function getFormatForFile(fileName) {
|
|
|
29
30
|
*/
|
|
30
31
|
export async function uninstallIntegrations(projectPath, manifest, options = {}) {
|
|
31
32
|
const { dryRun = false, interactive = false, promptFn = null } = options;
|
|
32
|
-
const result = { removed: [], skipped: [], errors: [] };
|
|
33
|
+
const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
|
|
33
34
|
|
|
34
35
|
const integrations = manifest?.integrations || [];
|
|
35
36
|
if (integrations.length === 0) {
|
|
@@ -64,11 +65,27 @@ export async function uninstallIntegrations(projectPath, manifest, options = {})
|
|
|
64
65
|
const userContent = (parts.before.trim() + parts.after.trim()).trim();
|
|
65
66
|
const hasUserContent = userContent.length > 0;
|
|
66
67
|
|
|
68
|
+
// Text outside the UDS block is not automatically the adopter's. A file UDS
|
|
69
|
+
// creates from nothing carries generated text there — AGENTS.md's
|
|
70
|
+
// "> Auto-generated by ..." header and sections, CLAUDE.md's leading guidance —
|
|
71
|
+
// and treating all of it as "user content" left an orphaned file pointing at
|
|
72
|
+
// the `.standards/` this very command removes. It is only "not the adopter's"
|
|
73
|
+
// if UDS recorded a hash of it when it created the file and the text still
|
|
74
|
+
// matches; without that proof it stays, and the reason is printed.
|
|
75
|
+
const proof = hasUserContent ? proveUnchanged(manifest, projectPath, fileName) : null;
|
|
76
|
+
const outsideIsUdsOwn = hasUserContent && proof.state === 'proven';
|
|
77
|
+
const keptNote = () => `${fileName} (kept: the text outside the UDS block — ${proof.why})`;
|
|
78
|
+
|
|
67
79
|
if (dryRun) {
|
|
68
|
-
if (hasUserContent) {
|
|
69
|
-
result.removed.push(`${fileName} (remove UDS block, keep user content)`);
|
|
70
|
-
} else {
|
|
80
|
+
if (!hasUserContent) {
|
|
71
81
|
result.removed.push(`${fileName} (delete file)`);
|
|
82
|
+
result.deletedPaths.push(fileName);
|
|
83
|
+
} else if (outsideIsUdsOwn) {
|
|
84
|
+
result.removed.push(`${fileName} (delete file — everything outside the UDS block was generated by UDS)`);
|
|
85
|
+
result.deletedPaths.push(fileName);
|
|
86
|
+
} else {
|
|
87
|
+
result.removed.push(`${fileName} (remove UDS block, keep user content)`);
|
|
88
|
+
result.skipped.push(keptNote());
|
|
72
89
|
}
|
|
73
90
|
continue;
|
|
74
91
|
}
|
|
@@ -77,26 +94,39 @@ export async function uninstallIntegrations(projectPath, manifest, options = {})
|
|
|
77
94
|
// 100% UDS-generated → delete entire file
|
|
78
95
|
unlinkSync(filePath);
|
|
79
96
|
result.removed.push(`${fileName} (deleted)`);
|
|
97
|
+
result.deletedPaths.push(fileName);
|
|
98
|
+
} else if (outsideIsUdsOwn) {
|
|
99
|
+
// UDS created this file and its non-block text is still exactly what UDS
|
|
100
|
+
// wrote: there is no adopter content to preserve, so no question to ask.
|
|
101
|
+
unlinkSync(filePath);
|
|
102
|
+
result.removed.push(`${fileName} (deleted — everything outside the UDS block was generated by UDS and is unchanged)`);
|
|
103
|
+
result.deletedPaths.push(fileName);
|
|
80
104
|
} else if (interactive && promptFn) {
|
|
81
105
|
// Ask user what to do
|
|
82
106
|
const action = await promptFn(fileName, hasUserContent);
|
|
83
107
|
if (action === 'delete-file') {
|
|
84
108
|
unlinkSync(filePath);
|
|
85
109
|
result.removed.push(`${fileName} (deleted)`);
|
|
110
|
+
result.deletedPaths.push(fileName);
|
|
86
111
|
} else if (action === 'remove-block') {
|
|
87
112
|
const cleaned = (parts.before + parts.after).trim() + '\n';
|
|
88
113
|
writeFileSync(filePath, cleaned, 'utf-8');
|
|
89
114
|
result.removed.push(`${fileName} (UDS block removed)`);
|
|
115
|
+
result.skipped.push(keptNote());
|
|
90
116
|
} else {
|
|
91
117
|
result.skipped.push(`${fileName} (user skipped)`);
|
|
92
118
|
}
|
|
93
119
|
} else {
|
|
94
|
-
// Non-interactive (--yes): remove UDS block only, preserve
|
|
120
|
+
// Non-interactive (--yes): remove UDS block only, preserve everything else
|
|
95
121
|
const cleaned = (parts.before + parts.after).trim() + '\n';
|
|
96
122
|
writeFileSync(filePath, cleaned, 'utf-8');
|
|
97
123
|
result.removed.push(`${fileName} (UDS block removed)`);
|
|
124
|
+
result.skipped.push(keptNote());
|
|
98
125
|
}
|
|
99
126
|
} catch (error) {
|
|
127
|
+
// A closed prompt is the user stopping the run, not a failure of this file:
|
|
128
|
+
// let the command report it (and exit non-zero) instead of retrying the next file.
|
|
129
|
+
if (error?.name === 'ExitPromptError') throw error;
|
|
100
130
|
result.errors.push(`${fileName} — ${error.message}`);
|
|
101
131
|
}
|
|
102
132
|
}
|
package/src/utils/copier.js
CHANGED
|
@@ -26,15 +26,72 @@ 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
|
+
|
|
29
81
|
/**
|
|
30
82
|
* Copy a standard file to target project
|
|
31
83
|
* Falls back to downloading from GitHub if local file not found
|
|
84
|
+
* (except for `extensions/` files, which are package-only — see copyExtension)
|
|
32
85
|
* @param {string} sourcePath - Relative path from repo root (e.g., 'core/anti-hallucination.md')
|
|
33
86
|
* @param {string} targetDir - Target directory (usually '.standards')
|
|
34
87
|
* @param {string} projectPath - Project root path
|
|
35
88
|
* @returns {Promise<Object>} Result with success status and copied path
|
|
36
89
|
*/
|
|
37
90
|
export async function copyStandard(sourcePath, targetDir, projectPath) {
|
|
91
|
+
// Extensions never take the GitHub fallback below, whichever caller got here (XSPEC-452 R2).
|
|
92
|
+
if (isExtensionPath(sourcePath)) {
|
|
93
|
+
return copyExtension(sourcePath, targetDir, projectPath);
|
|
94
|
+
}
|
|
38
95
|
try {
|
|
39
96
|
const targetFolder = join(projectPath, targetDir);
|
|
40
97
|
const targetFile = join(targetFolder, basename(sourcePath));
|