@smartmemory/compose 0.1.37-beta → 0.1.39-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/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/bin/compose.js
CHANGED
|
@@ -1589,6 +1589,7 @@ if (cmd === 'validate') {
|
|
|
1589
1589
|
let code = null
|
|
1590
1590
|
let blockOn = 'error'
|
|
1591
1591
|
let asJson = false
|
|
1592
|
+
let externalXref = false
|
|
1592
1593
|
for (let i = 0; i < args.length; i++) {
|
|
1593
1594
|
const a = args[i]
|
|
1594
1595
|
if (a === '--help' || a === '-h') {
|
|
@@ -1608,6 +1609,7 @@ Exit codes:
|
|
|
1608
1609
|
process.exit(0)
|
|
1609
1610
|
}
|
|
1610
1611
|
if (a === '--json') { asJson = true; continue }
|
|
1612
|
+
if (a === '--external') { externalXref = true; continue }
|
|
1611
1613
|
if (a.startsWith('--scope=')) scope = a.slice('--scope='.length)
|
|
1612
1614
|
else if (a === '--scope') scope = args[++i]
|
|
1613
1615
|
else if (a.startsWith('--code=')) code = a.slice('--code='.length)
|
|
@@ -1638,7 +1640,7 @@ Exit codes:
|
|
|
1638
1640
|
try {
|
|
1639
1641
|
result = scope === 'feature'
|
|
1640
1642
|
? await validateFeature(valCwd, code)
|
|
1641
|
-
: await validateProject(valCwd)
|
|
1643
|
+
: await validateProject(valCwd, { external: externalXref })
|
|
1642
1644
|
} catch (err) {
|
|
1643
1645
|
if (err.code === 'INVALID_INPUT') {
|
|
1644
1646
|
console.error(`Error [INVALID_INPUT]: ${err.message}`)
|
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
# Compose pre-push hook — runs `compose validate` and blocks the push on
|
|
3
3
|
# any error-severity drift finding. Installed by `compose hooks install --pre-push`;
|
|
4
4
|
# placeholders below are substituted at install time.
|
|
5
|
+
#
|
|
6
|
+
# External-reference (xref) resolution is OFF here by default: github xref:
|
|
7
|
+
# citations / kind:"external" links emit XREF_RESOLUTION_SKIPPED (warning,
|
|
8
|
+
# non-blocking under --block-on=error) while local/url/malformed still
|
|
9
|
+
# surface. Opt in to network github resolution by exporting
|
|
10
|
+
# COMPOSE_XREF_ONLINE=1, or set `xref.prePushOnline: true` in
|
|
11
|
+
# .compose/compose.json — both are honored by `compose validate` directly
|
|
12
|
+
# (no flag change needed below). XREF_TARGET_MISSING stays error and blocks.
|
|
5
13
|
|
|
6
14
|
set -u
|
|
7
15
|
COMPOSE_NODE="__COMPOSE_NODE__"
|
|
@@ -68,12 +68,62 @@
|
|
|
68
68
|
"type": "array",
|
|
69
69
|
"items": {
|
|
70
70
|
"type": "object",
|
|
71
|
-
"required": ["kind", "to_code"],
|
|
72
71
|
"properties": {
|
|
73
|
-
"kind": { "type": "string", "enum": ["surfaced_by", "blocks", "depends_on", "follow_up", "supersedes", "related"] },
|
|
72
|
+
"kind": { "type": "string", "enum": ["surfaced_by", "blocks", "depends_on", "follow_up", "supersedes", "related", "external"] },
|
|
74
73
|
"to_code": { "type": "string", "pattern": "^[A-Z][A-Z0-9-]*[A-Z0-9]$" },
|
|
75
|
-
"note": { "type": "string" }
|
|
76
|
-
|
|
74
|
+
"note": { "type": "string" },
|
|
75
|
+
"provider": { "type": "string", "enum": ["github", "local", "url", "jira", "linear", "notion", "obsidian"] },
|
|
76
|
+
"repo": { "type": "string" },
|
|
77
|
+
"issue": { "type": "integer", "minimum": 1 },
|
|
78
|
+
"url": { "type": "string", "pattern": "^[a-zA-Z][a-zA-Z0-9+\\-.]*://" },
|
|
79
|
+
"expect": { "type": "string" }
|
|
80
|
+
},
|
|
81
|
+
"allOf": [
|
|
82
|
+
{
|
|
83
|
+
"if": { "not": { "required": ["kind"], "properties": { "kind": { "const": "external" } } } },
|
|
84
|
+
"then": { "required": ["kind", "to_code"] }
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"if": { "properties": { "kind": { "const": "external" } }, "required": ["kind"] },
|
|
88
|
+
"then": {
|
|
89
|
+
"required": ["provider"],
|
|
90
|
+
"allOf": [
|
|
91
|
+
{
|
|
92
|
+
"if": { "properties": { "provider": { "const": "github" } }, "required": ["provider"] },
|
|
93
|
+
"then": {
|
|
94
|
+
"required": ["repo", "issue"],
|
|
95
|
+
"properties": {
|
|
96
|
+
"repo": { "pattern": "^[^\\s/#]+/[^\\s/#]+$" },
|
|
97
|
+
"expect": { "enum": ["open", "closed"] }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"if": { "properties": { "provider": { "const": "local" } }, "required": ["provider"] },
|
|
103
|
+
"then": {
|
|
104
|
+
"required": ["repo", "to_code"],
|
|
105
|
+
"properties": {
|
|
106
|
+
"repo": {
|
|
107
|
+
"pattern": "^[A-Za-z0-9._-]+$",
|
|
108
|
+
"not": { "enum": [".", ".."] }
|
|
109
|
+
},
|
|
110
|
+
"expect": { "enum": ["PLANNED", "IN_PROGRESS", "PARTIAL", "COMPLETE", "SUPERSEDED", "PARKED", "BLOCKED", "KILLED"] }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"if": { "properties": { "provider": { "enum": ["url", "jira", "linear", "notion", "obsidian"] } }, "required": ["provider"] },
|
|
116
|
+
"then": {
|
|
117
|
+
"required": ["url"],
|
|
118
|
+
"properties": {
|
|
119
|
+
"url": { "type": "string", "pattern": "^[a-zA-Z][a-zA-Z0-9+\\-.]*://" }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
]
|
|
77
127
|
}
|
|
78
128
|
},
|
|
79
129
|
"completions": {
|
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 ?? [];
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xref-citation.js — pure parser for inline cross-project external-reference
|
|
3
|
+
* citations embedded in a ROADMAP/description cell (COMP-MCP-XREF-SCHEMA, #15).
|
|
4
|
+
*
|
|
5
|
+
* A citation is an HTML comment so it renders invisibly in markdown:
|
|
6
|
+
*
|
|
7
|
+
* <!-- xref: github owner/repo#123 expect=open -->
|
|
8
|
+
* <!-- xref: github smartmemory/compose#7 expect=closed note="shipped X" -->
|
|
9
|
+
* <!-- xref: local compose COMP-MCP-VALIDATE expect=COMPLETE -->
|
|
10
|
+
* <!-- xref: url https://example.com/spec note="design ref" -->
|
|
11
|
+
*
|
|
12
|
+
* Grammar (spec §3.1 EBNF):
|
|
13
|
+
* citation = "<!--" ws "xref:" ws provider ws target
|
|
14
|
+
* [ ws "expect=" expect ] [ ws "note=" qstring ] ws "-->"
|
|
15
|
+
* provider = "github" | "local" | "url" ; resolvable
|
|
16
|
+
* | "jira" | "linear" | "notion" | "obsidian" ; reserved url-class
|
|
17
|
+
* gh_target = repo "#" issue ; repo = owner "/" name
|
|
18
|
+
* local_target = repo_token ws feature_code
|
|
19
|
+
* url_target = URL ; url + every reserved provider
|
|
20
|
+
*
|
|
21
|
+
* This module performs ZERO I/O and ZERO network. It is a pure
|
|
22
|
+
* string → object function. Consumed by #16 (`runExternalRefChecks`); #15
|
|
23
|
+
* ships it standalone with no caller in the validator path.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// url-class = `url` + every reserved provider (carry a url_target, never
|
|
27
|
+
// resolved in v1). ALL_PROVIDERS is the full accepted set.
|
|
28
|
+
const RESOLVABLE_PROVIDERS = ['github', 'local', 'url'];
|
|
29
|
+
const RESERVED_PROVIDERS = ['jira', 'linear', 'notion', 'obsidian'];
|
|
30
|
+
const ALL_PROVIDERS = new Set([...RESOLVABLE_PROVIDERS, ...RESERVED_PROVIDERS]);
|
|
31
|
+
|
|
32
|
+
const GITHUB_EXPECT = new Set(['open', 'closed']);
|
|
33
|
+
const LOCAL_EXPECT = new Set([
|
|
34
|
+
'PLANNED', 'IN_PROGRESS', 'PARTIAL', 'COMPLETE',
|
|
35
|
+
'SUPERSEDED', 'PARKED', 'BLOCKED', 'KILLED',
|
|
36
|
+
]);
|
|
37
|
+
const FEATURE_CODE_RE = /^[A-Z][A-Z0-9-]*[A-Z0-9]$/;
|
|
38
|
+
const URI_SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//;
|
|
39
|
+
|
|
40
|
+
// Anchored scan: only HTML comments whose body begins with `xref:` are
|
|
41
|
+
// considered. Any other `<!-- ... -->` is ignored entirely.
|
|
42
|
+
const CITATION_RE = /<!--\s*xref:\s*([\s\S]*?)\s*-->/g;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Structured parse error for a comment that matched `<!--\s*xref:` but
|
|
46
|
+
* failed the grammar. Consumed by #16 as the `XREF_MALFORMED` finding;
|
|
47
|
+
* #15 only surfaces it via the return value (never throws, never logs).
|
|
48
|
+
*/
|
|
49
|
+
export class ParseError {
|
|
50
|
+
constructor(raw, reason) {
|
|
51
|
+
this.raw = raw;
|
|
52
|
+
this.reason = reason;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @typedef {object} PartialExternalRef
|
|
58
|
+
* @property {string} provider
|
|
59
|
+
* @property {string|null} repo github "owner/name" | local repo token | null
|
|
60
|
+
* @property {number|null} issue github only
|
|
61
|
+
* @property {string|null} toCode local only (target feature code)
|
|
62
|
+
* @property {string|null} url url-class only (url + reserved providers)
|
|
63
|
+
* @property {string|null} expect optional expected-state token
|
|
64
|
+
* @property {string|null} note
|
|
65
|
+
* @property {string} raw the citation body (for locatability)
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse every `xref:` citation in a description cell.
|
|
70
|
+
* @param {string} descriptionCell
|
|
71
|
+
* @returns {{ refs: PartialExternalRef[], errors: ParseError[] }}
|
|
72
|
+
*/
|
|
73
|
+
export function parseCitations(descriptionCell) {
|
|
74
|
+
const refs = [];
|
|
75
|
+
const errors = [];
|
|
76
|
+
if (typeof descriptionCell !== 'string' || descriptionCell.length === 0) {
|
|
77
|
+
return { refs, errors };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
CITATION_RE.lastIndex = 0;
|
|
81
|
+
let m;
|
|
82
|
+
while ((m = CITATION_RE.exec(descriptionCell)) !== null) {
|
|
83
|
+
const raw = m[1];
|
|
84
|
+
try {
|
|
85
|
+
refs.push(parseOne(raw));
|
|
86
|
+
} catch (e) {
|
|
87
|
+
if (e instanceof ParseError) errors.push(e);
|
|
88
|
+
else errors.push(new ParseError(raw, String(e && e.message ? e.message : e)));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { refs, errors };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseOne(raw) {
|
|
95
|
+
let rest = raw.trim();
|
|
96
|
+
if (rest.length === 0) throw new ParseError(raw, 'empty xref citation');
|
|
97
|
+
|
|
98
|
+
// Optional trailing options `expect=<tok>` and `note="..."`, order-
|
|
99
|
+
// independent. They are stripped **end-anchored** (must be the trailing
|
|
100
|
+
// whitespace-separated token), so a `note=`/`expect=` substring inside the
|
|
101
|
+
// target itself — e.g. a URL query `https://x/?note=a&expect=b` — is left
|
|
102
|
+
// in the target and never mis-consumed. Each option may appear at most once.
|
|
103
|
+
//
|
|
104
|
+
// Known v1 limitations (faithful to spec §3.1 EBNF, which defines
|
|
105
|
+
// `qstring = DQUOTE *CHAR DQUOTE` with no escape and an HTML-comment
|
|
106
|
+
// carrier): a `note="..."` value cannot contain `"` or the literal `-->`.
|
|
107
|
+
let note = null;
|
|
108
|
+
let expect = null;
|
|
109
|
+
for (let i = 0; i < 2; i++) {
|
|
110
|
+
let m;
|
|
111
|
+
if (note === null && (m = rest.match(/\s+note="([^"]*)"\s*$/))) {
|
|
112
|
+
note = m[1];
|
|
113
|
+
rest = rest.slice(0, m.index).trimEnd();
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (expect === null && (m = rest.match(/\s+expect=(\S+)\s*$/))) {
|
|
117
|
+
expect = m[1];
|
|
118
|
+
rest = rest.slice(0, m.index).trimEnd();
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
// A `note=` option token that exists but was not consumable as a trailing
|
|
124
|
+
// quoted string is a hard parse error (don't silently fold it into target).
|
|
125
|
+
if (note === null && /(^|\s)note=/.test(rest)) {
|
|
126
|
+
if (/(^|\s)note="/.test(rest)) {
|
|
127
|
+
throw new ParseError(raw, 'unterminated or misplaced note="..." (must be a trailing double-quoted token)');
|
|
128
|
+
}
|
|
129
|
+
throw new ParseError(raw, 'note= value must be a double-quoted string');
|
|
130
|
+
}
|
|
131
|
+
// Likewise a stray trailing `expect=` token that wasn't consumed.
|
|
132
|
+
if (expect === null && /(^|\s)expect=\S*\s*$/.test(rest)) {
|
|
133
|
+
throw new ParseError(raw, 'malformed expect= option');
|
|
134
|
+
}
|
|
135
|
+
rest = rest.replace(/\s+/g, ' ').trim();
|
|
136
|
+
|
|
137
|
+
// Remaining: `<provider> <target...>`.
|
|
138
|
+
const firstWs = rest.search(/\s/);
|
|
139
|
+
if (firstWs === -1) {
|
|
140
|
+
throw new ParseError(raw, `missing target after provider "${rest}"`);
|
|
141
|
+
}
|
|
142
|
+
const provider = rest.slice(0, firstWs);
|
|
143
|
+
const target = rest.slice(firstWs + 1).trim();
|
|
144
|
+
|
|
145
|
+
if (!ALL_PROVIDERS.has(provider)) {
|
|
146
|
+
throw new ParseError(
|
|
147
|
+
raw,
|
|
148
|
+
`unknown provider "${provider}" (expected one of ${[...ALL_PROVIDERS].join(', ')})`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (target.length === 0) {
|
|
152
|
+
throw new ParseError(raw, `missing target for provider "${provider}"`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const ref = {
|
|
156
|
+
provider,
|
|
157
|
+
repo: null,
|
|
158
|
+
issue: null,
|
|
159
|
+
toCode: null,
|
|
160
|
+
url: null,
|
|
161
|
+
expect: null,
|
|
162
|
+
note,
|
|
163
|
+
raw,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
if (provider === 'github') {
|
|
167
|
+
// No `#` in either repo half — `#` delimits the issue (owner/name#issue)
|
|
168
|
+
// and GitHub owners/names cannot contain it. Keeps the citation carrier
|
|
169
|
+
// carrier-equivalent with the feature.json-link writer (XREF_GH_REPO_RE).
|
|
170
|
+
const gh = target.match(/^([^\s/#]+\/[^\s/#]+)#(\d+)$/);
|
|
171
|
+
if (!gh) {
|
|
172
|
+
throw new ParseError(raw, `github target must be "owner/name#issue", got "${target}"`);
|
|
173
|
+
}
|
|
174
|
+
ref.repo = gh[1];
|
|
175
|
+
ref.issue = Number(gh[2]);
|
|
176
|
+
if (expect !== null) {
|
|
177
|
+
if (!GITHUB_EXPECT.has(expect)) {
|
|
178
|
+
throw new ParseError(
|
|
179
|
+
raw, `github expect must be open|closed, got "${expect}"`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
ref.expect = expect;
|
|
183
|
+
}
|
|
184
|
+
} else if (provider === 'local') {
|
|
185
|
+
const parts = target.split(/\s+/);
|
|
186
|
+
if (parts.length !== 2) {
|
|
187
|
+
throw new ParseError(
|
|
188
|
+
raw, `local target must be "<repo> <FEATURE_CODE>", got "${target}"`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const [repoTok, code] = parts;
|
|
192
|
+
// repo token must be a single safe directory name — it is resolved as a
|
|
193
|
+
// sibling dir (path.join(cwd, '..', repoTok)); reject anything with a
|
|
194
|
+
// path separator or traversal so a citation cannot escape the workspace.
|
|
195
|
+
if (!/^[A-Za-z0-9._-]+$/.test(repoTok) || repoTok === '.' || repoTok === '..') {
|
|
196
|
+
throw new ParseError(
|
|
197
|
+
raw,
|
|
198
|
+
`local repo token "${repoTok}" must be a single directory name `
|
|
199
|
+
+ '([A-Za-z0-9._-], no path separators or "."/"..")',
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (!FEATURE_CODE_RE.test(code)) {
|
|
203
|
+
throw new ParseError(raw, `local target feature code "${code}" is not a valid code`);
|
|
204
|
+
}
|
|
205
|
+
ref.repo = repoTok;
|
|
206
|
+
ref.toCode = code;
|
|
207
|
+
if (expect !== null) {
|
|
208
|
+
if (!LOCAL_EXPECT.has(expect)) {
|
|
209
|
+
throw new ParseError(
|
|
210
|
+
raw,
|
|
211
|
+
`local expect must be one of ${[...LOCAL_EXPECT].join('|')}, got "${expect}"`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
ref.expect = expect;
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
// url-class: provider `url` and every reserved provider. The target is a
|
|
218
|
+
// single URL token; `expect=` is syntactically accepted but ignored
|
|
219
|
+
// (these refs are never resolved in v1 — spec §5.2/§9), never a ParseError.
|
|
220
|
+
if (/\s/.test(target)) {
|
|
221
|
+
throw new ParseError(
|
|
222
|
+
raw, `${provider} target must be a single URL, got "${target}"`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (!URI_SCHEME_RE.test(target)) {
|
|
226
|
+
throw new ParseError(
|
|
227
|
+
raw, `${provider} target must be a scheme:// URL, got "${target}"`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
ref.url = target;
|
|
231
|
+
if (expect !== null) ref.expect = expect; // recorded, not validated, not resolved
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return ref;
|
|
235
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smartmemory/compose",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.39-beta",
|
|
4
4
|
"description": "Structured AI dev pipeline — goal-to-product orchestration with gates, iteration loops, and feature lifecycle management.",
|
|
5
5
|
"author": "SmartMemory",
|
|
6
6
|
"license": "MIT",
|
|
@@ -291,10 +291,11 @@ export async function toolValidateFeature(args = {}) {
|
|
|
291
291
|
|
|
292
292
|
export async function toolValidateProject(args = {}) {
|
|
293
293
|
const { validateProject } = await import('../lib/feature-validator.js');
|
|
294
|
-
const { external_prefixes, feature_json_mode } = args;
|
|
294
|
+
const { external_prefixes, feature_json_mode, external } = args;
|
|
295
295
|
return validateProject(getTargetRoot(), {
|
|
296
296
|
externalPrefixes: external_prefixes,
|
|
297
297
|
featureJsonMode: feature_json_mode,
|
|
298
|
+
external: external === true,
|
|
298
299
|
});
|
|
299
300
|
}
|
|
300
301
|
|
package/server/compose-mcp.js
CHANGED
|
@@ -363,12 +363,13 @@ const TOOLS = [
|
|
|
363
363
|
},
|
|
364
364
|
{
|
|
365
365
|
name: 'validate_project',
|
|
366
|
-
description: 'Run validate_feature for every code in vision-state, ROADMAP, and folders, plus cross-cutting checks (orphan folders, dangling cross-refs, CHANGELOG references, journal index drift). Returns the union of all findings.',
|
|
366
|
+
description: 'Run validate_feature for every code in vision-state, ROADMAP, and folders, plus cross-cutting checks (orphan folders, dangling cross-refs, CHANGELOG references, journal index drift) and read-only external-reference staleness (kind:"external" links + xref: roadmap citations). external:true enables network resolution of github refs (off by default — github refs then emit XREF_RESOLUTION_SKIPPED). Returns the union of all findings.',
|
|
367
367
|
inputSchema: {
|
|
368
368
|
type: 'object',
|
|
369
369
|
properties: {
|
|
370
370
|
external_prefixes: { type: 'array', items: { type: 'string' } },
|
|
371
371
|
feature_json_mode: { type: 'boolean' },
|
|
372
|
+
external: { type: 'boolean', description: 'Resolve github external refs over the network (read-only). Default false: github refs degrade to XREF_RESOLUTION_SKIPPED.' },
|
|
372
373
|
},
|
|
373
374
|
},
|
|
374
375
|
},
|
|
@@ -411,14 +412,19 @@ const TOOLS = [
|
|
|
411
412
|
},
|
|
412
413
|
{
|
|
413
414
|
name: 'link_features',
|
|
414
|
-
description: 'Register a typed cross-feature relationship.
|
|
415
|
+
description: 'Register a typed cross-feature relationship. Two shapes: (1) SAME-PROJECT — kind ∈ surfaced_by|blocks|depends_on|follow_up|supersedes|related, requires to_code; self-links rejected; dedups on (kind,to_code). (2) EXTERNAL (kind:"external") — a cross-project pointer, NOT a same-project link: requires provider; three resolvable sub-shapes — github (repo "owner/name" + integer issue), local (repo token + to_code), url (url); plus reserved url-class providers jira|linear|notion|obsidian (parse-valid, require url, NOT resolved in v1). External dedups on (kind=external, provider, repo, issue|to_code|url). Stores on the source feature; query inverse via get_feature_links(direction:"incoming").',
|
|
415
416
|
inputSchema: {
|
|
416
417
|
type: 'object',
|
|
417
|
-
required: ['from_code', '
|
|
418
|
+
required: ['from_code', 'kind'],
|
|
418
419
|
properties: {
|
|
419
420
|
from_code: { type: 'string' },
|
|
420
|
-
to_code: { type: 'string', description: '
|
|
421
|
-
kind: { type: 'string', enum: ['surfaced_by', 'blocks', 'depends_on', 'follow_up', 'supersedes', 'related'] },
|
|
421
|
+
to_code: { type: 'string', description: 'Same-project: target feature code (required unless kind:"external"). External local: the cited feature code. Need not exist yet.' },
|
|
422
|
+
kind: { type: 'string', enum: ['surfaced_by', 'blocks', 'depends_on', 'follow_up', 'supersedes', 'related', 'external'] },
|
|
423
|
+
provider: { type: 'string', enum: ['github', 'local', 'url', 'jira', 'linear', 'notion', 'obsidian'], description: 'Required when kind:"external". Resolvable: github|local|url. Reserved url-class (require url, not resolved in v1): jira|linear|notion|obsidian.' },
|
|
424
|
+
repo: { type: 'string', description: 'External github: "owner/name". External local: workspace-relative repo token.' },
|
|
425
|
+
issue: { type: 'integer', minimum: 1, description: 'External github: issue/PR number.' },
|
|
426
|
+
url: { type: 'string', description: 'External url-class (url|jira|linear|notion|obsidian): the pointer URL.' },
|
|
427
|
+
expect: { type: 'string', description: 'Optional expected state. github: open|closed. local: a status token. url-class: recorded, never resolved.' },
|
|
422
428
|
note: { type: 'string' },
|
|
423
429
|
force: { type: 'boolean' },
|
|
424
430
|
idempotency_key: { type: 'string' },
|