@smartmemory/compose 0.1.38-beta → 0.1.40-beta

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 (62) hide show
  1. package/bin/compose.js +3 -1
  2. package/bin/git-hooks/pre-push.template +8 -0
  3. package/contracts/feature-json.schema.json +54 -4
  4. package/contracts/judge-result.json +27 -4
  5. package/dist/assets/{App-BOmZUQK9.js → App-DAlZEv-O.js} +163 -163
  6. package/dist/assets/{arc-BFMYxU8d.js → arc-CJH3_faQ.js} +1 -1
  7. package/dist/assets/{architectureDiagram-3BPJPVTR-Dr1TLm4n.js → architectureDiagram-3BPJPVTR-CcbjDL0X.js} +1 -1
  8. package/dist/assets/{blockDiagram-GPEHLZMM-DfiOIWI5.js → blockDiagram-GPEHLZMM-CUfnQl0d.js} +1 -1
  9. package/dist/assets/{c4Diagram-AAUBKEIU-CWZPV6Gi.js → c4Diagram-AAUBKEIU-BmyI1ykb.js} +1 -1
  10. package/dist/assets/channel-ZbvrEgVw.js +1 -0
  11. package/dist/assets/{chunk-2J33WTMH-DsfEX96t.js → chunk-2J33WTMH-BBqApkEh.js} +1 -1
  12. package/dist/assets/{chunk-4BX2VUAB-BYmdEdqH.js → chunk-4BX2VUAB-CH3WPC9v.js} +1 -1
  13. package/dist/assets/{chunk-55IACEB6-C074eKNZ.js → chunk-55IACEB6-DSF1sU8o.js} +1 -1
  14. package/dist/assets/{chunk-727SXJPM-rxaajQx0.js → chunk-727SXJPM-HjTVn9xp.js} +1 -1
  15. package/dist/assets/{chunk-AQP2D5EJ-Cg6-5M28.js → chunk-AQP2D5EJ-C2ZGpxjS.js} +1 -1
  16. package/dist/assets/{chunk-FMBD7UC4-FSbyjfSh.js → chunk-FMBD7UC4-CCfvaMbx.js} +1 -1
  17. package/dist/assets/{chunk-ND2GUHAM-DhD_mt3D.js → chunk-ND2GUHAM-Ke_5zZ4E.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-LMU-D6pF.js → chunk-QZHKN3VN-D_3X_vH5.js} +1 -1
  19. package/dist/assets/classDiagram-4FO5ZUOK-CIpDpiGU.js +1 -0
  20. package/dist/assets/classDiagram-v2-Q7XG4LA2-CIpDpiGU.js +1 -0
  21. package/dist/assets/{cose-bilkent-S5V4N54A-CknvjYWn.js → cose-bilkent-S5V4N54A-BavWz_Ts.js} +1 -1
  22. package/dist/assets/{dagre-BM42HDAG-BWNVctIO.js → dagre-BM42HDAG-CJN2rIrd.js} +1 -1
  23. package/dist/assets/{diagram-2AECGRRQ-DhPeu8mD.js → diagram-2AECGRRQ-DFSD9lyx.js} +1 -1
  24. package/dist/assets/{diagram-5GNKFQAL-B8e2ppCQ.js → diagram-5GNKFQAL-B2Iz6xSI.js} +1 -1
  25. package/dist/assets/{diagram-KO2AKTUF-CJ2bkC5P.js → diagram-KO2AKTUF-BF77NHeO.js} +1 -1
  26. package/dist/assets/{diagram-LMA3HP47-CCyEuSkM.js → diagram-LMA3HP47-B9rERFCD.js} +1 -1
  27. package/dist/assets/{diagram-OG6HWLK6-CDe7c8Ym.js → diagram-OG6HWLK6-CIS_DZW7.js} +1 -1
  28. package/dist/assets/{erDiagram-TEJ5UH35-BDTpNyUZ.js → erDiagram-TEJ5UH35-BubvCPWm.js} +1 -1
  29. package/dist/assets/{flowDiagram-I6XJVG4X-CSEQ9sFL.js → flowDiagram-I6XJVG4X-CVjDg-md.js} +1 -1
  30. package/dist/assets/{ganttDiagram-6RSMTGT7-CJI3ZQCl.js → ganttDiagram-6RSMTGT7-rdIEkSjo.js} +1 -1
  31. package/dist/assets/{gitGraphDiagram-PVQCEYII-BmIOl3oN.js → gitGraphDiagram-PVQCEYII-Bv7mceGn.js} +1 -1
  32. package/dist/assets/{index-DHyg44Px.js → index-BPSrV_ie.js} +2 -2
  33. package/dist/assets/{infoDiagram-5YYISTIA-BiJXa7xo.js → infoDiagram-5YYISTIA-BhwEnN8y.js} +1 -1
  34. package/dist/assets/{ishikawaDiagram-YF4QCWOH-D2S-R9bH.js → ishikawaDiagram-YF4QCWOH-Bc3cYCKN.js} +1 -1
  35. package/dist/assets/{journeyDiagram-JHISSGLW-Dk6NJYdI.js → journeyDiagram-JHISSGLW-DA82WE4d.js} +1 -1
  36. package/dist/assets/{kanban-definition-UN3LZRKU-DrTme_f5.js → kanban-definition-UN3LZRKU-CS3ptTQL.js} +1 -1
  37. package/dist/assets/{linear-DAL46yON.js → linear-VVoHRBmK.js} +1 -1
  38. package/dist/assets/{mindmap-definition-RKZ34NQL-lzCtbkrz.js → mindmap-definition-RKZ34NQL-Dx93Nhkp.js} +1 -1
  39. package/dist/assets/{pieDiagram-4H26LBE5-BGGc2f7U.js → pieDiagram-4H26LBE5-CO107nas.js} +1 -1
  40. package/dist/assets/{quadrantDiagram-W4KKPZXB-DN8kBgF9.js → quadrantDiagram-W4KKPZXB-CrMXIHGP.js} +1 -1
  41. package/dist/assets/{requirementDiagram-4Y6WPE33-CqTXzyF-.js → requirementDiagram-4Y6WPE33-Jhy0JAv1.js} +1 -1
  42. package/dist/assets/{sankeyDiagram-5OEKKPKP-l5oIrkxp.js → sankeyDiagram-5OEKKPKP-Byqar1oo.js} +1 -1
  43. package/dist/assets/{sequenceDiagram-3UESZ5HK-B5mMGwpr.js → sequenceDiagram-3UESZ5HK-BXn-Yp_S.js} +1 -1
  44. package/dist/assets/{stateDiagram-AJRCARHV-B8yZRkdR.js → stateDiagram-AJRCARHV-BsH4tWQo.js} +1 -1
  45. package/dist/assets/stateDiagram-v2-BHNVJYJU-CW-BZJp6.js +1 -0
  46. package/dist/assets/{timeline-definition-PNZ67QCA-uc4a9RNG.js → timeline-definition-PNZ67QCA-RaJbKHNh.js} +1 -1
  47. package/dist/assets/{vennDiagram-CIIHVFJN-Dxp9IL4U.js → vennDiagram-CIIHVFJN-Cq50SepU.js} +1 -1
  48. package/dist/assets/{wardley-L42UT6IY-1u4vjxUt.js → wardley-L42UT6IY-CfLD0dlo.js} +1 -1
  49. package/dist/assets/{wardleyDiagram-YWT4CUSO-DWpWWd1b.js → wardleyDiagram-YWT4CUSO-O_yLrImv.js} +1 -1
  50. package/dist/assets/{xychartDiagram-2RQKCTM6-Cpcj31O3.js → xychartDiagram-2RQKCTM6-DnL9O2jh.js} +1 -1
  51. package/dist/index.html +1 -1
  52. package/lib/feature-validator.js +373 -3
  53. package/lib/feature-writer.js +158 -1
  54. package/lib/tracker/github-api.js +8 -1
  55. package/lib/xref-citation.js +235 -0
  56. package/package.json +1 -1
  57. package/server/compose-mcp-tools.js +2 -1
  58. package/server/compose-mcp.js +11 -5
  59. package/dist/assets/channel-GvJclmQg.js +0 -1
  60. package/dist/assets/classDiagram-4FO5ZUOK-DV6SPGMl.js +0 -1
  61. package/dist/assets/classDiagram-v2-Q7XG4LA2-DV6SPGMl.js +0 -1
  62. package/dist/assets/stateDiagram-v2-BHNVJYJU-BK-1uf4O.js +0 -1
@@ -13,7 +13,19 @@
13
13
  *
14
14
  * Each finding: { severity: 'error'|'warning'|'info', kind, feature_code?, detail, source? }.
15
15
  *
16
- * Catalog (27 kinds): see docs/features/COMP-MCP-VALIDATE/design.md.
16
+ * Catalog (32 kinds). The original 27 cross-artifact kinds, plus the 5
17
+ * COMP-MCP-XREF-VALIDATE (#16) read-only external-reference kinds:
18
+ * - XREF_DRIFT (warning) resolved state blatantly contradicts the
19
+ * citing row / explicit expect=
20
+ * - XREF_TARGET_MISSING (error) github 404 / local target absent
21
+ * - XREF_MALFORMED (warning) <!--xref:…--> matched but failed grammar
22
+ * - XREF_RESOLUTION_SKIPPED (warning) offline / no-token / rate-limit / ≥500
23
+ * / gate off — NEVER error, never aborts
24
+ * - XREF_URL_UNCHECKED (info) url + reserved url-class providers
25
+ * (jira|linear|notion|obsidian) — recorded,
26
+ * not resolved
27
+ * Full catalog + trigger/degrade/gating contract:
28
+ * docs/features/COMP-MCP-VALIDATE/design.md
17
29
  */
18
30
 
19
31
  import fs from 'node:fs';
@@ -22,6 +34,8 @@ import { fileURLToPath } from 'node:url';
22
34
  import { FEATURE_CODE_RE_STRICT, validateCode } from './feature-code.js';
23
35
  import { parseRoadmap } from './roadmap-parser.js';
24
36
  import { listFeatures, readFeature } from './feature-json.js';
37
+ import { parseCitations } from './xref-citation.js';
38
+ import { GitHubApi } from './tracker/github-api.js';
25
39
  import { ArtifactManager } from '../server/artifact-manager.js';
26
40
  import { SchemaValidator } from '../server/schema-validator.js';
27
41
 
@@ -79,6 +93,11 @@ function loadValidationContext(cwd, options = {}) {
79
93
  // status values (PARTIAL, COMPLETE) match the strict code regex, or where
80
94
  // descriptions contain code-like uppercase tokens.
81
95
  let roadmapRows = [];
96
+ // COMP-MCP-XREF-VALIDATE #16: anon-row-safe citation capture. Independent
97
+ // of roadmapByCode (which drops rows whose code is not strict). Additive —
98
+ // does not change roadmapRows / position / roadmapByCode.
99
+ const citationRows = [];
100
+ let citePosition = 0;
82
101
  try {
83
102
  const text = fs.readFileSync(options.roadmapPath || paths.roadmap, 'utf8');
84
103
  let phaseId = '';
@@ -123,9 +142,18 @@ function loadValidationContext(cwd, options = {}) {
123
142
  if (codeIdx >= cols.length || statusIdx >= cols.length) continue;
124
143
 
125
144
  const codeRaw = cols[codeIdx].replace(/\*/g, '').replace(/`/g, '').trim();
126
- if (!FEATURE_CODE_RE_STRICT.test(codeRaw)) continue;
127
- const status = cols[statusIdx].replace(/\*/g, '').trim();
128
145
  const description = descIdx >= 0 && descIdx < cols.length ? cols[descIdx] : '';
146
+ const status = cols[statusIdx].replace(/\*/g, '').trim();
147
+ const isStrictCode = FEATURE_CODE_RE_STRICT.test(codeRaw);
148
+ // Anon-inclusive citation row capture (independent counter).
149
+ citePosition += 1;
150
+ citationRows.push({
151
+ code: isStrictCode ? codeRaw : null,
152
+ description,
153
+ status,
154
+ rowPosition: citePosition,
155
+ });
156
+ if (!isStrictCode) continue;
129
157
  position += 1;
130
158
  roadmapRows.push({ code: codeRaw, description, status, phaseId, position });
131
159
  }
@@ -177,6 +205,7 @@ function loadValidationContext(cwd, options = {}) {
177
205
  visionItems,
178
206
  visionStateRaw,
179
207
  foldersByCode,
208
+ citationRows,
180
209
  externalPrefixes: options.externalPrefixes || [],
181
210
  featureJsonMode: options.featureJsonMode !== false,
182
211
  };
@@ -605,6 +634,335 @@ export async function validateFeature(cwd, code, options = {}) {
605
634
  return { scope: 'feature', feature_code: code, validated_at: nowIso(), findings };
606
635
  }
607
636
 
637
+ // ---------------------------------------------------------------------------
638
+ // COMP-MCP-XREF-VALIDATE #16 — read-only external-reference staleness checks
639
+ // ---------------------------------------------------------------------------
640
+
641
+ const WS_ID_RE = /^[a-z][a-z0-9-]{1,63}$/;
642
+ const URL_CLASS = new Set(['url', 'jira', 'linear', 'notion', 'obsidian']);
643
+ const TERMINAL_ISH = new Set(['COMPLETE', 'SUPERSEDED']);
644
+ const OPEN_ISH = new Set(['PLANNED', 'IN_PROGRESS']);
645
+
646
+ function resolveCitingWorkspaceId(cwd, options, cfg) {
647
+ if (options.citingWorkspaceId) return options.citingWorkspaceId;
648
+ if (cfg && typeof cfg.workspaceId === 'string' && WS_ID_RE.test(cfg.workspaceId)) {
649
+ return cfg.workspaceId;
650
+ }
651
+ const base = path.basename(cwd);
652
+ return base === 'forge' ? 'forge-top' : base;
653
+ }
654
+
655
+ function xrefGateOn(options, cfg) {
656
+ return options.external === true
657
+ || process.env.COMPOSE_XREF_ONLINE === '1'
658
+ || !!(cfg && cfg.xref && cfg.xref.prePushOnline === true);
659
+ }
660
+
661
+ // Build the normalized ExternalRef list from both carriers (roadmap citations
662
+ // — anon-row-safe — and feature.json links[] kind:"external"). Parse errors
663
+ // from the grammar become XREF_MALFORMED findings.
664
+ function collectExternalRefs(ctx, citingWorkspaceId, findings) {
665
+ const refs = [];
666
+ for (const row of ctx.citationRows || []) {
667
+ const { refs: parsed, errors } = parseCitations(row.description || '');
668
+ for (const e of errors) {
669
+ findings.push(finding(
670
+ 'warning', 'XREF_MALFORMED', row.code || undefined,
671
+ `row #${row.rowPosition}: malformed xref citation (${e.reason}) — "${String(row.description).slice(0, 80)}"`,
672
+ 'roadmap-citation',
673
+ ));
674
+ }
675
+ for (const p of parsed) {
676
+ refs.push({
677
+ source: 'roadmap-citation',
678
+ citing: {
679
+ workspaceId: citingWorkspaceId,
680
+ code: row.code || null,
681
+ rowPosition: row.rowPosition,
682
+ rowDescription: String(row.description || '').slice(0, 80),
683
+ status: row.status || null,
684
+ },
685
+ provider: p.provider, repo: p.repo, issue: p.issue,
686
+ toCode: p.toCode, url: p.url, expect: p.expect, note: p.note,
687
+ });
688
+ }
689
+ }
690
+ for (const [code, folder] of ctx.foldersByCode) {
691
+ if (!folder.hasFeatureJson) continue;
692
+ let fj;
693
+ try { fj = JSON.parse(fs.readFileSync(path.join(folder.dir, 'feature.json'), 'utf8')); }
694
+ catch { continue; }
695
+ if (!Array.isArray(fj.links)) continue;
696
+ for (const l of fj.links) {
697
+ if (l && l.kind === 'external') {
698
+ refs.push({
699
+ source: 'feature-json-link',
700
+ citing: {
701
+ workspaceId: citingWorkspaceId,
702
+ code,
703
+ rowPosition: null,
704
+ rowDescription: null,
705
+ status: fj.status || ctx.roadmapByCode.get(code)?.status || null,
706
+ },
707
+ provider: l.provider, repo: l.repo ?? null, issue: l.issue ?? null,
708
+ toCode: l.to_code ?? null, url: l.url ?? null,
709
+ expect: l.expect ?? null, note: l.note ?? null,
710
+ });
711
+ }
712
+ }
713
+ }
714
+ return refs;
715
+ }
716
+
717
+ function locatorDetail(ref, msg) {
718
+ // citing.workspaceId is the spec-contracted "citing label" (spec §3.3/§4):
719
+ // surface it so cross-repo findings name which workspace cited the ref.
720
+ const ws = ref.citing.workspaceId ? `[${ref.citing.workspaceId}] ` : '';
721
+ if (ref.citing.code) return `${ws}${msg}`;
722
+ return `${ws}row #${ref.citing.rowPosition}: "${ref.citing.rowDescription}" — ${msg}`;
723
+ }
724
+
725
+ function githubDrift(ref, state) {
726
+ // explicit expect is authoritative
727
+ if (ref.expect === 'open' || ref.expect === 'closed') {
728
+ return state !== ref.expect
729
+ ? `expected ${ref.repo}#${ref.issue} to be ${ref.expect} but it is ${state}`
730
+ : null;
731
+ }
732
+ // absent expect → derive from citing-row status; blatant contradiction only
733
+ const s = ref.citing.status;
734
+ if (TERMINAL_ISH.has(s) && state === 'open') {
735
+ return `citing row is ${s} but ${ref.repo}#${ref.issue} is still open`;
736
+ }
737
+ if (OPEN_ISH.has(s) && state === 'closed') {
738
+ return `citing row is ${s} but ${ref.repo}#${ref.issue} is already closed`;
739
+ }
740
+ return null;
741
+ }
742
+
743
+ async function resolveGithubRef(ref, gh, findings) {
744
+ const code = ref.citing.code || undefined;
745
+ let r;
746
+ try {
747
+ r = await gh.getIssueResult(ref.issue);
748
+ } catch (e) {
749
+ if (e && e.rateLimit) { const x = new Error('ratelimit'); x._rateLimit = true; throw x; }
750
+ // offline / fetch reject / unexpected — per-ref degrade
751
+ findings.push(finding(
752
+ 'warning', 'XREF_RESOLUTION_SKIPPED', code,
753
+ locatorDetail(ref, `github resolution skipped for ${ref.repo}#${ref.issue}: ${e && e.message ? e.message : e}`),
754
+ ref.source,
755
+ ));
756
+ return;
757
+ }
758
+ if (r.status === 404) {
759
+ findings.push(finding(
760
+ 'error', 'XREF_TARGET_MISSING', code,
761
+ locatorDetail(ref, `github ${ref.repo}#${ref.issue} not found (404)`),
762
+ ref.source,
763
+ ));
764
+ return;
765
+ }
766
+ if (r.status < 200 || r.status >= 300) {
767
+ findings.push(finding(
768
+ 'warning', 'XREF_RESOLUTION_SKIPPED', code,
769
+ locatorDetail(ref, `github ${ref.repo}#${ref.issue} unresolved (HTTP ${r.status})`),
770
+ ref.source,
771
+ ));
772
+ return;
773
+ }
774
+ if (!r.body || (r.body.state !== 'open' && r.body.state !== 'closed')) {
775
+ // 2xx but unparseable/missing state (github-api.js _req coerces JSON
776
+ // parse failures to {}) — degrade, do not assume a state.
777
+ findings.push(finding(
778
+ 'warning', 'XREF_RESOLUTION_SKIPPED', code,
779
+ locatorDetail(ref, `github ${ref.repo}#${ref.issue} returned no parseable issue state (HTTP ${r.status})`),
780
+ ref.source,
781
+ ));
782
+ return;
783
+ }
784
+ const state = r.body.state;
785
+ const drift = githubDrift(ref, state);
786
+ if (drift) {
787
+ findings.push(finding('warning', 'XREF_DRIFT', code, locatorDetail(ref, drift), ref.source));
788
+ }
789
+ }
790
+
791
+ function resolveLocalRef(ref, cwd, findings) {
792
+ const code = ref.citing.code || undefined;
793
+ // Containment guard: repo token must resolve to a direct sibling of cwd.
794
+ // (The grammar already constrains roadmap citations; this also covers the
795
+ // feature.json-link carrier and is belt-and-suspenders against traversal.)
796
+ const parentDir = path.resolve(cwd, '..');
797
+ const citedRoot = path.resolve(parentDir, String(ref.repo || ''));
798
+ // Lexical check first (cheap, rejects obvious traversal / separators).
799
+ let unsafe = !ref.repo || /[\\/]/.test(ref.repo) || ref.repo === '.' || ref.repo === '..'
800
+ || path.dirname(citedRoot) !== parentDir;
801
+ // Canonicalize to defeat a valid-named sibling that is a symlink pointing
802
+ // outside the parent. realpath throws if the path is absent — that is just
803
+ // "target missing", handled by the same finding below.
804
+ if (!unsafe) {
805
+ try {
806
+ const realParent = fs.realpathSync(parentDir);
807
+ const realCited = fs.realpathSync(citedRoot);
808
+ if (path.dirname(realCited) !== realParent) unsafe = true;
809
+ } catch { unsafe = true; }
810
+ }
811
+ if (unsafe) {
812
+ findings.push(finding(
813
+ 'error', 'XREF_TARGET_MISSING', code,
814
+ locatorDetail(ref, `local repo token "${ref.repo}" is not a valid sibling directory (missing or escapes the workspace parent)`),
815
+ ref.source,
816
+ ));
817
+ return;
818
+ }
819
+ let resolvedStatus = null;
820
+ try {
821
+ const paths = resolveProjectPaths(citedRoot);
822
+ const fjPath = path.join(paths.features, ref.toCode, 'feature.json');
823
+ if (fs.existsSync(fjPath)) {
824
+ resolvedStatus = JSON.parse(fs.readFileSync(fjPath, 'utf8')).status || null;
825
+ } else {
826
+ // fall back to a ROADMAP row in the cited repo
827
+ const sub = loadValidationContext(citedRoot, {});
828
+ resolvedStatus = sub.roadmapByCode.get(ref.toCode)?.status || null;
829
+ }
830
+ } catch { resolvedStatus = null; }
831
+ if (resolvedStatus === null) {
832
+ findings.push(finding(
833
+ 'error', 'XREF_TARGET_MISSING', code,
834
+ locatorDetail(ref, `local ${ref.repo} ${ref.toCode} not found (no feature.json or ROADMAP row)`),
835
+ ref.source,
836
+ ));
837
+ return;
838
+ }
839
+ let drift = null;
840
+ if (ref.expect && resolvedStatus !== ref.expect) {
841
+ drift = `expected ${ref.toCode} to be ${ref.expect} but it is ${resolvedStatus}`;
842
+ } else if (!ref.expect) {
843
+ const s = ref.citing.status;
844
+ if (OPEN_ISH.has(s) && TERMINAL_ISH.has(resolvedStatus) === false && resolvedStatus === 'KILLED') {
845
+ drift = `citing row is ${s} but ${ref.toCode} is KILLED`;
846
+ } else if (TERMINAL_ISH.has(s) && OPEN_ISH.has(resolvedStatus)) {
847
+ drift = `citing row is ${s} but ${ref.toCode} is still ${resolvedStatus}`;
848
+ }
849
+ }
850
+ if (drift) {
851
+ findings.push(finding('warning', 'XREF_DRIFT', code, locatorDetail(ref, drift), ref.source));
852
+ }
853
+ }
854
+
855
+ /**
856
+ * Read-only external-reference resolution. Extends validateProject; never
857
+ * writes any file or issue. Gated: full network resolution only when
858
+ * options.external / COMPOSE_XREF_ONLINE=1 / compose.json xref.prePushOnline.
859
+ * Degrade contract (spec §6): every resolution failure is a WARNING
860
+ * (XREF_RESOLUTION_SKIPPED), never an error, never aborts the run.
861
+ */
862
+ async function runExternalRefChecks(ctx, findings, options = {}) {
863
+ const cfg = readProjectConfig(ctx.cwd);
864
+ const citingWorkspaceId = resolveCitingWorkspaceId(ctx.cwd, options, cfg);
865
+ const refs = collectExternalRefs(ctx, citingWorkspaceId, findings);
866
+ if (refs.length === 0) return;
867
+
868
+ const gateOn = xrefGateOn(options, cfg);
869
+ let noTokenAggregated = false;
870
+ let githubShortCircuited = false;
871
+
872
+ for (const ref of refs) {
873
+ const code = ref.citing.code || undefined;
874
+ try {
875
+ if (ref.provider === 'local') {
876
+ resolveLocalRef(ref, ctx.cwd, findings);
877
+ continue;
878
+ }
879
+ if (URL_CLASS.has(ref.provider)) {
880
+ findings.push(finding(
881
+ 'info', 'XREF_URL_UNCHECKED', code,
882
+ locatorDetail(ref, `${ref.provider} pointer recorded, not status-resolved: ${ref.url}`),
883
+ ref.source,
884
+ ));
885
+ continue;
886
+ }
887
+ if (ref.provider === 'github') {
888
+ if (!gateOn) {
889
+ findings.push(finding(
890
+ 'warning', 'XREF_RESOLUTION_SKIPPED', code,
891
+ locatorDetail(ref, `github ${ref.repo}#${ref.issue} not resolved (network off; pass --external / COMPOSE_XREF_ONLINE=1)`),
892
+ ref.source,
893
+ ));
894
+ continue;
895
+ }
896
+ if (githubShortCircuited) {
897
+ // An aggregate XREF_RESOLUTION_SKIPPED (no-token or rate-limit) was
898
+ // already emitted for the whole github batch — skip the rest
899
+ // silently rather than double-counting with a per-ref warning
900
+ // (and a wrong reason string for the no-token case).
901
+ continue;
902
+ }
903
+ let gh;
904
+ try {
905
+ gh = new GitHubApi(
906
+ { repo: ref.repo, auth: options.githubAuth || { tokenEnv: 'GITHUB_TOKEN' } },
907
+ options.githubTransport || null,
908
+ );
909
+ } catch (e) {
910
+ if (e && e.name === 'TrackerConfigError' && e.detail && e.detail.missing === 'token') {
911
+ if (!noTokenAggregated) {
912
+ noTokenAggregated = true;
913
+ findings.push(finding(
914
+ 'warning', 'XREF_RESOLUTION_SKIPPED', undefined,
915
+ 'github external refs skipped: no GitHub token (set tracker auth or `gh auth login`)',
916
+ 'xref',
917
+ ));
918
+ }
919
+ githubShortCircuited = true;
920
+ continue;
921
+ }
922
+ findings.push(finding(
923
+ 'warning', 'XREF_RESOLUTION_SKIPPED', code,
924
+ locatorDetail(ref, `github client init failed: ${e && e.message ? e.message : e}`),
925
+ ref.source,
926
+ ));
927
+ continue;
928
+ }
929
+ try {
930
+ await resolveGithubRef(ref, gh, findings);
931
+ } catch (e) {
932
+ if (e && e._rateLimit) {
933
+ githubShortCircuited = true;
934
+ findings.push(finding(
935
+ 'warning', 'XREF_RESOLUTION_SKIPPED', undefined,
936
+ 'github external refs skipped: GitHub rate-limited (remaining github refs not resolved this run)',
937
+ 'xref',
938
+ ));
939
+ continue;
940
+ }
941
+ findings.push(finding(
942
+ 'warning', 'XREF_RESOLUTION_SKIPPED', code,
943
+ locatorDetail(ref, `github resolution error: ${e && e.message ? e.message : e}`),
944
+ ref.source,
945
+ ));
946
+ }
947
+ continue;
948
+ }
949
+ // unknown provider that slipped past the grammar — treat as url-class info
950
+ findings.push(finding(
951
+ 'info', 'XREF_URL_UNCHECKED', code,
952
+ locatorDetail(ref, `provider "${ref.provider}" not resolvable; recorded only`),
953
+ ref.source,
954
+ ));
955
+ } catch (e) {
956
+ // absolute backstop: a single bad ref never poisons the run
957
+ findings.push(finding(
958
+ 'warning', 'XREF_RESOLUTION_SKIPPED', code,
959
+ locatorDetail(ref, `unexpected error resolving ref: ${e && e.message ? e.message : e}`),
960
+ ref.source,
961
+ ));
962
+ }
963
+ }
964
+ }
965
+
608
966
  export async function validateProject(cwd, options = {}) {
609
967
  const ctx = loadValidationContext(cwd, options);
610
968
  const findings = [];
@@ -624,6 +982,18 @@ export async function validateProject(cwd, options = {}) {
624
982
  runOrphanFolderCheck(ctx, findings);
625
983
  runChangelogReferenceCheck(ctx, findings);
626
984
  runJournalIndexDriftCheck(ctx, findings);
985
+ try {
986
+ await runExternalRefChecks(ctx, findings, options);
987
+ } catch (e) {
988
+ // Read-only staleness checks must never abort the validator run
989
+ // (spec §6: degrade, never hard-fail). Any unexpected pre-loop failure
990
+ // degrades to a single warning.
991
+ findings.push(finding(
992
+ 'warning', 'XREF_RESOLUTION_SKIPPED', undefined,
993
+ `external-reference checks skipped (unexpected error): ${e && e.message ? e.message : e}`,
994
+ 'xref',
995
+ ));
996
+ }
627
997
 
628
998
  return { scope: 'project', validated_at: nowIso(), findings };
629
999
  }
@@ -463,6 +463,14 @@ export async function linkArtifact(cwd, args) {
463
463
  */
464
464
  export async function linkFeatures(cwd, args) {
465
465
  validateCode(args.from_code);
466
+
467
+ // COMP-MCP-XREF-SCHEMA #15: external cross-project references. These do NOT
468
+ // resolve through same-project `to_code` semantics, so the validateCode /
469
+ // self-link / LINK_KINDS guards below are skipped for kind:"external".
470
+ if (args.kind === 'external') {
471
+ return linkFeatureExternal(cwd, args);
472
+ }
473
+
466
474
  validateCode(args.to_code);
467
475
  if (args.from_code === args.to_code) {
468
476
  throw new Error(`feature-writer: cannot link a feature to itself ("${args.from_code}")`);
@@ -513,6 +521,144 @@ export async function linkFeatures(cwd, args) {
513
521
  });
514
522
  }
515
523
 
524
+ const XREF_PROVIDERS = new Set(['github', 'local', 'url', 'jira', 'linear', 'notion', 'obsidian']);
525
+ const XREF_URL_CLASS = new Set(['url', 'jira', 'linear', 'notion', 'obsidian']);
526
+ const URI_SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//;
527
+ // Carrier equivalence: the feature.json-link carrier must reject exactly what
528
+ // the inline citation grammar (lib/xref-citation.js) rejects at parse time, so
529
+ // a stored link can never carry a value #16's resolver would mishandle.
530
+ const XREF_GITHUB_EXPECT = new Set(['open', 'closed']);
531
+ const XREF_LOCAL_EXPECT = new Set([
532
+ 'PLANNED', 'IN_PROGRESS', 'PARTIAL', 'COMPLETE',
533
+ 'SUPERSEDED', 'PARKED', 'BLOCKED', 'KILLED',
534
+ ]);
535
+ // No `#` in either half — the citation grammar uses `#` to delimit the issue
536
+ // (gh_target = owner/name#issue), so a repo token containing `#` is not
537
+ // representable in the inline carrier. Keep both carriers equivalent.
538
+ const XREF_GH_REPO_RE = /^[^\s/#]+\/[^\s/#]+$/;
539
+ const XREF_LOCAL_REPO_RE = /^[A-Za-z0-9._-]+$/;
540
+
541
+ /**
542
+ * Validate the external link variant in-code (the schema enforces the same
543
+ * shape on disk; this gives a clear error at the call site). Mirrors
544
+ * contracts/feature-json.schema.json links external branch.
545
+ */
546
+ function validateExternalArgs(args) {
547
+ const p = args.provider;
548
+ if (!p || !XREF_PROVIDERS.has(p)) {
549
+ throw new Error(
550
+ `feature-writer: external link requires provider ∈ {${[...XREF_PROVIDERS].join(', ')}}, got "${p}"`,
551
+ );
552
+ }
553
+ if (p === 'github') {
554
+ if (!args.repo || !Number.isInteger(args.issue) || args.issue < 1) {
555
+ throw new Error('feature-writer: external github link requires repo + integer issue ≥ 1');
556
+ }
557
+ if (!XREF_GH_REPO_RE.test(args.repo)) {
558
+ throw new Error(`feature-writer: external github repo "${args.repo}" must be "owner/name"`);
559
+ }
560
+ if (args.expect != null && !XREF_GITHUB_EXPECT.has(args.expect)) {
561
+ throw new Error(`feature-writer: external github expect must be open|closed, got "${args.expect}"`);
562
+ }
563
+ } else if (p === 'local') {
564
+ if (!args.repo || !args.to_code) {
565
+ throw new Error('feature-writer: external local link requires repo + to_code');
566
+ }
567
+ if (!XREF_LOCAL_REPO_RE.test(args.repo) || args.repo === '.' || args.repo === '..') {
568
+ throw new Error(
569
+ `feature-writer: external local repo "${args.repo}" must be a single sibling directory name `
570
+ + '([A-Za-z0-9._-], no path separators or "."/"..")',
571
+ );
572
+ }
573
+ if (!FEATURE_CODE_RE.test(args.to_code)) {
574
+ throw new Error(
575
+ `feature-writer: external local to_code "${args.to_code}" must match ${FEATURE_CODE_RE}`,
576
+ );
577
+ }
578
+ if (args.expect != null && !XREF_LOCAL_EXPECT.has(args.expect)) {
579
+ throw new Error(
580
+ `feature-writer: external local expect must be one of ${[...XREF_LOCAL_EXPECT].join('|')}, got "${args.expect}"`,
581
+ );
582
+ }
583
+ } else if (XREF_URL_CLASS.has(p)) {
584
+ if (!args.url) {
585
+ throw new Error(`feature-writer: external ${p} link (url-class) requires url`);
586
+ }
587
+ if (!URI_SCHEME_RE.test(args.url)) {
588
+ throw new Error(`feature-writer: url must be a valid URI (got: ${args.url})`);
589
+ }
590
+ // url-class: `expect` is recorded but never resolved (parity with the
591
+ // citation grammar, which also accepts but ignores it) — no validation.
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Store a kind:"external" cross-project reference on the source feature.
597
+ * Idempotency key is (kind=external, provider, repo, issue|to_code|url) so a
598
+ * re-link of the same external pointer is a noop. Read-only with respect to
599
+ * the cited repo/issue — this only writes the citing feature.json.
600
+ */
601
+ async function linkFeatureExternal(cwd, args) {
602
+ validateExternalArgs(args);
603
+
604
+ return maybeIdempotent({ ...args, cwd }, async () => {
605
+ const provider = await getProvider(cwd);
606
+ const feature = await provider.getFeature(args.from_code);
607
+ if (!feature) {
608
+ throw new Error(`feature-writer: feature "${args.from_code}" not found`);
609
+ }
610
+
611
+ const links = Array.isArray(feature.links) ? [...feature.links] : [];
612
+ const targetKey = args.provider === 'github'
613
+ ? String(args.issue)
614
+ : args.provider === 'local'
615
+ ? args.to_code
616
+ : args.url;
617
+ const matchIdx = links.findIndex(
618
+ l => l.kind === 'external'
619
+ && l.provider === args.provider
620
+ && (l.repo ?? null) === (args.repo ?? null)
621
+ && (
622
+ l.provider === 'github' ? String(l.issue) === targetKey
623
+ : l.provider === 'local' ? l.to_code === targetKey
624
+ : l.url === targetKey
625
+ ),
626
+ );
627
+
628
+ if (matchIdx !== -1 && !args.force) {
629
+ return {
630
+ from_code: args.from_code, kind: 'external',
631
+ provider: args.provider, noop: true,
632
+ };
633
+ }
634
+
635
+ const entry = { kind: 'external', provider: args.provider };
636
+ if (args.repo != null) entry.repo = args.repo;
637
+ if (args.issue != null) entry.issue = args.issue;
638
+ if (args.to_code != null) entry.to_code = args.to_code;
639
+ if (args.url != null) entry.url = args.url;
640
+ if (args.expect != null) entry.expect = args.expect;
641
+ if (args.note) entry.note = args.note;
642
+
643
+ if (matchIdx !== -1) links[matchIdx] = entry;
644
+ else links.push(entry);
645
+
646
+ await provider.putFeature(args.from_code, { ...feature, links });
647
+
648
+ await safeAppendEvent(cwd, {
649
+ tool: 'link_features',
650
+ code: args.from_code,
651
+ kind: 'external',
652
+ provider: args.provider,
653
+ note: args.note,
654
+ forced: matchIdx !== -1 ? true : undefined,
655
+ idempotency_key: args.idempotency_key,
656
+ });
657
+
658
+ return { from_code: args.from_code, kind: 'external', provider: args.provider };
659
+ });
660
+ }
661
+
516
662
  /**
517
663
  * Read both canonical and linked artifacts for a feature in one call.
518
664
  *
@@ -594,7 +740,18 @@ export async function getFeatureLinks(cwd, args) {
594
740
  }
595
741
  out.outgoing = (feature.links ?? [])
596
742
  .filter(l => !kind || l.kind === kind)
597
- .map(l => ({ kind: l.kind, to_code: l.to_code, note: l.note }));
743
+ .map((l) => (l.kind === 'external'
744
+ ? {
745
+ kind: l.kind,
746
+ provider: l.provider,
747
+ repo: l.repo,
748
+ issue: l.issue,
749
+ url: l.url,
750
+ to_code: l.to_code,
751
+ expect: l.expect,
752
+ note: l.note,
753
+ }
754
+ : { kind: l.kind, to_code: l.to_code, note: l.note }));
598
755
  }
599
756
 
600
757
  if (direction === 'incoming' || direction === 'both') {
@@ -12,7 +12,7 @@ function resolveToken(auth = {}, noGhFallback = false) {
12
12
  export class GitHubApi {
13
13
  constructor(cfg, transport = null) {
14
14
  this.repo = cfg.repo;
15
- if (!this.repo || !/^[^/]+\/[^/]+$/.test(this.repo)) {
15
+ if (!this.repo || !/^[^\s/#]+\/[^\s/#]+$/.test(this.repo)) {
16
16
  throw new TrackerConfigError(`tracker.github.repo must be "owner/name" (got "${this.repo}")`);
17
17
  }
18
18
  this.token = resolveToken(cfg.auth, cfg.auth?._noGhFallback || cfg._noGhFallback);
@@ -42,6 +42,13 @@ export class GitHubApi {
42
42
  return r.body;
43
43
  }
44
44
  async getIssue(number) { return (await this._req('GET', `/repos/${this.repo}/issues/${number}`)).body; }
45
+ /**
46
+ * GET /repos/:repo/issues/:number — status-returning sibling of getIssue().
47
+ * Returns { status, body, headers }; does NOT throw on 4xx (mirrors
48
+ * getRepo()). Used by COMP-MCP-XREF-VALIDATE (#16) to distinguish 404
49
+ * (target missing) from ≥500 (degrade). Read-only. getIssue() untouched.
50
+ */
51
+ async getIssueResult(number) { return this._req('GET', `/repos/${this.repo}/issues/${number}`); }
45
52
  async updateIssue(number, patch) { return (await this._req('PATCH', `/repos/${this.repo}/issues/${number}`, patch)).body; }
46
53
  async searchFeatureIssues() {
47
54
  return (await this._req('GET', `/search/issues?q=repo:${this.repo}+label:compose-feature`)).body.items ?? [];