@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.
- package/bin/compose.js +3 -1
- package/bin/git-hooks/pre-push.template +8 -0
- package/contracts/feature-json.schema.json +54 -4
- package/contracts/judge-result.json +27 -4
- package/dist/assets/{App-BOmZUQK9.js → App-DAlZEv-O.js} +163 -163
- package/dist/assets/{arc-BFMYxU8d.js → arc-CJH3_faQ.js} +1 -1
- package/dist/assets/{architectureDiagram-3BPJPVTR-Dr1TLm4n.js → architectureDiagram-3BPJPVTR-CcbjDL0X.js} +1 -1
- package/dist/assets/{blockDiagram-GPEHLZMM-DfiOIWI5.js → blockDiagram-GPEHLZMM-CUfnQl0d.js} +1 -1
- package/dist/assets/{c4Diagram-AAUBKEIU-CWZPV6Gi.js → c4Diagram-AAUBKEIU-BmyI1ykb.js} +1 -1
- package/dist/assets/channel-ZbvrEgVw.js +1 -0
- package/dist/assets/{chunk-2J33WTMH-DsfEX96t.js → chunk-2J33WTMH-BBqApkEh.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-BYmdEdqH.js → chunk-4BX2VUAB-CH3WPC9v.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-C074eKNZ.js → chunk-55IACEB6-DSF1sU8o.js} +1 -1
- package/dist/assets/{chunk-727SXJPM-rxaajQx0.js → chunk-727SXJPM-HjTVn9xp.js} +1 -1
- package/dist/assets/{chunk-AQP2D5EJ-Cg6-5M28.js → chunk-AQP2D5EJ-C2ZGpxjS.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-FSbyjfSh.js → chunk-FMBD7UC4-CCfvaMbx.js} +1 -1
- package/dist/assets/{chunk-ND2GUHAM-DhD_mt3D.js → chunk-ND2GUHAM-Ke_5zZ4E.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-LMU-D6pF.js → chunk-QZHKN3VN-D_3X_vH5.js} +1 -1
- package/dist/assets/classDiagram-4FO5ZUOK-CIpDpiGU.js +1 -0
- package/dist/assets/classDiagram-v2-Q7XG4LA2-CIpDpiGU.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-CknvjYWn.js → cose-bilkent-S5V4N54A-BavWz_Ts.js} +1 -1
- package/dist/assets/{dagre-BM42HDAG-BWNVctIO.js → dagre-BM42HDAG-CJN2rIrd.js} +1 -1
- package/dist/assets/{diagram-2AECGRRQ-DhPeu8mD.js → diagram-2AECGRRQ-DFSD9lyx.js} +1 -1
- package/dist/assets/{diagram-5GNKFQAL-B8e2ppCQ.js → diagram-5GNKFQAL-B2Iz6xSI.js} +1 -1
- package/dist/assets/{diagram-KO2AKTUF-CJ2bkC5P.js → diagram-KO2AKTUF-BF77NHeO.js} +1 -1
- package/dist/assets/{diagram-LMA3HP47-CCyEuSkM.js → diagram-LMA3HP47-B9rERFCD.js} +1 -1
- package/dist/assets/{diagram-OG6HWLK6-CDe7c8Ym.js → diagram-OG6HWLK6-CIS_DZW7.js} +1 -1
- package/dist/assets/{erDiagram-TEJ5UH35-BDTpNyUZ.js → erDiagram-TEJ5UH35-BubvCPWm.js} +1 -1
- package/dist/assets/{flowDiagram-I6XJVG4X-CSEQ9sFL.js → flowDiagram-I6XJVG4X-CVjDg-md.js} +1 -1
- package/dist/assets/{ganttDiagram-6RSMTGT7-CJI3ZQCl.js → ganttDiagram-6RSMTGT7-rdIEkSjo.js} +1 -1
- package/dist/assets/{gitGraphDiagram-PVQCEYII-BmIOl3oN.js → gitGraphDiagram-PVQCEYII-Bv7mceGn.js} +1 -1
- package/dist/assets/{index-DHyg44Px.js → index-BPSrV_ie.js} +2 -2
- package/dist/assets/{infoDiagram-5YYISTIA-BiJXa7xo.js → infoDiagram-5YYISTIA-BhwEnN8y.js} +1 -1
- package/dist/assets/{ishikawaDiagram-YF4QCWOH-D2S-R9bH.js → ishikawaDiagram-YF4QCWOH-Bc3cYCKN.js} +1 -1
- package/dist/assets/{journeyDiagram-JHISSGLW-Dk6NJYdI.js → journeyDiagram-JHISSGLW-DA82WE4d.js} +1 -1
- package/dist/assets/{kanban-definition-UN3LZRKU-DrTme_f5.js → kanban-definition-UN3LZRKU-CS3ptTQL.js} +1 -1
- package/dist/assets/{linear-DAL46yON.js → linear-VVoHRBmK.js} +1 -1
- package/dist/assets/{mindmap-definition-RKZ34NQL-lzCtbkrz.js → mindmap-definition-RKZ34NQL-Dx93Nhkp.js} +1 -1
- package/dist/assets/{pieDiagram-4H26LBE5-BGGc2f7U.js → pieDiagram-4H26LBE5-CO107nas.js} +1 -1
- package/dist/assets/{quadrantDiagram-W4KKPZXB-DN8kBgF9.js → quadrantDiagram-W4KKPZXB-CrMXIHGP.js} +1 -1
- package/dist/assets/{requirementDiagram-4Y6WPE33-CqTXzyF-.js → requirementDiagram-4Y6WPE33-Jhy0JAv1.js} +1 -1
- package/dist/assets/{sankeyDiagram-5OEKKPKP-l5oIrkxp.js → sankeyDiagram-5OEKKPKP-Byqar1oo.js} +1 -1
- package/dist/assets/{sequenceDiagram-3UESZ5HK-B5mMGwpr.js → sequenceDiagram-3UESZ5HK-BXn-Yp_S.js} +1 -1
- package/dist/assets/{stateDiagram-AJRCARHV-B8yZRkdR.js → stateDiagram-AJRCARHV-BsH4tWQo.js} +1 -1
- package/dist/assets/stateDiagram-v2-BHNVJYJU-CW-BZJp6.js +1 -0
- package/dist/assets/{timeline-definition-PNZ67QCA-uc4a9RNG.js → timeline-definition-PNZ67QCA-RaJbKHNh.js} +1 -1
- package/dist/assets/{vennDiagram-CIIHVFJN-Dxp9IL4U.js → vennDiagram-CIIHVFJN-Cq50SepU.js} +1 -1
- package/dist/assets/{wardley-L42UT6IY-1u4vjxUt.js → wardley-L42UT6IY-CfLD0dlo.js} +1 -1
- package/dist/assets/{wardleyDiagram-YWT4CUSO-DWpWWd1b.js → wardleyDiagram-YWT4CUSO-O_yLrImv.js} +1 -1
- package/dist/assets/{xychartDiagram-2RQKCTM6-Cpcj31O3.js → xychartDiagram-2RQKCTM6-DnL9O2jh.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/feature-validator.js +373 -3
- package/lib/feature-writer.js +158 -1
- package/lib/tracker/github-api.js +8 -1
- package/lib/xref-citation.js +235 -0
- package/package.json +1 -1
- package/server/compose-mcp-tools.js +2 -1
- package/server/compose-mcp.js +11 -5
- package/dist/assets/channel-GvJclmQg.js +0 -1
- package/dist/assets/classDiagram-4FO5ZUOK-DV6SPGMl.js +0 -1
- package/dist/assets/classDiagram-v2-Q7XG4LA2-DV6SPGMl.js +0 -1
- package/dist/assets/stateDiagram-v2-BHNVJYJU-BK-1uf4O.js +0 -1
package/lib/feature-validator.js
CHANGED
|
@@ -13,7 +13,19 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Each finding: { severity: 'error'|'warning'|'info', kind, feature_code?, detail, source? }.
|
|
15
15
|
*
|
|
16
|
-
* Catalog (
|
|
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
|
}
|
package/lib/feature-writer.js
CHANGED
|
@@ -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 => (
|
|
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 || !/^[
|
|
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 ?? [];
|