A plain JavaScript validator for AT Protocol lexicon schemas

Fix cross-lexicon ref context resolution

When lexicon A refs B#foo and B#foo contains a local ref #bar,
it now correctly resolves to B#bar instead of A#bar.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+79 -4
+9 -3
lexicon.js
··· 1494 1494 return { path, message: `Definition '${defName}' not found in lexicon '${nsid}'` }; 1495 1495 } 1496 1496 1497 - return validateFieldData(data, referencedDef, path, ctx); 1497 + // Update context to use the referenced lexicon for local ref resolution 1498 + const newCtx = { ...ctx, currentLexicon: nsid }; 1499 + 1500 + return validateFieldData(data, referencedDef, path, newCtx); 1498 1501 } 1499 1502 1500 1503 /** ··· 1566 1569 const newVisited = new Set(visited); 1567 1570 newVisited.add(refKey); 1568 1571 1572 + // Update context to use the referenced lexicon for local ref resolution 1573 + const newCtx = { ...ctx, currentLexicon: nsid }; 1574 + 1569 1575 // If the resolved def is another ref, follow it with cycle detection 1570 1576 if (referencedDef.type === 'ref') { 1571 - return validateRefData(data, referencedDef, path, ctx, newVisited); 1577 + return validateRefData(data, referencedDef, path, newCtx, newVisited); 1572 1578 } 1573 1579 1574 1580 // Validate data against the resolved schema 1575 - return validateFieldData(data, referencedDef, path, ctx); 1581 + return validateFieldData(data, referencedDef, path, newCtx); 1576 1582 } 1577 1583 1578 1584 /**
+1 -1
package.json
··· 1 1 { 2 2 "name": "@bigmoves/lexicon", 3 - "version": "0.2.0", 3 + "version": "0.1.3", 4 4 "license": "MIT", 5 5 "type": "module", 6 6 "exports": "./lexicon.js",
+69
test/inputs/data/ref.js
··· 153 153 }, 154 154 }; 155 155 156 + // Cross-lexicon reference with nested local refs 157 + // Tests: when A refs B#foo, and B#foo refs #bar, it should resolve to B#bar (not A#bar) 158 + // This mirrors real-world case: app.bsky.actor.profile -> com.atproto.label.defs#selfLabels -> #selfLabel 159 + const crossRefNestedLexicon1 = { 160 + lexicon: 1, 161 + id: 'app.test.profile', 162 + defs: { 163 + main: { 164 + type: 'record', 165 + key: 'tid', 166 + record: { 167 + type: 'object', 168 + properties: { 169 + labels: { type: 'ref', ref: 'app.test.labels#selfLabels' }, 170 + }, 171 + }, 172 + }, 173 + // This should NOT be used - the ref should resolve to app.test.labels#selfLabel 174 + selfLabel: { 175 + type: 'object', 176 + required: ['wrongField'], 177 + properties: { 178 + wrongField: { type: 'string' }, 179 + }, 180 + }, 181 + }, 182 + }; 183 + 184 + const crossRefNestedLexicon2 = { 185 + lexicon: 1, 186 + id: 'app.test.labels', 187 + defs: { 188 + selfLabels: { 189 + type: 'object', 190 + properties: { 191 + values: { 192 + type: 'array', 193 + items: { type: 'ref', ref: '#selfLabel' }, 194 + }, 195 + }, 196 + }, 197 + selfLabel: { 198 + type: 'object', 199 + required: ['val'], 200 + properties: { 201 + val: { type: 'string' }, 202 + }, 203 + }, 204 + }, 205 + }; 206 + 156 207 export const refDataInputs = [ 157 208 { 158 209 name: 'ref-data-valid-to-string', ··· 224 275 collection: 'app.test.badref', 225 276 record: { 226 277 data: 'test', 278 + }, 279 + }, 280 + // Cross-lexicon ref with nested local ref - should resolve to target lexicon's def 281 + { 282 + name: 'ref-data-valid-cross-lexicon-nested-local-ref', 283 + lexicons: [crossRefNestedLexicon1, crossRefNestedLexicon2], 284 + collection: 'app.test.profile', 285 + record: { 286 + labels: { values: [{ val: 'self-label-value' }] }, 287 + }, 288 + }, 289 + // This should fail because it uses the wrong lexicon's selfLabel definition 290 + { 291 + name: 'ref-data-invalid-cross-lexicon-wrong-context', 292 + lexicons: [crossRefNestedLexicon1, crossRefNestedLexicon2], 293 + collection: 'app.test.profile', 294 + record: { 295 + labels: { values: [{ wrongField: 'this-uses-wrong-lexicon' }] }, 227 296 }, 228 297 }, 229 298 ];