From ee959d4621d7f6a936280f847a87643be1099073 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 19:27:45 +0000 Subject: [PATCH] feat(adapter,inspector): single-item collections + accurate capability report (WS-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapter: - extractCollectionField honors foundry_item_single → returns the first matching item's name (or first projected value) as a plain string, for "exactly one X item" fields like a hero's class/ancestry/kit/subclass. Capability Inspector: - Stop mislabeling collection-mapped fields (abilities_json, class, …) as "declared-unmapped" — they're mapped via a collection, so classify them collection-mapped and count them separately (Status panel + Copy Report). - Surface one sample item schema PER item type in the collections section (was: only the first item overall). The Copy Report now lists the projectable fields for ability/feature/class/ancestry/kit/… — exactly what's needed to finish the remaining item-field mappings (feature split, immunities/weaknesses). Tests: 568 node tests green (+3). New coverage for single-item extraction (name, projection, empty), collection-mapped classification, and per-type sample schemas in the report. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LaDDiXB5GTVurEzJwYYopf --- scripts/adapters/generic-adapter.mjs | 16 ++++++++ scripts/capability-inspector.mjs | 59 ++++++++++++++++++++++------ templates/sync-dashboard.hbs | 1 + tools/test-capability-inspector.mjs | 33 ++++++++++++++++ tools/test-generic-adapter.mjs | 33 ++++++++++++++++ 5 files changed, 130 insertions(+), 12 deletions(-) diff --git a/scripts/adapters/generic-adapter.mjs b/scripts/adapters/generic-adapter.mjs index 4d6df50..6fa7672 100644 --- a/scripts/adapters/generic-adapter.mjs +++ b/scripts/adapters/generic-adapter.mjs @@ -150,6 +150,9 @@ export function getNestedValue(obj, path) { * Reads field.foundry_collection off the actor, optionally filters by * foundry_item_type, projects each entry per foundry_item_fields (or a default * {id,name,type}), and returns a JSON string (type json/string) or a raw array. + * When field.foundry_item_single is set, collapses to the FIRST matching item's + * name (or first projected value) as a plain string — for "exactly one X item" + * fields like a hero's class/ancestry/kit. * Defensive — a malformed actor/collection yields an empty result, never throws. * * @param {object} actor @@ -176,6 +179,19 @@ export function extractCollectionField(actor, field) { ? field.foundry_item_fields : null; + // Single-item collapse: "exactly one X item" fields (class/ancestry/kit) + // want the item's NAME as a plain string, not a one-element JSON array. + // Uses the first projected path when a projection is given, else item.name. + if (field.foundry_item_single) { + const first = contents[0]; + if (!first) return ''; + if (proj) { + const firstPath = Object.values(proj)[0]; + return getNestedValue(first, firstPath) ?? ''; + } + return first.name ?? ''; + } + const items = contents.map((it) => { if (!proj) return { id: it.id ?? null, name: it.name ?? null, type: it.type ?? null }; const out = {}; diff --git a/scripts/capability-inspector.mjs b/scripts/capability-inspector.mjs index a7ae9b7..867db62 100644 --- a/scripts/capability-inspector.mjs +++ b/scripts/capability-inspector.mjs @@ -132,19 +132,27 @@ function _summarizeCollection(coll, withSampleSchema) { } result.types = [...typeSet]; if (withSampleSchema && contents.length > 0) { + const readSys = (it) => { + try { + const raw = it.system; + return raw && typeof raw.toObject === 'function' ? raw.toObject() : (raw || {}); + } catch (err) { + return {}; + } + }; const first = contents[0]; - let sys = {}; - try { - const raw = first.system; - sys = raw && typeof raw.toObject === 'function' ? raw.toObject() : (raw || {}); - } catch (err) { - sys = {}; - } result.sample = { name: first.name ?? '(unnamed)', type: first.type ?? null, - schema: walkSchema(sys, 'system', 3), + schema: walkSchema(readSys(first), 'system', 3), }; + // One representative item PER distinct type, so the report exposes the + // projectable fields for each (ability vs class vs feature vs kit…) — what + // a future collection mapping needs to know to project the right paths. + result.samples = result.types.map((t) => { + const it = contents.find((c) => c && c.type === t) || {}; + return { type: t, name: it.name ?? '(unnamed)', schema: walkSchema(readSys(it), 'system', 2) }; + }); } return result; } @@ -161,6 +169,7 @@ export const CAP_STATUS = Object.freeze({ SYNCED: 'synced', MAPPED_MISSING: 'mapped-missing', DECLARED_UNMAPPED: 'declared-unmapped', + COLLECTION_MAPPED: 'collection-mapped', AVAILABLE_UNMAPPED: 'available-unmapped', COLLECTION: 'collection', }); @@ -190,6 +199,22 @@ export function buildCapabilityReport(snapshot, fieldDefs) { // 1. Walk the manifest: classify each declared field. for (const f of defs) { if (!f.foundry_path) { + // A field with no foundry_path may still be MAPPED via a collection + // (e.g. abilities_json → actor.items[type=ability], or class → the one + // class item). Only a field with neither is genuinely unmapped. + if (f.foundry_collection) { + const types = Array.isArray(f.foundry_item_type) ? f.foundry_item_type.join(',') : (f.foundry_item_type || ''); + const desc = `${f.foundry_collection}[${types}]${f.foundry_item_single ? ' (single)' : ''}`; + rows.push({ + status: CAP_STATUS.COLLECTION_MAPPED, + key: f.key, + foundry_path: desc, + type: f.type || null, + writable: f.foundry_writable !== false, + sample: undefined, + }); + continue; + } rows.push({ status: CAP_STATUS.DECLARED_UNMAPPED, key: f.key, @@ -233,8 +258,9 @@ export function buildCapabilityReport(snapshot, fieldDefs) { source: 'actor.items', count: snapshot.items.count, types: snapshot.items.types, - note: 'Embedded items (abilities/inventory) — not pullable by the dot-path adapter yet (WS-3).', + note: 'Embedded items. Pullable via a collection mapping (foundry_collection + foundry_item_type[, foundry_item_single]); per-type schemas below show the projectable fields.', sample: snapshot.items.sample, + samples: snapshot.items.samples || [], }); } if (snapshot.effects && snapshot.effects.available) { @@ -267,6 +293,7 @@ function _summarize(rows, collections) { synced: 0, mappedMissing: 0, declaredUnmapped: 0, + collectionMapped: 0, availableUnmapped: 0, collectionsAvailable: collections.length, totalDeclared: 0, @@ -275,9 +302,10 @@ function _summarize(rows, collections) { if (r.status === CAP_STATUS.SYNCED) c.synced++; else if (r.status === CAP_STATUS.MAPPED_MISSING) c.mappedMissing++; else if (r.status === CAP_STATUS.DECLARED_UNMAPPED) c.declaredUnmapped++; + else if (r.status === CAP_STATUS.COLLECTION_MAPPED) c.collectionMapped++; else if (r.status === CAP_STATUS.AVAILABLE_UNMAPPED) c.availableUnmapped++; } - c.totalDeclared = c.synced + c.mappedMissing + c.declaredUnmapped; + c.totalDeclared = c.synced + c.mappedMissing + c.declaredUnmapped + c.collectionMapped; return c; } @@ -296,7 +324,8 @@ export function renderCapabilityMarkdown(report) { lines.push(`Sampled actor: **${report.source.actorName}** (type \`${report.source.actorType}\`)`); lines.push(''); lines.push(`- Synced: **${s.synced}** / ${s.totalDeclared} declared`); - lines.push(`- Declared but unmapped (no foundry_path): **${s.declaredUnmapped}**`); + lines.push(`- Collection-mapped (items, e.g. abilities/class): **${s.collectionMapped}**`); + lines.push(`- Declared but unmapped (no foundry_path/collection): **${s.declaredUnmapped}**`); lines.push(`- Mapped but missing on this actor: **${s.mappedMissing}**`); lines.push(`- Available but not mapped (candidates): **${s.availableUnmapped}**`); lines.push(`- Collections available (items/effects): **${s.collectionsAvailable}**`); @@ -308,9 +337,15 @@ export function renderCapabilityMarkdown(report) { } if (report.collections.length) { lines.push(''); - lines.push('## Collections (not yet pullable)'); + lines.push('## Collections'); for (const c of report.collections) { lines.push(`- \`${c.source}\` — ${c.count} item(s), types: ${c.types.join(', ') || '—'} — ${c.note}`); + // Per-type item schemas — the projectable fields for each item type, so a + // collection mapping can target the right foundry_item_fields paths. + for (const sample of (c.samples || [])) { + const paths = (sample.schema || []).map((r) => `\`${r.path}\` (${r.type})`).join(', '); + lines.push(` - **${sample.type}** — e.g. "${sample.name}": ${paths || '(no system fields)'}`); + } } } return lines.join('\n') + '\n'; diff --git a/templates/sync-dashboard.hbs b/templates/sync-dashboard.hbs index 72e428f..808dea8 100644 --- a/templates/sync-dashboard.hbs +++ b/templates/sync-dashboard.hbs @@ -1097,6 +1097,7 @@
Synced:{{capability.summary.synced}} / {{capability.summary.totalDeclared}} declared
+
Collection-mapped (items):{{capability.summary.collectionMapped}}
Declared, unmapped:{{capability.summary.declaredUnmapped}}
Mapped, missing on actor:{{capability.summary.mappedMissing}}
Available, not mapped:{{capability.summary.availableUnmapped}}
diff --git a/tools/test-capability-inspector.mjs b/tools/test-capability-inspector.mjs index 6a79336..6cfff1f 100644 --- a/tools/test-capability-inspector.mjs +++ b/tools/test-capability-inspector.mjs @@ -116,6 +116,39 @@ test('buildCapabilityReport classifies synced / mapped-missing / declared-unmapp assert.ok(report.summary.availableUnmapped >= 2); }); +test('buildCapabilityReport: a foundry_collection field is collection-mapped, not declared-unmapped', () => { + const defs = { + preset_slug: 'drawsteel-character', + foundry_actor_type: 'hero', + fields: [ + { key: 'abilities_json', type: 'string', foundry_collection: 'items', foundry_item_type: ['ability'] }, + { key: 'class', type: 'string', foundry_collection: 'items', foundry_item_type: ['class'], foundry_item_single: true }, + { key: 'immunities', type: 'string' }, // genuinely unmapped (no path, no collection) + ], + }; + const report = buildCapabilityReport(captureActorSnapshot(makeActor()), defs); + const byKey = Object.fromEntries(report.fields.filter((r) => r.key).map((r) => [r.key, r])); + assert.equal(byKey.abilities_json.status, CAP_STATUS.COLLECTION_MAPPED); + assert.equal(byKey.class.status, CAP_STATUS.COLLECTION_MAPPED); + assert.match(byKey.class.foundry_path, /items\[class\] \(single\)/); // descriptor surfaced + assert.equal(byKey.immunities.status, CAP_STATUS.DECLARED_UNMAPPED); + assert.equal(report.summary.collectionMapped, 2); + assert.equal(report.summary.declaredUnmapped, 1); +}); + +test('collections expose a sample item schema per type (enables item-field mapping)', () => { + const report = buildCapabilityReport(captureActorSnapshot(makeActor()), FIELD_DEFS); + const items = report.collections.find((c) => c.source === 'actor.items'); + assert.ok(items, 'expected actor.items collection'); + const byType = Object.fromEntries((items.samples || []).map((s) => [s.type, s])); + assert.ok(byType.ability && byType.kit, 'expected one sample per distinct type'); + // the ability sample exposes its system fields (e.g. keywords) for projection + assert.ok(byType.ability.schema.some((r) => r.path === 'system.keywords')); + // and the markdown renders the per-type schema lines + const md = renderCapabilityMarkdown(report); + assert.match(md, /\*\*ability\*\* — e\.g\. "Ashfall Strike"/); +}); + test('renderers produce a titled markdown report and valid JSON; bad input tolerated', () => { const report = buildCapabilityReport(captureActorSnapshot(makeActor()), FIELD_DEFS); const md = renderCapabilityMarkdown(report); diff --git a/tools/test-generic-adapter.mjs b/tools/test-generic-adapter.mjs index 5342ab3..2fea09c 100644 --- a/tools/test-generic-adapter.mjs +++ b/tools/test-generic-adapter.mjs @@ -30,6 +30,8 @@ function makeActor() { { id: 'i1', name: 'Ashfall Strike', type: 'ability', system: { keywords: ['melee'], heroic: 3 } }, { id: 'i2', name: 'Mountain Stance', type: 'ability', system: { keywords: ['stance'] } }, { id: 'i3', name: 'Ashbrand', type: 'equipment', system: { equipped: true } }, + { id: 'i4', name: 'Conduit', type: 'class', system: { level: 4 } }, + { id: 'i5', name: 'Hakaan', type: 'ancestry', system: {} }, ], }, }; @@ -71,6 +73,37 @@ test('extractCollectionField supports multiple types + default projection + raw assert.deepEqual(arr[0], { id: 'i3', name: 'Ashbrand', type: 'equipment' }); }); +test('extractCollectionField single → first matching item name (class/ancestry/kit)', () => { + const cls = extractCollectionField(makeActor(), { + key: 'class', type: 'string', + foundry_collection: 'items', foundry_item_type: 'class', foundry_item_single: true, + }); + assert.equal(cls, 'Conduit'); // plain string, not a JSON array + + const anc = extractCollectionField(makeActor(), { + key: 'ancestry', type: 'string', + foundry_collection: 'items', foundry_item_type: ['ancestry'], foundry_item_single: true, + }); + assert.equal(anc, 'Hakaan'); +}); + +test('extractCollectionField single honors a projection path and is empty when none match', () => { + // projection → use the first projected path instead of name + const lvl = extractCollectionField(makeActor(), { + key: 'class_level', type: 'string', + foundry_collection: 'items', foundry_item_type: 'class', foundry_item_single: true, + foundry_item_fields: { level: 'system.level' }, + }); + assert.equal(lvl, 4); + + // no matching item → empty string (a string field, not '[]') + const none = extractCollectionField(makeActor(), { + key: 'kit', type: 'string', + foundry_collection: 'items', foundry_item_type: 'kit', foundry_item_single: true, + }); + assert.equal(none, ''); +}); + test('extractCollectionField is defensive (missing collection / bad actor → empty)', () => { assert.equal(extractCollectionField({}, { key: 'x', type: 'string', foundry_collection: 'items' }), '[]'); assert.deepEqual(extractCollectionField({}, { key: 'x', type: 'array', foundry_collection: 'items' }), []);