universal-dev-standards 6.10.0 → 6.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/bin/uds.js +2 -0
  2. package/bundled/locales/zh-CN/CHANGELOG.md +23 -3
  3. package/bundled/locales/zh-CN/README.md +1 -1
  4. package/bundled/locales/zh-CN/SECURITY.md +1 -1
  5. package/bundled/locales/zh-CN/docs/CHEATSHEET.md +1 -1
  6. package/bundled/locales/zh-CN/docs/CLI-INIT-OPTIONS.md +52 -5
  7. package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +3 -1
  8. package/bundled/locales/zh-TW/CHANGELOG.md +23 -3
  9. package/bundled/locales/zh-TW/README.md +1 -1
  10. package/bundled/locales/zh-TW/SECURITY.md +1 -1
  11. package/bundled/locales/zh-TW/docs/CHEATSHEET.md +1 -1
  12. package/bundled/locales/zh-TW/docs/CLI-INIT-OPTIONS.md +52 -5
  13. package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +3 -1
  14. package/bundled/locales/zh-TW/integrations/claude-code/README.md +14 -5
  15. package/package.json +1 -1
  16. package/src/commands/check.js +253 -21
  17. package/src/commands/config.js +15 -9
  18. package/src/commands/init.js +24 -3
  19. package/src/commands/update.js +311 -47
  20. package/src/core/manifest.js +39 -1
  21. package/src/flows/init-flow.js +9 -1
  22. package/src/generators/layered-claudemd.js +13 -4
  23. package/src/i18n/messages.js +3 -3
  24. package/src/installers/integration-installer.js +13 -6
  25. package/src/installers/manifest-installer.js +4 -0
  26. package/src/reconciler/actual-state-scanner.js +29 -2
  27. package/src/reconciler/desired-state-calculator.js +51 -2
  28. package/src/reconciler/diff-engine.js +19 -3
  29. package/src/reconciler/plan-executor.js +17 -16
  30. package/src/utils/hasher.js +61 -5
  31. package/src/utils/integration-generator.js +239 -28
  32. package/src/utils/marker-locator.js +140 -0
  33. package/src/utils/reference-sync.js +53 -1
  34. package/standards-registry.json +7 -7
@@ -3,8 +3,10 @@ import { dirname, join, basename } from 'path';
3
3
  import { getLanguageRules } from '../prompts/integrations.js';
4
4
  import { computeIntegrationBlockHash } from './hasher.js';
5
5
  import { UDS_MARKERS, SUPPORTED_AI_TOOLS, LEGACY_TOOL_MAPPINGS } from '../core/constants.js';
6
+ import { locateMarkerBlock } from './marker-locator.js';
6
7
  import { resolveSelectedOptionSources, resolveStandardFilename, getAllStandards } from './registry.js';
7
8
  import { getAgentConfig, getAgentTier } from '../config/ai-agent-paths.js';
9
+ import { STANDARD_ID_MAPPING } from './conversion-rules.js';
8
10
 
9
11
  /**
10
12
  * Resolve contentMode and level based on AI tool's tier and capabilities.
@@ -2746,6 +2748,81 @@ function generateWorkflowGateContent(language) {
2746
2748
  * @param {Object} config - Integration configuration
2747
2749
  * @returns {string} Generated content
2748
2750
  */
2751
+ /**
2752
+ * Strip a standard's extension to get its bare identifying stem, e.g.
2753
+ * 'commit-message.ai.yaml' -> 'commit-message'. Shared by
2754
+ * resolveStandardReferences and getHeadingToPrimaryStandardStem below.
2755
+ */
2756
+ function stemOf(value) {
2757
+ return basename(String(value)).replace(/\.(ai\.yaml|yaml|md)$/i, '');
2758
+ }
2759
+
2760
+ /**
2761
+ * Normalize a Markdown ATX heading line for comparison, e.g.
2762
+ * '## 提交訊息標準 ' -> '提交訊息標準'. Returns null for a non-heading line.
2763
+ */
2764
+ function normalizeHeadingLine(line) {
2765
+ const m = String(line).match(/^#{1,6}\s+(.+?)\s*$/);
2766
+ return m ? m[1].trim() : null;
2767
+ }
2768
+
2769
+ let _headingToPrimaryStandardStem = null;
2770
+
2771
+ /**
2772
+ * Map every RULE_TEMPLATES section heading (any category, detail level,
2773
+ * language that has a Reference:/參考: line) to the stem of the FIRST
2774
+ * `.standards/...` path on that line — that section's "primary standard".
2775
+ *
2776
+ * XSPEC adopter-report, narrow auto-repair (decision 1, 2026-09-18):
2777
+ * 6.10.0's resolveStandardReferences deleted `.standards/commit-message-guide.md`
2778
+ * from an existing file's "## 提交訊息標準" section — it looked exactly like a
2779
+ * retired, uninstalled UDS standard — leaving only the trailing options
2780
+ * reference behind. Rewriting away the resulting dangling comma (the earlier
2781
+ * fix, same XSPEC) does not bring the deleted item back: it is gone from the
2782
+ * file, and nothing else regenerates that section, because rule-template
2783
+ * sections live OUTSIDE the UDS markers by design.
2784
+ *
2785
+ * This map is how resolveStandardReferences recognizes "this heading belongs
2786
+ * to a UDS rule template, and here is the one reference it must never be
2787
+ * missing" — so it can restore ONLY that single item, ONLY when it is
2788
+ * genuinely installed and missing from the line. A heading that does not
2789
+ * match any template (a user's own section) is simply absent from this map,
2790
+ * so it is never touched.
2791
+ *
2792
+ * Computed once and cached — RULE_TEMPLATES is a static module constant.
2793
+ *
2794
+ * @returns {Map<string, string>} heading text (without leading '#'s) -> primary standard stem
2795
+ */
2796
+ function getHeadingToPrimaryStandardStem() {
2797
+ if (_headingToPrimaryStandardStem) return _headingToPrimaryStandardStem;
2798
+
2799
+ const map = new Map();
2800
+ const refLineRe = /^(?:Reference|參考|参考)[::]([^\n]*)$/im;
2801
+
2802
+ for (const categoryTemplates of Object.values(RULE_TEMPLATES)) {
2803
+ for (const modeTemplates of Object.values(categoryTemplates)) {
2804
+ for (const text of Object.values(modeTemplates)) {
2805
+ const heading = normalizeHeadingLine(String(text).split('\n')[0]);
2806
+ if (!heading) continue;
2807
+
2808
+ // 'minimal' mode has no Reference: line at all — nothing to repair,
2809
+ // and no primary standard to record for that heading.
2810
+ const refMatch = text.match(refLineRe);
2811
+ if (!refMatch) continue;
2812
+
2813
+ const firstItem = refMatch[1].split(',')[0].trim();
2814
+ const pathMatch = firstItem.match(/^\.standards\/([^\s,`)\]]+)$/);
2815
+ if (!pathMatch) continue;
2816
+
2817
+ map.set(heading, stemOf(pathMatch[1]));
2818
+ }
2819
+ }
2820
+ }
2821
+
2822
+ _headingToPrimaryStandardStem = map;
2823
+ return map;
2824
+ }
2825
+
2749
2826
  /**
2750
2827
  * Point every `.standards/<file>` reference at the file this project actually has.
2751
2828
  *
@@ -2769,7 +2846,6 @@ function generateWorkflowGateContent(language) {
2769
2846
  function resolveStandardReferences(content, installedStandards = [], standardsFormat = 'ai', projectPath = null) {
2770
2847
  if (!content) return content;
2771
2848
 
2772
- const stemOf = (value) => basename(String(value)).replace(/\.(ai\.yaml|yaml|md)$/i, '');
2773
2849
  // Every stem UDS itself ships — the only references this function may delete.
2774
2850
  //
2775
2851
  // Read defensively: this runs inside pure content generation, and callers
@@ -2796,15 +2872,68 @@ function resolveStandardReferences(content, installedStandards = [], standardsFo
2796
2872
  installedByStem.set(stemOf(filename), filename);
2797
2873
  }
2798
2874
 
2799
- const referenceLine = /^([^\n]*(?:Reference|參考|参考)[::])([^\n]*)$/gim;
2800
- return content.replace(referenceLine, (line, label, rest) => {
2801
- let dropped = 0;
2802
- let kept = 0;
2803
- const rewritten = rest.replace(/(\.standards\/)([^\s,`)\]]+)/g, (match, prefix, path) => {
2875
+ // XSPEC adopter-report Q2: a reference written against a pre-6.0.0
2876
+ // filename (`commit-message-guide.md`) must still resolve to whatever this
2877
+ // project installed under the CURRENT id (`commit-message`) — that rename
2878
+ // is exactly what STANDARD_ID_MAPPING already records, previously
2879
+ // consulted only by the human→AI YAML generator. Without it, the old name
2880
+ // was indistinguishable from a retired, UDS-owned standard the project
2881
+ // chose not to install, and got deleted instead of rewritten.
2882
+ const resolveActualFilename = (path) => {
2883
+ const stem = stemOf(path);
2884
+ return installedByStem.get(stem) || installedByStem.get(STANDARD_ID_MAPPING[stem] || stem) || null;
2885
+ };
2886
+
2887
+ const headingToPrimaryStem = getHeadingToPrimaryStandardStem();
2888
+ const headingLineRe = /^#{1,6}\s+/;
2889
+ const referenceLineRe = /^([^\n]*(?:Reference|參考|参考)[::])([^\n]*)$/i;
2890
+
2891
+ // XSPEC adopter-report Q2: rebuilt as a line-by-line split → filter → join
2892
+ // instead of a single global regex, so each "Reference:" line's PRECEDING
2893
+ // heading is known (needed for decision 1's narrow auto-repair below) —
2894
+ // and instead of patching a deleted substring's separators with more
2895
+ // regexes. The old single-regex approach deleted only the matched
2896
+ // `.standards/...` text in place and then tried to tidy up whatever
2897
+ // punctuation was left around the hole; it handled a trailing comma, a
2898
+ // doubled comma, and a comma with stray space, but not a comma left
2899
+ // dangling right after the colon when the FIRST item was the one dropped
2900
+ // (`Reference:, .standards/other.md`).
2901
+ const lines = content.split('\n');
2902
+ let currentHeading = null;
2903
+ const outLines = [];
2904
+
2905
+ for (const rawLine of lines) {
2906
+ if (headingLineRe.test(rawLine)) {
2907
+ currentHeading = normalizeHeadingLine(rawLine);
2908
+ outLines.push(rawLine);
2909
+ continue;
2910
+ }
2911
+
2912
+ const refMatch = rawLine.match(referenceLineRe);
2913
+ if (!refMatch) {
2914
+ outLines.push(rawLine);
2915
+ continue;
2916
+ }
2917
+
2918
+ const [, label, rest] = refMatch;
2919
+ const items = rest.split(',').map((s) => s.trim()).filter(Boolean);
2920
+ const kept = [];
2921
+ const presentStems = new Set();
2922
+
2923
+ for (const item of items) {
2924
+ const pathMatch = item.match(/^(\.standards\/)([^\s,`)\]]+)$/);
2925
+ if (!pathMatch) {
2926
+ // Not a bare `.standards/...` reference (e.g. trailing prose) — leave untouched.
2927
+ kept.push(item);
2928
+ continue;
2929
+ }
2930
+ const [, prefix, path] = pathMatch;
2931
+
2804
2932
  // Option files live in `.standards/options/…` and are named by path, not ID.
2805
- if (path.startsWith('options/')) { kept++; return match; }
2806
- const actual = installedByStem.get(stemOf(path));
2807
- if (actual) { kept++; return `${prefix}${actual}`; }
2933
+ if (path.startsWith('options/')) { kept.push(item); presentStems.add(stemOf(path)); continue; }
2934
+
2935
+ const actual = resolveActualFilename(path);
2936
+ if (actual) { kept.push(`${prefix}${actual}`); presentStems.add(stemOf(actual)); continue; }
2808
2937
 
2809
2938
  // 🔴 Only UDS's own standards may be dropped. The first version of this
2810
2939
  // rewrite removed every unmatched reference, which deleted a project's own
@@ -2816,14 +2945,38 @@ function resolveStandardReferences(content, installedStandards = [], standardsFo
2816
2945
  } catch {
2817
2946
  onDisk = false;
2818
2947
  }
2819
- if (onDisk || !udsOwnedStems.has(stemOf(path))) { kept++; return match; }
2820
- dropped++;
2821
- return '';
2822
- });
2823
- if (kept === 0 && dropped > 0) return '';
2824
- // Tidy the separators left behind by a dropped path.
2825
- return `${label}${rewritten.replace(/,\s*,/g, ',').replace(/[::]?\s*,\s*$/, '').replace(/\s+,/g, ',')}`;
2826
- }).replace(/\n{3,}/g, '\n\n');
2948
+ if (onDisk || !udsOwnedStems.has(stemOf(path))) {
2949
+ kept.push(item);
2950
+ presentStems.add(stemOf(path));
2951
+ continue;
2952
+ }
2953
+ // Dropped: a UDS-owned reference to a standard this project did not install.
2954
+ }
2955
+
2956
+ // XSPEC adopter-report, narrow auto-repair (decision 1): this line's
2957
+ // heading matches a UDS rule template EXACTLY (see
2958
+ // getHeadingToPrimaryStandardStem's docblock) and that template's
2959
+ // primary standard is genuinely installed but missing from the line —
2960
+ // put it back at the front. Idempotent: a line that already has it does
2961
+ // nothing. Everything else about the line — the project's own entries,
2962
+ // options files, existing order — is untouched, and no OTHER (secondary)
2963
+ // template item is ever added back.
2964
+ const primaryStem = currentHeading ? headingToPrimaryStem.get(currentHeading) : null;
2965
+ if (primaryStem && !presentStems.has(primaryStem)) {
2966
+ const primaryActual = installedByStem.get(primaryStem);
2967
+ if (primaryActual) {
2968
+ kept.unshift(`.standards/${primaryActual}`);
2969
+ }
2970
+ }
2971
+
2972
+ if (kept.length === 0) {
2973
+ outLines.push('');
2974
+ continue;
2975
+ }
2976
+ outLines.push(`${label} ${kept.join(', ')}`);
2977
+ }
2978
+
2979
+ return outLines.join('\n').replace(/\n{3,}/g, '\n\n');
2827
2980
  }
2828
2981
 
2829
2982
  export function generateIntegrationContent(config) {
@@ -3030,7 +3183,10 @@ export function mergeRules(existingContent, newContent, strategy) {
3030
3183
  * @returns {Object} Result with success status
3031
3184
  */
3032
3185
  export function writeIntegrationFile(tool, config, projectPath) {
3033
- const fileName = getToolFileName(tool);
3186
+ // XSPEC-418 R2/R3: config.integrationTargets carries manifest.integrationTargets
3187
+ // (or its init-time equivalent) through unchanged — this is the actual write path,
3188
+ // so it must be the one place that honors a per-tool target override.
3189
+ const fileName = resolveIntegrationTargetFile(tool, { integrationTargets: config.integrationTargets });
3034
3190
  if (!fileName) {
3035
3191
  return { success: false, error: `Unknown tool: ${tool}` };
3036
3192
  }
@@ -3097,10 +3253,13 @@ export function writeIntegrationFile(tool, config, projectPath) {
3097
3253
  * Check if integration file exists
3098
3254
  * @param {string} tool - Tool name
3099
3255
  * @param {string} projectPath - Project root path
3256
+ * @param {Object} [manifestLike] - Manifest-like object; only `integrationTargets` is
3257
+ * read (XSPEC-418 R2) — pass the real manifest, or `{ integrationTargets }` at
3258
+ * init time before a manifest object exists.
3100
3259
  * @returns {boolean} True if file exists
3101
3260
  */
3102
- export function integrationFileExists(tool, projectPath) {
3103
- const fileName = getToolFileName(tool);
3261
+ export function integrationFileExists(tool, projectPath, manifestLike) {
3262
+ const fileName = resolveIntegrationTargetFile(tool, manifestLike);
3104
3263
  return fileName && existsSync(join(projectPath, fileName));
3105
3264
  }
3106
3265
 
@@ -3334,7 +3493,12 @@ export function buildToolIntegrationConfig(manifest, tool) {
3334
3493
  contentMode: resolved.contentMode,
3335
3494
  level: resolved.level,
3336
3495
  outputLanguage: selected,
3337
- methodology: manifest.methodology
3496
+ methodology: manifest.methodology,
3497
+ // XSPEC-418 R2/R3: threads the per-tool target override through to
3498
+ // writeIntegrationFile via resolveIntegrationTargetFile. Every caller of
3499
+ // this function (regenerateIntegrations, plan-executor's migrate_block,
3500
+ // backfillIntegrationConfigs) gets the override for free.
3501
+ integrationTargets: manifest.integrationTargets
3338
3502
  };
3339
3503
  }
3340
3504
 
@@ -3394,13 +3558,18 @@ export function wrapWithMarkers(content, format) {
3394
3558
  */
3395
3559
  export function extractMarkedContent(fileContent, format) {
3396
3560
  const markers = UDS_MARKERS[format] || UDS_MARKERS.markdown;
3397
- const startIdx = fileContent.indexOf(markers.start);
3398
- const endIdx = fileContent.indexOf(markers.end);
3399
-
3400
- if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
3561
+ // XSPEC adopter-report Q5: locateMarkerBlock requires the marker to occupy
3562
+ // a whole line by itself (and not be inside a fenced code block), so a
3563
+ // sentence that merely mentions the marker text is never mistaken for the
3564
+ // real boundary. It throws AmbiguousMarkerError if more than one real pair
3565
+ // exists — callers must not catch that away silently.
3566
+ const block = locateMarkerBlock(fileContent, markers);
3567
+
3568
+ if (!block) {
3401
3569
  return { before: fileContent, content: '', after: '' };
3402
3570
  }
3403
3571
 
3572
+ const { startIdx, endIdx } = block;
3404
3573
  return {
3405
3574
  before: fileContent.substring(0, startIdx),
3406
3575
  content: fileContent.substring(startIdx + markers.start.length, endIdx).trim(),
@@ -3417,15 +3586,18 @@ export function extractMarkedContent(fileContent, format) {
3417
3586
  */
3418
3587
  export function updateMarkedSection(existingContent, newMarkedContent, format) {
3419
3588
  const markers = UDS_MARKERS[format] || UDS_MARKERS.markdown;
3420
- const startIdx = existingContent.indexOf(markers.start);
3421
- const endIdx = existingContent.indexOf(markers.end);
3589
+ // XSPEC adopter-report Q5: same rule as extractMarkedContent — a line that
3590
+ // merely mentions the marker text is not a boundary, so it is never
3591
+ // deleted along with (what used to be mistaken for) "the UDS block".
3592
+ const block = locateMarkerBlock(existingContent, markers);
3422
3593
 
3423
- if (startIdx === -1 || endIdx === -1) {
3594
+ if (!block) {
3424
3595
  // No existing markers, append new content
3425
3596
  return existingContent.trim() + '\n\n' + wrapWithMarkers(newMarkedContent, format) + '\n';
3426
3597
  }
3427
3598
 
3428
3599
  // Replace existing marked section
3600
+ const { startIdx, endIdx } = block;
3429
3601
  const before = existingContent.substring(0, startIdx);
3430
3602
  const after = existingContent.substring(endIdx + markers.end.length);
3431
3603
 
@@ -3441,6 +3613,45 @@ export function getToolFilePath(tool) {
3441
3613
  return getToolFileName(tool) || null;
3442
3614
  }
3443
3615
 
3616
+ /**
3617
+ * Tool × manifest → the integration file this tool actually reads/writes.
3618
+ *
3619
+ * XSPEC-418 R2: honors a per-tool override recorded in
3620
+ * `manifest.integrationTargets` (currently only ever set for `claude-code`, to
3621
+ * `CLAUDE.local.md` — a personal UDS adoption in a repo with a team-owned,
3622
+ * version-controlled `CLAUDE.md`). No override set → same file
3623
+ * `getToolFilePath` already returns, so every manifest that never sets
3624
+ * `integrationTargets` gets byte-identical output (XSPEC-418 AC-5).
3625
+ *
3626
+ * This is the single place "tool → target file" is decided once a manifest (or
3627
+ * a manifest-shaped config carrying `integrationTargets`) is in scope. Every
3628
+ * call site that used to call `getToolFilePath`/`getToolFileName` to answer
3629
+ * "what file do I read/write for this tool right now" must call this instead
3630
+ * — enforced by the static-scan guard in
3631
+ * tests/unit/core/integration-target-resolver-guard.test.js.
3632
+ *
3633
+ * Placed here rather than core/constants.js, where XSPEC-418 originally
3634
+ * suggested it next to `resolveIntegrationFile`: the default-file fallback
3635
+ * must be the `getToolFileName` above — the one with the legacy-mapping guard,
3636
+ * the already-a-filename passthrough, and the "known agent missing from
3637
+ * SUPPORTED_AI_TOOLS" throw (see its own comment) — not a re-implementation in
3638
+ * constants.js, which would silently drop that throw for every call site this
3639
+ * migrates. constants.js is imported BY this file already, so this file
3640
+ * cannot be imported back from constants.js without a cycle; it lives beside
3641
+ * the function it wraps instead.
3642
+ *
3643
+ * @param {string} tool - Tool key (e.g. 'claude-code'), or an entry already
3644
+ * resolved to a file path — passed straight to `getToolFileName`, which
3645
+ * already tolerates that shape (XSPEC-208 BUG-208-01).
3646
+ * @param {Object} [manifest] - Manifest-like object; only `integrationTargets` is read.
3647
+ * @returns {string|null} Repo-relative target file, or null for a genuinely unknown tool.
3648
+ */
3649
+ export function resolveIntegrationTargetFile(tool, manifest) {
3650
+ const key = LEGACY_TOOL_MAPPINGS[tool] || tool;
3651
+ const override = manifest?.integrationTargets?.[key];
3652
+ if (typeof override === 'string' && override.length > 0) return override;
3653
+ return getToolFileName(tool) || null;
3654
+ }
3444
3655
 
3445
3656
  /**
3446
3657
  * Get default commands based on ecosystem
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Shared marker-block location logic for UDS-managed sections in integration
3
+ * files (CLAUDE.md, AGENTS.md, etc.).
4
+ *
5
+ * XSPEC adopter-report Q5 (v3.5.0–6.10.0): every call site that located the
6
+ * `<!-- UDS:STANDARDS:START -->` / `<!-- UDS:STANDARDS:END -->` pair used
7
+ * `content.indexOf(markers.start)` / `.indexOf(markers.end)` directly. That
8
+ * matches the marker text anywhere in the file — including inside a sentence
9
+ * that merely *mentions* it (this project's own CLAUDE.md explains the marker
10
+ * syntax in prose, `<!-- UDS:STANDARDS:START -->`, inline) or inside a fenced
11
+ * code sample quoting it. A write path built on that wrong index then treated
12
+ * everything between the mention and the real END marker as "the UDS block"
13
+ * and deleted it — including the user's own content in between.
14
+ *
15
+ * The fix: a marker only counts when, after trimming, it occupies an entire
16
+ * line by itself, and that line is not inside a fenced code block (``` or
17
+ * ~~~). Every reader of a marker pair in this codebase must go through
18
+ * `locateMarkerBlock`/`tryLocateMarkerBlock` instead of indexOf/includes —
19
+ * enforced by a static-scan guard test.
20
+ */
21
+
22
+ /**
23
+ * @typedef {Object} MarkerLine
24
+ * @property {number} offset - Character offset of the marker text itself
25
+ * within `content` (not the start of the line — leading whitespace before
26
+ * the marker, if any, is excluded).
27
+ * @property {number} line - 1-based line number.
28
+ */
29
+
30
+ /**
31
+ * Find every standalone-line, non-fenced-code occurrence of `markerText`.
32
+ * @param {string} content
33
+ * @param {string} markerText
34
+ * @returns {MarkerLine[]}
35
+ */
36
+ function findMarkerLines(content, markerText) {
37
+ const lines = content.split('\n');
38
+ const occurrences = [];
39
+ let offset = 0;
40
+ let inFence = false;
41
+
42
+ for (let i = 0; i < lines.length; i++) {
43
+ const rawLine = lines[i];
44
+ const trimmed = rawLine.trim();
45
+
46
+ if (/^(`{3,}|~{3,})/.test(trimmed)) {
47
+ // Fenced code block delimiter (``` or ```lang, closing ``` too) — a
48
+ // marker string appearing between a pair of these is a documentation
49
+ // sample, not a real boundary. This intentionally does not try to
50
+ // distinguish open/close by matching fence characters or length; any
51
+ // triple-backtick-or-tilde line toggles the state, matching how
52
+ // Markdown renderers treat fences in practice.
53
+ inFence = !inFence;
54
+ } else if (!inFence && trimmed === markerText) {
55
+ const leading = rawLine.length - rawLine.trimStart().length;
56
+ occurrences.push({ offset: offset + leading, line: i + 1 });
57
+ }
58
+
59
+ offset += rawLine.length + 1; // +1 for the '\n' that split('\n') consumed
60
+ }
61
+
62
+ return occurrences;
63
+ }
64
+
65
+ /**
66
+ * Thrown when a marker's start or end text has more than one standalone,
67
+ * non-fenced occurrence in a file — the file is ambiguous and no caller
68
+ * should guess which one is the real boundary.
69
+ */
70
+ export class AmbiguousMarkerError extends Error {
71
+ /**
72
+ * @param {string} markerText
73
+ * @param {number[]} lines - 1-based line numbers of every occurrence found.
74
+ */
75
+ constructor(markerText, lines) {
76
+ super(
77
+ `Found ${lines.length} standalone occurrences of marker "${markerText}" ` +
78
+ `on line(s) ${lines.join(', ')} — expected exactly one. Refusing to guess ` +
79
+ 'which one bounds the UDS-managed block.'
80
+ );
81
+ this.name = 'AmbiguousMarkerError';
82
+ this.markerText = markerText;
83
+ this.lines = lines;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Locate the single UDS marker block in `content`.
89
+ *
90
+ * @param {string} content
91
+ * @param {{start: string, end: string}} markers
92
+ * @returns {{startIdx: number, endIdx: number, startLine: number, endLine: number} | null}
93
+ * `null` means "this file has no UDS block" — the same meaning the old
94
+ * `startIdx === -1` check carried. This is not an error.
95
+ * @throws {AmbiguousMarkerError} when more than one valid START or END line
96
+ * exists — the file has two or more marker pairs, or a corrupted one.
97
+ */
98
+ export function locateMarkerBlock(content, markers) {
99
+ const starts = findMarkerLines(content, markers.start);
100
+ const ends = findMarkerLines(content, markers.end);
101
+
102
+ if (starts.length > 1) {
103
+ throw new AmbiguousMarkerError(markers.start, starts.map((s) => s.line));
104
+ }
105
+ if (ends.length > 1) {
106
+ throw new AmbiguousMarkerError(markers.end, ends.map((e) => e.line));
107
+ }
108
+ if (starts.length === 0 || ends.length === 0) {
109
+ return null;
110
+ }
111
+
112
+ const startIdx = starts[0].offset;
113
+ const endIdx = ends[0].offset;
114
+ if (endIdx <= startIdx) {
115
+ return null;
116
+ }
117
+
118
+ return { startIdx, endIdx, startLine: starts[0].line, endLine: ends[0].line };
119
+ }
120
+
121
+ /**
122
+ * Non-throwing variant of `locateMarkerBlock` for callers that must not
123
+ * crash a broader scan on one ambiguous file (e.g. a full-project state
124
+ * scan reconciling many files at once). Ambiguity is reported through the
125
+ * return value instead of an exception.
126
+ *
127
+ * @param {string} content
128
+ * @param {{start: string, end: string}} markers
129
+ * @returns {{ok: true, block: ReturnType<typeof locateMarkerBlock>} | {ok: false, error: AmbiguousMarkerError}}
130
+ */
131
+ export function tryLocateMarkerBlock(content, markers) {
132
+ try {
133
+ return { ok: true, block: locateMarkerBlock(content, markers) };
134
+ } catch (error) {
135
+ if (error instanceof AmbiguousMarkerError) {
136
+ return { ok: false, error };
137
+ }
138
+ throw error;
139
+ }
140
+ }
@@ -338,7 +338,12 @@ export function calculateCategoriesFromStandards(standards) {
338
338
  const categories = new Set();
339
339
 
340
340
  for (const std of standards) {
341
- const category = getStandardCategory(std);
341
+ // XSPEC adopter-report Q1: getStandardCategory is keyed by filename and
342
+ // returns null for a bare manifest stem ('commit-message'), which is
343
+ // exactly what a 3.4.0 manifest stores — this emptied the whole category
344
+ // set on a real install. categoryForStandard (below) tries the stem
345
+ // against both known extensions first.
346
+ const category = categoryForStandard(std);
342
347
  if (category) {
343
348
  categories.add(category);
344
349
  }
@@ -347,6 +352,53 @@ export function calculateCategoriesFromStandards(standards) {
347
352
  return Array.from(categories);
348
353
  }
349
354
 
355
+ /**
356
+ * Whether a stored `categories` array is broken and should not be trusted:
357
+ * missing/not-an-array, empty, or containing a value this build does not
358
+ * recognize as a category id.
359
+ *
360
+ * @param {*} categories - `manifest.integrationConfigs[file].categories`
361
+ * @returns {boolean}
362
+ */
363
+ function categoriesLookBroken(categories) {
364
+ if (!Array.isArray(categories) || categories.length === 0) return true;
365
+ const known = new Set(Object.keys(CATEGORY_TO_STANDARDS));
366
+ return categories.some((c) => !known.has(c));
367
+ }
368
+
369
+ /**
370
+ * Repair `manifest.integrationConfigs[file].categories` wherever it is empty
371
+ * or contains an unrecognized value, recomputing it from `manifest.standards`
372
+ * the same way `calculateCategoriesFromStandards` (used by `--sync-refs`)
373
+ * does.
374
+ *
375
+ * XSPEC adopter-report Q1 follow-up (main-session review): the original Q1
376
+ * fix repaired this only inside `--sync-refs`. A manifest that picked up a
377
+ * broken `categories: []` from an older buggy `--sync-refs` run stayed
378
+ * broken forever afterward — a plain `uds update` reaches its OWN,
379
+ * independent integration-sync code path (update.js's general update flow)
380
+ * which writes `integrationBlockHashes` but never even reads
381
+ * `integrationConfigs`, so nothing there ever corrected it. The next `uds
382
+ * check --restore-missing` then rebuilt the file from that broken stored
383
+ * config and silently dropped sections. Deliberately conservative: an
384
+ * already-valid (if outdated) categories list is left alone — that is
385
+ * `--sync-refs`'s job, not every plain `update`'s — this only self-heals
386
+ * outright corruption (empty or unrecognized values).
387
+ *
388
+ * @param {Object} manifest - Manifest object (mutated in place)
389
+ * @returns {string[]} Paths whose `categories` were repaired
390
+ */
391
+ export function repairIntegrationConfigCategories(manifest) {
392
+ if (!manifest?.integrationConfigs) return [];
393
+ const repaired = [];
394
+ for (const [file, config] of Object.entries(manifest.integrationConfigs)) {
395
+ if (!config || !categoriesLookBroken(config.categories)) continue;
396
+ config.categories = calculateCategoriesFromStandards(manifest.standards || []);
397
+ repaired.push(file);
398
+ }
399
+ return repaired;
400
+ }
401
+
350
402
  /**
351
403
  * Get all standard filenames that should be referenced for given categories
352
404
  *
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "version": "6.10.0",
3
+ "version": "6.11.0",
4
4
  "lastUpdated": "2026-05-13",
5
5
  "description": "Standards registry for universal-dev-standards with integrated skills and AI-optimized formats",
6
6
  "formats": {
@@ -58,14 +58,14 @@
58
58
  "standards": {
59
59
  "name": "universal-dev-standards",
60
60
  "url": "https://github.com/AsiaOstrich/universal-dev-standards",
61
- "version": "6.10.0"
61
+ "version": "6.11.0"
62
62
  },
63
63
  "skills": {
64
64
  "name": "universal-dev-standards",
65
65
  "url": "https://github.com/AsiaOstrich/universal-dev-standards",
66
66
  "localPath": "skills",
67
67
  "rawUrl": "https://raw.githubusercontent.com/AsiaOstrich/universal-dev-standards/main/skills",
68
- "version": "6.10.0",
68
+ "version": "6.11.0",
69
69
  "note": "Skills are now included in the main repository under skills/"
70
70
  }
71
71
  },
@@ -2283,7 +2283,7 @@
2283
2283
  "id": "license-compliance",
2284
2284
  "name": "License Compliance Standards",
2285
2285
  "nameZh": "授權合規標準",
2286
- "version": "6.10.0",
2286
+ "version": "6.11.0",
2287
2287
  "source": {
2288
2288
  "human": "core/license-compliance.md",
2289
2289
  "ai": "ai/standards/license-compliance.ai.yaml"
@@ -2295,7 +2295,7 @@
2295
2295
  "id": "verification-oracle",
2296
2296
  "name": "Verification Oracle Standards",
2297
2297
  "nameZh": "驗證 Oracle 標準",
2298
- "version": "6.10.0",
2298
+ "version": "6.11.0",
2299
2299
  "source": {
2300
2300
  "human": "core/verification-oracle.md",
2301
2301
  "ai": "ai/standards/verification-oracle.ai.yaml"
@@ -2307,7 +2307,7 @@
2307
2307
  "id": "model-provenance",
2308
2308
  "name": "Model Provenance Policy Standards",
2309
2309
  "nameZh": "模型來源政策標準",
2310
- "version": "6.10.0",
2310
+ "version": "6.11.0",
2311
2311
  "source": {
2312
2312
  "human": "core/model-provenance.md",
2313
2313
  "ai": "ai/standards/model-provenance.ai.yaml"
@@ -2319,7 +2319,7 @@
2319
2319
  "id": "resource-cost-boundary",
2320
2320
  "name": "Resource / Cost Boundary Declaration Standards",
2321
2321
  "nameZh": "資源/成本邊界宣告標準",
2322
- "version": "6.10.0",
2322
+ "version": "6.11.0",
2323
2323
  "source": {
2324
2324
  "human": "core/resource-cost-boundary.md",
2325
2325
  "ai": "ai/standards/resource-cost-boundary.ai.yaml"